+ )
+}
+```
+
+### ReScript projects
+
+If your app is built with ReScript React, Blend is supported.
+
+- Install and initialize Blend the same way as JS/TS projects.
+- Import Blend CSS in your app entry.
+- Create ReScript externals/bindings for the Blend components you use.
+
+This keeps the CLI + Studio workflow identical, with only a thin binding layer in your ReScript app.
+
+---
+
+## π PART 5: Sync with Studio
+
+### Login to Studio
+
+```bash
+npx blend-studio login
+# OR
+npm run blend:login
+```
+
+### When Designer Updates Tokens
+
+```bash
+npx blend-studio pull
+# OR
+npm run blend:pull
+```
+
+Your app automatically gets new colors/fonts!
+
+### Push Your Changes (If you edited tokens)
+
+```bash
+npx blend-studio push
+# OR
+npm run blend:push
+```
+
+### After init + publish branch: what should I do?
+
+Use this flow to avoid confusion:
+
+1. Run `npx blend-studio init` and `npx blend-studio brand --preset blend`
+2. Verify app runs with `BlendProvider` and at least one Blend component
+3. Commit generated files (`blend.config.json` and `src/blend/*`)
+4. Push branch and open PR
+5. After PR is merged, run `blend-studio login` once on your machine
+6. Day-to-day:
+ - Designer changed tokens? run `blend-studio pull`
+ - You changed tokens locally? run `blend-studio push`
+
+**Tip:** Keep token sync (`pull`/`push`) in small, focused commits so review is easy.
+
+---
+
+## π Quick Reference
+
+### Package Install
+
+```bash
+npm i @juspay/blend-design-system blend-studio
+```
+
+### Required Imports
+
+```tsx
+// In your app entry file
+import '@juspay/blend-design-system/style.css'
+import { BlendProvider } from './blend/provider'
+```
+
+### Using Components
+
+```tsx
+import { ButtonV2, TextInputV2, CardV2 } from '@juspay/blend-design-system'
+```
+
+### Studio Commands
+
+| Command | Action |
+| ------------------------ | ---------------------------- |
+| `npx blend-studio init` | Create config + blend folder |
+| `npx blend-studio brand` | Apply default brand |
+| `npx blend-studio login` | Login to Studio |
+| `npx blend-studio pull` | Get latest designs |
+| `npx blend-studio push` | Send your designs |
+
+---
+
+## π Studio URLs
+
+| Environment | URL | Use When |
+| -------------- | ------------------------------------------------------- | --------- |
+| **Staging** | `https://blend-backend-staging-2oyuucbkoa-uc.a.run.app` | Testing |
+| **Production** | `https://blend.juspay.design` | Live apps |
+
+Update `blend.config.json` β `studio.apiUrl` to switch.
+
+---
+
+## π What Gets Added to Your Project
+
+```
+your-existing-project/
+βββ blend.config.json β NEW: Studio connection
+βββ src/
+β βββ blend/ β NEW: Auto-generated
+β βββ provider.tsx β Theme wrapper
+β βββ tokens.ts β Colors/fonts
+β βββ theme.css β Styles
+βββ package.json β MODIFIED: Added deps
+```
+
+---
+
+## π Troubleshooting
+
+### "Cannot find module '@juspay/blend-design-system'"
+
+```bash
+npm install
+# Ensure you're in a workspace if using monorepo
+```
+
+### Styles not applied
+
+Check CSS import exists in your entry file:
+
+```tsx
+import '@juspay/blend-design-system/style.css'
+```
+
+### TypeScript errors
+
+Add to `tsconfig.json`:
+
+```json
+{
+ "compilerOptions": {
+ "moduleResolution": "bundler"
+ }
+}
+```
+
+### Blend folder not found
+
+Run init again:
+
+```bash
+npx blend-studio init
+```
+
+---
+
+## β Checklist
+
+- [ ] Installed packages (`npm i @juspay/blend-design-system`)
+- [ ] Added CSS import
+- [ ] Ran `npx blend-studio init` (creates `blend.config.json`)
+- [ ] Ran `npx blend-studio brand`
+- [ ] Wrapped app with ``
+- [ ] Used a component (``)
+- [ ] Can login to Studio
+- [ ] Can pull tokens
+
+---
+
+## π― Example: Adding to Existing Vite App
+
+```bash
+# 1. Go to your project
+cd my-existing-vite-app
+
+# 2. Install
+npm i @juspay/blend-design-system blend-studio
+
+# 3. Add CSS import to src/main.tsx
+import '@juspay/blend-design-system/style.css'
+
+# 4. Initialize (creates blend.config.json automatically)
+npx blend-studio init
+npx blend-studio brand
+
+# 5. Wrap your app with BlendProvider
+
+# 6. Use components!
+```
+
+Done! π
diff --git a/BLEND_TOKEN_STUDIO.md b/BLEND_TOKEN_STUDIO.md
new file mode 100644
index 000000000..4b64e76b8
--- /dev/null
+++ b/BLEND_TOKEN_STUDIO.md
@@ -0,0 +1,475 @@
+# Blend Token Studio β API & CLI Reference
+
+> **For infrastructure deployment (GCP/Firebase/NPM), see [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)**
+
+This file covers: Token Engine API, CLI commands, and local development.
+
+---
+
+## Two Components
+
+| Component | Package | Purpose |
+| ---------- | -------------- | ----------------------------- |
+| **CLI** | `blend-studio` | Developer commands for tokens |
+| **Studio** | `blend-studio` | Visual web editor |
+
+### How They Work Together
+
+```
+βββββββββββββββββββ βββββββββββββββββββββββββββββββββββββββ βββββββββββββββββββ
+β Studio Web ββββββΆβ Token Engine (@juspay/blend- ββββββΆβ Your React App β
+β (Edit Colors) β β design-system/tokens) β β (Use Tokens) β
+βββββββββββββββββββ βββββββββββββββββββββββββββββββββββββββ βββββββββββββββββββ
+ β β² β²
+ β β β
+ βΌ β β
+ brand.json ββββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββ
+ (download/pull) CLI
+```
+
+---
+
+## Quick Start (Local Development)
+
+### Prerequisites
+
+- Node.js 18+
+- pnpm 10+ (`npm install -g pnpm`)
+
+### Run in 4 Commands
+
+```bash
+# 1. Install
+pnpm install
+
+# 2. Build packages
+cd packages/token-engine && pnpm build && cd ../cli && pnpm build && cd ../..
+
+# 3. Start Studio
+cd apps/blend-studio && pnpm dev
+
+# 4. Open in browser
+# http://localhost:3000/studio/test
+```
+
+> **Note**: The test page at `/studio/test` is always accessible without authentication.
+
+---
+
+## Token Engine API
+
+### Import
+
+```typescript
+// Main import (includes React components)
+import { resolveBrandTokens } from '@juspay/blend-design-system/tokens'
+
+// Server-safe import (no React components)
+import { resolveBrandTokens } from '@juspay/blend-design-system/tokens/server'
+```
+
+### Functions
+
+#### `resolveBrandTokens(brandConfig, theme)`
+
+Resolves a brand configuration into component tokens.
+
+```typescript
+import {
+ resolveBrandTokens,
+ type BrandConfig,
+} from '@juspay/blend-design-system/tokens'
+
+const brand: BrandConfig = {
+ brandId: 'my-brand',
+ name: 'My Brand',
+ version: '1.0.0',
+ colors: {
+ primary: { '500': '#E31837' },
+ gray: { '100': '#F3F4F6' },
+ },
+ radius: {
+ '8': '8px',
+ '10': '10px',
+ },
+}
+
+const lightTokens = resolveBrandTokens(brand, 'light')
+const darkTokens = resolveBrandTokens(brand, 'dark')
+
+// Returns tokens for 26+ components
+console.log(Object.keys(lightTokens))
+// ['BUTTONV2', 'ALERTV2', 'TEXTINPUTV2', ...]
+```
+
+#### `validateBrandConfig(brandConfig)`
+
+Validates a brand configuration.
+
+```typescript
+import { validateBrandConfig } from '@juspay/blend-design-system/tokens'
+
+const result = validateBrandConfig(brand)
+
+if (result.valid) {
+ console.log('Valid!')
+} else {
+ result.errors.forEach((err) => {
+ console.log(`${err.path}: ${err.message}`)
+ })
+ result.warnings.forEach((warn) => {
+ console.log(`Warning: ${warn.path}: ${warn.message}`)
+ })
+}
+```
+
+#### `generateColorScale(hex)`
+
+Generates a full color scale (50-950) from a single hex color.
+
+```typescript
+import { generateColorScale } from '@juspay/blend-design-system/tokens'
+
+const scale = generateColorScale('#E31837')
+// Returns: { '50': '#FFF1F3', '100': '#FFE0E4', ..., '500': '#E31837', ..., '950': '#4C050D' }
+```
+
+#### `diffBrandConfigs(oldConfig, newConfig)`
+
+Compares two brand configurations.
+
+```typescript
+import { diffBrandConfigs } from '@juspay/blend-design-system/tokens'
+
+const diff = diffBrandConfigs(oldBrand, newBrand)
+// Returns array of changes:
+// [{ path: 'colors.primary.500', oldValue: '#E31837', newValue: '#FF0000' }, ...]
+```
+
+### Types
+
+```typescript
+interface BrandConfig {
+ brandId: string
+ name: string
+ version: string
+ colors?: BrandColors
+ radius?: RadiusOverrides
+ shadows?: ShadowOverrides
+ font?: FontOverrides
+}
+
+interface BrandColors {
+ primary?: ColorOverrides
+ gray?: ColorOverrides
+ red?: ColorOverrides
+ green?: ColorOverrides
+ yellow?: ColorOverrides
+ orange?: ColorOverrides
+ purple?: ColorOverrides
+}
+
+type ColorOverrides = Partial>
+type RadiusOverrides = Partial>
+
+interface ValidationResult {
+ valid: boolean
+ errors: ValidationError[]
+ warnings: ValidationWarning[]
+}
+```
+
+---
+
+## CLI Reference
+
+### Installation
+
+```bash
+# Global install
+npm install -g blend-studio
+
+# Or use npx (no install needed)
+npx blend-studio
+```
+
+### Commands
+
+#### `init` β Initialize Project
+
+```bash
+blend-studio init [options]
+
+Options:
+ --defaults Skip prompts, use defaults
+ --force Overwrite existing files
+ --template Template: nextjs, vite, cra
+
+Examples:
+ blend-studio init
+ blend-studio init --defaults --template nextjs
+```
+
+Creates:
+
+- `blend.config.json` β Configuration
+- `src/blend/provider.tsx` β React provider
+- `src/blend/tokens.ts` β Token exports
+
+#### `brand` β Apply Branding
+
+```bash
+blend-studio brand [options]
+
+Options:
+ --preset Preset: blend, hdfc, neobank, fintech
+ --primary Primary color (#E31837)
+ --secondary Secondary color
+ --radius Border radius: sharp, soft, round, pill
+
+Examples:
+ blend-studio brand --preset hdfc
+ blend-studio brand --primary "#E31837" --radius soft
+```
+
+#### `pull` β Pull from Studio
+
+```bash
+blend-studio pull [options]
+
+Options:
+ --version Pull specific version
+ --theme Theme: light, dark
+
+Examples:
+ blend-studio pull hdfc/retail
+ blend-studio pull hdfc/retail --version 1.2.0
+```
+
+#### `push` β Push to Studio
+
+```bash
+blend-studio push [branch] [options]
+
+Options:
+ --new Create branch if not exists
+ --publish Publish as version
+ --minor B minor version
+ --major Bump major version
+ --patch Bump patch version
+
+Examples:
+ blend-studio push hdfc/retail
+ blend-studio push mycompany/brand --new
+ blend-studio push hdfc/retail --publish --minor
+```
+
+#### `list` β List Branches
+
+```bash
+blend-studio list [options]
+
+Options:
+ --status Filter: draft, published, archived
+ --search Search query
+ --json JSON output
+ --limit Max results
+```
+
+#### `login` / `logout` β Authentication
+
+```bash
+# Interactive login
+blend-studio login
+
+# With token (for CI/CD)
+blend-studio login --token "your-firebase-token"
+
+# Check current user
+blend-studio whoami
+
+# Logout
+blend-studio logout
+```
+
+#### `validate` β Validate Config
+
+```bash
+blend-studio validate [file]
+
+Examples:
+ blend-studio validate
+ blend-studio validate ./configs/brand.json
+```
+
+#### `generate` β Generate Tokens Offline
+
+```bash
+blend-studio generate [options]
+
+Options:
+ --output Output directory
+ --theme Theme: light, dark, both
+ --format Format: ts, js, json
+
+Examples:
+ blend-studio generate ./brand.json
+ blend-studio generate ./brand.json --theme both
+```
+
+#### `preview` β Open Browser Preview
+
+```bash
+blend-studio preview [options]
+
+Options:
+ --port Dev server port (default: 3000)
+```
+
+---
+
+## Testing Locally
+
+### Test Studio (Without Firebase)
+
+```bash
+cd apps/blend-studio
+pnpm dev
+# Open: http://localhost:3000/studio/test
+```
+
+You can edit primary color, border radius, see live preview, and export brand.json.
+
+### Test Studio (With Firebase)
+
+1. Create `.env` with Firebase credentials (see [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md))
+2. Restart: `pnpm dev`
+3. Open: `http://localhost:3000/`
+4. Login with Google
+
+### Test CLI
+
+```bash
+cd packages/cli && pnpm build && pnpm link --global
+
+mkdir /tmp/test-blend && cd /tmp/test-blend
+blend-studio init --defaults
+blend-studio brand --preset hdfc
+cat src/blend/brand.json
+```
+
+### Test Token Engine
+
+```bash
+cd packages/blend && pnpm build
+
+node -e "
+const { resolveBrandTokens } = require('@juspay/blend-design-system/tokens');
+const tokens = resolveBrandTokens({
+ brandId: 'test', name: 'Test', version: '1.0.0',
+ colors: { primary: { '500': '#E31837' } }
+}, 'light');
+console.log('Components:', Object.keys(tokens).length);
+"
+```
+
+---
+
+## Data Architecture
+
+### PostgreSQL vs Firestore Split
+
+| Data Type | Storage | Why |
+| ------------------------- | ------------------- | ------------------------------- |
+| Users, Teams, Memberships | PostgreSQL | Relational data, fuzzy search |
+| Roles, Permissions | PostgreSQL | Complex queries, joins |
+| Authentication | PostgreSQL+Firebase | Firebase Auth β PostgreSQL sync |
+| Brand configs (JSON) | Firestore | Document storage, real-time |
+| Branches | Firestore | JSON blobs, live preview |
+| Versions | Firestore | Immutable snapshots |
+| Snapshots | Firestore | Auto-saved drafts |
+
+### Team Roles & Permissions
+
+| Role | Manage Team | Invite Members | Create Branches | Edit Branches | Publish | Delete Branches |
+| ---------- | ----------- | -------------- | --------------- | ------------- | ------- | --------------- |
+| **Owner** | β | β | β | β | β | β |
+| **Admin** | β | β | β | β | β | β |
+| **Editor** | β | β | β | β | β | β |
+| **Viewer** | β | β | β | β | β | β |
+
+### User Preferences (localStorage)
+
+```typescript
+// Key: 'blend_studio_preferences'
+interface UserPreferences {
+ theme: 'light' | 'dark' | 'system'
+ defaultTheme: 'light' | 'dark'
+ emailNotifications: boolean
+ branchCreatedNotifications: boolean
+ branchPublishedNotifications: boolean
+ teamInviteNotifications: boolean
+}
+
+// Key: 'blend_studio_onboarding'
+interface OnboardingState {
+ hasCompletedOnboarding: boolean
+ completedAt: string | null
+ skippedAt: string | null
+}
+```
+
+---
+
+## Troubleshooting
+
+### "Nothing found" at /test
+
+```
+β http://localhost:3000/studio/test
+β http://localhost:3000/test
+```
+
+### Module not found @juspay/blend-design-system/tokens
+
+```bash
+cd packages/blend && pnpm build
+```
+
+### CLI command not found
+
+```bash
+cd packages/cli && pnpm build && pnpm link --global
+```
+
+### Build TypeScript Errors
+
+```bash
+rm -rf node_modules dist && pnpm install && pnpm build
+```
+
+### Debug Mode
+
+```bash
+DEBUG=* blend-studio init
+firebase deploy --debug
+```
+
+---
+
+## Quick Reference
+
+```bash
+# === LOCAL DEVELOPMENT ===
+pnpm install # Install everything
+cd packages/blend && pnpm build # Build blend (includes tokens)
+cd packages/cli && pnpm build # Build CLI
+cd apps/blend-studio && pnpm dev # Start Studio
+
+# === TESTING ===
+http://localhost:3000/studio/test # Open Studio
+blend-studio init --defaults # Test CLI
+
+# === FOR DEPLOYMENT ===
+See DEPLOYMENT_GUIDE.md
+```
diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md
new file mode 100644
index 000000000..d40d2eea5
--- /dev/null
+++ b/DEPLOYMENT_GUIDE.md
@@ -0,0 +1,782 @@
+# Blend Design System β Complete Creator's Guide
+
+**Author:** Creator of Blend Design System
+**Purpose:** Full infrastructure deployment + NPM publishing guide
+**Last Updated:** April 21, 2026
+
+---
+
+## Table of Contents
+
+1. [Architecture Overview](#architecture-overview)
+2. [Prerequisites](#prerequisites)
+3. [Phase 1: Firebase Setup](#phase-1-firebase-setup)
+4. [Phase 2: GCP Cloud SQL (PostgreSQL)](#phase-2-gcp-cloud-sql-postgresql)
+5. [Phase 3: Backend Deployment (Cloud Run)](#phase-3-backend-deployment-cloud-run)
+6. [Phase 4: Frontend Deployment (Firebase Hosting)](#phase-4-frontend-deployment-firebase-hosting)
+7. [Phase 5: NPM Publishing](#phase-5-npm-publishing)
+8. [Configuration Files](#configuration-files)
+9. [Database Operations](#database-operations)
+10. [Troubleshooting](#troubleshooting)
+11. [Quick Reference](#quick-reference)
+
+---
+
+## Architecture Overview
+
+```
+Internet
+ β
+ ββββββββββββββββββββββββββββββββββββββββββ
+ β β
+ βΌ βΌ
+ββββββββββββββββββββ ββββββββββββββββββββββββ
+β Firebase Hosting β β Cloud Run β
+β (Studio Frontend)β β (Backend API) β
+β β β β
+β /studio/* β SPA β β /api/* endpoints β
+β /api/* β rewrite βββββββββββββββββββ Express + Prisma β
+β to Cloud Run β ββββββββββββ¬ββββββββββββ
+ββββββββββββββββββββ β
+ β β
+ β βββββββββββββββββββββββββββββββββββ β
+ β β Secret Manager β β
+ β β β’ DB password β β
+ β β β’ JWT secrets β β
+ β β β’ Firebase private key β β
+ β βββββββββββββββββββββββββββββββββββ β
+ β βΌ
+ β ββββββββββββββββββββββββ
+ β β Cloud SQL β
+ β β (PostgreSQL 16) β
+ β β β
+ β β β’ Users, Teams β
+ β β β’ Branches, Versions β
+ β β β’ Audit Logs β
+ β ββββββββββββββββββββββββ
+ β
+ βΌ
+ββββββββββββββββββββ
+β Firebase β
+β β’ Auth (Google) β
+β β’ Firestore β
+β (brand JSON) β
+ββββββββββββββββββββ
+```
+
+**Data Split:**
+
+- **PostgreSQL:** Users, teams, roles, relational data, audit logs
+- **Firestore:** Brand configs (JSON blobs), snapshots, versions
+- **Secret Manager:** All secrets (DB passwords, JWT, Firebase keys)
+
+---
+
+## Prerequisites
+
+- Node.js 18+, pnpm 10+
+- gcloud CLI installed and authenticated (`gcloud auth login`)
+- Firebase CLI: `npm install -g firebase-tools`
+- npm account with @juspay scope access
+- A GCP project with billing enabled
+
+---
+
+## Phase 1: Firebase Setup
+
+### 1.1 Create Firebase Project
+
+1. Go to [Firebase Console](https://console.firebase.google.com)
+2. Click **Create a project**
+3. Name: `blend-studio-prod` (or your choice)
+4. Enable Google Analytics (optional)
+5. Note the **Project ID** (e.g., `blend-studio-prod`)
+
+### 1.2 Enable Authentication
+
+1. Go to **Authentication** β **Sign-in method**
+2. Click **Google**
+3. Toggle **Enable**
+4. Add authorized domains:
+ - `localhost:3000` (dev)
+ - `localhost:5173` (dev)
+ - `studio.blend.juspay.design` (production)
+ - `blend-studio-prod.web.app` (Firebase default)
+5. Click **Save**
+
+### 1.3 Configure Google OAuth (Cloud Console)
+
+1. Go to [Google Cloud Console β APIs & Services β Credentials](https://console.cloud.google.com/apis/credentials)
+2. Find your OAuth 2.0 Client ID (auto-created by Firebase)
+3. Add authorized origins:
+ ```
+ http://localhost:3000
+ http://localhost:5173
+ https://studio.blend.juspay.design
+ https://blend-studio-prod.web.app
+ ```
+4. Add redirect URIs:
+ ```
+ http://localhost:3000/__/auth/handler
+ https://studio.blend.juspay.design/__/auth/handler
+ https://blend-studio-prod.web.app/__/auth/handler
+ ```
+
+### 1.4 Enable Firestore
+
+1. Go to **Firestore Database**
+2. Click **Create database**
+3. Select **Start in production mode**
+4. Choose location: `us-central1`
+5. Deploy rules:
+ ```bash
+ firebase deploy --only firestore:rules
+ ```
+
+### 1.5 Create Web App & Get Config
+
+1. Go to **Project Settings** (gear icon)
+2. Scroll to **Your apps**
+3. Click **Web** (`>`)
+4. Nickname: `blend-studio-web`
+5. Click **Register**
+6. **Copy the firebaseConfig** β you'll need this for environment variables
+
+### 1.6 Create Service Account
+
+1. Go to **Project Settings** β **Service accounts**
+2. Click **Generate new private key**
+3. Save JSON file securely (never commit!)
+4. You'll need: `project_id`, `client_email`, `private_key`
+
+---
+
+## Phase 2: GCP Cloud SQL (PostgreSQL)
+
+### 2.1 Enable APIs
+
+```bash
+gcloud services enable \
+ run.googleapis.com \
+ sqladmin.googleapis.com \
+ secretmanager.googleapis.com \
+ cloudbuild.googleapis.com \
+ artifactregistry.googleapis.com \
+ iam.googleapis.com
+```
+
+### 2.2 Create Cloud SQL Instance
+
+```bash
+# Create instance
+gcloud sql instances create blend-db \
+ --database-version=POSTGRES_16 \
+ --tier=db-f1-micro \
+ --region=us-central1 \
+ --storage-auto-increase
+
+# Set admin password
+gcloud sql users set-password admin \
+ --instance=blend-db \
+ --password="YOUR_STRONG_PASSWORD_HERE"
+
+# Create database
+gcloud sql databases create blend_studio \
+ --instance=blend-db
+
+# Get connection name
+gcloud sql instances describe blend-db --format="value(connectionName)"
+# Output: YOUR_PROJECT:us-central1:blend-db
+```
+
+### 2.3 Create Secrets in Secret Manager
+
+```bash
+# Database password
+echo -n "YOUR_STRONG_PASSWORD_HERE" | \
+ gcloud secrets create blend-backend-db-password --data-file=-
+
+# JWT secret
+openssl rand -base64 48 | \
+ gcloud secrets create blend-backend-jwt-secret --data-file=-
+
+# JWT refresh secret
+openssl rand -base64 48 | \
+ gcloud secrets create blend-backend-jwt-refresh-secret --data-file=-
+
+# Firebase private key (paste the actual key content)
+echo -n "-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQE...
+-----END PRIVATE KEY-----" | \
+ gcloud secrets create blend-backend-firebase-key --data-file=-
+```
+
+### 2.4 Create Service Account for Backend
+
+```bash
+# Create service account
+gcloud iam service-accounts create blend-backend-sa \
+ --display-name="Blend Backend Service Account"
+
+# Grant Cloud SQL access
+gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
+ --member="serviceAccount:blend-backend-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
+ --role="roles/cloudsql.client"
+
+# Grant Secret Manager access
+gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
+ --member="serviceAccount:blend-backend-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
+ --role="roles/secretmanager.secretAccessor"
+```
+
+---
+
+## Phase 3: Backend Deployment (Cloud Run)
+
+### 3.1 Create Environment File
+
+Create `apps/backend/.env.production`:
+
+```env
+NODE_ENV=production
+PORT=3001
+
+# Cloud SQL
+INSTANCE_CONNECTION_NAME=YOUR_PROJECT:us-central1:blend-db
+DATABASE_NAME=blend_studio
+DATABASE_USER=admin
+# DATABASE_PASSWORD comes from Secret Manager
+
+# JWT
+# JWT_SECRET and JWT_REFRESH_SECRET come from Secret Manager
+
+# Google OAuth
+GOOGLE_CLIENT_ID=your_client_id_from_cloud_console
+GOOGLE_CLIENT_SECRET=your_client_secret
+
+# Frontend URL for CORS
+FRONTEND_URL=https://studio.blend.juspay.design
+
+# Firebase Admin
+FIREBASE_PROJECT_ID=YOUR_PROJECT_ID
+FIREBASE_CLIENT_EMAIL=firebase-adminsdk-xxxxx@YOUR_PROJECT_ID.iam.gserviceaccount.com
+# FIREBASE_PRIVATE_KEY comes from Secret Manager
+```
+
+### 3.2 Build and Deploy Backend
+
+```bash
+# Build and push container
+gcloud builds submit --tag gcr.io/YOUR_PROJECT_ID/blend-backend
+
+# Deploy to Cloud Run
+gcloud run deploy blend-backend \
+ --image gcr.io/YOUR_PROJECT_ID/blend-backend:latest \
+ --region us-central1 \
+ --platform managed \
+ --no-allow-unauthenticated \
+ --add-cloudsql-instances YOUR_PROJECT:us-central1:blend-db \
+ --set-env-vars "NODE_ENV=production,PORT=3001,INSTANCE_CONNECTION_NAME=YOUR_PROJECT:us-central1:blend-db,DATABASE_NAME=blend_studio,DATABASE_USER=admin,FRONTEND_URL=https://studio.blend.juspay.design,FIREBASE_PROJECT_ID=YOUR_PROJECT_ID,FIREBASE_CLIENT_EMAIL=firebase-adminsdk-xxxxx@YOUR_PROJECT_ID.iam.gserviceaccount.com,GOOGLE_CLIENT_ID=your_client_id,GOOGLE_CLIENT_SECRET=your_client_secret" \
+ --set-secrets "DATABASE_PASSWORD=blend-backend-db-password:latest,JWT_SECRET=blend-backend-jwt-secret:latest,JWT_REFRESH_SECRET=blend-backend-jwt-refresh-secret:latest,FIREBASE_PRIVATE_KEY=blend-backend-firebase-key:latest" \
+ --memory 512Mi \
+ --cpu 1 \
+ --min-instances 0 \
+ --max-instances 10 \
+ --service-account blend-backend-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com
+
+# Get the deployed URL
+gcloud run services describe blend-backend \
+ --region us-central1 \
+ --format="value(status.url)"
+# Output: https://blend-backend-xxxxx-uc.a.run.app
+```
+
+### 3.3 Run Database Migrations
+
+**Option A: Via Cloud Run Job**
+
+```bash
+# Temporary: Run migration as startup command
+gcloud run services update blend-backend \
+ --region us-central1 \
+ --command "sh" \
+ --args "-c,npx prisma migrate deploy && node dist/server.js"
+```
+
+**Option B: Via Cloud SQL Proxy (Local)**
+
+```bash
+# Download proxy
+curl -o cloud_sql_proxy https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2/cloud-sql-proxy.linux.amd64
+chmod +x cloud_sql_proxy
+
+# Start proxy
+./cloud_sql_proxy YOUR_PROJECT:us-central1:blend-db --port 5433 &
+
+# Run migrations
+cd apps/backend
+DATABASE_URL="postgresql://admin:YOUR_PASSWORD@localhost:5433/blend_studio" \
+ npx prisma migrate deploy
+```
+
+### 3.4 Verify Backend Health
+
+```bash
+# Test health endpoint
+curl https://blend-backend-xxxxx-uc.a.run.app/health
+
+# Expected: {"status":"ok","timestamp":"...","version":"0.1.0"}
+```
+
+---
+
+## Phase 4: Frontend Deployment (Firebase Hosting)
+
+### 4.1 Create Environment File
+
+Create `apps/blend-studio/.env.production`:
+
+```env
+# Firebase Client Config
+VITE_FIREBASE_API_KEY=AIzaSyxxxxxxxxxxxxxxxxxxx
+VITE_FIREBASE_AUTH_DOMAIN=blend-studio-prod.firebaseapp.com
+VITE_FIREBASE_PROJECT_ID=blend-studio-prod
+VITE_FIREBASE_STORAGE_BUCKET=blend-studio-prod.appspot.com
+VITE_FIREBASE_MESSAGING_SENDER_ID=123456789012
+VITE_FIREBASE_APP_ID=1:123456789012:web:xxxxxxxxxxxx
+
+# API Base URL
+# Leave empty when using Firebase Hosting rewrites (recommended)
+VITE_API_BASE_URL=
+
+# Disable mock data
+VITE_USE_MOCK_DATA=false
+```
+
+### 4.2 Configure Firebase Hosting
+
+Create/update `firebase.json` in project root:
+
+```json
+{
+ "hosting": {
+ "public": "apps/blend-studio/dist",
+ "ignore": ["firebase.json", "**/node_modules/**"],
+ "rewrites": [
+ {
+ "source": "/api/**",
+ "run": {
+ "serviceId": "blend-backend",
+ "region": "us-central1"
+ }
+ },
+ {
+ "source": "/studio/**",
+ "destination": "/studio/index.html"
+ }
+ ],
+ "headers": [
+ {
+ "source": "/studio/**/*.{js,css,svg,png,jpg,woff2}",
+ "headers": [
+ {
+ "key": "Cache-Control",
+ "value": "public, max-age=31536000, immutable"
+ }
+ ]
+ },
+ {
+ "source": "/studio/**/*.html",
+ "headers": [
+ {
+ "key": "Cache-Control",
+ "value": "no-cache"
+ }
+ ]
+ }
+ ]
+ }
+}
+```
+
+Create `.firebaserc`:
+
+```json
+{
+ "projects": {
+ "production": "YOUR_PROJECT_ID",
+ "staging": "YOUR_STAGING_PROJECT_ID"
+ }
+}
+```
+
+### 4.3 Build Frontend
+
+```bash
+cd apps/blend-studio
+
+# Install dependencies
+pnpm install
+
+# Build for production
+pnpm build
+
+# Verify dist folder
+ls -la dist/
+```
+
+### 4.4 Deploy to Firebase Hosting
+
+```bash
+# Deploy to production
+firebase deploy --only hosting --project production
+
+# Or deploy to staging
+firebase deploy --only hosting --project staging
+
+# Your app will be at:
+# https://YOUR_PROJECT_ID.web.app
+# https://YOUR_PROJECT_ID.firebaseapp.com
+```
+
+### 4.5 Configure Custom Domain
+
+1. Go to [Firebase Console β Hosting](https://console.firebase.google.com/project/_/hosting)
+2. Click **Add custom domain**
+3. Enter: `studio.blend.juspay.design`
+4. Follow DNS verification steps
+5. Wait for SSL provisioning (< 1 hour)
+
+---
+
+## Phase 5: NPM Publishing
+
+### 5.1 Prerequisites
+
+```bash
+# Login to npm
+npm login
+
+# Verify access
+npm whoami
+npm access list packages @juspay
+```
+
+### 5.2 Publish CLI
+
+```bash
+# Build CLI
+cd packages/cli
+pnpm build
+
+# Verify binary
+node dist/index.js --help
+
+# Publish
+npm publish --access public
+
+# Verify
+npm view blend-studio
+
+# Test global install
+npm install -g blend-studio
+blend-studio --version
+```
+
+**Note**: The CLI depends on `@juspay/blend-design-system` which already includes the token engine at `@juspay/blend-design-system/tokens`.
+
+### 5.5 Publish Blend Components
+
+```bash
+# Version bump (root)
+pnpm version:blend patch # or minor/major
+
+# Build
+pnpm -w run build:blend
+
+# Dry run
+pnpm -w run publish:blend:dry
+
+# Publish
+pnpm -w run publish:blend
+
+# Or manually:
+cd packages/blend
+pnpm publish --access public
+```
+
+---
+
+## Configuration Files
+
+### apps/blend-studio/vite.config.ts
+
+```typescript
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'path'
+
+export default defineConfig(({ mode }) => ({
+ plugins: [react()],
+ base: mode === 'production' ? '/studio/' : '/',
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ build: {
+ outDir: 'dist',
+ rollupOptions: {
+ output: {
+ manualChunks: {
+ vendor: ['react', 'react-dom'],
+ },
+ },
+ },
+ },
+}))
+```
+
+### apps/blend-studio/database/SCHEMA.md
+
+See `apps/blend-studio/database/SCHEMA.md` for complete database schema documentation.
+
+### firestore.rules
+
+```
+rules_version = '2';
+service cloud.firestore {
+ match /databases/{database}/documents {
+ // Allow authenticated users to read branches
+ match /branches/{branchId} {
+ allow read: if request.auth != null;
+ allow write: if request.auth != null &&
+ resource.data.owner == request.auth.uid;
+ }
+
+ // Versions are immutable
+ match /branches/{branchId}/versions/{versionId} {
+ allow read: if request.auth != null;
+ allow write: if false; // Immutable after creation
+ }
+ }
+}
+```
+
+---
+
+## Database Operations
+
+### Local Development Database
+
+```bash
+# Create local database
+createdb blend_studio
+
+# Set environment
+export DATABASE_URL=postgresql://postgres:password@localhost:5432/blend_studio
+
+# Run migrations
+cd apps/blend-studio
+pnpm db:generate
+pnpm db:migrate
+
+# Seed database
+pnpm db:seed
+```
+
+### Production Migrations
+
+```bash
+# Via Cloud SQL Proxy
+./cloud_sql_proxy YOUR_PROJECT:us-central1:blend-db --port 5433 &
+cd apps/backend
+DATABASE_URL="postgresql://admin:PASSWORD@localhost:5433/blend_studio" \
+ npx prisma migrate deploy
+```
+
+### Backup Database
+
+```bash
+# Export
+gcloud sql export sql blend-db gs://YOUR_BUCKET/backup-$(date +%Y%m%d).sql
+
+# Import
+gcloud sql import sql blend-db gs://YOUR_BUCKET/backup-20240421.sql
+```
+
+---
+
+## Troubleshooting
+
+### Firebase "Permission Denied"
+
+```bash
+# Cause: Firestore rules not deployed
+firebase deploy --only firestore:rules
+```
+
+### Cloud Run CORS Error
+
+```bash
+# Cause: FRONTEND_URL mismatch
+# Fix: Use Firebase Hosting rewrites instead of CORS
+# Or update FRONTEND_URL
+gcloud run services update blend-backend \
+ --region us-central1 \
+ --set-env-vars "FRONTEND_URL=https://studio.blend.juspay.design"
+```
+
+### Cloud SQL Connection Refused
+
+```bash
+# Verify instance exists
+gcloud sql instances describe blend-db
+
+# Check service account permissions
+gcloud projects get-iam-policy YOUR_PROJECT_ID \
+ --flatten="bindings[].members" \
+ --format='table(bindings.role)' \
+ --filter="bindings.members:blend-backend-sa"
+
+# Verify Cloud SQL instance attached to Cloud Run
+gcloud run services describe blend-backend --region us-central1
+```
+
+### Prisma Migration Fails
+
+```bash
+# Reset migrations (dev only)
+npx prisma migrate reset
+
+# Or mark as applied
+npx prisma migrate resolve --applied 20240101_migration_name
+```
+
+### NPM Publishing 403 Error
+
+```bash
+# Check if logged in
+npm whoami
+
+# Check permissions
+npm access list packages @juspay
+
+# May need organization admin to add you
+```
+
+### Build Failures
+
+```bash
+# Clean and rebuild
+rm -rf node_modules dist
+pnpm install
+pnpm build
+```
+
+---
+
+## Quick Reference
+
+### Environment Variables Summary
+
+#### Client-side (VITE\_\* prefix)
+
+| Variable | Required | Description |
+| ----------------------------------- | -------- | ----------------------------------------- |
+| `VITE_FIREBASE_API_KEY` | Yes | Firebase public API key |
+| `VITE_FIREBASE_AUTH_DOMAIN` | Yes | e.g., `project.firebaseapp.com` |
+| `VITE_FIREBASE_PROJECT_ID` | Yes | Firebase project ID |
+| `VITE_FIREBASE_STORAGE_BUCKET` | No | Cloud Storage bucket |
+| `VITE_FIREBASE_MESSAGING_SENDER_ID` | No | Push notifications |
+| `VITE_FIREBASE_APP_ID` | Yes | Firebase web app ID |
+| `VITE_API_BASE_URL` | No | Backend URL (empty for Firebase rewrites) |
+| `VITE_USE_MOCK_DATA` | No | Set `true` to skip Firebase |
+
+#### Server-side
+
+| Variable | Required | Description |
+| -------------------------- | -------- | ------------------------- |
+| `INSTANCE_CONNECTION_NAME` | Yes | Cloud SQL connection name |
+| `DATABASE_NAME` | Yes | Database name |
+| `DATABASE_USER` | Yes | Database username |
+| `DATABASE_PASSWORD` | Yes | From Secret Manager |
+| `JWT_SECRET` | Yes | From Secret Manager |
+| `JWT_REFRESH_SECRET` | Yes | From Secret Manager |
+| `GOOGLE_CLIENT_ID` | Yes | OAuth client ID |
+| `GOOGLE_CLIENT_SECRET` | Yes | OAuth client secret |
+| `FRONTEND_URL` | Yes | Allowed CORS origin |
+| `FIREBASE_PROJECT_ID` | Yes | Firebase project ID |
+| `FIREBASE_CLIENT_EMAIL` | Yes | Service account email |
+| `FIREBASE_PRIVATE_KEY` | Yes | From Secret Manager |
+
+### Common Commands
+
+```bash
+# === NPM ===
+cd packages/cli && npm publish --access public # Publish CLI
+pnpm -w run publish:blend # Publish components
+```
+
+---
+
+## Deployment Checklist
+
+### Pre-deployment
+
+- [ ] Firebase project created and configured
+- [ ] Google OAuth credentials set up
+- [ ] Firestore database enabled
+- [ ] Cloud SQL instance created
+- [ ] Secrets stored in Secret Manager
+- [ ] Service account created with proper IAM roles
+- [ ] Environment files created (.env.production)
+
+### Deployment
+
+- [ ] Backend built and deployed to Cloud Run
+- [ ] Database migrations run successfully
+- [ ] Backend health check passes
+- [ ] Frontend built successfully
+- [ ] Firebase hosting configured with rewrites
+- [ ] Frontend deployed to Firebase Hosting
+- [ ] Custom domain configured (if applicable)
+
+### Post-deployment
+
+- [ ] Authentication works (Google Sign-in)
+- [ ] Brand creation works
+- [ ] Token generation works
+- [ ] Database connections stable
+- [ ] Monitoring/alerting configured (optional)
+
+### NPM Publishing (if releasing new version)
+
+- [ ] CLI dependencies updated (depends on `@juspay/blend-design-system`)
+- [ ] CLI built and tested
+- [ ] CLI published to NPM
+- [ ] Components version bumped
+- [ ] Components built and published
+
+---
+
+## Next Steps
+
+1. **Monitor:** Set up Cloud Monitoring alerts for:
+ - High error rates in Cloud Run
+ - Database connection limits
+ - Firebase Auth anomalies
+
+2. **Scale:** Adjust Cloud Run min/max instances based on traffic
+
+3. **Backup:** Automate daily database backups via Cloud Scheduler
+
+4. **Security:** Rotate secrets every 90 days
+
+---
+
+**Questions?** Check the troubleshooting section or refer to:
+
+- `apps/blend-studio/database/SCHEMA.md` β Database schema
+- `packages/cli/README.md` β CLI documentation
+- `packages/blend/README.md` β Component library documentation
diff --git a/PUBLISHING.md b/PUBLISHING.md
index a190dfcdf..47de6a740 100644
--- a/PUBLISHING.md
+++ b/PUBLISHING.md
@@ -1,249 +1,171 @@
-# Publishing Guide for Blend Design System
+# Publishing Guide β CLI & MCP
-This guide explains how to publish the Blend Design System package to npm.
+> **For full infrastructure deployment (GCP/Firebase), see [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)**
-For package-specific flows:
+This file covers: Publishing `blend-studio` (CLI) and `blend-ui-mcp`.
-- `packages/mcp/PUBLISHING.md` for `blend-ui-mcp`
+> **Note:** `@juspay/blend-design-system` (component library) is already published. See its existing workflow for that.
-## Package Information
-
-- **Package Name**: `@juspay/blend-design-system`
-- **Current Version**: `0.2.43`
-- **Registry**: npm (public)
-- **Scope**: `@juspay`
+---
-## Prerequisites
+## Publishing Order
-1. **npm Account**: Ensure you have an npm account with publish permissions for the `@juspay` scope
-2. **Authentication**: Login to npm using `npm login`
-3. **Permissions**: You need to be added as a maintainer/owner of the `@juspay` organization
+```
+1. blend-studio (CLI tool)
+2. blend-ui-mcp (MCP package)
+```
-## Publishing Process
+---
-### 1. Prepare for Publishing
+## Prerequisites
```bash
-# Ensure you're on the main branch and up to date
-git checkout main
-git pull origin main
-
-# Install dependencies
-pnpm install
-
-# Run tests and linting
-pnpm lint
+npm login
+npm whoami
```
-### 2. Version Management
+You need publish permissions for:
-Update the version in `packages/blend/package.json` before publishing:
+- `@juspay` scope (blend-design-system)
+- Unscoped packages (CLI, MCP)
-```bash
-# From root directory - patch version (1.0.0 -> 1.0.1)
-pnpm version:blend patch
+---
+
+## 1. Publish CLI
-# Or minor version (1.0.0 -> 1.1.0)
-pnpm version:blend minor
+### Build
-# Or major version (1.0.0 -> 2.0.0)
-pnpm version:blend major
+```bash
+cd packages/cli
+pnpm build
-# Or specific version
-cd packages/blend && npm version 1.2.3
+# Verify binary works
+node dist/index.js --help
```
-### 3. Build the Package
+### Dry Run
```bash
-# Build from root directory (monorepo)
-pnpm -w run build:blend
+npm pack --dry-run
```
-This will:
-
-- Run ESLint checks
-- Compile TypeScript
-- Bundle with Vite
-- Generate type definitions
-
-### 4. Test the Build (Optional)
+### Publish
```bash
-# Dry run to see what would be published
-pnpm -w run publish:blend:dry
+npm publish --access public
```
-### 5. Publish to npm
+### Verify
```bash
-# Publish from root directory
-pnpm -w run publish:blend
+npm view blend-studio
+
+# Test global install
+npm install -g blend-studio
+blend-studio --version
```
-Or manually:
+### Version Bump
```bash
-cd packages/blend
-pnpm publish --access public
-```
+# Patch (0.1.0 β 0.1.1)
+npm version patch && npm publish
-**Note**: The `--access public` flag is required for scoped packages (packages with `@` prefix like `@juspay/blend-design-system`) to make them publicly accessible on npm.
+# Minor (0.1.0 β 0.2.0)
+npm version minor && npm publish
-### 6. Verify Publication
+# Major (0.1.0 β 1.0.0)
+npm version major && npm publish
+```
-1. Check the package on npm: https://www.npmjs.com/package/@juspay/blend-design-system
-2. Test installation in a new project:
+---
-```bash
-mkdir test-blend
-cd test-blend
-npm init -y
-npm install @juspay/blend-design-system
-```
+## 2. Publish MCP
-## Package Structure
+See `packages/mcp/PUBLISHING.md` for the full MCP runbook.
-The published package includes:
+Quick commands:
-```
-dist/
-βββ main.js # Main entry point
-βββ main.d.ts # TypeScript definitions
-βββ style.css # Bundled styles
-βββ assets/ # Additional assets
-README.md # Package documentation
+```bash
+cd packages/mcp
+npm version patch
+npm run build
+npm pack --dry-run
+npm publish
```
-## Version Strategy
-
-We follow [Semantic Versioning](https://semver.org/):
+---
-- **MAJOR** (X.0.0): Breaking changes
-- **MINOR** (1.X.0): New features, backward compatible
-- **PATCH** (1.0.X): Bug fixes, backward compatible
+## Automated Publishing via GitHub Actions
-## For Other Contributors
+The CLI has a GitHub Actions workflow at `.github/workflows/publish-cli.yml`.
-If you want to publish your own version or fork:
+### Setup
-### 1. Update Package Name
+1. Create an NPM token at [npmjs.com](https://www.npmjs.com) β Access Tokens β Generate New Token
+2. Add `NPM_TOKEN` secret to GitHub:
+ - Go to Settings β Secrets and variables β Actions
+ - Create an Environment called `npm`
+ - Add `NPM_TOKEN` secret
-Edit `packages/blend/package.json`:
+### Trigger
-```json
-{
- "name": "@juspay/blend-design-system",
- "version": "1.0.0",
- "repository": {
- "type": "git",
- "url": "https://github.com/your-username/your-repo.git"
- },
- "bugs": {
- "url": "https://github.com/your-username/your-repo/issues"
- },
- "homepage": "https://your-website.com/"
-}
-```
+1. Go to Actions β **Publish CLI (blend-studio)**
+2. Click **Run workflow**
+3. Select:
+ - **Version bump**: `patch`, `minor`, or `major`
+ - **Tag**: `latest` (stable) or `beta` (pre-release)
+ - **Confirm**: type `PUBLISH`
-### 2. Update README
+The workflow will:
-Update the installation instructions in `packages/blend/README.md`:
+1. Bump the CLI version
+2. Build the CLI
+3. Publish to NPM under the selected tag
+4. Commit the version bump back to the repo
-```bash
-npm install @juspay/blend-design-system
-```
-
-### 3. Update Import Examples
-
-```tsx
-import { Button } from '@juspay/blend-design-system'
-import '@juspay/blend-design-system/style.css'
-```
+---
## Troubleshooting
-### Authentication Issues
+### NPM 403 β Permission Denied
```bash
-# Login to npm
-npm login
-
-# Check if you're logged in
npm whoami
-
-# Check access to @juspay scope
npm access list packages @juspay
+# Contact org admin to add you as maintainer
```
-### Build Issues
+### NPM 404 β Package Not Found
+
+Package hasn't been published yet. Run `npm publish --access public`.
+
+### Build Fails
```bash
-# Clean and rebuild
-pnpm clean
+rm -rf node_modules dist
pnpm install
-pnpm build:blend
+pnpm build
```
-### Permission Issues
+### Token Engine Not Found in CLI
-If you get permission errors:
+The token engine is now part of `@juspay/blend-design-system`. Ensure CLI's `package.json` references `@juspay/blend-design-system` with the tokens submodule:
-1. Ensure you're a member of the `@juspay` organization
-2. Check if the package exists and you have publish rights
-3. Contact the organization admin to add you as a maintainer
-
-### Version Conflicts
-
-If the version already exists:
-
-```bash
-# Check current published version
-npm view @juspay/blend-design-system version
-
-# Update to a new version
-cd packages/blend && npm version patch
+```json
+{
+ "dependencies": {
+ "@juspay/blend-design-system": "^0.x.x"
+ }
+}
```
-## Automation (Future)
+Then run `pnpm install`.
-Consider setting up GitHub Actions for automated publishing:
-
-1. **On Release**: Automatically publish when a GitHub release is created
-2. **On Tag**: Publish when a version tag is pushed
-3. **Manual Trigger**: Workflow dispatch for manual publishing
+---
## Security
- Never commit npm tokens to the repository
- Use npm automation tokens for CI/CD
- Regularly audit dependencies: `npm audit`
-- Keep the package updated with security patches
-
-## Support
-
-For questions about publishing:
-
-1. Check npm documentation: https://docs.npmjs.com/
-2. Contact the Juspay development team
-3. Create an issue in the repository
-
----
-
-**Note**: Always test the package thoroughly before publishing to ensure it works correctly for end users.
-
-## Publishing `blend-ui-mcp` (MCP package)
-
-For the full MCP publishing runbook, see:
-
-- `packages/mcp/PUBLISHING.md`
-
-Quick commands:
-
-```bash
-cd packages/mcp
-npm version patch
-npm run build
-npm pack --dry-run
-npm publish
-```
diff --git a/SETUP.md b/SETUP.md
new file mode 100644
index 000000000..b41ab29fd
--- /dev/null
+++ b/SETUP.md
@@ -0,0 +1,457 @@
+# Blend Design System β Developer Setup Guide
+
+> **For infrastructure deployment (GCP/Firebase/NPM), see [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md)**
+> **For Token Engine API & CLI reference, see [BLEND_TOKEN_STUDIO.md](./BLEND_TOKEN_STUDIO.md)**
+
+This file covers: How to install and use Blend components in your project.
+
+---
+
+## Overview
+
+Blend Design System has two pieces:
+
+| Piece | Package | What it does |
+| --------------------- | ----------------------------- | ------------------------------------------------------------ |
+| **Component Library** | `@juspay/blend-design-system` | React components (Button, Input, Alert, etc.) + Token Engine |
+| **CLI** | `blend-studio` | Scaffolds projects, generates tokens, syncs with Studio |
+
+The flow:
+
+```
+Studio (web editor) β brand.json β CLI pull β tokens.ts β β Your App
+```
+
+You write ~20 lines of JSON (your brand colors, radius, font). The token engine expands it into ~10,000+ resolved token values that every Blend component consumes.
+
+---
+
+## Quick Start (5 minutes)
+
+```bash
+# 1. Install the component library
+npm install @juspay/blend-design-system styled-components
+
+# 2. Scaffold your project
+npx blend-studio init
+
+# 3. Apply a brand preset
+npx blend-studio brand --preset juspay
+
+# 4. Wrap your app
+# In your root layout:
+import { BlendProvider } from './src/blend/provider'
+
+```
+
+That's it. All Blend components now use your brand colors and radius.
+
+---
+
+## Installing the Component Library
+
+### Prerequisites
+
+- React 18+
+- styled-components 6+
+
+### Install
+
+```bash
+# With pnpm (recommended)
+pnpm add @juspay/blend-design-system styled-components
+
+# With npm
+npm install @juspay/blend-design-system styled-components
+
+# With yarn
+yarn add @juspay/blend-design-system styled-components
+```
+
+### Verify
+
+```tsx
+import { ButtonV2 } from '@juspay/blend-design-system'
+
+function App() {
+ return Click me
+}
+```
+
+If this renders, the component library is installed correctly.
+
+---
+
+## CLI Setup & Authentication
+
+### Install the CLI
+
+The CLI is used via `npx` β no global install needed:
+
+```bash
+npx blend-studio --version
+```
+
+### Login
+
+```bash
+# Interactive login (opens prompt for JWT token)
+npx blend-studio login
+
+# Or with a token directly
+npx blend-studio login --token
+
+# Or via environment variable (for CI)
+export BLEND_STUDIO_API_TOKEN=
+```
+
+### Get an API Token
+
+1. Open [Blend Token Studio](https://studio.blend.juspay.design)
+2. Sign in with Google
+3. Open the user menu (top right) β **API Token (for CLI)**
+4. Copy the token
+
+### Verify Authentication
+
+```bash
+npx blend-studio whoami
+# Output: Logged in as: you@company.com
+```
+
+### Logout
+
+```bash
+npx blend-studio logout
+```
+
+---
+
+## Scaffolding Your Project
+
+```bash
+npx blend-studio init
+```
+
+This command:
+
+1. **Detects** your project type (Next.js, Vite, CRA, or ReScript)
+2. **Installs** missing dependencies (`@juspay/blend-design-system`, `styled-components`)
+3. **Creates** `blend.config.json` with defaults
+4. **Generates** `src/blend/provider.tsx` β the wrapper component
+5. **Generates** `src/blend/tokens.ts` β default (Blend) tokens
+
+### Options
+
+| Flag | Description |
+| ------------ | -------------------------- |
+| `--defaults` | Skip prompts, use defaults |
+| `--force` | Overwrite existing files |
+
+### What gets created
+
+```
+your-project/
+βββ blend.config.json # Project config (brand, output dir, studio URL)
+βββ src/blend/
+ βββ provider.tsx # wrapper β safe to edit
+ βββ tokens.ts # Resolved tokens β auto-generated, don't edit
+```
+
+### Use the provider
+
+```tsx
+// app/layout.tsx (Next.js) or src/main.tsx (Vite)
+import { BlendProvider } from './src/blend/provider'
+
+export default function RootLayout({ children }) {
+ return {children}
+}
+```
+
+---
+
+## Applying a Brand
+
+### Interactive
+
+```bash
+npx blend-studio brand
+```
+
+Walks you through picking a primary color, radius style, etc.
+
+### Using a preset
+
+```bash
+npx blend-studio brand --preset juspay
+```
+
+Available presets: `blend`, `juspay`, `purple`, `green`, `orange`
+
+### With specific colors
+
+```bash
+npx blend-studio brand --primary "#E11D48" --radius rounded
+```
+
+### What this does
+
+1. Updates `src/blend/tokens.ts` with your branded tokens
+2. All Blend components immediately reflect your brand when the app reloads
+
+---
+
+## Using Blend Token Studio
+
+Blend Token Studio is the web UI for visually editing brand tokens.
+
+### Access
+
+Open [studio.blend.juspay.design](https://studio.blend.juspay.design) and sign in with Google.
+
+### Workflow
+
+1. **Create a Workspace** β each workspace holds a brand config
+2. **Edit tokens** β change colors, radius, shadows, typography, per-component overrides
+3. **Preview** β see live component previews with your brand
+4. **Publish** β version your brand config
+5. **Pull** β CLI downloads the published config into your project
+
+### Vocabulary
+
+| Developer Concept | Studio UI Label |
+| ----------------- | --------------- |
+| Branch | Workspace |
+| Default Branch | Master Theme |
+| Fork | Duplicate |
+| Merge Request | Change Request |
+| Publish | Release |
+
+---
+
+## Pulling Tokens from Studio
+
+```bash
+# Pull the latest version of a workspace
+npx blend-studio pull my-org/retail
+
+# Pull a specific version
+npx blend-studio pull my-org/retail --version 1.2.0
+
+# Pull with ReScript output
+npx blend-studio pull my-org/retail --language rescript
+
+# Pull for CI (no prompts, JSON output)
+npx blend-studio pull my-org/retail --ci --format json
+```
+
+### What gets generated
+
+| File | Contents |
+| ------------- | --------------------------------------------- |
+| `tokens.ts` | Resolved light + dark tokens as TypeScript |
+| `brand.json` | The raw brand config (for version control) |
+| `studio.json` | Metadata (branch ID, version, pull timestamp) |
+
+### Offline generation
+
+If you have a `brand.json` but no Studio access:
+
+```bash
+npx blend-studio generate ./brand.json
+npx blend-studio generate ./brand.json --language rescript
+```
+
+---
+
+## ReScript Projects
+
+### Detection
+
+The CLI auto-detects ReScript projects via:
+
+- `rescript.json` or `bsconfig.json` in project root
+- `rescript` or `@rescript/core` in dependencies
+
+### Setup
+
+```bash
+npx blend-studio init
+# The CLI will detect ReScript and configure accordingly
+```
+
+### Generate ReScript tokens
+
+```bash
+# From Studio
+npx blend-studio pull my-org/retail --language rescript
+
+# From local brand.json
+npx blend-studio generate ./brand.json --language rescript
+```
+
+### Output
+
+Generates `src/blend/BlendTokens.res`:
+
+```rescript
+/** Light theme */
+let componentTokens: JSON.t = %raw(`{ ... }`)
+
+/** Dark theme */
+let darkComponentTokens: JSON.t = %raw(`{ ... }`)
+```
+
+---
+
+## CI/CD Integration
+
+### Environment Variables
+
+| Variable | Description | Required |
+| ------------------------ | ------------------------ | -------- |
+| `BLEND_STUDIO_API_TOKEN` | JWT token for Studio API | Yes |
+| `BLEND_STUDIO_API_URL` | Override Studio API URL | No |
+
+### Example GitHub Actions workflow
+
+```yaml
+name: Update Tokens
+
+on:
+ workflow_dispatch:
+
+jobs:
+ pull-tokens:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Pull tokens
+ env:
+ BLEND_STUDIO_API_TOKEN: ${{ secrets.BLEND_STUDIO_API_TOKEN }}
+ run: npx blend-studio pull my-org/retail --ci --format json
+
+ - name: Commit updated tokens
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add src/blend/
+ git diff --cached --quiet || git commit -m "chore: update brand tokens"
+ git push
+```
+
+### CLI exit codes
+
+| Code | Meaning |
+| ---- | ----------------------------------- |
+| 0 | Success |
+| 1 | Failure (auth, network, validation) |
+
+The `--ci` flag ensures:
+
+- No interactive prompts
+- Non-zero exit on failure
+- `--format json` outputs machine-readable JSON
+
+---
+
+## Token Inheritance & Locking
+
+Blend uses a 3-tier inheritance model:
+
+```
+Blend Foundation (default theme)
+ βββ Org Master Theme (org-level overrides, some tokens locked)
+ βββ Product Workspace (product overrides, respects locks)
+```
+
+### How it works
+
+1. The org admin sets brand defaults and marks certain tokens as **locked** (e.g. primary color must stay blue)
+2. Product teams create workspaces that inherit from the org master
+3. Product teams can override any non-locked token
+4. If a product tries to override a locked token, the system blocks it
+
+### Lock management (Studio)
+
+Org admins can lock tokens in Studio:
+
+1. Go to Organization Settings β Token Locks
+2. Add a token path (e.g. `colors.primary.500`) and a reason
+3. All child workspaces must respect this lock
+
+### Change Requests
+
+When an editor wants to promote changes from a workspace to the master theme:
+
+1. Create a **Change Request** (Merge Request)
+2. Org admin reviews the diff
+3. Approve or reject with comments
+
+---
+
+## GitHub Actions β Publishing the CLI
+
+> **For full publishing workflow, see [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md#phase-5-npm-publishing)**
+
+### Setup
+
+1. Create an NPM token at [npmjs.com](https://www.npmjs.com) β Access Tokens β Generate New Token
+2. Add the secret to your GitHub repository:
+ - Go to Settings β Secrets and variables β Actions
+ - Create an Environment called `npm`
+ - Add `NPM_TOKEN` secret to that environment
+
+### Trigger
+
+1. Go to Actions β **Publish CLI (blend-studio)**
+2. Click **Run workflow**
+3. Select version bump type, tag, and confirm
+
+---
+
+## Troubleshooting
+
+### "Not authenticated" error
+
+```bash
+npx blend-studio login
+# Or: export BLEND_STUDIO_API_TOKEN=
+```
+
+### "Token has expired"
+
+Get a fresh token from Studio β User Menu β API Token.
+
+### "blend.config.json not found"
+
+```bash
+npx blend-studio init
+```
+
+### Components don't reflect my brand
+
+1. Make sure `` wraps your app
+2. Make sure `tokens.ts` has actual values (not empty `{}`)
+3. Run `npx blend-studio brand` to regenerate
+
+### "Invalid hex color" validation error
+
+Brand config colors must be in `#RGB` or `#RRGGBB` format. No `rgb()`, `hsl()`, or named colors.
+
+### WCAG contrast warnings
+
+The validator checks color scales against white and dark backgrounds. Pick a darker or lighter shade for that color.
+
+### ReScript output not generated
+
+```bash
+npx blend-studio pull my-org/retail --language rescript
+npx blend-studio generate ./brand.json --language rescript
+```
diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md
new file mode 100644
index 000000000..8a32a4047
--- /dev/null
+++ b/TESTING_GUIDE.md
@@ -0,0 +1,192 @@
+# Blend Token Studio - Unified Testing and Release Guide
+
+This is the single source of truth for testing Blend Token Studio end-to-end:
+
+- local monorepo development
+- external project validation
+- npm canary and stable publish checks
+
+Quick command (root):
+
+```bash
+pnpm test:token-studio:release-gate
+```
+
+NPM-like consumer smoke only:
+
+```bash
+pnpm test:token-studio:npm-smoke
+```
+
+---
+
+## 1) Goal
+
+Ensure this flow works for any React/Next/Vite project without per-component binding:
+
+1. `npx blend-studio init`
+2. wrap app with generated `BlendProvider`
+3. `npx blend-studio brand ...` or `npx blend-studio pull `
+4. run app and confirm Blend components render with resolved tokens
+
+---
+
+## 2) Pre-flight Checks (Monorepo)
+
+Run from repo root:
+
+```bash
+pnpm install
+pnpm --filter @juspay/blend-design-system build
+pnpm --filter @juspay/blend-design-system build
+pnpm --filter blend-studio build
+pnpm --filter @juspay/blend-design-system typecheck
+pnpm --filter blend-studio typecheck
+```
+
+Pass criteria:
+
+- all commands succeed
+- `packages/blend/dist` exists with token engine files
+- `packages/cli/dist/index.js` exists
+
+---
+
+## 3) Local Product Testing (Studio + Engine + CLI)
+
+### A. Studio workflow
+
+```bash
+cd apps/blend-studio
+pnpm dev
+```
+
+Verify:
+
+1. create branch at `/studio`
+2. edit colors/radius/components
+3. preview updates live
+4. publish version
+
+### B. CLI workflow inside repo
+
+From repo root:
+
+```bash
+pnpm --filter blend-studio build
+pnpm --filter @juspay/blend-design-system build
+```
+
+Then test commands in a sample app folder:
+
+```bash
+npx blend-studio --help
+npx blend-studio init --defaults
+npx blend-studio brand --preset blend
+npx blend-studio validate
+npx blend-studio diff
+```
+
+Pass criteria:
+
+- `blend.config.json` created
+- `src/blend/provider.tsx` created
+- `src/blend/brand.json` created
+- `src/blend/tokens.ts` generated with light + dark tokens
+
+---
+
+## 4) External Project Compatibility Test (Critical)
+
+Create fresh projects and run same flow:
+
+- Vite React (TS)
+- Next.js app router (TS)
+
+For each:
+
+```bash
+npx blend-studio init --defaults
+npx blend-studio brand --primary "#E31837" --radius sharp
+```
+
+Then:
+
+1. import generated `BlendProvider`
+2. render `ButtonV2`, `TextInputV2`, `AlertV2`
+3. run dev server
+4. verify light/dark behavior
+
+Pass criteria:
+
+- no manual token wiring
+- no component-level binding
+- app compiles and renders with branded styles
+
+---
+
+## 5) Canary Publish Validation (Before Stable)
+
+Publish canary versions first:
+
+1. publish `@juspay/blend-design-system` with canary tag (includes token engine)
+2. publish `blend-studio` with canary tag
+3. test in a fresh external project using only npm packages
+
+Checklist:
+
+- `npx blend-studio@next init` works
+- `brand` generation works
+- `pull/list/login` behavior is correct against target API
+
+If any failure occurs, fix and republish canary; do not publish stable.
+
+---
+
+## 6) Stable Publish Gate
+
+Publish stable only if all below pass:
+
+1. monorepo build + typecheck pass
+2. studio manual workflow pass
+3. external Vite test pass
+4. external Next.js test pass
+5. canary validation pass
+
+---
+
+## 7) Recommended CI Gates
+
+Add CI jobs:
+
+1. `build-packages`: build blend + token-engine + cli
+2. `typecheck-packages`: token-engine + cli
+3. `smoke-cli`: run `init` + `brand` in temp fixture project
+4. `studio-e2e`: minimal editor create/edit/preview test
+
+---
+
+## 8) Known Good Contract
+
+`tokens.ts` is generated; users should not edit it manually.
+`brand.json` is the user-editable source of truth and must be committed.
+
+`provider.tsx` is generated once and can be edited safely by consumers.
+
+---
+
+## 9) Fast Troubleshooting
+
+- **d.ts build failure in token-engine**: check for inferred exported return types in blend token utilities; add explicit return types.
+- **CLI write errors**: ensure commands create output directory before writing files.
+- **missing auth for pull/list/push**: run `blend-studio login` or provide token env vars.
+- **npm-smoke pnpm store errors**: by default the smoke runs an npm consumer only. To also run the pnpm consumer, set `TOKEN_STUDIO_SMOKE_PNPM=1`.
+
+---
+
+## 10) Versioning Policy (Non-Hacky)
+
+- `blend-studio` can remain on `0.1.x` while `@juspay/blend-design-system` is on `0.0.x`; they are separate packages with separate semver lifecycles.
+- Internal monorepo links should use semver ranges (e.g. `^0.1.0`) so npm installs work. Use changesets so these ranges update automatically during release.
+- `@juspay/blend-design-system` includes the token engine as part of its core functionality.
+- Use changesets to bump and publish; avoid manual cross-editing package versions.
diff --git a/apps/ascent/public/search-index.json b/apps/ascent/public/search-index.json
index 1998e1c51..24efcfa88 100644
--- a/apps/ascent/public/search-index.json
+++ b/apps/ascent/public/search-index.json
@@ -123,8 +123,8 @@
"slug": "avatar",
"category": "components",
"tags": ["avatar", "component", "user", "profile", "image"],
- "content": "Usage\n\n\n\nAPI Reference\n\n\n\nComponent Tokens\n\nYou can style the avatar component using the following tokens:",
- "excerpt": "Usage\n\n\n\nAPI Reference\n\n\n\nComponent Tokens\n\nYou can style the avatar component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called when the avatar is clicked or activated via keyboard',\n },\n { content: '' },\n ],\n [\n {\n content: 'leadingSlot',\n hintText:\n 'Optional React element displayed before (to the left of) the avatar. Useful for adding icons, badges, or other elements that should appear before the avatar in the layout. When provided, the avatar is wrapped in a container with proper spacing.',\n },\n {\n content: 'React.ReactNode',\n hintText:\n 'Any React element to display before the avatar element',\n },\n { content: '' },\n ],\n [\n {\n content: 'trailingSlot',\n hintText:\n 'Optional React element displayed after (to the right of) the avatar. Useful for adding icons, badges, labels, or other elements that should appear after the avatar in the layout. When provided, the avatar is wrapped in a container with proper spacing.',\n },\n {\n content: 'React.ReactNode',\n hintText:\n 'Any React element to display after the avatar element',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the avatar component using the following tokens:",
+ "excerpt": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called when the avatar is clicked or activated via keyboard',...",
"sections": [
{
"title": "Usage",
@@ -150,8 +150,8 @@
"slug": "breadcrumb",
"category": "components",
"tags": ["breadcrumb", "component", "navigation", "hierarchy", "path"],
- "content": "Usage\n\nBasic Usage\n\n\n\nWith Custom Routing (React Router)\n\n\n\nAPI Reference\n\n\n\nBreadcrumbItemType\n\nEach item in the items array should have the following structure:\n\n) => void',\n hintText:\n 'Function called when breadcrumb item is clicked, receives mouse event',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the breadcrumb component using the following tokens:",
- "excerpt": "Usage\n\nBasic Usage\n\n\n\nWith Custom Routing (React Router)\n\n\n\nAPI Reference\n\n\n\nBreadcrumbItemType\n\nEach item in the items array should have the followin...",
+ "content": "Usage\n\nBasic Usage\n\n\n\nWith Custom Routing (React Router)\n\n\n\nOverflow Behavior\n\nWhen there are more than 4 items, the breadcrumb automatically collapses to save space:\n\n- First item is always shown (root)\n- Middle items collapse into a ... menu button\n- Last 3 items are always visible (including current page)\n\nThis ensures users can always see where they are (last 3 items) and navigate back to the root, while keeping the UI clean.\n\nAPI Reference\n\n\n\nBreadcrumbItemType\n\nEach item in the items array should have the following structure:\n\n) => void',\n hintText:\n 'Function called when breadcrumb item is clicked, receives mouse event',\n },\n { content: '' },\n ],\n [\n {\n content: 'skeleton',\n hintText:\n 'Optional skeleton configuration for this specific breadcrumb item. When provided, overrides the component-level skeleton prop for this item. Useful when individual items need different loading states or variants.',\n },\n {\n content: '{ show: boolean, variant?: SkeletonVariant }',\n hintText:\n 'Object with show (required boolean) and optional variant (pulse, shimmer, etc.)',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the breadcrumb component using the following tokens:",
+ "excerpt": "Usage\n\nBasic Usage\n\n\n\nWith Custom Routing (React Router)\n\n\n\nOverflow Behavior\n\nWhen there are more than 4 items, the breadcrumb automatically collapse...",
"sections": [
{
"title": "Usage",
@@ -168,6 +168,11 @@
"level": 3,
"id": "with-custom-routing-react-router"
},
+ {
+ "title": "Overflow Behavior",
+ "level": 2,
+ "id": "overflow-behavior"
+ },
{
"title": "API Reference",
"level": 2,
@@ -219,7 +224,7 @@
"slug": "button",
"category": "components",
"tags": ["button", "component", "interaction", "action", "primary"],
- "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText: 'Function called when the user clicks the button',\n },\n { content: '' },\n ],\n [\n {\n content: 'loading',\n hintText:\n 'When true, the button displays a loading spinner and becomes non-interactive. Useful for async operations like form submissions or API calls. The button automatically disables itself during loading. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows loading state, false shows normal state',\n },\n { content: '' },\n ],\n [\n {\n content: 'fullWidth',\n hintText:\n \"When true, the button expands to fill 100% of its container's width. Useful for full-width buttons in forms, modals, or narrow containers. Defaults to false (button only takes width needed for content).\",\n },\n {\n content: 'boolean',\n hintText:\n 'true makes button full-width, false uses content-based width',\n },\n { content: '' },\n ],\n [\n {\n content: 'buttonGroupPosition',\n hintText:\n 'Controls border radius when used within a ButtonGroup. \"left\" removes right border radius, \"right\" removes left border radius, \"center\" removes both (for middle buttons). Undefined uses normal border radius. Used automatically by ButtonGroup.',\n },\n {\n content: \"'center' | 'left' | 'right'\",\n hintText: 'Union type that positions the button within a group',\n },\n { content: '' },\n ],\n [\n {\n content: 'justifyContent',\n hintText:\n 'CSS justify-content value for aligning button content horizontally. Controls how text and icons are positioned within the button. Common values: \"center\", \"flex-start\", \"flex-end\", \"space-between\". Defaults to \"center\".',\n },\n {\n content: 'CSSObject[\"justifyContent\"]',\n hintText: 'CSS property value for horizontal content alignment',\n },\n { content: '' },\n ],\n [\n {\n content: 'state',\n hintText:\n 'Manually controls the visual state of the button for styling purposes. DEFAULT is normal, HOVER is hovered, ACTIVE is pressed/active, DISABLED is disabled. Typically controlled automatically by user interaction, but can be set manually. Defaults to DEFAULT.',\n },\n {\n content: 'ButtonState',\n hintText: 'Enum that determines the visual state styling',\n },\n { content: 'DEFAULT, HOVER, ACTIVE, DISABLED' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the Button component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText: 'Function called when the user clicks the button',\n },\n { content: '' },\n ],\n [\n {\n content: 'loading',\n hintText:\n 'When true, the button displays a loading spinner and becomes non-interactive. Useful for async operations like form submissions or API calls. The button automatically disables itself during loading. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows loading state, false shows normal state',\n },\n { content: '' },\n ],\n [\n {\n content: 'showSkeleton',\n hintText:\n 'When true, displays a skeleton loading state instead of the button content. Useful for showing placeholder UI while button data is loading. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows skeleton, false shows normal button',\n },\n { content: '' },\n ],\n [\n {\n content: 'skeletonVariant',\n hintText:\n 'Controls the animation style of the skeleton loading state. PULSE creates a pulsing fade effect, SHIMMER creates a sliding shimmer effect. Defaults to pulse.',\n },\n {\n content: 'SkeletonVariant',\n hintText: 'Enum that determines the skeleton animation style',\n },\n { content: 'PULSE, SHIMMER' },\n ],\n [\n {\n content: 'fullWidth',\n hintText:\n \"When true, the button expands to fill 100% of its container's width. Useful for full-width buttons in forms, modals, or narrow containers. Defaults to false (button only takes width needed for content).\",\n },\n {\n content: 'boolean',\n hintText:\n 'true makes button full-width, false uses content-based width',\n },\n { content: '' },\n ],\n [\n {\n content: 'width',\n hintText:\n 'Optional fixed width value for the button. Accepts CSS width values (e.g., \"120px\", \"100%\", \"10rem\") or a number (converted to pixels). Overrides the default content-based width. Use fullWidth for 100% container width.',\n },\n {\n content: 'string | number',\n hintText:\n 'CSS width value (e.g., \"120px\", \"100%\") or number (converted to pixels)',\n },\n { content: '' },\n ],\n [\n {\n content: 'buttonGroupPosition',\n hintText:\n 'Controls border radius when used within a ButtonGroup. \"left\" removes right border radius, \"right\" removes left border radius, \"center\" removes both (for middle buttons). Undefined uses normal border radius. Used automatically by ButtonGroup.',\n },\n {\n content: \"'center' | 'left' | 'right'\",\n hintText: 'Union type that positions the button within a group',\n },\n { content: '' },\n ],\n [\n {\n content: 'justifyContent',\n hintText:\n 'CSS justify-content value for aligning button content horizontally. Controls how text and icons are positioned within the button. Common values: \"center\", \"flex-start\", \"flex-end\", \"space-between\". Defaults to \"center\".',\n },\n {\n content: 'CSSObject[\"justifyContent\"]',\n hintText: 'CSS property value for horizontal content alignment',\n },\n { content: '' },\n ],\n [\n {\n content: 'state',\n hintText:\n 'Manually controls the visual state of the button for styling purposes. DEFAULT is normal, HOVER is hovered, ACTIVE is pressed/active, DISABLED is disabled. Typically controlled automatically by user interaction, but can be set manually. Defaults to DEFAULT.',\n },\n {\n content: 'ButtonState',\n hintText: 'Enum that determines the visual state styling',\n },\n { content: 'DEFAULT, HOVER, ACTIVE, DISABLED' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the Button component using the following tokens:",
"excerpt": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText: 'Function called when the user clicks the button',\n },\n { content: ''...",
"sections": [
{
@@ -280,7 +285,7 @@
"graphs",
"recharts"
],
- "content": "Usage\n\n\n\nSankey Charts (Flow Diagrams)\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new expanded state when it changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'chartName',\n hintText:\n 'Optional name identifier string used for accessibility and in the noData empty state message. Helps screen readers identify the chart and provides context in error messages. Defaults to \"Chart\".',\n },\n {\n content: 'string',\n hintText: 'Name or identifier for the chart component',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nSankey Chart Features\n\nSankey charts have unique features for visualizing flows:\n\nNode and Link Customization\n\n\n\nInteractive Tooltips\n\nSankey charts display tooltips on hover:\n\n- Node Tooltip: Shows node name and total value\n- Link Tooltip: Displays source β target flow with value\n\nData Format Options\n\nYou can use either numeric indices or string IDs for links:\n\n\n\nComponent Tokens\n\nYou can style the Charts component using the following tokens:",
+ "content": "Usage\n\n\n\nSankey Charts (Flow Diagrams)\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new expanded state when it changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'chartName',\n hintText:\n 'Optional name identifier string used for accessibility and in the noData empty state message. Helps screen readers identify the chart and provides context in error messages. Defaults to \"Chart\".',\n },\n {\n content: 'string',\n hintText: 'Name or identifier for the chart component',\n },\n { content: '' },\n ],\n [\n {\n content: 'skeleton',\n hintText:\n 'Optional configuration for showing skeleton loading state. When show is true, displays skeleton placeholders instead of the actual chart content. Useful for loading states while chart data is being fetched.',\n },\n {\n content: '{ show: boolean, variant: SkeletonVariant }',\n hintText:\n 'Object with show (required boolean) and variant (required - pulse or shimmer)',\n },\n { content: '' },\n ],\n [\n {\n content: 'legends',\n hintText:\n 'Optional array of custom legend items with titles and optional totals. Used with stackedLegends to display rich legend information. Each item has a title (required) and optional total string.',\n },\n {\n content: '{ title: string; total?: string }[]',\n hintText:\n 'Array of objects with title (required) and optional total value',\n },\n { content: '' },\n ],\n [\n {\n content: 'CustomizedDot',\n hintText:\n 'Optional custom render function for scatter chart data points. Receives dot props (cx, cy, value, payload) and must return an SVG element. Useful for custom shapes, conditional colors, or animations.',\n },\n {\n content: '(props: DotItemDotProps) => ReactElement',\n hintText:\n 'Function that receives dot properties and returns an SVG element',\n },\n { content: '' },\n ],\n [\n {\n content: 'lineSeriesKeys',\n hintText:\n 'Optional array of data series keys to render as lines in LINE_BAR chart type. Series not in this array render as bars. Useful for mixed line/bar visualizations.',\n },\n {\n content: 'string[]',\n hintText:\n 'Array of data series keys to render as lines (others become bars)',\n },\n { content: '' },\n ],\n [\n {\n content: 'tooltip',\n hintText:\n 'Optional configuration for tooltip position and behavior. Controls fixed position and whether tooltip can extend beyond chart boundaries.',\n },\n {\n content:\n '{ position?: { x?: number; y?: number }; allowEscapeViewBox?: { x?: boolean; y?: boolean } }',\n hintText:\n 'Object with optional position (fixed coordinates) and allowEscapeViewBox (overflow control)',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nAxis Configuration\n\nThe xAxis and yAxis props accept configuration objects with extensive customization options:\n\nBasic Axis Config\n\n\n\nAxisIntervalType\n\nControls how axis labels are spaced when there are too many to fit:\n\nPRESERVE_START - Keep first label, hide overlapping others\n\nPRESERVE_END - Keep last label, hide overlapping others\n\nPRESERVE_START_END - Keep first and last, hide middle overlaps\n\n\n\ntickFormatter\n\nCustom function to format tick values:\n\n\n\ncustomTick\n\nCustom React component to render tick marks:\n\n\n\nDate Formatting Options (DATE_TIME type)\n\nAvailable options when using type: AxisType.DATE_TIME:\n\ndateOnly - Show only date (no time)\n\ntimeOnly - Show only time (no date)\n\nuseUTC - Use UTC timezone\n\nformatString - Custom format string (e.g., \"YYYY-MM-DD\")\n\nshowYear - Always include year\n\nsmartDateTimeFormat - Auto-alternate between date and time\n\n\n\nTick Control\n\n\n\nSankey Chart Features\n\nSankey charts have unique features for visualizing flows:\n\nNode and Link Customization\n\n\n\nInteractive Tooltips\n\nSankey charts display tooltips on hover:\n\n- Node Tooltip: Shows node name and total value\n- Link Tooltip: Displays source β target flow with value\n\nData Format Options\n\nYou can use either numeric indices or string IDs for links:\n\n\n\nComponent Tokens\n\nYou can style the Charts component using the following tokens:",
"excerpt": "Usage\n\n\n\nSankey Charts (Flow Diagrams)\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new expanded stat...",
"sections": [
{
@@ -298,6 +303,41 @@
"level": 2,
"id": "api-reference"
},
+ {
+ "title": "Axis Configuration",
+ "level": 2,
+ "id": "axis-configuration"
+ },
+ {
+ "title": "Basic Axis Config",
+ "level": 3,
+ "id": "basic-axis-config"
+ },
+ {
+ "title": "AxisIntervalType",
+ "level": 3,
+ "id": "axisintervaltype"
+ },
+ {
+ "title": "tickFormatter",
+ "level": 3,
+ "id": "tickformatter"
+ },
+ {
+ "title": "customTick",
+ "level": 3,
+ "id": "customtick"
+ },
+ {
+ "title": "Date Formatting Options (DATE_TIME type)",
+ "level": 3,
+ "id": "date-formatting-options-date-time-type"
+ },
+ {
+ "title": "Tick Control",
+ "level": 3,
+ "id": "tick-control"
+ },
{
"title": "Sankey Chart Features",
"level": 2,
@@ -340,7 +380,7 @@
"chat",
"messaging"
],
- "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new input value when it changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'onSend',\n hintText:\n 'Optional callback function invoked when the user sends a message. Triggered by pressing Enter (or Shift+Enter for new lines) or clicking the send button. Receives the message string and array of attached files as parameters.',\n },\n {\n content: '(message: string, files: AttachedFile[]) => void',\n hintText:\n 'Function called with message text and attached files when sending',\n },\n { content: '' },\n ],\n [\n {\n content: 'onAttachFiles',\n hintText:\n 'Optional callback function invoked when files are selected via the attach button. Receives an array of native File objects. Use this to handle file uploads, validation, or conversion to AttachedFile format for the attachedFiles prop.',\n },\n {\n content: '(files: File[]) => void',\n hintText: 'Function called with array of selected files',\n },\n { content: '' },\n ],\n [\n {\n content: 'onVoiceRecord',\n hintText:\n 'Optional callback function invoked when the voice recording button is clicked. Use this to trigger voice recording functionality, open recording dialogs, or handle voice input features.',\n },\n {\n content: '() => void',\n hintText:\n 'Function called when voice recording button is clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'onFileRemove',\n hintText:\n 'Optional callback function invoked when a user removes an attached file (via remove button or overflow menu). Receives the fileId string of the removed file. Use this to update your attachedFiles array.',\n },\n {\n content: '(fileId: string) => void',\n hintText:\n 'Function called with the ID of the file being removed',\n },\n { content: '' },\n ],\n [\n {\n content: 'onFileClick',\n hintText:\n 'Optional callback function invoked when a user clicks on an attached file. Receives the AttachedFile object. Use this to open file previews, download files, or navigate to file details.',\n },\n {\n content: '(file: AttachedFile) => void',\n hintText: 'Function called with the clicked file object',\n },\n { content: '' },\n ],\n [\n {\n content: 'placeholder',\n hintText:\n 'Optional placeholder text displayed in the input field when it\\'s empty. Provides guidance to users about what to type. Should be concise and actionable. Defaults to \"Type a message...\".',\n },\n {\n content: 'string',\n hintText: 'Placeholder text shown when input is empty',\n },\n { content: '' },\n ],\n [\n {\n content: 'disabled',\n hintText:\n 'When true, disables the entire chat input, making it non-interactive. Disabled inputs cannot be typed in, and all buttons (attach, voice, send) are disabled. Useful for preventing input during loading states or when user actions are not allowed. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true disables all interactions, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'maxLength',\n hintText:\n 'Optional maximum character length for the message input. When provided, the input enforces this limit and prevents typing beyond it. Useful for API constraints or database field limits. If not provided, no length restriction is applied.',\n },\n {\n content: 'number',\n hintText:\n 'Positive integer representing maximum allowed characters',\n },\n { content: '' },\n ],\n [\n {\n content: 'autoResize',\n hintText:\n 'When true, the textarea automatically adjusts its height based on content, growing vertically as the user types. When false, the textarea maintains a fixed height with scrolling. Provides a better UX for longer messages. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables auto-resizing, false uses fixed height with scroll',\n },\n { content: '' },\n ],\n [\n {\n content: 'attachedFiles',\n hintText:\n 'Optional array of AttachedFile objects representing files currently attached to the message. Each file should have id, name, type, and optionally size, url, and preview properties. Files are displayed as tags with overflow handling when many are attached. Defaults to empty array.',\n },\n {\n content: 'AttachedFile[]',\n hintText:\n 'Array of objects with id, name, type, and optional file metadata',\n },\n { content: '' },\n ],\n [\n {\n content: 'topQueries',\n hintText:\n 'Optional array of TopQuery objects representing suggested queries or frequently asked questions. Displayed in a collapsible section below the input. Each query should have id and text properties. Useful for quick replies or common questions. Defaults to empty array.',\n },\n {\n content: 'TopQuery[]',\n hintText:\n 'Array of objects with id and text properties for query suggestions',\n },\n { content: '' },\n ],\n [\n {\n content: 'onTopQuerySelect',\n hintText:\n 'Optional callback function invoked when a user clicks on a top query suggestion. Receives the selected TopQuery object. Use this to populate the input with the query text or trigger related actions.',\n },\n {\n content: '(query: TopQuery) => void',\n hintText: 'Function called with the selected query object',\n },\n { content: '' },\n ],\n [\n {\n content: 'topQueriesMaxHeight',\n hintText:\n 'Maximum height in pixels for the top queries container before scrolling. When the queries list exceeds this height, a scrollbar appears. Useful for limiting the vertical space taken by suggestions. Defaults to 200 pixels.',\n },\n {\n content: 'number',\n hintText:\n 'Positive number representing maximum height in pixels',\n },\n { content: '' },\n ],\n [\n {\n content: 'attachButtonIcon',\n hintText:\n 'Optional custom React element (typically an icon) to replace the default attach file button icon. Allows branding customization or using different icon libraries. If not provided, uses default Paperclip icon.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Any React element (icon, image, etc.) for the attach button',\n },\n { content: '' },\n ],\n [\n {\n content: 'voiceButtonIcon',\n hintText:\n 'Optional custom React element (typically an icon) to replace the default voice recording button icon. Allows branding customization or using different icon libraries. If not provided, uses default AudioLines icon.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Any React element (icon, image, etc.) for the voice button',\n },\n { content: '' },\n ],\n [\n {\n content: 'sendButtonIcon',\n hintText:\n 'Optional custom React element (typically an icon) to replace the default send button icon. Allows branding customization or using different icon libraries. If not provided, uses default send icon.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Any React element (icon, image, etc.) for the send button',\n },\n { content: '' },\n ],\n [\n {\n content: 'overflowMenuProps',\n hintText:\n 'Optional partial MenuProps object for customizing the overflow menu that appears when many files are attached. Allows styling, positioning, and behavior customization of the menu component used to display hidden files.',\n },\n {\n content: 'Partial',\n hintText:\n 'Partial Menu component props for overflow menu customization',\n },\n { content: '' },\n ],\n [\n {\n content: 'aria-label',\n hintText:\n 'Optional ARIA label string for accessibility. Provides an accessible name for the chat input component when the visible label is not sufficient. Important for screen reader users.',\n },\n {\n content: 'string',\n hintText: 'Accessible label text for screen readers',\n },\n { content: '' },\n ],\n [\n {\n content: 'aria-describedby',\n hintText:\n 'Optional ARIA describedby string for accessibility. References the ID of an element that provides additional descriptive information about the chat input. Helps screen reader users understand context or requirements.',\n },\n {\n content: 'string',\n hintText: 'ID of element providing additional description',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the ChatInput component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new input value when it changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'onAttachFiles',\n hintText:\n 'Optional callback function invoked when files are selected via the attach button. Receives an array of native File objects. Use this to handle file uploads, validation, or conversion to AttachedFile format for the attachedFiles prop.',\n },\n {\n content: '(files: File[]) => void',\n hintText: 'Function called with array of selected files',\n },\n { content: '' },\n ],\n [\n {\n content: 'onFileRemove',\n hintText:\n 'Optional callback function invoked when a user removes an attached file (via remove button or overflow menu). Receives the fileId string of the removed file. Use this to update your attachedFiles array.',\n },\n {\n content: '(fileId: string) => void',\n hintText:\n 'Function called with the ID of the file being removed',\n },\n { content: '' },\n ],\n [\n {\n content: 'onFileClick',\n hintText:\n 'Optional callback function invoked when a user clicks on an attached file. Receives the AttachedFile object. Use this to open file previews, download files, or navigate to file details.',\n },\n {\n content: '(file: AttachedFile) => void',\n hintText: 'Function called with the clicked file object',\n },\n { content: '' },\n ],\n [\n {\n content: 'placeholder',\n hintText:\n 'Optional placeholder text displayed in the input field when it\\'s empty. Provides guidance to users about what to type. Should be concise and actionable. Defaults to \"Type a message...\".',\n },\n {\n content: 'string',\n hintText: 'Placeholder text shown when input is empty',\n },\n { content: '' },\n ],\n [\n {\n content: 'disabled',\n hintText:\n 'When true, disables the entire chat input, making it non-interactive. Disabled inputs cannot be typed in, and all buttons (attach, voice, send) are disabled. Useful for preventing input during loading states or when user actions are not allowed. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true disables all interactions, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'maxLength',\n hintText:\n 'Optional maximum character length for the message input. When provided, the input enforces this limit and prevents typing beyond it. Useful for API constraints or database field limits. If not provided, no length restriction is applied.',\n },\n {\n content: 'number',\n hintText:\n 'Positive integer representing maximum allowed characters',\n },\n { content: '' },\n ],\n [\n {\n content: 'autoResize',\n hintText:\n 'When true, the textarea automatically adjusts its height based on content, growing vertically as the user types. When false, the textarea maintains a fixed height with scrolling. Provides a better UX for longer messages. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables auto-resizing, false uses fixed height with scroll',\n },\n { content: '' },\n ],\n [\n {\n content: 'attachedFiles',\n hintText:\n 'Optional array of AttachedFile objects representing files currently attached to the message. Each file should have id, name, type, and optionally size, url, and preview properties. Files are displayed as tags with overflow handling when many are attached. Defaults to empty array.',\n },\n {\n content: 'AttachedFile[]',\n hintText:\n 'Array of objects with id, name, type, and optional file metadata',\n },\n { content: '' },\n ],\n [\n {\n content: 'topQueries',\n hintText:\n 'Optional array of TopQuery objects representing suggested queries or frequently asked questions. Displayed in a collapsible section below the input. Each query should have id and text properties. Useful for quick replies or common questions. Defaults to empty array.',\n },\n {\n content: 'TopQuery[]',\n hintText:\n 'Array of objects with id and text properties for query suggestions',\n },\n { content: '' },\n ],\n [\n {\n content: 'onTopQuerySelect',\n hintText:\n 'Optional callback function invoked when a user clicks on a top query suggestion. Receives the selected TopQuery object. Use this to populate the input with the query text or trigger related actions.',\n },\n {\n content: '(query: TopQuery) => void',\n hintText: 'Function called with the selected query object',\n },\n { content: '' },\n ],\n [\n {\n content: 'topQueriesMaxHeight',\n hintText:\n 'Maximum height in pixels for the top queries container before scrolling. When the queries list exceeds this height, a scrollbar appears. Useful for limiting the vertical space taken by suggestions. Defaults to 200 pixels.',\n },\n {\n content: 'number',\n hintText:\n 'Positive number representing maximum height in pixels',\n },\n { content: '' },\n ],\n [\n {\n content: 'attachButtonIcon',\n hintText:\n 'Optional custom React element (typically an icon) to replace the default attach file button icon. Allows branding customization or using different icon libraries. If not provided, uses default Paperclip icon.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Any React element (icon, image, etc.) for the attach button',\n },\n { content: '' },\n ],\n [\n {\n content: 'slot1',\n hintText:\n 'Optional React element displayed in the actions toolbar next to the attach button. Useful for adding custom action buttons like send, emoji picker, or other controls. Rendered inside the bottom actions area.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Any React element (button, icon, etc.) to display in the actions toolbar',\n },\n { content: '' },\n ],\n [\n {\n content: 'slot2',\n hintText:\n 'Optional React element displayed above the textarea when there are attached files or when provided. Useful for displaying file upload progress, additional inputs, or custom content that should appear before the message input area.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Any React element to display above the textarea input',\n },\n { content: '' },\n ],\n [\n {\n content: 'overflowMenuProps',\n hintText:\n 'Optional partial MenuProps object for customizing the overflow menu that appears when many files are attached. Allows styling, positioning, and behavior customization of the menu component used to display hidden files.',\n },\n {\n content: 'Partial',\n hintText:\n 'Partial Menu component props for overflow menu customization',\n },\n { content: '' },\n ],\n [\n {\n content: 'aria-label',\n hintText:\n 'Optional ARIA label string for accessibility. Provides an accessible name for the chat input component when the visible label is not sufficient. Important for screen reader users.',\n },\n {\n content: 'string',\n hintText: 'Accessible label text for screen readers',\n },\n { content: '' },\n ],\n [\n {\n content: 'aria-describedby',\n hintText:\n 'Optional ARIA describedby string for accessibility. References the ID of an element that provides additional descriptive information about the chat input. Helps screen reader users understand context or requirements.',\n },\n {\n content: 'string',\n hintText: 'ID of element providing additional description',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the ChatInput component using the following tokens:",
"excerpt": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new input value when it changes',\n },...",
"sections": [
{
@@ -374,7 +414,7 @@
"selection",
"control"
],
- "content": "Usage\n\n\n\nAPI Reference\n\n void\",\n hintText:\n 'Function called with new checked state when it changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'disabled',\n hintText:\n \"When true, disables the checkbox, making it non-interactive and visually muted. Disabled checkboxes cannot be clicked and are excluded from form submissions. Useful for preventing interaction when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables interaction, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'required',\n hintText:\n 'When true, marks the checkbox as required for form validation. Browsers and form libraries will validate that the checkbox is checked before allowing form submission. Visual indicators (asterisk) may be displayed. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true makes checkbox required, false makes it optional',\n },\n { content: '' },\n ],\n [\n {\n content: 'error',\n hintText:\n 'When true, displays the checkbox in an error state with error styling (typically red border/indicator). Useful for form validation feedback when the checkbox is required but unchecked, or when validation fails. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows error state, false shows normal state',\n },\n { content: '' },\n ],\n [\n {\n content: 'size',\n hintText:\n 'Controls the dimensions of the checkbox indicator. SMALL creates a compact checkbox suitable for dense layouts, MEDIUM provides standard sizing for most use cases. Size affects both the checkbox box and the checkmark icon. Defaults to MEDIUM.',\n },\n {\n content: 'CheckboxSize',\n hintText:\n 'Enum that determines the physical dimensions of the checkbox',\n },\n { content: 'SMALL, MEDIUM' },\n ],\n [\n {\n content: 'children',\n hintText:\n 'Optional React node displayed as the label text next to the checkbox. Typically a string but can be any React content. Clicking the label text also toggles the checkbox. The label is associated with the checkbox for accessibility.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Any React content (text, elements) to display as the label',\n },\n { content: '' },\n ],\n [\n {\n content: 'subtext',\n hintText:\n 'Optional descriptive text string displayed below the main label. Provides additional context, explanation, or clarification about the checkbox option. Styled with smaller, lighter text than the main label.',\n },\n {\n content: 'string',\n hintText: 'Secondary descriptive text shown below the label',\n },\n { content: '' },\n ],\n [\n {\n content: 'slot',\n hintText:\n 'Optional React element displayed to the right of the label and subtext. Useful for adding icons, badges, links, or other supplementary content that should appear after the checkbox label area.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Any React element to display after the label content',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the Checkbox component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\n void\",\n hintText:\n 'Function called with new checked state when it changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'disabled',\n hintText:\n \"When true, disables the checkbox, making it non-interactive and visually muted. Disabled checkboxes cannot be clicked and are excluded from form submissions. Useful for preventing interaction when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables interaction, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'required',\n hintText:\n 'When true, marks the checkbox as required for form validation. Browsers and form libraries will validate that the checkbox is checked before allowing form submission. Visual indicators (asterisk) may be displayed. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true makes checkbox required, false makes it optional',\n },\n { content: '' },\n ],\n [\n {\n content: 'error',\n hintText:\n 'When true, displays the checkbox in an error state with error styling (typically red border/indicator). Useful for form validation feedback when the checkbox is required but unchecked, or when validation fails. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows error state, false shows normal state',\n },\n { content: '' },\n ],\n [\n {\n content: 'size',\n hintText:\n 'Controls the dimensions of the checkbox indicator. SMALL creates a compact checkbox suitable for dense layouts, MEDIUM provides standard sizing for most use cases. Size affects both the checkbox box and the checkmark icon. Defaults to MEDIUM.',\n },\n {\n content: 'CheckboxSize',\n hintText:\n 'Enum that determines the physical dimensions of the checkbox',\n },\n { content: 'SMALL, MEDIUM' },\n ],\n [\n {\n content: 'children',\n hintText:\n 'Optional React node displayed as the label text next to the checkbox. Typically a string but can be any React content. Clicking the label text also toggles the checkbox. The label is associated with the checkbox for accessibility.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Any React content (text, elements) to display as the label',\n },\n { content: '' },\n ],\n [\n {\n content: 'subtext',\n hintText:\n 'Optional descriptive text string displayed below the main label. Provides additional context, explanation, or clarification about the checkbox option. Styled with smaller, lighter text than the main label.',\n },\n {\n content: 'string',\n hintText: 'Secondary descriptive text shown below the label',\n },\n { content: '' },\n ],\n [\n {\n content: 'slot',\n hintText:\n 'Optional React element displayed to the right of the label and subtext. Useful for adding icons, badges, links, or other supplementary content that should appear after the checkbox label area.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Any React element to display after the label content',\n },\n { content: '' },\n ],\n [\n {\n content: 'maxLength',\n hintText:\n 'Optional configuration to truncate label and subtext with ellipsis when they exceed the specified character count. Displays full text in a tooltip when truncated. Useful for long text in constrained layouts.',\n },\n {\n content: '{ label?: number; subtext?: number }',\n hintText:\n 'Object with optional label and subtext character limits',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the Checkbox component using the following tokens:",
"excerpt": "Usage\n\n\n\nAPI Reference\n\n void\",\n hintText:\n 'Function called with new checked state when it changes',\n },...",
"sections": [
{
@@ -442,7 +482,7 @@
"sorting",
"filtering"
],
- "content": "Usage\n\n\n\nSkeleton Loading\n\nThe DataTable supports granular skeleton loading for better UX during data fetching. You can control skeleton loading at the table level, column level, or row level.\n\nGlobal Skeleton Loading\n\n\n\nPer-Column Skeleton Control\n\n\n\nPer-Row Skeleton Loading\n\n\n\nSkeleton Priority\n\nSkeleton loading follows this priority order:\n\n1. Column-level showSkeleton (highest priority)\n2. Row-level isRowLoading function\n3. Global showSkeleton or isLoading (lowest priority)\n\nDelta Sorting\n\nThe DataTable supports delta sorting, allowing you to sort by a different field than the column's primary field. This is useful when you have related fields like total_volume and delta_total_volume, where you want to sort by the delta value instead of the primary value.\n\nEnabling Delta Sorting\n\nTo enable delta sorting for a column:\n\n1. Set isDeltaSortable: true on the column definition\n2. Provide a getSortField function that returns the appropriate field based on sortType\n3. Optionally provide a sortValueFormatter for custom value transformation\n\n\n\nWhen isDeltaSortable is enabled, the sorting popover displays two sections:\n\n- Value: Standard sorting by the primary field\n- Delta: Sorting by the delta field (via getSortField)\n\nSort Value Formatter\n\nThe sortValueFormatter function allows you to transform values before comparison. This is useful for:\n\n- Extracting numbers from formatted currency strings (e.g., \"INR 276\" β 276)\n- Parsing percentage strings (e.g., \"12.5%\" β 12.5)\n- Custom normalization logic\n\nIf the formatter throws an error, the original value is used as a fallback.\n\nHiding the Footer\n\nFor compact tables with only a few rows, you can hide the pagination footer completely using the showFooter prop. This is useful when displaying 1-2 rows where pagination controls are unnecessary.\n\n\n\nAPI Reference\n\n[]',\n hintText:\n 'Array of column configuration objects defining table structure',\n },\n { content: '' },\n ],\n [\n {\n content: 'idField',\n hintText:\n 'Required key of type keyof T that identifies the unique identifier field in each data object. This field is used for row identification, selection tracking, expansion state, and row-level operations. Must be a field that exists in all data objects.',\n },\n {\n content: 'keyof T',\n hintText:\n 'Property name from the data type that serves as unique row identifier',\n },\n { content: '' },\n ],\n [\n {\n content: 'title',\n hintText:\n \"Optional title string displayed prominently at the top of the table header. Provides context and description of the table's purpose. Typically shown above the description and toolbar when showHeader is true.\",\n },\n {\n content: 'string',\n hintText:\n 'Text content for the table title displayed in the header',\n },\n { content: '' },\n ],\n [\n {\n content: 'description',\n hintText:\n 'Optional descriptive text displayed below the title in the table header. Provides additional context, instructions, or summary information about the table content. Useful for explaining data sources or usage guidelines. When the text is truncated due to space constraints, a tooltip will automatically appear on hover showing the full text.',\n },\n {\n content: 'string',\n hintText:\n 'Descriptive text shown below the title in the header',\n },\n { content: '' },\n ],\n [\n {\n content: 'descriptionTooltipProps',\n hintText:\n 'Optional tooltip configuration object for customizing the tooltip that appears when the description text is truncated. Allows control over tooltip direction (side: top, right, bottom, left), alignment (align: start, center, end), size (sm, lg), arrow visibility, delay duration, and offset distance. When the description overflows its container, hovering over it will show a tooltip with the full text. Use this prop to position the tooltip optimally based on your layout and prevent it from being cut off by viewport edges.',\n },\n {\n content:\n '{\\n side?: \"top\" | \"right\" | \"bottom\" | \"left\"\\n align?: \"start\" | \"center\" | \"end\"\\n size?: \"sm\" | \"lg\"\\n showArrow?: boolean\\n delayDuration?: number\\n offset?: number\\n}',\n hintText:\n 'Object with optional tooltip configuration properties',\n },\n { content: '' },\n ],\n [\n {\n content: 'defaultSort',\n hintText:\n 'Optional SortConfig object that sets the initial sort state when the table first renders. Contains field (column to sort by) and direction (NONE, ASCENDING, or DESCENDING). If not provided, the table starts unsorted.',\n },\n {\n content: 'SortConfig',\n hintText:\n 'Object with field and direction properties for initial sorting',\n },\n { content: '' },\n ],\n [\n {\n content: 'SortConfig.field',\n hintText:\n 'Required field name string from the data type (keyof T) that specifies which column to sort by. This field must exist in the data objects and should correspond to a sortable column definition.',\n },\n {\n content: 'string',\n hintText: 'Property name from the data type to sort by',\n },\n { content: '' },\n ],\n [\n {\n content: 'SortConfig.direction',\n hintText:\n 'Required SortDirection enum value that determines the sort order. NONE means no sorting is applied, ASCENDING sorts from lowest to highest (A-Z, 0-9), DESCENDING sorts from highest to lowest (Z-A, 9-0).',\n },\n {\n content: 'SortDirection',\n hintText: 'Enum that determines the sort order direction',\n },\n { content: 'NONE, ASCENDING, DESCENDING' },\n ],\n [\n {\n content: 'onSortChange',\n hintText:\n 'Optional callback function invoked when the user changes the sort configuration (clicks a column header). Receives the new SortConfig object with field and direction. Use this for controlled sorting or to sync sort state with your application.',\n },\n {\n content: '(sortConfig: SortConfig) => void',\n hintText:\n 'Function called with new sort configuration when sorting changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'enableSearch',\n hintText:\n 'When true, displays a global search input in the toolbar that allows users to search across all columns. The search filters rows client-side by default, or triggers server-side search if serverSideSearch is enabled. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows search input, false hides search functionality',\n },\n { content: '' },\n ],\n [\n {\n content: 'searchPlaceholder',\n hintText:\n 'Optional placeholder text displayed in the search input field when it\\'s empty. Provides guidance to users about what they can search for. Should be concise and descriptive. Defaults to \"Search...\".',\n },\n {\n content: 'string',\n hintText: 'Placeholder text shown when search input is empty',\n },\n { content: '' },\n ],\n [\n {\n content: 'serverSideSearch',\n hintText:\n 'When true, search functionality is handled on the server side. The search query is passed to onSearchChange callback instead of filtering locally. Useful for large datasets or when search requires backend processing. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables server-side search, false uses client-side filtering',\n },\n { content: '' },\n ],\n [\n {\n content: 'onSearchChange',\n hintText:\n 'Optional callback function invoked when the search query changes. Receives a SearchConfig object containing the search query. Required when serverSideSearch is true. Use this to trigger server-side search or update your data source.',\n },\n {\n content: '(searchConfig: SearchConfig) => void',\n hintText:\n 'Function called with search configuration when query changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'enableFiltering',\n hintText:\n 'When true, enables column-based filtering functionality. Users can click filter icons in column headers to apply filters. Filters can be combined and work together. Requires onFilterChange callback for controlled filtering. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables column filtering, false disables filter functionality',\n },\n { content: '' },\n ],\n [\n {\n content: 'enableAdvancedFilter',\n hintText:\n 'When true, displays an advanced filter component that allows complex filtering logic beyond simple column filters. Requires advancedFilterComponent to be provided. Useful for multi-condition filtering with AND/OR logic. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows advanced filter component, false hides it',\n },\n { content: '' },\n ],\n [\n {\n content: 'advancedFilterComponent',\n hintText:\n 'Optional custom React component type for advanced filtering. This component receives AdvancedFilterProps and should render a custom filter UI. Use this when you need complex filtering beyond the built-in column filters.',\n },\n {\n content: 'React.ComponentType',\n hintText:\n 'React component class or function for custom advanced filtering',\n },\n { content: '' },\n ],\n [\n {\n content: 'advancedFilters',\n hintText:\n 'Optional array of advanced filter values in a format determined by your advancedFilterComponent. This represents the current state of advanced filters. Use with onAdvancedFiltersChange for controlled advanced filtering.',\n },\n {\n content: 'unknown[]',\n hintText:\n 'Array of filter values in custom format for advanced filtering',\n },\n { content: '' },\n ],\n [\n {\n content: 'serverSideFiltering',\n hintText:\n 'When true, filtering is handled on the server side. Filter configurations are passed to onFilterChange callback instead of filtering locally. Useful for large datasets or when filtering requires backend processing. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables server-side filtering, false uses client-side filtering',\n },\n { content: '' },\n ],\n [\n {\n content: 'onFilterChange',\n hintText:\n 'Optional callback function invoked when column filters change. Receives an array of ColumnFilter objects, each representing an active filter. Required when serverSideFiltering is true. Use this to trigger server-side filtering or update your data source.',\n },\n {\n content: '(filters: ColumnFilter[]) => void',\n hintText:\n 'Function called with array of active filters when filters change',\n },\n { content: '' },\n ],\n [\n {\n content: 'ColumnFilter.field',\n hintText:\n 'Required field name string that identifies which column to filter. This should match a field name from your data type (keyof T) and correspond to a filterable column definition.',\n },\n {\n content: 'keyof Record',\n hintText: 'Property name from the data type to filter by',\n },\n { content: '' },\n ],\n [\n {\n content: 'ColumnFilter.type',\n hintText:\n 'Required FilterType enum value that determines the filter input type. TEXT uses text input, NUMBER uses numeric input, SELECT uses dropdown, MULTISELECT allows multiple selections, DATE uses date picker, BOOLEAN uses checkbox, SLIDER uses range slider.',\n },\n {\n content: 'FilterType',\n hintText:\n 'Enum that determines the filter input component type',\n },\n {\n content:\n 'TEXT, NUMBER, SELECT, MULTISELECT, DATE, BOOLEAN, SLIDER',\n },\n ],\n [\n {\n content: 'ColumnFilter.value',\n hintText:\n 'Required filter value that can be a string (for TEXT, DATE, BOOLEAN), string[] (for MULTISELECT), or { min: number; max: number } (for SLIDER range). The value format depends on the filter type and operator.',\n },\n {\n content: 'string | string[] | { min: number; max: number }',\n hintText:\n 'Filter value matching the filter type (string, array, or range object)',\n },\n { content: '' },\n ],\n [\n {\n content: 'ColumnFilter.operator',\n hintText:\n 'Required filter operator string that determines how the filter value is compared to data. Options include: equals (exact match), contains (substring), startsWith, endsWith, gt/lt/gte/lte (numeric comparisons), and range (min-max). Defaults to \"contains\".',\n },\n {\n content:\n \"'equals' | 'contains' | 'startsWith' | 'endsWith' | 'gt' | 'lt' | 'gte' | 'lte' | 'range'\",\n hintText:\n 'Union type defining comparison operators for filtering',\n },\n {\n content:\n 'equals, contains, startsWith, endsWith, gt, lt, gte, lte, range',\n },\n ],\n [\n {\n content: 'onAdvancedFiltersChange',\n hintText:\n 'Optional callback function invoked when advanced filters change. Receives an array of filter values in the format defined by your advancedFilterComponent. Use this for controlled advanced filtering to sync state with your application.',\n },\n {\n content: '(filters: unknown[]) => void',\n hintText:\n 'Function called with array of advanced filter values when they change',\n },\n { content: '' },\n ],\n [\n {\n content: 'columnFreeze',\n hintText:\n 'Number of columns to freeze (make sticky) on the left side of the table. Frozen columns remain visible when scrolling horizontally. Useful for keeping important identifier columns (like names or IDs) always visible. Defaults to 0 (no frozen columns).',\n },\n {\n content: 'number',\n hintText:\n 'Positive integer representing number of columns to freeze from the left',\n },\n { content: '' },\n ],\n [\n {\n content: 'enableColumnManager',\n hintText:\n 'When true, enables column visibility management through a column manager menu. Users can show/hide columns dynamically. The column manager icon appears in the toolbar. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables column manager, false disables column visibility controls',\n },\n { content: '' },\n ],\n [\n {\n content: 'enableColumnReordering',\n hintText:\n 'When true, enables drag-and-drop column reordering functionality. Users can click and drag column headers to reorder columns dynamically. Works seamlessly with frozen columns and column visibility management. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables drag-and-drop column reordering, false disables column reordering',\n },\n { content: '' },\n ],\n [\n {\n content: 'onColumnReorder',\n hintText:\n 'Optional callback function invoked when the user reorders columns via drag-and-drop. Receives the new array of ColumnDefinition objects in the updated order. Use this to persist the column order state or sync with your application state.',\n },\n {\n content: '(columns: ColumnDefinition[]) => void',\n hintText:\n 'Function called with reordered column array when column order changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'columnManagerMaxSelections',\n hintText:\n 'Optional maximum number of columns that can be selected/visible at once in the column manager. When reached, users cannot enable additional columns. Useful for performance optimization or layout constraints.',\n },\n {\n content: 'number',\n hintText:\n 'Positive integer limiting the maximum number of visible columns',\n },\n { content: '' },\n ],\n [\n {\n content: 'columnManagerAlwaysSelected',\n hintText:\n 'Optional array of column field names (keyof T) that are always visible and cannot be hidden via the column manager. These columns appear in the manager but are disabled/unchecked. Useful for keeping critical columns always visible.',\n },\n {\n content: '(keyof T)[]',\n hintText:\n 'Array of field names that must always remain visible',\n },\n { content: '' },\n ],\n [\n {\n content: 'columnManagerPrimaryAction',\n hintText:\n 'Optional configuration object for the primary action button in the column manager menu. Contains text (button label), onClick (receives selected column IDs), and optional disabled/loading states. Typically used for \"Apply\" or \"Save\" actions.',\n },\n {\n content:\n '{ text: string; onClick: (selectedColumns: string[]) => void; disabled?: boolean; loading?: boolean }',\n hintText:\n 'Object with button text, click handler, and optional state flags',\n },\n { content: '' },\n ],\n [\n {\n content: 'columnManagerSecondaryAction',\n hintText:\n 'Optional configuration object for the secondary action button in the column manager menu. Contains text (button label), onClick (callback), and optional disabled/loading states. Typically used for \"Cancel\" or \"Reset\" actions.',\n },\n {\n content:\n '{ text: string; onClick: () => void; disabled?: boolean; loading?: boolean }',\n hintText:\n 'Object with button text, click handler, and optional state flags',\n },\n { content: '' },\n ],\n [\n {\n content: 'columnManagerWidth',\n hintText:\n 'Optional width in pixels for the column manager menu/dropdown. Controls the horizontal size of the column selection interface. If not provided, uses default width based on content.',\n },\n {\n content: 'number',\n hintText: 'Positive number representing menu width in pixels',\n },\n { content: '' },\n ],\n [\n {\n content: 'pagination',\n hintText:\n 'Optional PaginationConfig object that controls pagination behavior. Contains currentPage (current page number, 1-based), pageSize (rows per page), totalRows (total number of rows), and pageSizeOptions (available page size choices). Defaults to { currentPage: 1, pageSize: 10, totalRows: 0, pageSizeOptions: [10, 20, 50, 100] }.',\n },\n {\n content: 'PaginationConfig',\n hintText:\n 'Object with currentPage, pageSize, totalRows, and pageSizeOptions properties',\n },\n { content: '' },\n ],\n [\n {\n content: 'serverSidePagination',\n hintText:\n 'When true, pagination is handled on the server side. Page changes trigger onPageChange callback instead of slicing data locally. Use this for large datasets or when pagination requires backend processing. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables server-side pagination, false uses client-side pagination',\n },\n { content: '' },\n ],\n [\n {\n content: 'onPageChange',\n hintText:\n 'Optional callback function invoked when the user navigates to a different page. Receives the new page number (1-based). Required when serverSidePagination is true. Use this to fetch the appropriate page of data from your server.',\n },\n {\n content: '(page: number) => void',\n hintText:\n 'Function called with new page number when page changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'onPageSizeChange',\n hintText:\n 'Optional callback function invoked when the user changes the page size (rows per page). Receives the new page size number. Use this to update pagination configuration and potentially refetch data with the new page size.',\n },\n {\n content: '(pageSize: number) => void',\n hintText: 'Function called with new page size when it changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'isLoading',\n hintText:\n 'When true, displays skeleton loading in table cells, indicating that data is being fetched or processed. Useful for async operations like API calls. Skeleton loaders provide better UX than empty states. Works together with showSkeleton for granular control. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows skeleton loading state, false shows normal table state',\n },\n { content: '' },\n ],\n [\n {\n content: 'showSkeleton',\n hintText:\n 'When true, displays skeleton loading placeholders in all table cells (including checkboxes and expansion buttons). Provides granular control over skeleton display separate from isLoading. Can be overridden at the column level via column.showSkeleton or at the row level via isRowLoading. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows skeleton in all cells, false shows normal content',\n },\n { content: '' },\n ],\n [\n {\n content: 'skeletonVariant',\n hintText:\n 'Determines the skeleton animation style globally. \"pulse\" creates a pulsing fade effect, \"wave\" creates a shimmer wave effect moving across the skeleton. Can be overridden per column via column.skeletonVariant. Defaults to \"pulse\".',\n },\n {\n content: \"'pulse' | 'wave'\",\n hintText: 'Union type defining skeleton animation variants',\n },\n { content: 'pulse, wave' },\n ],\n [\n {\n content: 'isRowLoading',\n hintText:\n 'Optional function that determines whether a specific row should show skeleton loading. Receives the row data and index, returns boolean. Useful for showing skeleton on individual rows during updates (e.g., during save operations). Takes precedence over global showSkeleton/isLoading but can be overridden by column.showSkeleton.',\n },\n {\n content: '(row: T, index: number) => boolean',\n hintText:\n 'Function that returns true if row should show skeleton, false otherwise',\n },\n { content: '' },\n ],\n [\n {\n content: 'showHeader',\n hintText:\n 'When true, displays the table header section containing title, description, and header slots. When false, hides the header completely, showing only the table content. Useful for embedding tables in custom layouts. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows header section, false hides header completely',\n },\n { content: '' },\n ],\n [\n {\n content: 'showToolbar',\n hintText:\n 'When true, displays the toolbar section containing search input, filter controls, and action buttons. When false, hides the toolbar, removing search and filter functionality from the UI. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText: 'true shows toolbar, false hides toolbar completely',\n },\n { content: '' },\n ],\n [\n {\n content: 'showSettings',\n hintText:\n 'When true, displays a settings menu button in the toolbar that provides access to table configuration options. The settings menu typically includes column manager and other table customization options. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows settings button, false hides settings menu',\n },\n { content: '' },\n ],\n [\n {\n content: 'showFooter',\n hintText:\n 'When true, displays the pagination footer at the bottom of the table with page navigation and page size controls. When false, hides the footer completely. Useful for compact tables with only 1-2 rows where pagination controls are unnecessary. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows pagination footer, false hides footer completely',\n },\n { content: '' },\n ],\n [\n {\n content: 'headerSlot1',\n hintText:\n 'Optional React element displayed in the first position of the table header, before the title. Useful for adding icons, badges, or other visual elements that should appear at the start of the header area.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Any React element to display in the first header slot position',\n },\n { content: '' },\n ],\n [\n {\n content: 'headerSlot2',\n hintText:\n 'Optional React element displayed in the second position of the table header, after the title but before other header content. Useful for adding action buttons, controls, or supplementary information in the header.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Any React element to display in the second header slot position',\n },\n { content: '' },\n ],\n [\n {\n content: 'enableInlineEdit',\n hintText:\n 'When true, enables inline editing of table cells. Users can click on editable cells (defined by isEditable in column definitions) to edit values directly in the table. Requires onRowSave and optionally onRowCancel callbacks. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables inline editing, false disables cell editing',\n },\n { content: '' },\n ],\n [\n {\n content: 'onRowSave',\n hintText:\n 'Optional callback function invoked when a user saves changes to an edited row (typically by pressing Enter or clicking a save button). Receives rowId (the unique identifier) and updatedRow (the complete updated row object with all changes). Use this to persist changes to your data source.',\n },\n {\n content: '(rowId: unknown, updatedRow: T) => void',\n hintText:\n 'Function called with row ID and updated row data when saving',\n },\n { content: '' },\n ],\n [\n {\n content: 'onRowCancel',\n hintText:\n 'Optional callback function invoked when a user cancels editing a row (typically by pressing Escape or clicking a cancel button). Receives rowId (the unique identifier). Use this to handle cancellation logic or reset row state.',\n },\n {\n content: '(rowId: unknown) => void',\n hintText:\n 'Function called with row ID when editing is cancelled',\n },\n { content: '' },\n ],\n [\n {\n content: 'onRowClick',\n hintText:\n 'Optional callback function invoked when a user clicks on a table row. Receives the row data object and its index. Useful for navigation, row selection, or triggering row-specific actions. The row typically shows hover feedback when this is provided.',\n },\n {\n content: '(row: T, index: number) => void',\n hintText:\n 'Function called with row data and index when row is clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'onFieldChange',\n hintText:\n 'Optional callback function invoked when a field value changes during inline editing (before saving). Receives rowId (the unique identifier), fieldName (the field being edited), and value (the new field value). Use this for real-time validation or state updates.',\n },\n {\n content:\n '(rowId: unknown, fieldName: keyof T, value: unknown) => void',\n hintText:\n 'Function called with row ID, field name, and new value during editing',\n },\n { content: '' },\n ],\n [\n {\n content: 'enableRowExpansion',\n hintText:\n 'When true, enables expandable rows that can reveal additional content below the main row. Requires renderExpandedRow to define what content is shown when expanded. Useful for showing detailed information, nested data, or related content. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables row expansion, false disables expandable rows',\n },\n { content: '' },\n ],\n [\n {\n content: 'renderExpandedRow',\n hintText:\n 'Optional function that renders the content displayed when a row is expanded. Receives an expandedData object containing row (the row data), index (row index), isExpanded (current expansion state), and toggleExpansion (function to toggle expansion). Must return a ReactNode.',\n },\n {\n content:\n '(expandedData: { row: T; index: number; isExpanded: boolean; toggleExpansion: () => void }) => ReactNode',\n hintText:\n 'Function that returns React content for expanded row display',\n },\n { content: '' },\n ],\n [\n {\n content: 'isRowExpandable',\n hintText:\n 'Optional function that determines whether a specific row can be expanded. Receives the row data and index, returns a boolean. If not provided, all rows are expandable when enableRowExpansion is true. Useful for conditionally allowing expansion based on row properties.',\n },\n {\n content: '(row: T, index: number) => boolean',\n hintText:\n 'Function that returns true if row can be expanded, false otherwise',\n },\n { content: '' },\n ],\n [\n {\n content: 'onRowExpansionChange',\n hintText:\n \"Optional callback function invoked when a row's expansion state changes (expanded or collapsed). Receives rowId (the unique identifier), isExpanded (new expansion state), and rowData (the row object). Use this to track expansion state or trigger side effects.\",\n },\n {\n content:\n '(rowId: unknown, isExpanded: boolean, rowData: T) => void',\n hintText:\n 'Function called with row ID, expansion state, and row data when expansion changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'enableRowSelection',\n hintText:\n 'When true, enables row selection with checkboxes in the first column. Users can select individual rows or use \"select all\" functionality. Selected rows can trigger bulk actions when showBulkActionBar is true. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables row selection, false disables selection checkboxes',\n },\n { content: '' },\n ],\n [\n {\n content: 'showBulkActionBar',\n hintText:\n 'When true (default), the bulk action bar is shown when rows are selected, with Export, Deselect all, and custom actions. When false, the bulk action bar is hidden while checkbox selection remains available. Use when you need selection state (e.g. via onRowSelectionChange) but do not want to show the bulk actions UI.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows bulk action bar when rows are selected (default), false hides it',\n },\n { content: 'true' },\n ],\n [\n {\n content: 'onRowSelectionChange',\n hintText:\n 'Optional callback function invoked when row selection state changes (row selected or deselected). Receives selectedRowIds (array of all currently selected row IDs), isSelected (whether the triggering row was selected or deselected), rowId (the ID of the row that triggered the change), and rowData (the RAW data from the original data array, not processed/filtered/formatted data displayed in the table). The rowData parameter contains the exact data structure from your API response, ensuring you have access to all original fields for API operations, even if they are not displayed in the table columns. This is essential for operations like updates, deletes, or exports where you need the complete original data.',\n },\n {\n content:\n '(selectedRowIds: string[], isSelected: boolean, rowId: string, rowData: T) => void',\n hintText:\n 'Function called with selection state and raw row data when row selection changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'bulkActions',\n hintText:\n 'Optional BulkActionsConfig object that configures the bulk actions bar displayed when rows are selected. Contains showSelectAll, showDeselectAll, onSelectAll, onDeselectAll callbacks, customActions (React element for custom action buttons), and showExport (boolean to control visibility of the default Export button). When showExport is false, the Export button is hidden and only customActions are shown. Defaults to true (Export button visible). The bulk actions bar appears above the table when one or more rows are selected.',\n },\n {\n content: 'BulkActionsConfig',\n hintText: 'Object with bulk action configuration properties',\n },\n {\n content: 'BulkActionsConfig: see BulkActionsConfig props below',\n },\n ],\n [\n {\n content: 'bulkActions.showSelectAll',\n hintText:\n 'Optional boolean that controls whether to show the \"Select All\" button in the bulk actions bar. When true, displays a button that allows users to select all rows on the current page. When false, the select all functionality is hidden. Defaults to undefined (not shown).',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows select all button, false/undefined hides it',\n },\n { content: '' },\n ],\n [\n {\n content: 'bulkActions.showDeselectAll',\n hintText:\n 'Optional boolean that controls whether to show the \"Deselect All\" button in the bulk actions bar. When true, displays a button that allows users to deselect all currently selected rows. When false, the deselect all functionality is hidden. Defaults to undefined (not shown).',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows deselect all button, false/undefined hides it',\n },\n { content: '' },\n ],\n [\n {\n content: 'bulkActions.showExport',\n hintText:\n 'Optional boolean that controls whether to show the default Export button in the BulkActionBar. When true (default), the Export button appears in the bulk actions bar and triggers CSV export of selected rows. When false, the Export button is hidden and only customActions are shown. Useful when you want to provide your own export functionality or hide export entirely. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText: 'true shows export button (default), false hides it',\n },\n { content: 'true' },\n ],\n [\n {\n content: 'bulkActions.customActions',\n hintText:\n 'Optional React element (typically buttons or action components) displayed in the bulk actions bar alongside the default Export button (if shown). Use this to add custom bulk actions like \"Delete\", \"Archive\", \"Send Email\", etc. The custom actions appear after the default Export button and Deselect All button.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React elements (typically buttons) for custom bulk actions',\n },\n { content: '' },\n ],\n [\n {\n content: 'bulkActions.onSelectAll',\n hintText:\n 'Optional callback function invoked when the \"Select All\" button is clicked. Use this to handle custom select all logic if needed. Note that the DataTable component handles selection internally, so this callback is primarily for side effects or custom behavior.',\n },\n {\n content: '() => void',\n hintText: 'Function called when select all button is clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'bulkActions.onDeselectAll',\n hintText:\n 'Optional callback function invoked when the \"Deselect All\" button is clicked. Use this to handle custom deselect all logic if needed. Note that the DataTable component handles deselection internally, so this callback is primarily for side effects or custom behavior.',\n },\n {\n content: '() => void',\n hintText: 'Function called when deselect all button is clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'BulkAction.id',\n hintText:\n 'Required unique identifier string for the bulk action. Used internally to track and identify the action. Should be unique across all bulk actions in the array.',\n },\n {\n content: 'string',\n hintText: 'Unique identifier string for the bulk action',\n },\n { content: '' },\n ],\n [\n {\n content: 'BulkAction.label',\n hintText:\n 'Required text string displayed on the bulk action button. Should be concise and action-oriented, clearly describing what the action does (e.g., \"Delete\", \"Export\", \"Archive\").',\n },\n {\n content: 'string',\n hintText: 'Button label text displayed to users',\n },\n { content: '' },\n ],\n [\n {\n content: 'BulkAction.variant',\n hintText:\n 'Required visual style variant string for the bulk action button. \"primary\" creates a prominent button for main actions, \"secondary\" creates a less prominent button, \"danger\" creates a warning-style button for destructive actions. Each variant has distinct styling.',\n },\n {\n content: \"'primary' | 'secondary' | 'danger'\",\n hintText: 'Union type defining button visual style variants',\n },\n { content: 'primary, secondary, danger' },\n ],\n [\n {\n content: 'BulkAction.onClick',\n hintText:\n 'Required callback function invoked when the bulk action button is clicked. Receives an array of selected row IDs (strings) corresponding to the idField values of selected rows. Use this to perform the bulk operation on the selected rows.',\n },\n {\n content: '(selectedRowIds: string[]) => void',\n hintText:\n 'Function called with array of selected row IDs when action is clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'rowActions',\n hintText:\n 'Optional RowActionsConfig object that configures action buttons displayed at the row level (typically in the last column or a dedicated actions column). Contains slot1 and slot2 properties for positioning two action buttons per row. Useful for row-specific operations like edit, delete, or view details.',\n },\n {\n content: 'RowActionsConfig',\n hintText:\n 'Object with slot1 and slot2 properties for row action buttons',\n },\n { content: '' },\n ],\n [\n {\n content: 'getRowStyle',\n hintText:\n 'Optional function that returns custom CSS styles for individual rows. Receives the row data and index, returns a React.CSSProperties object. Useful for conditional styling based on row data (e.g., highlighting rows based on status, priority, or other properties).',\n },\n {\n content: '(row: T, index: number) => React.CSSProperties',\n hintText:\n 'Function that returns CSS properties for custom row styling',\n },\n { content: '' },\n ],\n [\n {\n content: 'tableBodyHeight',\n hintText:\n 'Optional fixed height for the table body (scrollable content area). Accepts CSS values as string (e.g., \"400px\", \"50vh\") or numbers (interpreted as pixels). When set, the table body becomes scrollable with a fixed height. Useful for maintaining consistent layout in dashboards or constrained spaces.',\n },\n {\n content: 'string | number',\n hintText:\n 'CSS height value (px, vh, rem) as string or number for pixels',\n },\n { content: '' },\n ],\n [\n {\n content: 'mobileColumnsToShow',\n hintText:\n 'Optional number specifying how many columns to display on mobile devices. When provided, the table automatically adapts to show only the specified number of columns on small screens, with remaining columns accessible via overflow or expansion. Useful for responsive design.',\n },\n {\n content: 'number',\n hintText:\n 'Positive integer representing number of columns to show on mobile',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nColumnDefinition\n\nEach column in the columns array should have the following structure:\n\n string',\n hintText:\n 'Function that returns the field name to sort by based on sort type',\n },\n { content: '' },\n ],\n [\n {\n content: 'isDeltaSortable',\n hintText:\n 'When true, enables delta sorting UI in the sorting popover. When enabled, the sorting popover displays \"Value | Delta\" sections, allowing users to sort by either the primary field or a delta field. Requires getSortField to be provided for delta sorting to work. When false or undefined, only standard sorting options are shown. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows delta sorting UI, false/undefined shows only standard sorting',\n },\n { content: '' },\n ],\n [\n {\n content: 'sortValueFormatter',\n hintText:\n 'Optional function to format/transform values before comparison during sorting. Receives value (the raw value from the data row), row (the entire row data), column (the column definition), and sortType (optional sort type identifier like \"primary\", \"delta\", \"absolute\" - same value passed to getSortField). The sortType parameter allows you to apply different formatting logic for delta sorting vs primary sorting. For example, delta values might already be numbers while primary values might be formatted strings. Returns the formatted value to use for comparison. Useful for custom sorting logic, such as extracting numbers from formatted strings (e.g., \"INR 276\" -> 276) or parsing percentage strings (e.g., \"12.5%\" -> 12.5). If the formatter throws an error, the original value is used as a fallback.',\n },\n {\n content:\n '(value: unknown, row: T, column: ColumnDefinition, sortType?: string) => unknown',\n hintText:\n 'Function that transforms values before sorting comparison, with sortType context to differentiate delta vs primary sorting',\n },\n { content: '' },\n ],\n [\n {\n content: 'isVisible',\n hintText:\n 'When true, the column is displayed in the table. When false, the column is hidden but can be shown via the column manager if enableColumnManager is true. Useful for conditionally showing columns or starting with a subset visible. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows column, false hides column (but can be shown via column manager)',\n },\n { content: '' },\n ],\n [\n {\n content: 'isEditable',\n hintText:\n 'When true and enableInlineEdit is enabled, cells in this column can be edited inline by clicking on them. Users can modify values directly in the table. Editable cells show visual feedback when in edit mode. If false or enableInlineEdit is false, cells are read-only.',\n },\n {\n content: 'boolean',\n hintText:\n 'true allows inline editing, false makes cells read-only',\n },\n { content: '' },\n ],\n [\n {\n content: 'minWidth',\n hintText:\n 'Optional minimum width constraint for the column. Accepts any valid CSS width value (e.g., \"100px\", \"10rem\", \"20%\"). The column will not shrink below this width, ensuring minimum readability. Useful for preventing columns from becoming too narrow.',\n },\n {\n content: 'string',\n hintText:\n 'CSS width value (px, %, em, rem) for minimum column width',\n },\n { content: '' },\n ],\n [\n {\n content: 'maxWidth',\n hintText:\n 'Optional maximum width constraint for the column. Accepts any valid CSS width value. The column will not grow beyond this width, preventing it from taking too much space. Useful for constraining wide columns or maintaining table layout.',\n },\n {\n content: 'string',\n hintText:\n 'CSS width value (px, %, em, rem) for maximum column width',\n },\n { content: '' },\n ],\n [\n {\n content: 'width',\n hintText:\n 'Optional fixed width for the column. Accepts any valid CSS width value. When provided, the column maintains this exact width regardless of content. Overrides minWidth and maxWidth. Useful for consistent column sizing.',\n },\n {\n content: 'string',\n hintText:\n 'CSS width value (px, %, em, rem) for fixed column width',\n },\n { content: '' },\n ],\n [\n {\n content: 'filterOptions',\n hintText:\n 'Optional array of FilterOption objects that provide predefined filter choices for this column. Used when filterType is SELECT or MULTISELECT. Each option should have a label and value. Users can select from these options instead of typing custom values.',\n },\n {\n content: 'FilterOption[]',\n hintText:\n 'Array of filter option objects with label and value properties',\n },\n { content: '' },\n ],\n [\n {\n content: 'canHide',\n hintText:\n 'When true, this column can be hidden/shown via the column manager menu. When false, the column is always visible and cannot be hidden by users. Useful for keeping critical columns always visible regardless of column manager settings.',\n },\n {\n content: 'boolean',\n hintText:\n 'true allows hiding via column manager, false forces column to always be visible',\n },\n { content: '' },\n ],\n [\n {\n content: 'frozen',\n hintText:\n 'When true, this column is frozen (sticky) and remains visible when scrolling horizontally. Frozen columns stay fixed on the left side. Useful for keeping identifier columns (like names or IDs) always visible. The number of frozen columns should match columnFreeze prop.',\n },\n {\n content: 'boolean',\n hintText:\n 'true freezes column on left, false allows normal scrolling',\n },\n { content: '' },\n ],\n [\n {\n content: 'className',\n hintText:\n 'Optional CSS class name string applied to column cells. Useful for custom styling, conditional styling via CSS, or targeting specific columns with external stylesheets. The class is applied to all cells in this column.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to column cells',\n },\n { content: '' },\n ],\n [\n {\n content: 'showSkeleton',\n hintText:\n 'Optional boolean that controls skeleton loading for this specific column. When true, cells in this column show skeleton during loading. When false, cells never show skeleton regardless of global showSkeleton or isLoading. When undefined (default), inherits from global showSkeleton/isLoading or row-level isRowLoading. Useful for excluding specific columns from skeleton loading.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows skeleton, false hides skeleton, undefined inherits from global/row settings',\n },\n { content: '' },\n ],\n [\n {\n content: 'skeletonVariant',\n hintText:\n 'Optional skeleton animation variant for this specific column. Overrides the global skeletonVariant prop. \"pulse\" creates a pulsing fade effect, \"wave\" creates a shimmer wave effect. Useful for having different animation styles for different columns (e.g., wave for text columns, pulse for numeric columns).',\n },\n {\n content: \"'pulse' | 'wave'\",\n hintText:\n 'Union type defining skeleton animation variant for this column',\n },\n { content: 'pulse, wave' },\n ],\n [\n {\n content: 'filterType',\n hintText:\n 'Optional FilterType enum value that overrides the default filter type for this column. If not provided, the filter type is inferred from the column type. Specifying this allows custom filter behavior even when using a different column type for rendering.',\n },\n {\n content: 'FilterType',\n hintText:\n 'Enum that determines the filter input component for this column',\n },\n {\n content:\n 'TEXT, NUMBER, SELECT, MULTISELECT, DATE, BOOLEAN, SLIDER',\n },\n ],\n [\n {\n content: 'renderCell',\n hintText:\n 'Optional custom render function that completely overrides the default cell rendering for this column. Receives value (the cell value), row (the entire row data), and index (the row index). Must return a ReactNode. Use this for highly customized cell content.',\n },\n {\n content: '(value, row, index) => ReactNode',\n hintText:\n 'Function that returns custom React content for cell rendering',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the DataTable component using the following tokens:",
+ "content": "Usage\n\n\n\nSkeleton Loading\n\nThe DataTable supports granular skeleton loading for better UX during data fetching. You can control skeleton loading at the table level, column level, or row level.\n\nGlobal Skeleton Loading\n\n\n\nPer-Column Skeleton Control\n\n\n\nPer-Row Skeleton Loading\n\n\n\nSkeleton Priority\n\nSkeleton loading follows this priority order:\n\n1. Column-level showSkeleton (highest priority)\n2. Row-level isRowLoading function\n3. Global showSkeleton or isLoading (lowest priority)\n\nDelta Sorting\n\nThe DataTable supports delta sorting, allowing you to sort by a different field than the column's primary field. This is useful when you have related fields like total_volume and delta_total_volume, where you want to sort by the delta value instead of the primary value.\n\nEnabling Delta Sorting\n\nTo enable delta sorting for a column:\n\n1. Set isDeltaSortable: true on the column definition\n2. Provide a getSortField function that returns the appropriate field based on sortType\n3. Optionally provide a sortValueFormatter for custom value transformation\n\n\n\nWhen isDeltaSortable is enabled, the sorting popover displays two sections:\n\n- Value: Standard sorting by the primary field\n- Delta: Sorting by the delta field (via getSortField)\n\nSort Value Formatter\n\nThe sortValueFormatter function allows you to transform values before comparison. This is useful for:\n\n- Extracting numbers from formatted currency strings (e.g., \"INR 276\" β 276)\n- Parsing percentage strings (e.g., \"12.5%\" β 12.5)\n- Custom normalization logic\n\nIf the formatter throws an error, the original value is used as a fallback.\n\nHiding the Footer\n\nFor compact tables with only a few rows, you can hide the pagination footer completely using the showFooter prop. This is useful when displaying 1-2 rows where pagination controls are unnecessary.\n\n\n\nAPI Reference\n\n[]',\n hintText:\n 'Array of column configuration objects defining table structure',\n },\n { content: '' },\n ],\n [\n {\n content: 'idField',\n hintText:\n 'Required key of type keyof T that identifies the unique identifier field in each data object. This field is used for row identification, selection tracking, expansion state, and row-level operations. Must be a field that exists in all data objects.',\n },\n {\n content: 'keyof T',\n hintText:\n 'Property name from the data type that serves as unique row identifier',\n },\n { content: '' },\n ],\n [\n {\n content: 'title',\n hintText:\n \"Optional title string displayed prominently at the top of the table header. Provides context and description of the table's purpose. Typically shown above the description and toolbar when showHeader is true.\",\n },\n {\n content: 'string',\n hintText:\n 'Text content for the table title displayed in the header',\n },\n { content: '' },\n ],\n [\n {\n content: 'description',\n hintText:\n 'Optional descriptive text displayed below the title in the table header. Provides additional context, instructions, or summary information about the table content. Useful for explaining data sources or usage guidelines. When the text is truncated due to space constraints, a tooltip will automatically appear on hover showing the full text.',\n },\n {\n content: 'string',\n hintText:\n 'Descriptive text shown below the title in the header',\n },\n { content: '' },\n ],\n [\n {\n content: 'descriptionTooltipProps',\n hintText:\n 'Optional tooltip configuration object for customizing the tooltip that appears when the description text is truncated. Allows control over tooltip direction (side: top, right, bottom, left), alignment (align: start, center, end), size (sm, lg), arrow visibility, delay duration, and offset distance. When the description overflows its container, hovering over it will show a tooltip with the full text. Use this prop to position the tooltip optimally based on your layout and prevent it from being cut off by viewport edges.',\n },\n {\n content:\n '{\\n side?: \"top\" | \"right\" | \"bottom\" | \"left\"\\n align?: \"start\" | \"center\" | \"end\"\\n size?: \"sm\" | \"lg\"\\n showArrow?: boolean\\n delayDuration?: number\\n offset?: number\\n}',\n hintText:\n 'Object with optional tooltip configuration properties',\n },\n { content: '' },\n ],\n [\n {\n content: 'defaultSort',\n hintText:\n 'Optional SortConfig object that sets the initial sort state when the table first renders. Contains field (column to sort by) and direction (NONE, ASCENDING, or DESCENDING). If not provided, the table starts unsorted.',\n },\n {\n content: 'SortConfig',\n hintText:\n 'Object with field and direction properties for initial sorting',\n },\n { content: '' },\n ],\n [\n {\n content: 'SortConfig.field',\n hintText:\n 'Required field name string from the data type (keyof T) that specifies which column to sort by. This field must exist in the data objects and should correspond to a sortable column definition.',\n },\n {\n content: 'string',\n hintText: 'Property name from the data type to sort by',\n },\n { content: '' },\n ],\n [\n {\n content: 'SortConfig.direction',\n hintText:\n 'Required SortDirection enum value that determines the sort order. NONE means no sorting is applied, ASCENDING sorts from lowest to highest (A-Z, 0-9), DESCENDING sorts from highest to lowest (Z-A, 9-0).',\n },\n {\n content: 'SortDirection',\n hintText: 'Enum that determines the sort order direction',\n },\n { content: 'NONE, ASCENDING, DESCENDING' },\n ],\n [\n {\n content: 'onSortChange',\n hintText:\n 'Optional callback function invoked when the user changes the sort configuration (clicks a column header). Receives the new SortConfig object with field and direction. Use this for controlled sorting or to sync sort state with your application.',\n },\n {\n content: '(sortConfig: SortConfig) => void',\n hintText:\n 'Function called with new sort configuration when sorting changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'enableSearch',\n hintText:\n 'When true, displays a global search input in the toolbar that allows users to search across all columns. The search filters rows client-side by default, or triggers server-side search if serverSideSearch is enabled. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows search input, false hides search functionality',\n },\n { content: '' },\n ],\n [\n {\n content: 'searchPlaceholder',\n hintText:\n 'Optional placeholder text displayed in the search input field when it\\'s empty. Provides guidance to users about what they can search for. Should be concise and descriptive. Defaults to \"Search...\".',\n },\n {\n content: 'string',\n hintText: 'Placeholder text shown when search input is empty',\n },\n { content: '' },\n ],\n [\n {\n content: 'serverSideSearch',\n hintText:\n 'When true, search functionality is handled on the server side. The search query is passed to onSearchChange callback instead of filtering locally. Useful for large datasets or when search requires backend processing. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables server-side search, false uses client-side filtering',\n },\n { content: '' },\n ],\n [\n {\n content: 'onSearchChange',\n hintText:\n 'Optional callback function invoked when the search query changes. Receives a SearchConfig object containing the search query. Required when serverSideSearch is true. Use this to trigger server-side search or update your data source.',\n },\n {\n content: '(searchConfig: SearchConfig) => void',\n hintText:\n 'Function called with search configuration when query changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'enableFiltering',\n hintText:\n 'When true, enables column-based filtering functionality. Users can click filter icons in column headers to apply filters. Filters can be combined and work together. Requires onFilterChange callback for controlled filtering. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables column filtering, false disables filter functionality',\n },\n { content: '' },\n ],\n [\n {\n content: 'enableAdvancedFilter',\n hintText:\n 'When true, displays an advanced filter component that allows complex filtering logic beyond simple column filters. Requires advancedFilterComponent to be provided. Useful for multi-condition filtering with AND/OR logic. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows advanced filter component, false hides it',\n },\n { content: '' },\n ],\n [\n {\n content: 'advancedFilterComponent',\n hintText:\n 'Optional custom React component type for advanced filtering. This component receives AdvancedFilterProps and should render a custom filter UI. Use this when you need complex filtering beyond the built-in column filters.',\n },\n {\n content: 'React.ComponentType',\n hintText:\n 'React component class or function for custom advanced filtering',\n },\n { content: '' },\n ],\n [\n {\n content: 'advancedFilters',\n hintText:\n 'Optional array of advanced filter values in a format determined by your advancedFilterComponent. This represents the current state of advanced filters. Use with onAdvancedFiltersChange for controlled advanced filtering.',\n },\n {\n content: 'unknown[]',\n hintText:\n 'Array of filter values in custom format for advanced filtering',\n },\n { content: '' },\n ],\n [\n {\n content: 'serverSideFiltering',\n hintText:\n 'When true, filtering is handled on the server side. Filter configurations are passed to onFilterChange callback instead of filtering locally. Useful for large datasets or when filtering requires backend processing. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables server-side filtering, false uses client-side filtering',\n },\n { content: '' },\n ],\n [\n {\n content: 'onFilterChange',\n hintText:\n 'Optional callback function invoked when column filters change. Receives an array of ColumnFilter objects, each representing an active filter. Required when serverSideFiltering is true. Use this to trigger server-side filtering or update your data source.',\n },\n {\n content: '(filters: ColumnFilter[]) => void',\n hintText:\n 'Function called with array of active filters when filters change',\n },\n { content: '' },\n ],\n [\n {\n content: 'ColumnFilter.field',\n hintText:\n 'Required field name string that identifies which column to filter. This should match a field name from your data type (keyof T) and correspond to a filterable column definition.',\n },\n {\n content: 'keyof Record',\n hintText: 'Property name from the data type to filter by',\n },\n { content: '' },\n ],\n [\n {\n content: 'ColumnFilter.type',\n hintText:\n 'Required FilterType enum value that determines the filter input type. TEXT uses text input, NUMBER uses numeric input, SELECT uses dropdown, MULTISELECT allows multiple selections, DATE uses date picker, BOOLEAN uses checkbox, SLIDER uses range slider.',\n },\n {\n content: 'FilterType',\n hintText:\n 'Enum that determines the filter input component type',\n },\n {\n content:\n 'TEXT, NUMBER, SELECT, MULTISELECT, DATE, BOOLEAN, SLIDER',\n },\n ],\n [\n {\n content: 'ColumnFilter.value',\n hintText:\n 'Required filter value that can be a string (for TEXT, DATE, BOOLEAN), string[] (for MULTISELECT), or { min: number; max: number } (for SLIDER range). The value format depends on the filter type and operator.',\n },\n {\n content: 'string | string[] | { min: number; max: number }',\n hintText:\n 'Filter value matching the filter type (string, array, or range object)',\n },\n { content: '' },\n ],\n [\n {\n content: 'ColumnFilter.operator',\n hintText:\n 'Required filter operator string that determines how the filter value is compared to data. Options include: equals (exact match), contains (substring), startsWith, endsWith, gt/lt/gte/lte (numeric comparisons), and range (min-max). Defaults to \"contains\".',\n },\n {\n content:\n \"'equals' | 'contains' | 'startsWith' | 'endsWith' | 'gt' | 'lt' | 'gte' | 'lte' | 'range'\",\n hintText:\n 'Union type defining comparison operators for filtering',\n },\n {\n content:\n 'equals, contains, startsWith, endsWith, gt, lt, gte, lte, range',\n },\n ],\n [\n {\n content: 'onAdvancedFiltersChange',\n hintText:\n 'Optional callback function invoked when advanced filters change. Receives an array of filter values in the format defined by your advancedFilterComponent. Use this for controlled advanced filtering to sync state with your application.',\n },\n {\n content: '(filters: unknown[]) => void',\n hintText:\n 'Function called with array of advanced filter values when they change',\n },\n { content: '' },\n ],\n [\n {\n content: 'columnFreeze',\n hintText:\n 'Number of columns to freeze (make sticky) on the left side of the table. Frozen columns remain visible when scrolling horizontally. Useful for keeping important identifier columns (like names or IDs) always visible. Defaults to 0 (no frozen columns).',\n },\n {\n content: 'number',\n hintText:\n 'Positive integer representing number of columns to freeze from the left',\n },\n { content: '' },\n ],\n [\n {\n content: 'enableColumnManager',\n hintText:\n 'When true, enables column visibility management through a column manager menu. Users can show/hide columns dynamically. The column manager icon appears in the toolbar. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables column manager, false disables column visibility controls',\n },\n { content: '' },\n ],\n [\n {\n content: 'enableColumnReordering',\n hintText:\n 'When true, enables drag-and-drop column reordering functionality. Users can click and drag column headers to reorder columns dynamically. Works seamlessly with frozen columns and column visibility management. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables drag-and-drop column reordering, false disables column reordering',\n },\n { content: '' },\n ],\n [\n {\n content: 'onColumnReorder',\n hintText:\n 'Optional callback function invoked when the user reorders columns via drag-and-drop. Receives the new array of ColumnDefinition objects in the updated order. Use this to persist the column order state or sync with your application state.',\n },\n {\n content: '(columns: ColumnDefinition[]) => void',\n hintText:\n 'Function called with reordered column array when column order changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'columnManagerMaxSelections',\n hintText:\n 'Optional maximum number of columns that can be selected/visible at once in the column manager. When reached, users cannot enable additional columns. Useful for performance optimization or layout constraints.',\n },\n {\n content: 'number',\n hintText:\n 'Positive integer limiting the maximum number of visible columns',\n },\n { content: '' },\n ],\n [\n {\n content: 'columnManagerAlwaysSelected',\n hintText:\n 'Optional array of column field names (keyof T) that are always visible and cannot be hidden via the column manager. These columns appear in the manager but are disabled/unchecked. Useful for keeping critical columns always visible.',\n },\n {\n content: '(keyof T)[]',\n hintText:\n 'Array of field names that must always remain visible',\n },\n { content: '' },\n ],\n [\n {\n content: 'columnManagerPrimaryAction',\n hintText:\n 'Optional configuration object for the primary action button in the column manager menu. Contains text (button label), onClick (receives selected column IDs), and optional disabled/loading states. Typically used for \"Apply\" or \"Save\" actions.',\n },\n {\n content:\n '{ text: string; onClick: (selectedColumns: string[]) => void; disabled?: boolean; loading?: boolean }',\n hintText:\n 'Object with button text, click handler, and optional state flags',\n },\n { content: '' },\n ],\n [\n {\n content: 'columnManagerSecondaryAction',\n hintText:\n 'Optional configuration object for the secondary action button in the column manager menu. Contains text (button label), onClick (callback), and optional disabled/loading states. Typically used for \"Cancel\" or \"Reset\" actions.',\n },\n {\n content:\n '{ text: string; onClick: () => void; disabled?: boolean; loading?: boolean }',\n hintText:\n 'Object with button text, click handler, and optional state flags',\n },\n { content: '' },\n ],\n [\n {\n content: 'columnManagerWidth',\n hintText:\n 'Optional width in pixels for the column manager menu/dropdown. Controls the horizontal size of the column selection interface. If not provided, uses default width based on content.',\n },\n {\n content: 'number',\n hintText: 'Positive number representing menu width in pixels',\n },\n { content: '' },\n ],\n [\n {\n content: 'pagination',\n hintText:\n 'Optional PaginationConfig object that controls pagination behavior. Contains currentPage (current page number, 1-based), pageSize (rows per page), totalRows (total number of rows), and pageSizeOptions (available page size choices). Defaults to { currentPage: 1, pageSize: 10, totalRows: 0, pageSizeOptions: [10, 20, 50, 100] }.',\n },\n {\n content: 'PaginationConfig',\n hintText:\n 'Object with currentPage, pageSize, totalRows, and pageSizeOptions properties',\n },\n { content: '' },\n ],\n [\n {\n content: 'serverSidePagination',\n hintText:\n 'When true, pagination is handled on the server side. Page changes trigger onPageChange callback instead of slicing data locally. Use this for large datasets or when pagination requires backend processing. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables server-side pagination, false uses client-side pagination',\n },\n { content: '' },\n ],\n [\n {\n content: 'onPageChange',\n hintText:\n 'Optional callback function invoked when the user navigates to a different page. Receives the new page number (1-based). Required when serverSidePagination is true. Use this to fetch the appropriate page of data from your server.',\n },\n {\n content: '(page: number) => void',\n hintText:\n 'Function called with new page number when page changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'onPageSizeChange',\n hintText:\n 'Optional callback function invoked when the user changes the page size (rows per page). Receives the new page size number. Use this to update pagination configuration and potentially refetch data with the new page size.',\n },\n {\n content: '(pageSize: number) => void',\n hintText: 'Function called with new page size when it changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'isLoading',\n hintText:\n 'When true, displays skeleton loading in table cells, indicating that data is being fetched or processed. Useful for async operations like API calls. Skeleton loaders provide better UX than empty states. Works together with showSkeleton for granular control. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows skeleton loading state, false shows normal table state',\n },\n { content: '' },\n ],\n [\n {\n content: 'showSkeleton',\n hintText:\n 'When true, displays skeleton loading placeholders in all table cells (including checkboxes and expansion buttons). Provides granular control over skeleton display separate from isLoading. Can be overridden at the column level via column.showSkeleton or at the row level via isRowLoading. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows skeleton in all cells, false shows normal content',\n },\n { content: '' },\n ],\n [\n {\n content: 'skeletonVariant',\n hintText:\n 'Determines the skeleton animation style globally. \"pulse\" creates a pulsing fade effect, \"wave\" creates a shimmer wave effect moving across the skeleton. Can be overridden per column via column.skeletonVariant. Defaults to \"pulse\".',\n },\n {\n content: \"'pulse' | 'wave'\",\n hintText: 'Union type defining skeleton animation variants',\n },\n { content: 'pulse, wave' },\n ],\n [\n {\n content: 'isRowLoading',\n hintText:\n 'Optional function that determines whether a specific row should show skeleton loading. Receives the row data and index, returns boolean. Useful for showing skeleton on individual rows during updates (e.g., during save operations). Takes precedence over global showSkeleton/isLoading but can be overridden by column.showSkeleton.',\n },\n {\n content: '(row: T, index: number) => boolean',\n hintText:\n 'Function that returns true if row should show skeleton, false otherwise',\n },\n { content: '' },\n ],\n [\n {\n content: 'showHeader',\n hintText:\n 'When true, displays the table header section containing title, description, and header slots. When false, hides the header completely, showing only the table content. Useful for embedding tables in custom layouts. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows header section, false hides header completely',\n },\n { content: '' },\n ],\n [\n {\n content: 'showToolbar',\n hintText:\n 'When true, displays the toolbar section containing search input, filter controls, and action buttons. When false, hides the toolbar, removing search and filter functionality from the UI. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText: 'true shows toolbar, false hides toolbar completely',\n },\n { content: '' },\n ],\n [\n {\n content: 'showSettings',\n hintText:\n 'When true, displays a settings menu button in the toolbar that provides access to table configuration options. The settings menu typically includes column manager and other table customization options. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows settings button, false hides settings menu',\n },\n { content: '' },\n ],\n [\n {\n content: 'showFooter',\n hintText:\n 'When true, displays the pagination footer at the bottom of the table with page navigation and page size controls. When false, hides the footer completely. Useful for compact tables with only 1-2 rows where pagination controls are unnecessary. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows pagination footer, false hides footer completely',\n },\n { content: '' },\n ],\n [\n {\n content: 'isHoverable',\n hintText:\n 'When true, enables hover effects on table rows, highlighting the row when the user hovers over it with the mouse cursor. This provides better visual feedback and improves the user experience when scanning through data. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables hover effects on rows, false disables hover highlighting',\n },\n { content: '' },\n ],\n [\n {\n content: 'headerSlot1',\n hintText:\n 'Optional React element displayed in the first position of the table header, before the title. Useful for adding icons, badges, or other visual elements that should appear at the start of the header area.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Any React element to display in the first header slot position',\n },\n { content: '' },\n ],\n [\n {\n content: 'headerSlot2',\n hintText:\n 'Optional React element displayed in the second position of the table header, after the title but before other header content. Useful for adding action buttons, controls, or supplementary information in the header.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Any React element to display in the second header slot position',\n },\n { content: '' },\n ],\n [\n {\n content: 'enableInlineEdit',\n hintText:\n 'When true, enables inline editing of table cells. Users can click on editable cells (defined by isEditable in column definitions) to edit values directly in the table. Requires onRowSave and optionally onRowCancel callbacks. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables inline editing, false disables cell editing',\n },\n { content: '' },\n ],\n [\n {\n content: 'onRowSave',\n hintText:\n 'Optional callback function invoked when a user saves changes to an edited row (typically by pressing Enter or clicking a save button). Receives rowId (the unique identifier) and updatedRow (the complete updated row object with all changes). Use this to persist changes to your data source.',\n },\n {\n content: '(rowId: unknown, updatedRow: T) => void',\n hintText:\n 'Function called with row ID and updated row data when saving',\n },\n { content: '' },\n ],\n [\n {\n content: 'onRowCancel',\n hintText:\n 'Optional callback function invoked when a user cancels editing a row (typically by pressing Escape or clicking a cancel button). Receives rowId (the unique identifier). Use this to handle cancellation logic or reset row state.',\n },\n {\n content: '(rowId: unknown) => void',\n hintText:\n 'Function called with row ID when editing is cancelled',\n },\n { content: '' },\n ],\n [\n {\n content: 'onRowClick',\n hintText:\n 'Optional callback function invoked when a user clicks on a table row. Receives the row data object and its index. Useful for navigation, row selection, or triggering row-specific actions. The row typically shows hover feedback when this is provided.',\n },\n {\n content: '(row: T, index: number) => void',\n hintText:\n 'Function called with row data and index when row is clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'onFieldChange',\n hintText:\n 'Optional callback function invoked when a field value changes during inline editing (before saving). Receives rowId (the unique identifier), fieldName (the field being edited), and value (the new field value). Use this for real-time validation or state updates.',\n },\n {\n content:\n '(rowId: unknown, fieldName: keyof T, value: unknown) => void',\n hintText:\n 'Function called with row ID, field name, and new value during editing',\n },\n { content: '' },\n ],\n [\n {\n content: 'enableRowExpansion',\n hintText:\n 'When true, enables expandable rows that can reveal additional content below the main row. Requires renderExpandedRow to define what content is shown when expanded. Useful for showing detailed information, nested data, or related content. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables row expansion, false disables expandable rows',\n },\n { content: '' },\n ],\n [\n {\n content: 'renderExpandedRow',\n hintText:\n 'Optional function that renders the content displayed when a row is expanded. Receives an expandedData object containing row (the row data), index (row index), isExpanded (current expansion state), and toggleExpansion (function to toggle expansion). Must return a ReactNode.',\n },\n {\n content:\n '(expandedData: { row: T; index: number; isExpanded: boolean; toggleExpansion: () => void }) => ReactNode',\n hintText:\n 'Function that returns React content for expanded row display',\n },\n { content: '' },\n ],\n [\n {\n content: 'isRowExpandable',\n hintText:\n 'Optional function that determines whether a specific row can be expanded. Receives the row data and index, returns a boolean. If not provided, all rows are expandable when enableRowExpansion is true. Useful for conditionally allowing expansion based on row properties.',\n },\n {\n content: '(row: T, index: number) => boolean',\n hintText:\n 'Function that returns true if row can be expanded, false otherwise',\n },\n { content: '' },\n ],\n [\n {\n content: 'onRowExpansionChange',\n hintText:\n \"Optional callback function invoked when a row's expansion state changes (expanded or collapsed). Receives rowId (the unique identifier), isExpanded (new expansion state), and rowData (the row object). Use this to track expansion state or trigger side effects.\",\n },\n {\n content:\n '(rowId: unknown, isExpanded: boolean, rowData: T) => void',\n hintText:\n 'Function called with row ID, expansion state, and row data when expansion changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'enableRowSelection',\n hintText:\n 'When true, enables row selection with checkboxes in the first column. Users can select individual rows or use \"select all\" functionality. Selected rows can trigger bulk actions when showBulkActionBar is true. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables row selection, false disables selection checkboxes',\n },\n { content: '' },\n ],\n [\n {\n content: 'showBulkActionBar',\n hintText:\n 'When true (default), the bulk action bar is shown when rows are selected, with Export, Deselect all, and custom actions. When false, the bulk action bar is hidden while checkbox selection remains available. Use when you need selection state (e.g. via onRowSelectionChange) but do not want to show the bulk actions UI.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows bulk action bar when rows are selected (default), false hides it',\n },\n { content: 'true' },\n ],\n [\n {\n content: 'onRowSelectionChange',\n hintText:\n 'Optional callback function invoked when row selection state changes (row selected or deselected). Receives selectedRowIds (array of all currently selected row IDs), isSelected (whether the triggering row was selected or deselected), rowId (the ID of the row that triggered the change), and rowData (the RAW data from the original data array, not processed/filtered/formatted data displayed in the table). The rowData parameter contains the exact data structure from your API response, ensuring you have access to all original fields for API operations, even if they are not displayed in the table columns. This is essential for operations like updates, deletes, or exports where you need the complete original data.',\n },\n {\n content:\n '(selectedRowIds: string[], isSelected: boolean, rowId: string, rowData: T) => void',\n hintText:\n 'Function called with selection state and raw row data when row selection changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'bulkActions',\n hintText:\n 'Optional BulkActionsConfig object that configures the bulk actions bar displayed when rows are selected. Contains showSelectAll, showDeselectAll, onSelectAll, onDeselectAll callbacks, customActions (React element for custom action buttons), and showExport (boolean to control visibility of the default Export button). When showExport is false, the Export button is hidden and only customActions are shown. Defaults to true (Export button visible). The bulk actions bar appears above the table when one or more rows are selected.',\n },\n {\n content: 'BulkActionsConfig',\n hintText: 'Object with bulk action configuration properties',\n },\n {\n content: 'BulkActionsConfig: see BulkActionsConfig props below',\n },\n ],\n [\n {\n content: 'bulkActions.showSelectAll',\n hintText:\n 'Optional boolean that controls whether to show the \"Select All\" button in the bulk actions bar. When true, displays a button that allows users to select all rows on the current page. When false, the select all functionality is hidden. Defaults to undefined (not shown).',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows select all button, false/undefined hides it',\n },\n { content: '' },\n ],\n [\n {\n content: 'bulkActions.showDeselectAll',\n hintText:\n 'Optional boolean that controls whether to show the \"Deselect All\" button in the bulk actions bar. When true, displays a button that allows users to deselect all currently selected rows. When false, the deselect all functionality is hidden. Defaults to undefined (not shown).',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows deselect all button, false/undefined hides it',\n },\n { content: '' },\n ],\n [\n {\n content: 'bulkActions.showExport',\n hintText:\n 'Optional boolean that controls whether to show the default Export button in the BulkActionBar. When true (default), the Export button appears in the bulk actions bar and triggers CSV export of selected rows. When false, the Export button is hidden and only customActions are shown. Useful when you want to provide your own export functionality or hide export entirely. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText: 'true shows export button (default), false hides it',\n },\n { content: 'true' },\n ],\n [\n {\n content: 'bulkActions.customActions',\n hintText:\n 'Optional React element (typically buttons or action components) displayed in the bulk actions bar alongside the default Export button (if shown). Use this to add custom bulk actions like \"Delete\", \"Archive\", \"Send Email\", etc. The custom actions appear after the default Export button and Deselect All button.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React elements (typically buttons) for custom bulk actions',\n },\n { content: '' },\n ],\n [\n {\n content: 'bulkActions.onSelectAll',\n hintText:\n 'Optional callback function invoked when the \"Select All\" button is clicked. Use this to handle custom select all logic if needed. Note that the DataTable component handles selection internally, so this callback is primarily for side effects or custom behavior.',\n },\n {\n content: '() => void',\n hintText: 'Function called when select all button is clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'bulkActions.onDeselectAll',\n hintText:\n 'Optional callback function invoked when the \"Deselect All\" button is clicked. Use this to handle custom deselect all logic if needed. Note that the DataTable component handles deselection internally, so this callback is primarily for side effects or custom behavior.',\n },\n {\n content: '() => void',\n hintText: 'Function called when deselect all button is clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'BulkAction.id',\n hintText:\n 'Required unique identifier string for the bulk action. Used internally to track and identify the action. Should be unique across all bulk actions in the array.',\n },\n {\n content: 'string',\n hintText: 'Unique identifier string for the bulk action',\n },\n { content: '' },\n ],\n [\n {\n content: 'BulkAction.label',\n hintText:\n 'Required text string displayed on the bulk action button. Should be concise and action-oriented, clearly describing what the action does (e.g., \"Delete\", \"Export\", \"Archive\").',\n },\n {\n content: 'string',\n hintText: 'Button label text displayed to users',\n },\n { content: '' },\n ],\n [\n {\n content: 'BulkAction.variant',\n hintText:\n 'Required visual style variant string for the bulk action button. \"primary\" creates a prominent button for main actions, \"secondary\" creates a less prominent button, \"danger\" creates a warning-style button for destructive actions. Each variant has distinct styling.',\n },\n {\n content: \"'primary' | 'secondary' | 'danger'\",\n hintText: 'Union type defining button visual style variants',\n },\n { content: 'primary, secondary, danger' },\n ],\n [\n {\n content: 'BulkAction.onClick',\n hintText:\n 'Required callback function invoked when the bulk action button is clicked. Receives an array of selected row IDs (strings) corresponding to the idField values of selected rows. Use this to perform the bulk operation on the selected rows.',\n },\n {\n content: '(selectedRowIds: string[]) => void',\n hintText:\n 'Function called with array of selected row IDs when action is clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'rowActions',\n hintText:\n 'Optional RowActionsConfig object that configures action buttons displayed at the row level (typically in the last column or a dedicated actions column). Contains slot1 and slot2 properties for positioning two action buttons per row. Useful for row-specific operations like edit, delete, or view details.',\n },\n {\n content: 'RowActionsConfig',\n hintText:\n 'Object with slot1 and slot2 properties for row action buttons',\n },\n { content: '' },\n ],\n [\n {\n content: 'getRowStyle',\n hintText:\n 'Optional function that returns custom CSS styles for individual rows. Receives the row data and index, returns a React.CSSProperties object. Useful for conditional styling based on row data (e.g., highlighting rows based on status, priority, or other properties).',\n },\n {\n content: '(row: T, index: number) => React.CSSProperties',\n hintText:\n 'Function that returns CSS properties for custom row styling',\n },\n { content: '' },\n ],\n [\n {\n content: 'tableBodyHeight',\n hintText:\n 'Optional fixed height for the table body (scrollable content area). Accepts CSS values as string (e.g., \"400px\", \"50vh\") or numbers (interpreted as pixels). When set, the table body becomes scrollable with a fixed height. Useful for maintaining consistent layout in dashboards or constrained spaces.',\n },\n {\n content: 'string | number',\n hintText:\n 'CSS height value (px, vh, rem) as string or number for pixels',\n },\n { content: '' },\n ],\n [\n {\n content: 'mobileColumnsToShow',\n hintText:\n 'Optional number specifying how many columns to display on mobile devices. When provided, the table automatically adapts to show only the specified number of columns on small screens, with remaining columns accessible via overflow or expansion. Useful for responsive design.',\n },\n {\n content: 'number',\n hintText:\n 'Positive integer representing number of columns to show on mobile',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nColumnDefinition\n\nEach column in the columns array should have the following structure:\n\n string',\n hintText:\n 'Function that returns the field name to sort by based on sort type',\n },\n { content: '' },\n ],\n [\n {\n content: 'isDeltaSortable',\n hintText:\n 'When true, enables delta sorting UI in the sorting popover. When enabled, the sorting popover displays \"Value | Delta\" sections, allowing users to sort by either the primary field or a delta field. Requires getSortField to be provided for delta sorting to work. When false or undefined, only standard sorting options are shown. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows delta sorting UI, false/undefined shows only standard sorting',\n },\n { content: '' },\n ],\n [\n {\n content: 'sortValueFormatter',\n hintText:\n 'Optional function to format/transform values before comparison during sorting. Receives value (the raw value from the data row), row (the entire row data), column (the column definition), and sortType (optional sort type identifier like \"primary\", \"delta\", \"absolute\" - same value passed to getSortField). The sortType parameter allows you to apply different formatting logic for delta sorting vs primary sorting. For example, delta values might already be numbers while primary values might be formatted strings. Returns the formatted value to use for comparison. Useful for custom sorting logic, such as extracting numbers from formatted strings (e.g., \"INR 276\" -> 276) or parsing percentage strings (e.g., \"12.5%\" -> 12.5). If the formatter throws an error, the original value is used as a fallback.',\n },\n {\n content:\n '(value: unknown, row: T, column: ColumnDefinition, sortType?: string) => unknown',\n hintText:\n 'Function that transforms values before sorting comparison, with sortType context to differentiate delta vs primary sorting',\n },\n { content: '' },\n ],\n [\n {\n content: 'isVisible',\n hintText:\n 'When true, the column is displayed in the table. When false, the column is hidden but can be shown via the column manager if enableColumnManager is true. Useful for conditionally showing columns or starting with a subset visible. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows column, false hides column (but can be shown via column manager)',\n },\n { content: '' },\n ],\n [\n {\n content: 'isEditable',\n hintText:\n 'When true and enableInlineEdit is enabled, cells in this column can be edited inline by clicking on them. Users can modify values directly in the table. Editable cells show visual feedback when in edit mode. If false or enableInlineEdit is false, cells are read-only.',\n },\n {\n content: 'boolean',\n hintText:\n 'true allows inline editing, false makes cells read-only',\n },\n { content: '' },\n ],\n [\n {\n content: 'minWidth',\n hintText:\n 'Optional minimum width constraint for the column. Accepts any valid CSS width value (e.g., \"100px\", \"10rem\", \"20%\"). The column will not shrink below this width, ensuring minimum readability. Useful for preventing columns from becoming too narrow.',\n },\n {\n content: 'string',\n hintText:\n 'CSS width value (px, %, em, rem) for minimum column width',\n },\n { content: '' },\n ],\n [\n {\n content: 'maxWidth',\n hintText:\n 'Optional maximum width constraint for the column. Accepts any valid CSS width value. The column will not grow beyond this width, preventing it from taking too much space. Useful for constraining wide columns or maintaining table layout.',\n },\n {\n content: 'string',\n hintText:\n 'CSS width value (px, %, em, rem) for maximum column width',\n },\n { content: '' },\n ],\n [\n {\n content: 'width',\n hintText:\n 'Optional fixed width for the column. Accepts any valid CSS width value. When provided, the column maintains this exact width regardless of content. Overrides minWidth and maxWidth. Useful for consistent column sizing.',\n },\n {\n content: 'string',\n hintText:\n 'CSS width value (px, %, em, rem) for fixed column width',\n },\n { content: '' },\n ],\n [\n {\n content: 'filterOptions',\n hintText:\n 'Optional array of FilterOption objects that provide predefined filter choices for this column. Used when filterType is SELECT or MULTISELECT. Each option should have a label and value. Users can select from these options instead of typing custom values.',\n },\n {\n content: 'FilterOption[]',\n hintText:\n 'Array of filter option objects with label and value properties',\n },\n { content: '' },\n ],\n [\n {\n content: 'canHide',\n hintText:\n 'When true, this column can be hidden/shown via the column manager menu. When false, the column is always visible and cannot be hidden by users. Useful for keeping critical columns always visible regardless of column manager settings.',\n },\n {\n content: 'boolean',\n hintText:\n 'true allows hiding via column manager, false forces column to always be visible',\n },\n { content: '' },\n ],\n [\n {\n content: 'frozen',\n hintText:\n 'When true, this column is frozen (sticky) and remains visible when scrolling horizontally. Frozen columns stay fixed on the left side. Useful for keeping identifier columns (like names or IDs) always visible. The number of frozen columns should match columnFreeze prop.',\n },\n {\n content: 'boolean',\n hintText:\n 'true freezes column on left, false allows normal scrolling',\n },\n { content: '' },\n ],\n [\n {\n content: 'className',\n hintText:\n 'Optional CSS class name string applied to column cells. Useful for custom styling, conditional styling via CSS, or targeting specific columns with external stylesheets. The class is applied to all cells in this column.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to column cells',\n },\n { content: '' },\n ],\n [\n {\n content: 'showSkeleton',\n hintText:\n 'Optional boolean that controls skeleton loading for this specific column. When true, cells in this column show skeleton during loading. When false, cells never show skeleton regardless of global showSkeleton or isLoading. When undefined (default), inherits from global showSkeleton/isLoading or row-level isRowLoading. Useful for excluding specific columns from skeleton loading.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows skeleton, false hides skeleton, undefined inherits from global/row settings',\n },\n { content: '' },\n ],\n [\n {\n content: 'skeletonVariant',\n hintText:\n 'Optional skeleton animation variant for this specific column. Overrides the global skeletonVariant prop. \"pulse\" creates a pulsing fade effect, \"wave\" creates a shimmer wave effect. Useful for having different animation styles for different columns (e.g., wave for text columns, pulse for numeric columns).',\n },\n {\n content: \"'pulse' | 'wave'\",\n hintText:\n 'Union type defining skeleton animation variant for this column',\n },\n { content: 'pulse, wave' },\n ],\n [\n {\n content: 'filterType',\n hintText:\n 'Optional FilterType enum value that overrides the default filter type for this column. If not provided, the filter type is inferred from the column type. Specifying this allows custom filter behavior even when using a different column type for rendering.',\n },\n {\n content: 'FilterType',\n hintText:\n 'Enum that determines the filter input component for this column',\n },\n {\n content:\n 'TEXT, NUMBER, SELECT, MULTISELECT, DATE, BOOLEAN, SLIDER',\n },\n ],\n [\n {\n content: 'renderCell',\n hintText:\n 'Optional custom render function that completely overrides the default cell rendering for this column. Receives value (the cell value), row (the entire row data), and index (the row index). Must return a ReactNode. Use this for highly customized cell content.',\n },\n {\n content: '(value, row, index) => ReactNode',\n hintText:\n 'Function that returns custom React content for cell rendering',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the DataTable component using the following tokens:",
"excerpt": "Usage\n\n\n\nSkeleton Loading\n\nThe DataTable supports granular skeleton loading for better UX during data fetching. You can control skeleton loading at th...",
"sections": [
{
@@ -530,7 +570,7 @@
"datetime",
"schedule"
],
- "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new date range when selection changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'onPresetSelection',\n hintText:\n 'Optional callback function invoked when a user selects a preset option (e.g., \"Today\", \"Last 7 days\"). Receives PresetSelectionData containing the preset information, date range, and formatted times. Useful for tracking preset usage or performing preset-specific actions.',\n },\n {\n content: '(data: PresetSelectionData) => void',\n hintText:\n 'Function called with preset selection data when preset is chosen',\n },\n { content: '' },\n ],\n [\n {\n content: 'showDateTimePicker',\n hintText:\n 'When true, displays date and time input fields in the calendar popover, allowing users to manually enter dates and times. When false, only the calendar grid is shown. Time inputs are useful for precise date-time selection. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows date and time inputs, false shows only calendar',\n },\n { content: '' },\n ],\n [\n {\n content: 'showPresets',\n hintText:\n 'When true, displays quick preset options (e.g., \"Today\", \"Last 7 days\", \"This month\") in the calendar interface. Presets allow users to quickly select common date ranges without manual calendar navigation. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows preset options, false hides preset selector',\n },\n { content: '' },\n ],\n [\n {\n content: 'customPresets',\n hintText:\n 'Optional custom preset configuration that can be an array of preset definitions, custom preset configs, or a mix. Allows you to add custom preset options beyond the default presets, or replace them entirely. Each preset should define a label and date range calculation.',\n },\n {\n content: 'PresetsConfig',\n hintText:\n 'Array or object containing custom preset definitions',\n },\n { content: '' },\n ],\n [\n {\n content: 'placeholder',\n hintText:\n 'Optional placeholder text displayed in the trigger button when no date range is selected. Provides guidance to users about what the picker does. Should be concise and descriptive (e.g., \"Select date range\").',\n },\n {\n content: 'string',\n hintText:\n 'Placeholder text shown in trigger when no date is selected',\n },\n { content: '' },\n ],\n [\n {\n content: 'isDisabled',\n hintText:\n \"When true, disables the date range picker, making it non-interactive. Disabled pickers cannot be opened and show reduced visual opacity. Useful for preventing selection when prerequisites aren't met or during loading states. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables interaction, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'icon',\n hintText:\n 'Optional custom React element (typically a calendar icon) to replace the default icon in the trigger button. Allows branding customization or using different icon libraries. If not provided, uses default Calendar icon.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Any React element (icon, image, etc.) for the trigger icon',\n },\n { content: '' },\n ],\n [\n {\n content: 'minDate',\n hintText:\n 'Optional Date object representing the earliest date that can be selected. Dates before this date are disabled and cannot be chosen. Useful for preventing selection of dates in the past or setting business rules. Takes precedence over disablePastDates.',\n },\n {\n content: 'Date',\n hintText:\n 'Date object representing the minimum selectable date',\n },\n { content: '' },\n ],\n [\n {\n content: 'maxDate',\n hintText:\n 'Optional Date object representing the latest date that can be selected. Dates after this date are disabled and cannot be chosen. Useful for preventing selection of future dates or setting business rules. Takes precedence over disableFutureDates.',\n },\n {\n content: 'Date',\n hintText:\n 'Date object representing the maximum selectable date',\n },\n { content: '' },\n ],\n [\n {\n content: 'dateFormat',\n hintText:\n 'Optional string format for displaying dates in the trigger and inputs. Uses format tokens (e.g., \"dd/MM/yyyy\" for day/month/year, \"MM/dd/yyyy\" for US format). The format affects how dates are displayed but not how they\\'re stored. Defaults to \"dd/MM/yyyy\".',\n },\n {\n content: 'string',\n hintText:\n 'Date format string using format tokens (dd, MM, yyyy, etc.)',\n },\n { content: '' },\n ],\n [\n {\n content: 'allowSingleDateSelection',\n hintText:\n 'When true, allows users to select a single date instead of requiring a range. In single date mode, selecting one date completes the selection. When false, users must select both start and end dates. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true allows single date, false requires date range',\n },\n { content: '' },\n ],\n [\n {\n content: 'disableFutureDates',\n hintText:\n 'When true, disables all dates in the future, preventing users from selecting dates after today. Future dates appear grayed out and are non-clickable. Useful for preventing selection of future dates in booking or scheduling scenarios. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true disables future dates, false allows all dates',\n },\n { content: '' },\n ],\n [\n {\n content: 'disablePastDates',\n hintText:\n 'When true, disables all dates in the past, preventing users from selecting dates before today. Past dates appear grayed out and are non-clickable. Useful for preventing selection of past dates in scheduling or future-only scenarios. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true disables past dates, false allows all dates',\n },\n { content: '' },\n ],\n [\n {\n content: 'hideFutureDates',\n hintText:\n \"When true, hides future dates from the calendar grid entirely (they don't appear in the calendar view). Unlike disableFutureDates, hidden dates are not visible but selection is still technically possible through other means. Useful for cleaner calendar views. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText: 'true hides future dates, false shows all dates',\n },\n { content: '' },\n ],\n [\n {\n content: 'hidePastDates',\n hintText:\n \"When true, hides past dates from the calendar grid entirely (they don't appear in the calendar view). Unlike disablePastDates, hidden dates are not visible but selection is still technically possible through other means. Useful for cleaner calendar views. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText: 'true hides past dates, false shows all dates',\n },\n { content: '' },\n ],\n [\n {\n content: 'customDisableDates',\n hintText:\n 'Optional function that receives a Date and returns a boolean indicating whether that date should be disabled. Allows fine-grained control over which specific dates are disabled based on custom logic (e.g., weekends, holidays, business rules). More flexible than minDate/maxDate.',\n },\n {\n content: '(date: Date) => boolean',\n hintText:\n 'Function that returns true to disable a date, false to enable it',\n },\n { content: '' },\n ],\n [\n {\n content: 'customRangeConfig',\n hintText:\n 'Optional configuration object for custom date range calculations. Allows defining custom range logic beyond standard presets. Useful for business-specific date range calculations or complex range requirements. The config defines how custom ranges are calculated.',\n },\n {\n content: 'CustomRangeConfig',\n hintText:\n 'Object with properties for custom range calculation logic',\n },\n { content: '' },\n ],\n [\n {\n content: 'triggerElement',\n hintText:\n 'Deprecated: Optional custom React element to use as the trigger button. This prop is deprecated in favor of triggerConfig. Use triggerConfig for custom trigger rendering instead. If provided, it will still work but may be removed in future versions.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Custom React element for trigger (deprecated, use triggerConfig)',\n },\n { content: '' },\n ],\n [\n {\n content: 'useDrawerOnMobile',\n hintText:\n 'When true, uses a mobile drawer (full-screen modal) instead of a popover on mobile devices (screen width \n\nComponent Tokens\n\nYou can style the DateRangePicker component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new date range when selection changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'onPresetSelection',\n hintText:\n 'Optional callback function invoked when a user selects a preset option (e.g., \"Today\", \"Last 7 days\"). Receives PresetSelectionData containing the preset information, date range, and formatted times. Useful for tracking preset usage or performing preset-specific actions.',\n },\n {\n content: '(data: PresetSelectionData) => void',\n hintText:\n 'Function called with preset selection data when preset is chosen',\n },\n { content: '' },\n ],\n [\n {\n content: 'showDateTimePicker',\n hintText:\n 'When true, displays date and time input fields in the calendar popover, allowing users to manually enter dates and times. When false, only the calendar grid is shown. Time inputs are useful for precise date-time selection. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows date and time inputs, false shows only calendar',\n },\n { content: '' },\n ],\n [\n {\n content: 'showPresets',\n hintText:\n 'When true, displays quick preset options (e.g., \"Today\", \"Last 7 days\", \"This month\") in the calendar interface. Presets allow users to quickly select common date ranges without manual calendar navigation. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows preset options, false hides preset selector',\n },\n { content: '' },\n ],\n [\n {\n content: 'customPresets',\n hintText:\n 'Optional custom preset configuration that can be an array of preset definitions, custom preset configs, or a mix. Allows you to add custom preset options beyond the default presets, or replace them entirely. Each preset should define a label and date range calculation.',\n },\n {\n content: 'PresetsConfig',\n hintText:\n 'Array or object containing custom preset definitions',\n },\n { content: '' },\n ],\n [\n {\n content: 'placeholder',\n hintText:\n 'Optional placeholder text displayed in the trigger button when no date range is selected. Provides guidance to users about what the picker does. Should be concise and descriptive (e.g., \"Select date range\").',\n },\n {\n content: 'string',\n hintText:\n 'Placeholder text shown in trigger when no date is selected',\n },\n { content: '' },\n ],\n [\n {\n content: 'isDisabled',\n hintText:\n \"When true, disables the date range picker, making it non-interactive. Disabled pickers cannot be opened and show reduced visual opacity. Useful for preventing selection when prerequisites aren't met or during loading states. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables interaction, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'dateFormat',\n hintText:\n 'Optional string format for displaying dates in the trigger and inputs. Uses format tokens (e.g., \"dd/MM/yyyy\" for day/month/year, \"MM/dd/yyyy\" for US format). The format affects how dates are displayed but not how they\\'re stored. Defaults to \"dd/MM/yyyy\".',\n },\n {\n content: 'string',\n hintText:\n 'Date format string using format tokens (dd, MM, yyyy, etc.)',\n },\n { content: '' },\n ],\n [\n {\n content: 'allowSingleDateSelection',\n hintText:\n 'When true, allows users to select a single date instead of requiring a range. In single date mode, selecting one date completes the selection. When false, users must select both start and end dates. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true allows single date, false requires date range',\n },\n { content: '' },\n ],\n [\n {\n content: 'isSingleDatePicker',\n hintText:\n 'When true, configures the component to work purely as a single date picker (not a range picker). The trigger displays a single date value and the calendar operates in single-select mode. This is distinct from allowSingleDateSelection which still allows range selection but permits single dates. Use this prop when you only need single date selection without range capability. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true for single date picker mode, false for range picker mode',\n },\n { content: '' },\n ],\n [\n {\n content: 'disableFutureDates',\n hintText:\n 'When true, disables all dates in the future, preventing users from selecting dates after today. Future dates appear grayed out and are non-clickable. Useful for preventing selection of future dates in booking or scheduling scenarios. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true disables future dates, false allows all dates',\n },\n { content: '' },\n ],\n [\n {\n content: 'disablePastDates',\n hintText:\n 'When true, disables all dates in the past, preventing users from selecting dates before today. Past dates appear grayed out and are non-clickable. Useful for preventing selection of past dates in scheduling or future-only scenarios. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true disables past dates, false allows all dates',\n },\n { content: '' },\n ],\n [\n {\n content: 'hideFutureDates',\n hintText:\n \"When true, hides future dates from the calendar grid entirely (they don't appear in the calendar view). Unlike disableFutureDates, hidden dates are not visible but selection is still technically possible through other means. Useful for cleaner calendar views. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText: 'true hides future dates, false shows all dates',\n },\n { content: '' },\n ],\n [\n {\n content: 'hidePastDates',\n hintText:\n \"When true, hides past dates from the calendar grid entirely (they don't appear in the calendar view). Unlike disablePastDates, hidden dates are not visible but selection is still technically possible through other means. Useful for cleaner calendar views. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText: 'true hides past dates, false shows all dates',\n },\n { content: '' },\n ],\n [\n {\n content: 'customDisableDates',\n hintText:\n 'Optional function that receives a Date and returns a boolean indicating whether that date should be disabled. Allows fine-grained control over which specific dates are disabled based on custom logic (e.g., weekends, holidays, business rules). More flexible than minDate/maxDate.',\n },\n {\n content: '(date: Date) => boolean',\n hintText:\n 'Function that returns true to disable a date, false to enable it',\n },\n { content: '' },\n ],\n [\n {\n content: 'customRangeConfig',\n hintText:\n 'Optional configuration object for custom date range calculations. Allows defining custom range logic beyond standard presets. Useful for business-specific date range calculations or complex range requirements. The config defines how custom ranges are calculated.',\n },\n {\n content: 'CustomRangeConfig',\n hintText:\n 'Object with properties for custom range calculation logic',\n },\n { content: '' },\n ],\n [\n {\n content: 'triggerElement',\n hintText:\n 'Deprecated: Optional custom React element to use as the trigger button. This prop is deprecated in favor of triggerConfig. Use triggerConfig for custom trigger rendering instead. If provided, it will still work but may be removed in future versions.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Custom React element for trigger (deprecated, use triggerConfig)',\n },\n { content: '' },\n ],\n [\n {\n content: 'useDrawerOnMobile',\n hintText:\n 'When true, uses a mobile drawer (full-screen modal) instead of a popover on mobile devices (screen width \n\nComponent Tokens\n\nYou can style the DateRangePicker component using the following tokens:",
"excerpt": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new date range when selection changes',...",
"sections": [
{
@@ -566,7 +606,7 @@
"modal",
"navigation"
],
- "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new open state when drawer visibility changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.direction',\n hintText:\n 'Optional direction string that determines from which side of the screen the drawer slides in. Options: \"top\" (slides down from top), \"bottom\" (slides up from bottom), \"left\" (slides in from left), \"right\" (slides in from right). Bottom drawers are common for mobile patterns. Defaults to \"bottom\".',\n },\n {\n content: \"'top' | 'bottom' | 'left' | 'right'\",\n hintText: 'Union type defining the slide-in direction',\n },\n { content: 'top, bottom, left, right' },\n ],\n [\n {\n content: 'Drawer.showHandle',\n hintText:\n 'When true, displays a drag handle (typically a horizontal bar) at the top or bottom of the drawer, depending on direction. The handle is only shown for top/bottom drawers and allows users to drag the drawer to resize or dismiss it. Useful for mobile interactions. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText: 'true shows drag handle, false hides drag handle',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.handle',\n hintText:\n 'Optional custom React element to replace the default drag handle. Allows full customization of the handle appearance, including icons, text, or custom styling. If provided, showHandle should typically be true. Useful for branding or special handle designs.',\n },\n {\n content: 'ReactNode',\n hintText: 'Custom React element to use as the drag handle',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.modal',\n hintText:\n 'When true, the drawer displays with a backdrop overlay that dims the background content and prevents interaction with elements behind it. When false, the drawer appears without an overlay, allowing background interaction. Modal drawers are typically used for important actions. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows overlay and prevents background interaction, false allows background interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.dismissible',\n hintText:\n 'When true, users can dismiss the drawer by clicking on the overlay/backdrop or pressing the Escape key. When false, the drawer can only be closed programmatically or via a close button. Useful for preventing accidental dismissal of important drawers. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true allows dismissal via overlay/Escape, false requires explicit close action',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.nested',\n hintText:\n 'When true, enables nested drawer support for stacking multiple drawers on top of each other. Creates a visual stacking effect where subsequent drawers appear above previous ones with proper z-index management. Useful for multi-level navigation or workflows. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables drawer stacking, false uses single drawer behavior',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.snapPoints',\n hintText:\n 'Optional array of snap point values (strings or numbers) that define discrete positions where the drawer can \"snap\" when dragged. Only applies to top/bottom drawers. Values can be percentages (e.g., \"50%\") or pixel values. Useful for creating drawer states like \"half-open\" or \"three-quarters open\".',\n },\n {\n content: '(string | number)[]',\n hintText:\n 'Array of snap point values as strings (percentages) or numbers (pixels)',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.activeSnapPoint',\n hintText:\n 'Optional value that specifies which snap point is currently active. Can be a number (index into snapPoints array), a string (matching a snap point value), or null (no snap point active). Use with onSnapPointChange for controlled snap point management.',\n },\n {\n content: 'number | string | null',\n hintText:\n 'Index, snap point value, or null for current active snap point',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.onSnapPointChange',\n hintText:\n 'Optional callback function invoked when the drawer snaps to a different snap point during drag interactions. Receives the new active snap point (number, string, or null). Use this to track snap point changes and update your application state accordingly.',\n },\n {\n content: '(activeSnapPoint: number | string | null) => void',\n hintText:\n 'Function called with new active snap point when it changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.fadeFromIndex',\n hintText:\n 'Optional number specifying the index in the snapPoints array from which the drawer content should start fading out. Content before this index is fully opaque, content after gradually fades. Useful for creating visual depth or indicating additional content is available.',\n },\n {\n content: 'number',\n hintText: 'Index in snapPoints array where fade effect begins',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.snapToSequentialPoint',\n hintText:\n 'When true, disables velocity-based snapping and forces sequential navigation through snap points. Users must drag through each snap point in order rather than jumping based on drag velocity. Useful for ensuring users experience all intermediate states. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true forces sequential snapping, false allows velocity-based snapping',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.mobileOffset',\n hintText:\n 'Optional object with top, bottom, left, and/or right properties (strings) that override the default positioning offsets on mobile devices. Allows fine-tuning drawer position for specific mobile layouts or design requirements. Each property accepts CSS values (e.g., \"20px\", \"10%\").',\n },\n {\n content:\n '{ top?: string; bottom?: string; left?: string; right?: string }',\n hintText:\n 'Object with optional CSS offset values for mobile positioning',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.children',\n hintText:\n 'Required React elements representing the drawer trigger (DrawerTrigger) and content structure (DrawerPortal containing DrawerOverlay and DrawerContent). The children define the complete drawer structure including what opens it and what content it displays.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React elements for trigger and drawer content structure',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerTrigger.children',\n hintText:\n 'Required React element that serves as the clickable trigger to open the drawer. Typically a Button component, but can be any interactive element. When clicked, it opens the drawer. The trigger element should be visually clear that it opens a drawer.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React element (typically a button) that opens the drawer when clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerTrigger.className',\n hintText:\n 'Optional CSS class name string applied to the trigger element. Useful for custom styling, positioning, or integrating with existing CSS frameworks. The class is added to the trigger wrapper element.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the trigger element',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerTrigger.disabled',\n hintText:\n \"When true, disables the trigger element, preventing it from opening the drawer. The trigger appears visually disabled (reduced opacity, non-clickable). Useful for preventing drawer opening when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables trigger, false allows normal trigger interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerTrigger.onClick',\n hintText:\n 'Optional custom click handler function that executes before the default drawer opening behavior. Receives no parameters. Use this to perform additional actions when the trigger is clicked, such as logging, validation, or state updates.',\n },\n {\n content: '() => void',\n hintText:\n 'Function called when trigger is clicked, before drawer opens',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerPortal.children',\n hintText:\n 'Required React elements that should be rendered in a portal (typically DrawerOverlay and DrawerContent). The portal ensures these elements are rendered outside the normal DOM hierarchy, usually at the document body level, preventing z-index and overflow issues.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React elements (overlay and content) to render in a portal',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerOverlay.className',\n hintText:\n 'Optional CSS class name string applied to the backdrop overlay element. Useful for custom styling the overlay appearance, such as changing opacity, background color, or adding animations. The overlay appears behind the drawer content.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the overlay backdrop',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.direction',\n hintText:\n 'Optional direction string that overrides the drawer direction set on the Drawer component. Allows different content sections to have different directions, though typically matches the parent Drawer direction. Options: \"top\", \"bottom\", \"left\", \"right\". Defaults to \"bottom\".',\n },\n {\n content: \"'top' | 'bottom' | 'left' | 'right'\",\n hintText:\n 'Union type defining the slide-in direction for this content',\n },\n { content: 'top, bottom, left, right' },\n ],\n [\n {\n content: 'DrawerContent.showHandle',\n hintText:\n 'When true, displays the drag handle on this content section. Overrides the parent Drawer showHandle prop for this specific content. Useful for showing handles only on certain drawer sections. Only applies to top/bottom drawers. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows drag handle on this content, false hides it',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.handle',\n hintText:\n 'Optional custom React element to replace the default drag handle for this content section. Overrides the parent Drawer handle prop. Allows per-section handle customization. If provided, showHandle should typically be true.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Custom React element to use as the drag handle for this content',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.width',\n hintText:\n 'Optional fixed width value (string or number) for left/right drawers. Accepts CSS values like \"300px\", \"50%\", or numeric values in pixels. When provided, the drawer maintains this exact width. Useful for consistent drawer sizing.',\n },\n {\n content: 'string | number',\n hintText:\n 'CSS width value (string with units) or number (pixels)',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.maxWidth',\n hintText:\n 'Optional maximum width constraint (string or number) for left/right drawers. The drawer will not grow beyond this width. Accepts CSS values like \"500px\", \"80%\", or numeric values in pixels. Useful for preventing drawers from becoming too wide.',\n },\n {\n content: 'string | number',\n hintText:\n 'CSS max-width value (string with units) or number (pixels)',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.mobileOffset',\n hintText:\n 'Optional object with top, bottom, left, and/or right properties (strings) that override mobile positioning offsets for this content section. Overrides the parent Drawer mobileOffset. Allows fine-grained mobile positioning control per section.',\n },\n {\n content:\n '{ top?: string; bottom?: string; left?: string; right?: string }',\n hintText:\n 'Object with optional CSS offset values for mobile positioning',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.fullScreen',\n hintText:\n 'When true, the drawer takes the full height and width of the screen with no border radius. Useful for mobile full-screen experiences or when you want a drawer that completely covers the viewport. When false, the drawer uses default sizing and border radius. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true makes drawer full screen with no border radius, false uses default sizing',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.className',\n hintText:\n 'Optional CSS class name string applied to the drawer content container. Useful for custom styling, animations, or integrating with CSS frameworks. The class is applied to the main content wrapper element.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the content container',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.style',\n hintText:\n \"Optional React.CSSProperties object for inline styles on the drawer content container. Allows dynamic styling based on runtime state. Useful for programmatic styling that can't be achieved with className. Inline styles take precedence over className styles.\",\n },\n {\n content: 'React.CSSProperties',\n hintText: 'Object with CSS properties for inline styling',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.children',\n hintText:\n 'Required React elements representing the content to display inside the drawer. Typically includes DrawerHeader, DrawerBody, and DrawerFooter components, but can be any custom content structure.',\n },\n {\n content: 'ReactNode',\n hintText: 'React elements for drawer content structure',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerHeader.children',\n hintText:\n 'Required React elements for the drawer header section. Typically contains DrawerTitle and DrawerDescription components, but can include any custom header content like icons, badges, or action buttons.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React elements (typically title and description) for header content',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerHeader.className',\n hintText:\n 'Optional CSS class name string applied to the header container. Useful for custom header styling, spacing adjustments, or layout modifications. The class is applied to the header wrapper element.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the header container',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerTitle.children',\n hintText:\n \"Required React element (typically a string) representing the main title text displayed in the drawer header. Should be concise and descriptive, clearly indicating the drawer's purpose or content. Styled as a prominent heading.\",\n },\n {\n content: 'ReactNode',\n hintText: 'React element (typically text) for the drawer title',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerTitle.className',\n hintText:\n 'Optional CSS class name string applied to the title element. Useful for custom title styling, font adjustments, or color modifications. The class is applied to the title text element.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the title element',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerDescription.children',\n hintText:\n \"Required React element (typically a string) representing the description text displayed below the title in the drawer header. Provides additional context, instructions, or explanation about the drawer's purpose. Styled as secondary text.\",\n },\n {\n content: 'ReactNode',\n hintText:\n 'React element (typically text) for the drawer description',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerDescription.className',\n hintText:\n 'Optional CSS class name string applied to the description element. Useful for custom description styling, font size adjustments, or color modifications. The class is applied to the description text element.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the description element',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerBody.overflowY',\n hintText:\n 'Optional string that controls vertical overflow behavior for the drawer body content. \"auto\" shows scrollbar when needed, \"hidden\" hides overflow, \"scroll\" always shows scrollbar, \"visible\" allows content to overflow. Useful for managing long content. Defaults to \"auto\".',\n },\n {\n content: \"'auto' | 'hidden' | 'scroll' | 'visible'\",\n hintText: 'Union type defining CSS overflow-y behavior',\n },\n { content: 'auto, hidden, scroll, visible' },\n ],\n [\n {\n content: 'DrawerBody.noPadding',\n hintText:\n 'When true, removes the default padding from the drawer body, allowing content to extend to the edges. When false, the body has standard padding. Useful for full-width content like images or custom layouts. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true removes padding, false applies default padding',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerBody.hasFooter',\n hintText:\n 'When true, indicates that the drawer has a footer section, which affects the border radius styling of the body (bottom corners are not rounded when footer is present). When false, the body may have rounded bottom corners. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true indicates footer exists (affects border radius), false indicates no footer',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerBody.direction',\n hintText:\n 'Optional direction string that affects border radius styling of the body based on drawer orientation. The direction determines which corners are rounded. Options: \"top\", \"bottom\", \"left\", \"right\". Defaults to \"bottom\".',\n },\n {\n content: \"'top' | 'bottom' | 'left' | 'right'\",\n hintText:\n 'Union type defining drawer direction for border radius calculation',\n },\n { content: 'top, bottom, left, right' },\n ],\n [\n {\n content: 'DrawerBody.className',\n hintText:\n 'Optional CSS class name string applied to the body container. Useful for custom body styling, spacing, or layout modifications. The class is applied to the body wrapper element.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the body container',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerBody.children',\n hintText:\n 'Required React elements representing the main content to display in the drawer body. This is the primary content area and can contain any custom components, forms, lists, or other interactive elements.',\n },\n {\n content: 'ReactNode',\n hintText: 'React elements for the main drawer body content',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerFooter.children',\n hintText:\n 'Required React elements for the drawer footer section. Typically contains action buttons (like \"Save\", \"Cancel\", \"Confirm\") wrapped in DrawerClose components, but can include any footer content.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React elements (typically action buttons) for footer content',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerFooter.className',\n hintText:\n 'Optional CSS class name string applied to the footer container. Useful for custom footer styling, spacing, or layout modifications. The class is applied to the footer wrapper element.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the footer container',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerFooter.direction',\n hintText:\n 'Optional direction string that affects border radius styling of the footer based on drawer orientation. The direction determines which corners are rounded. Options: \"top\", \"bottom\", \"left\", \"right\". Defaults to \"bottom\".',\n },\n {\n content: \"'top' | 'bottom' | 'left' | 'right'\",\n hintText:\n 'Union type defining drawer direction for border radius calculation',\n },\n { content: 'top, bottom, left, right' },\n ],\n [\n {\n content: 'DrawerClose.children',\n hintText:\n 'Required React element (typically a Button component) that serves as the close button. When clicked, it closes the drawer. The button should be visually clear that it closes the drawer. Often styled as a secondary or tertiary button.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React element (typically a button) that closes the drawer when clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerClose.className',\n hintText:\n 'Optional CSS class name string applied to the close button wrapper. Useful for custom close button styling or positioning. The class is applied to the wrapper element around the close button.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the close button wrapper',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerClose.disabled',\n hintText:\n 'When true, disables the close button, preventing it from closing the drawer. The button appears visually disabled. Useful for preventing drawer closure during critical operations or when validation is required. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true disables close button, false allows normal close functionality',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the Drawer component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new open state when drawer visibility changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.direction',\n hintText:\n 'Optional direction string that determines from which side of the screen the drawer slides in. Options: \"top\" (slides down from top), \"bottom\" (slides up from bottom), \"left\" (slides in from left), \"right\" (slides in from right). Bottom drawers are common for mobile patterns. Defaults to \"bottom\".',\n },\n {\n content: \"'top' | 'bottom' | 'left' | 'right'\",\n hintText: 'Union type defining the slide-in direction',\n },\n { content: 'top, bottom, left, right' },\n ],\n [\n {\n content: 'Drawer.showHandle',\n hintText:\n 'When true, displays a drag handle (typically a horizontal bar) at the top or bottom of the drawer, depending on direction. The handle is only shown for top/bottom drawers and allows users to drag the drawer to resize or dismiss it. Useful for mobile interactions. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText: 'true shows drag handle, false hides drag handle',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.handle',\n hintText:\n 'Optional custom React element to replace the default drag handle. Allows full customization of the handle appearance, including icons, text, or custom styling. If provided, showHandle should typically be true. Useful for branding or special handle designs.',\n },\n {\n content: 'ReactNode',\n hintText: 'Custom React element to use as the drag handle',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.modal',\n hintText:\n 'When true, the drawer displays with a backdrop overlay that dims the background content and prevents interaction with elements behind it. When false, the drawer appears without an overlay, allowing background interaction. Modal drawers are typically used for important actions. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows overlay and prevents background interaction, false allows background interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.dismissible',\n hintText:\n 'When true, users can dismiss the drawer by clicking on the overlay/backdrop or pressing the Escape key. When false, the drawer can only be closed programmatically or via a close button. Useful for preventing accidental dismissal of important drawers. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true allows dismissal via overlay/Escape, false requires explicit close action',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.nested',\n hintText:\n 'When true, enables nested drawer support for stacking multiple drawers on top of each other. Creates a visual stacking effect where subsequent drawers appear above previous ones with proper z-index management. Useful for multi-level navigation or workflows. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables drawer stacking, false uses single drawer behavior',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.snapPoints',\n hintText:\n 'Optional array of snap point values (strings or numbers) that define discrete positions where the drawer can \"snap\" when dragged. Only applies to top/bottom drawers. Values can be percentages (e.g., \"50%\") or pixel values. Useful for creating drawer states like \"half-open\" or \"three-quarters open\".',\n },\n {\n content: '(string | number)[]',\n hintText:\n 'Array of snap point values as strings (percentages) or numbers (pixels)',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.activeSnapPoint',\n hintText:\n 'Optional value that specifies which snap point is currently active. Can be a number (index into snapPoints array), a string (matching a snap point value), or null (no snap point active). Use with onSnapPointChange for controlled snap point management.',\n },\n {\n content: 'number | string | null',\n hintText:\n 'Index, snap point value, or null for current active snap point',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.onSnapPointChange',\n hintText:\n 'Optional callback function invoked when the drawer snaps to a different snap point during drag interactions. Receives the new active snap point (number, string, or null). Use this to track snap point changes and update your application state accordingly.',\n },\n {\n content: '(activeSnapPoint: number | string | null) => void',\n hintText:\n 'Function called with new active snap point when it changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.fadeFromIndex',\n hintText:\n 'Optional number specifying the index in the snapPoints array from which the drawer content should start fading out. Content before this index is fully opaque, content after gradually fades. Useful for creating visual depth or indicating additional content is available.',\n },\n {\n content: 'number',\n hintText: 'Index in snapPoints array where fade effect begins',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.snapToSequentialPoint',\n hintText:\n 'When true, disables velocity-based snapping and forces sequential navigation through snap points. Users must drag through each snap point in order rather than jumping based on drag velocity. Useful for ensuring users experience all intermediate states. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true forces sequential snapping, false allows velocity-based snapping',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.mobileOffset',\n hintText:\n 'Optional object with top, bottom, left, and/or right properties (strings) that override the default positioning offsets on mobile devices. Allows fine-tuning drawer position for specific mobile layouts or design requirements. Each property accepts CSS values (e.g., \"20px\", \"10%\").',\n },\n {\n content:\n '{ top?: string; bottom?: string; left?: string; right?: string }',\n hintText:\n 'Object with optional CSS offset values for mobile positioning',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.disableDrag',\n hintText:\n 'When true, prevents users from dragging the drawer to resize or dismiss it. The drag handle becomes non-draggable but the drawer can still be opened/closed programmatically or via click. Useful for drawers that should remain at a fixed size. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true disables drag functionality, false allows normal dragging',\n },\n { content: '' },\n ],\n [\n {\n content: 'Drawer.children',\n hintText:\n 'Required React elements representing the drawer trigger (DrawerTrigger) and content structure (DrawerPortal containing DrawerOverlay and DrawerContent). The children define the complete drawer structure including what opens it and what content it displays.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React elements for trigger and drawer content structure',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerTrigger.children',\n hintText:\n 'Required React element that serves as the clickable trigger to open the drawer. Typically a Button component, but can be any interactive element. When clicked, it opens the drawer. The trigger element should be visually clear that it opens a drawer.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React element (typically a button) that opens the drawer when clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerTrigger.className',\n hintText:\n 'Optional CSS class name string applied to the trigger element. Useful for custom styling, positioning, or integrating with existing CSS frameworks. The class is added to the trigger wrapper element.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the trigger element',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerTrigger.disabled',\n hintText:\n \"When true, disables the trigger element, preventing it from opening the drawer. The trigger appears visually disabled (reduced opacity, non-clickable). Useful for preventing drawer opening when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables trigger, false allows normal trigger interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerTrigger.onClick',\n hintText:\n 'Optional custom click handler function that executes before the default drawer opening behavior. Receives no parameters. Use this to perform additional actions when the trigger is clicked, such as logging, validation, or state updates.',\n },\n {\n content: '() => void',\n hintText:\n 'Function called when trigger is clicked, before drawer opens',\n },\n { content: '' },\n ],\n [\n {\n content: \"DrawerTrigger.'aria-label'\",\n hintText:\n 'Optional accessibility label for the trigger button. Provides a descriptive name for screen readers describing what the trigger does (e.g., \"Open navigation menu\"). Useful when the trigger has no visible text or when the visible text is not descriptive enough.',\n },\n {\n content: 'string',\n hintText: 'Accessible label text for screen readers',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerPortal.children',\n hintText:\n 'Required React elements that should be rendered in a portal (typically DrawerOverlay and DrawerContent). The portal ensures these elements are rendered outside the normal DOM hierarchy, usually at the document body level, preventing z-index and overflow issues.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React elements (overlay and content) to render in a portal',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerOverlay.className',\n hintText:\n 'Optional CSS class name string applied to the backdrop overlay element. Useful for custom styling the overlay appearance, such as changing opacity, background color, or adding animations. The overlay appears behind the drawer content.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the overlay backdrop',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.direction',\n hintText:\n 'Optional direction string that overrides the drawer direction set on the Drawer component. Allows different content sections to have different directions, though typically matches the parent Drawer direction. Options: \"top\", \"bottom\", \"left\", \"right\". Defaults to \"bottom\".',\n },\n {\n content: \"'top' | 'bottom' | 'left' | 'right'\",\n hintText:\n 'Union type defining the slide-in direction for this content',\n },\n { content: 'top, bottom, left, right' },\n ],\n [\n {\n content: 'DrawerContent.showHandle',\n hintText:\n 'When true, displays the drag handle on this content section. Overrides the parent Drawer showHandle prop for this specific content. Useful for showing handles only on certain drawer sections. Only applies to top/bottom drawers. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows drag handle on this content, false hides it',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.handle',\n hintText:\n 'Optional custom React element to replace the default drag handle for this content section. Overrides the parent Drawer handle prop. Allows per-section handle customization. If provided, showHandle should typically be true.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'Custom React element to use as the drag handle for this content',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.width',\n hintText:\n 'Optional fixed width value (string or number) for left/right drawers. Accepts CSS values like \"300px\", \"50%\", or numeric values in pixels. When provided, the drawer maintains this exact width. Useful for consistent drawer sizing.',\n },\n {\n content: 'string | number',\n hintText:\n 'CSS width value (string with units) or number (pixels)',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.maxWidth',\n hintText:\n 'Optional maximum width constraint (string or number) for left/right drawers. The drawer will not grow beyond this width. Accepts CSS values like \"500px\", \"80%\", or numeric values in pixels. Useful for preventing drawers from becoming too wide.',\n },\n {\n content: 'string | number',\n hintText:\n 'CSS max-width value (string with units) or number (pixels)',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.mobileOffset',\n hintText:\n 'Optional object with top, bottom, left, and/or right properties (strings) that override mobile positioning offsets for this content section. Overrides the parent Drawer mobileOffset. Allows fine-grained mobile positioning control per section.',\n },\n {\n content:\n '{ top?: string; bottom?: string; left?: string; right?: string }',\n hintText:\n 'Object with optional CSS offset values for mobile positioning',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.fullScreen',\n hintText:\n 'When true, the drawer takes the full height and width of the screen with no border radius. Useful for mobile full-screen experiences or when you want a drawer that completely covers the viewport. When false, the drawer uses default sizing and border radius. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true makes drawer full screen with no border radius, false uses default sizing',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.offSet',\n hintText:\n 'Optional custom margin value that creates space around the drawer content. Accepts CSS values like \"20px\", \"1rem\" or numeric values (converted to pixels). When provided with direction=bottom, the drawer respects this margin at the bottom. Useful for positioning drawers with spacing from screen edges.',\n },\n {\n content: 'string | number',\n hintText:\n 'CSS margin value (string with units) or number (pixels)',\n },\n { content: '' },\n ],\n [\n {\n content: \"DrawerContent.'aria-label'\",\n hintText:\n \"Optional accessibility label for the drawer dialog itself. Provides an accessible name when you don't have a visible DrawerTitle or when the title alone isn't descriptive enough. Used by screen readers to announce what the drawer contains.\",\n },\n {\n content: 'string',\n hintText: 'Accessible label text for the drawer dialog',\n },\n { content: '' },\n ],\n [\n {\n content: \"DrawerContent.'aria-describedby'\",\n hintText:\n \"Optional ID of the element that describes the drawer content (typically the DrawerDescription ID). Screen readers announce this description after the title, providing additional context about the drawer's purpose or contents.\",\n },\n {\n content: 'string',\n hintText: 'ID of the element describing the drawer',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.className',\n hintText:\n 'Optional CSS class name string applied to the drawer content container. Useful for custom styling, animations, or integrating with CSS frameworks. The class is applied to the main content wrapper element.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the content container',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.style',\n hintText:\n \"Optional React.CSSProperties object for inline styles on the drawer content container. Allows dynamic styling based on runtime state. Useful for programmatic styling that can't be achieved with className. Inline styles take precedence over className styles.\",\n },\n {\n content: 'React.CSSProperties',\n hintText: 'Object with CSS properties for inline styling',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerContent.children',\n hintText:\n 'Required React elements representing the content to display inside the drawer. Typically includes DrawerHeader, DrawerBody, and DrawerFooter components, but can be any custom content structure.',\n },\n {\n content: 'ReactNode',\n hintText: 'React elements for drawer content structure',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerHeader.children',\n hintText:\n 'Required React elements for the drawer header section. Typically contains DrawerTitle and DrawerDescription components, but can include any custom header content like icons, badges, or action buttons.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React elements (typically title and description) for header content',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerHeader.className',\n hintText:\n 'Optional CSS class name string applied to the header container. Useful for custom header styling, spacing adjustments, or layout modifications. The class is applied to the header wrapper element.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the header container',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerTitle.children',\n hintText:\n \"Required React element (typically a string) representing the main title text displayed in the drawer header. Should be concise and descriptive, clearly indicating the drawer's purpose or content. Styled as a prominent heading.\",\n },\n {\n content: 'ReactNode',\n hintText: 'React element (typically text) for the drawer title',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerTitle.className',\n hintText:\n 'Optional CSS class name string applied to the title element. Useful for custom title styling, font adjustments, or color modifications. The class is applied to the title text element.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the title element',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerTitle.id',\n hintText:\n 'Optional unique ID for the title element. Used for ARIA labeling - the DrawerContent automatically links to this ID via aria-labelledby for accessibility. If not provided, an ID is generated automatically.',\n },\n {\n content: 'string',\n hintText: 'Unique identifier for ARIA labeling',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerDescription.children',\n hintText:\n \"Required React element (typically a string) representing the description text displayed below the title in the drawer header. Provides additional context, instructions, or explanation about the drawer's purpose. Styled as secondary text.\",\n },\n {\n content: 'ReactNode',\n hintText:\n 'React element (typically text) for the drawer description',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerDescription.className',\n hintText:\n 'Optional CSS class name string applied to the description element. Useful for custom description styling, font size adjustments, or color modifications. The class is applied to the description text element.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the description element',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerDescription.id',\n hintText:\n 'Optional unique ID for the description element. Used for ARIA description - the DrawerContent automatically links to this ID via aria-describedby for accessibility. If not provided, an ID is generated automatically.',\n },\n {\n content: 'string',\n hintText: 'Unique identifier for ARIA description',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerBody.overflowY',\n hintText:\n 'Optional string that controls vertical overflow behavior for the drawer body content. \"auto\" shows scrollbar when needed, \"hidden\" hides overflow, \"scroll\" always shows scrollbar, \"visible\" allows content to overflow. Useful for managing long content. Defaults to \"auto\".',\n },\n {\n content: \"'auto' | 'hidden' | 'scroll' | 'visible'\",\n hintText: 'Union type defining CSS overflow-y behavior',\n },\n { content: 'auto, hidden, scroll, visible' },\n ],\n [\n {\n content: 'DrawerBody.noPadding',\n hintText:\n 'When true, removes the default padding from the drawer body, allowing content to extend to the edges. When false, the body has standard padding. Useful for full-width content like images or custom layouts. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true removes padding, false applies default padding',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerBody.hasFooter',\n hintText:\n 'When true, indicates that the drawer has a footer section, which affects the border radius styling of the body (bottom corners are not rounded when footer is present). When false, the body may have rounded bottom corners. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true indicates footer exists (affects border radius), false indicates no footer',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerBody.direction',\n hintText:\n 'Optional direction string that affects border radius styling of the body based on drawer orientation. The direction determines which corners are rounded. Options: \"top\", \"bottom\", \"left\", \"right\". Defaults to \"bottom\".',\n },\n {\n content: \"'top' | 'bottom' | 'left' | 'right'\",\n hintText:\n 'Union type defining drawer direction for border radius calculation',\n },\n { content: 'top, bottom, left, right' },\n ],\n [\n {\n content: 'DrawerBody.className',\n hintText:\n 'Optional CSS class name string applied to the body container. Useful for custom body styling, spacing, or layout modifications. The class is applied to the body wrapper element.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the body container',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerBody.children',\n hintText:\n 'Required React elements representing the main content to display in the drawer body. This is the primary content area and can contain any custom components, forms, lists, or other interactive elements.',\n },\n {\n content: 'ReactNode',\n hintText: 'React elements for the main drawer body content',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerFooter.children',\n hintText:\n 'Required React elements for the drawer footer section. Typically contains action buttons (like \"Save\", \"Cancel\", \"Confirm\") wrapped in DrawerClose components, but can include any footer content.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React elements (typically action buttons) for footer content',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerFooter.className',\n hintText:\n 'Optional CSS class name string applied to the footer container. Useful for custom footer styling, spacing, or layout modifications. The class is applied to the footer wrapper element.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the footer container',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerFooter.direction',\n hintText:\n 'Optional direction string that affects border radius styling of the footer based on drawer orientation. The direction determines which corners are rounded. Options: \"top\", \"bottom\", \"left\", \"right\". Defaults to \"bottom\".',\n },\n {\n content: \"'top' | 'bottom' | 'left' | 'right'\",\n hintText:\n 'Union type defining drawer direction for border radius calculation',\n },\n { content: 'top, bottom, left, right' },\n ],\n [\n {\n content: 'DrawerClose.children',\n hintText:\n 'Required React element (typically a Button component) that serves as the close button. When clicked, it closes the drawer. The button should be visually clear that it closes the drawer. Often styled as a secondary or tertiary button.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React element (typically a button) that closes the drawer when clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerClose.className',\n hintText:\n 'Optional CSS class name string applied to the close button wrapper. Useful for custom close button styling or positioning. The class is applied to the wrapper element around the close button.',\n },\n {\n content: 'string',\n hintText: 'CSS class name to apply to the close button wrapper',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerClose.disabled',\n hintText:\n 'When true, disables the close button, preventing it from closing the drawer. The button appears visually disabled. Useful for preventing drawer closure during critical operations or when validation is required. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true disables close button, false allows normal close functionality',\n },\n { content: '' },\n ],\n [\n {\n content: 'DrawerClose.asChild',\n hintText:\n 'When true, merges the close functionality props onto the child element instead of wrapping it in a button. Useful when you want a custom component (like a Button with specific styling) to handle the close action directly. The child element receives all necessary event handlers and accessibility attributes.',\n },\n {\n content: 'boolean',\n hintText:\n 'true merges props with child element, false renders a button wrapper',\n },\n { content: '' },\n ],\n [\n {\n content: \"DrawerClose.'aria-label'\",\n hintText:\n 'Optional accessibility label for the close button. Providesiated descriptive name for screen readers (e.g., \"Close drawer\" or \"Dismiss\"). Useful when the close button has no visible text or when the visible text is not descriptive enough.',\n },\n {\n content: 'string',\n hintText: 'Accessible label text for the close button',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the Drawer component using the following tokens:",
"excerpt": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new open state when drawer visibility changes',...",
"sections": [
{
@@ -739,7 +779,7 @@
"mobile-optimized",
"accessibility"
],
- "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with the toggled item value when selection changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.items',\n hintText:\n 'Required array of MultiSelectMenuGroupType objects that define the selectable options. Each group can contain multiple items organized under an optional label. Groups can be separated with visual separators. Each MultiSelectMenuGroupType contains: groupLabel (optional), items (required array), and showSeparator (optional). Defaults to empty array.',\n },\n {\n content: 'MultiSelectMenuGroupType[]',\n hintText:\n 'Array of menu group objects defining selectable options',\n },\n {\n content:\n 'MultiSelectMenuGroupType: see MultiSelectMenuGroupType props below',\n },\n ],\n [\n {\n content: 'MultiSelect.size',\n hintText:\n 'Optional MultiSelectMenuSize enum value that determines the size variant of the select field. Affects padding, height, font size, and overall dimensions. Common values include SMALL, MEDIUM, LARGE. Defaults to MEDIUM.',\n },\n {\n content: 'MultiSelectMenuSize',\n hintText:\n 'Enum that determines the size variant of the select field',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.variant',\n hintText:\n 'Optional MultiSelectVariant enum value that determines the visual styling approach. CONTAINER creates a container-style input with border and background, OUTLINED creates an outlined style. Affects the overall appearance and styling of the trigger. Defaults to CONTAINER.',\n },\n {\n content: 'MultiSelectVariant',\n hintText: 'Enum that determines the visual variant styling',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.selectionTagType',\n hintText:\n 'Optional MultiSelectSelectionTagType enum value that determines how selected items are displayed in the trigger. COUNT shows a badge with the number of selected items, TEXT shows the actual selected item labels. Defaults to COUNT.',\n },\n {\n content: 'MultiSelectSelectionTagType',\n hintText: 'Enum that determines how selections are displayed',\n },\n { content: 'COUNT, TEXT' },\n ],\n [\n {\n content: 'MultiSelect.required',\n hintText:\n 'When true, marks the field as required and displays an asterisk (*) next to the label. Useful for form validation. Required fields should have validation logic to ensure selections are made. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true marks field as required, false allows optional selection',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.disabled',\n hintText:\n \"When true, disables the select field, making it non-interactive and visually muted. Users cannot open the dropdown or make selections. Disabled fields show reduced opacity. Useful for preventing interaction when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables select field, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.helpIconHintText',\n hintText:\n 'Optional tooltip text string displayed when hovering over the help icon next to the label. Provides additional context or guidance about the select field. Should be concise and helpful. If not provided, no help icon is shown.',\n },\n {\n content: 'string',\n hintText: 'Tooltip text shown when hovering over the help icon',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.hintText',\n hintText:\n 'Optional helper text string displayed below the select field. Provides guidance, examples, or additional information about the selection. Styled with smaller, lighter text. Useful for clarifying complex selection requirements or providing examples.',\n },\n {\n content: 'string',\n hintText: 'Helper text shown below the select field',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.enableSearch',\n hintText:\n 'When true, displays a search input at the top of the dropdown menu that allows users to filter options by typing. The search filters items in real-time as the user types. Useful for menus with many items. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows search input, false hides search functionality',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.searchPlaceholder',\n hintText:\n 'Optional placeholder text string displayed in the search input when it\\'s empty. Provides guidance to users about what they can search for. Should be concise and descriptive. Defaults to \"Search options...\".',\n },\n {\n content: 'string',\n hintText: 'Placeholder text shown when search input is empty',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.enableSelectAll',\n hintText:\n 'When true, displays a \"Select All\" option at the top of the dropdown menu that allows users to select or deselect all items at once. Useful for menus with many items. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows select all option, false hides select all',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.selectAllText',\n hintText:\n 'Optional text string displayed for the select all option. Should be concise and action-oriented. Defaults to \"Select All\".',\n },\n {\n content: 'string',\n hintText: 'Text displayed for the select all option',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.slot',\n hintText:\n \"Optional React element displayed inside the trigger button, typically before the selection display. Commonly used for icons (from lucide-react or other icon libraries) that visually represent the select field's purpose. The slot maintains proper spacing and alignment.\",\n },\n {\n content: 'React.ReactNode',\n hintText:\n 'React element (typically an icon) displayed in the trigger',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.name',\n hintText:\n 'Optional name attribute string for the select field. Used for form identification, form submission, and accessibility. Should be unique within the form. Useful for form libraries and native form handling.',\n },\n {\n content: 'string',\n hintText:\n 'Name attribute for form identification and submission',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.customTrigger',\n hintText:\n 'Optional custom React element that completely replaces the default trigger button. Provides full control over the trigger appearance and behavior. When provided, the default trigger with label, placeholder, and selection display is not rendered. Useful for custom designs.',\n },\n {\n content: 'React.ReactNode',\n hintText:\n 'Custom React element that replaces the default trigger',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.useDrawerOnMobile',\n hintText:\n 'When true, renders the dropdown as a full-screen drawer on mobile devices (typically screen width ',\n hintText:\n 'Single element, array of elements, or null for collision boundaries',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.minMenuWidth',\n hintText:\n 'Optional minimum width in pixels for the dropdown menu. The menu will not shrink below this width, ensuring readability of menu items. Useful for preventing menus from becoming too narrow.',\n },\n {\n content: 'number',\n hintText:\n 'Positive number representing minimum menu width in pixels',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.maxMenuWidth',\n hintText:\n 'Optional maximum width in pixels for the dropdown menu. The menu will not grow beyond this width, preventing it from becoming too wide. Useful for maintaining consistent menu sizing.',\n },\n {\n content: 'number',\n hintText:\n 'Positive number representing maximum menu width in pixels',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.maxMenuHeight',\n hintText:\n 'Optional maximum height in pixels for the dropdown menu. When the menu content exceeds this height, a scrollbar appears. Useful for controlling the vertical space taken by the menu.',\n },\n {\n content: 'number',\n hintText:\n 'Positive number representing maximum menu height in pixels',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.inline',\n hintText:\n 'When true, renders the select field inline without a fixed height, allowing it to grow with content. When false, the select has a fixed height. Useful for dynamic content. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true renders inline without fixed height, false uses fixed height',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.onBlur',\n hintText:\n 'Optional callback function invoked when the select field loses focus. Receives no parameters. Useful for validation, cleanup, or tracking user interaction.',\n },\n {\n content: '() => void',\n hintText: 'Function called when select loses focus',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.onFocus',\n hintText:\n 'Optional callback function invoked when the select field gains focus. Receives no parameters. Useful for tracking user interaction or showing additional UI.',\n },\n {\n content: '() => void',\n hintText: 'Function called when select gains focus',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.error',\n hintText:\n 'When true, displays the select field in an error state with red border and error styling. Should be used with errorMessage to provide feedback. Useful for form validation. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows error state, false shows normal state',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.errorMessage',\n hintText:\n 'Optional error message string displayed below the select field when error is true. Should clearly explain what went wrong and how to fix it. Styled with error colors. Useful for form validation feedback.',\n },\n {\n content: 'string',\n hintText: 'Error message text displayed when error is true',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.showActionButtons',\n hintText:\n 'When true, displays action buttons (primary and/or secondary) in the dropdown footer. Buttons allow users to confirm or cancel selections. Useful for multi-step selection workflows. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows action buttons in footer, false hides action buttons',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.primaryAction',\n hintText:\n 'Optional object that configures the primary action button displayed in the dropdown footer. Contains: text (required button label), onClick (required callback receiving selectedValues array), disabled (optional boolean), and loading (optional boolean). Only shown when showActionButtons is true.',\n },\n {\n content:\n '{ text: string, onClick: (selectedValues: string[]) => void, disabled?: boolean, loading?: boolean }',\n hintText: 'Object with button configuration properties',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.secondaryAction',\n hintText:\n 'Optional object that configures the secondary action button displayed in the dropdown footer. Contains: text (required button label), onClick (required callback), disabled (optional boolean), and loading (optional boolean). Only shown when showActionButtons is true.',\n },\n {\n content:\n '{ text: string, onClick: () => void, disabled?: boolean, loading?: boolean }',\n hintText: 'Object with button configuration properties',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.showItemDividers',\n hintText:\n 'When true, displays visual divider lines between menu items. Dividers help visually separate items and create hierarchy. Useful for long lists or grouped items. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows dividers between items, false hides dividers',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.showHeaderBorder',\n hintText:\n 'When true, displays a border line below the dropdown header (search input and select all area). Creates visual separation between header and content. Useful for visual hierarchy. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows border below header, false hides border',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.height',\n hintText:\n 'Optional height in pixels for the select field. When provided, sets a fixed height for the trigger button. If not provided, height adapts to size variant and content. Useful for precise layout control.',\n },\n {\n content: 'number',\n hintText:\n 'Positive number representing select field height in pixels',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.maxSelections',\n hintText:\n 'Optional maximum number of items that can be selected. When the limit is reached, users cannot select additional items. Useful for enforcing business rules or API constraints. If not provided, unlimited selections are allowed.',\n },\n {\n content: 'number',\n hintText:\n 'Positive integer representing maximum number of selections',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.fullWidth',\n hintText:\n 'When true, the select field takes the full width of its container. When false, the select field only takes as much width as needed. Useful for form layouts. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true takes full width, false takes only needed width',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.enableVirtualization',\n hintText:\n 'When true, enables virtual scrolling for large item lists. Virtual scrolling only renders visible items plus an overscan buffer, improving performance for menus with hundreds or thousands of items. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables virtual scrolling, false uses standard rendering',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.virtualListItemHeight',\n hintText:\n 'Optional height in pixels for each virtualized list item. Required when enableVirtualization is true. Should match the actual height of your menu items for accurate scrolling. Defaults to 48 pixels.',\n },\n {\n content: 'number',\n hintText: 'Positive number representing item height in pixels',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.virtualListOverscan',\n hintText:\n 'Optional number of items to render outside the visible area when using virtual scrolling. Higher values provide smoother scrolling but use more memory. Lower values are more memory-efficient. Defaults to 5 items.',\n },\n {\n content: 'number',\n hintText:\n 'Positive integer representing number of items to render outside viewport',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.itemsToRender',\n hintText:\n 'Optional number of items to render initially when using virtualization or infinite scroll. Useful for controlling initial render performance. If not provided, all visible items plus overscan are rendered.',\n },\n {\n content: 'number',\n hintText:\n 'Positive integer representing initial number of items to render',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.onEndReached',\n hintText:\n 'Optional callback function invoked when the virtual list reaches the end (or within endReachedThreshold). Useful for infinite scrolling or lazy loading. Receives no parameters. Use with hasMore to control loading.',\n },\n {\n content: '() => void',\n hintText: 'Function called when list reaches the end',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.endReachedThreshold',\n hintText:\n 'Optional distance in pixels from the end of the list to trigger onEndReached. Allows pre-loading before the user reaches the absolute end. Useful for smooth infinite scrolling. If not provided, triggers at the exact end.',\n },\n {\n content: 'number',\n hintText:\n 'Positive number representing distance in pixels from end',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.hasMore',\n hintText:\n 'Optional boolean indicating whether there are more items to load. Used with onEndReached for infinite scrolling. When true and onEndReached is called, the loadingComponent is shown. If not provided, infinite scroll is not used.',\n },\n {\n content: 'boolean',\n hintText:\n 'true indicates more items available, false indicates all items loaded',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.loadingComponent',\n hintText:\n 'Optional React element displayed at the bottom of the list while loading more items (when hasMore is true and onEndReached is called). Typically a spinner or loading indicator. Useful for providing visual feedback during infinite scroll.',\n },\n {\n content: 'React.ReactNode',\n hintText:\n 'React element (typically a loading indicator) shown while loading',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.skeleton',\n hintText:\n 'Optional skeleton loading configuration object. Controls the appearance and behavior of skeleton loading states while data is being fetched. The skeleton provides visual feedback to users during loading. Contains count (number of skeleton items to show), show (boolean to toggle skeleton visibility), and variant (visual style: \"pulse\" or \"wave\").',\n },\n {\n content:\n '{ count?: number, show?: boolean, variant?: SkeletonVariant }',\n hintText: 'object',\n },\n {\n content:\n 'count: number of skeleton items, show: boolean to display skeleton, variant: \"pulse\" | \"wave\"',\n },\n {\n content: '{ count: 3, show: false, variant: \"pulse\" }',\n },\n ],\n [\n {\n content: 'MultiSelect.maxTriggerWidth',\n hintText:\n 'Optional maximum width constraint for the trigger button in pixels. Prevents the trigger from growing beyond this width even if the content is longer. Useful for maintaining consistent layouts and preventing the select from becoming too wide. Text that exceeds this width will be truncated with ellipsis.',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.minTriggerWidth',\n hintText:\n 'Optional minimum width constraint for the trigger button in pixels. Ensures the trigger maintains at least this width even if the content is shorter. Useful for maintaining consistent button sizes across multiple select fields or ensuring adequate click target area.',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.allowCustomValue',\n hintText:\n 'When true, allows users to enter custom values that are not in the predefined items list. Enables a special menu option that lets users specify their own value. Useful for scenarios where the list cannot cover all possible options. Defaults to false.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n { content: 'false' },\n ],\n [\n {\n content: 'MultiSelect.customValueLabel',\n hintText:\n 'Optional label text for the custom value option in the dropdown menu. This text appears as the label for the menu item that allows users to enter their own custom value. Only visible when allowCustomValue is true. Defaults to \"Specify\".',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n { content: '\"Specify\"' },\n ],\n [\n {\n content: 'MultiSelect.showClearButton',\n hintText:\n 'Optional boolean that controls the visibility of the X icon (clear button) beside the multi-select trigger. When true, the clear button is shown when items are selected. When false, the clear button is hidden. If not provided, defaults to showing the clear button when variant=CONTAINER and items are selected. Useful for analytics filters where API calls should only happen on explicit actions (Apply/Reset buttons).',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n { content: 'undefined (defaults based on variant and selection)' },\n ],\n [\n {\n content: 'MultiSelect.onClearAllClick',\n hintText:\n \"Optional callback function invoked when the X icon (clear button) is clicked. Provides a separate callback from onChange, allowing you to handle clear actions differently (e.g., making API calls). If not provided, clicking the clear button calls onChange('') to clear selections. Useful for analytics filters where clearing should trigger API calls.\",\n },\n {\n content: '() => void',\n hintText: 'Function called when clear button is clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.onOpenChange',\n hintText:\n 'Optional callback function invoked when the dropdown menu or mobile drawer opens or closes. Receives a boolean indicating the new open state. Useful for resetting internal state when the menu is dismissed without applying changes.',\n },\n {\n content: '(open: boolean) => void',\n hintText:\n 'Function called with boolean open state when menu visibility changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuGroupType.groupLabel',\n hintText:\n 'Optional string label displayed above the menu items in this group. Provides a heading or category name for organizing related items (e.g., \"Frontend\", \"Backend\", \"Tools\"). The label is styled distinctly from menu items.',\n },\n {\n content: 'string',\n hintText: 'Group heading text displayed above menu items',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuGroupType.items',\n hintText:\n 'Required array of MultiSelectMenuItemType objects that define the individual selectable items within this group. Each item represents a selectable option with its own label, value, and styling. The items are displayed in order within the group.',\n },\n {\n content: 'MultiSelectMenuItemType[]',\n hintText:\n 'Array of menu item objects defining selectable options in this group',\n },\n {\n content:\n 'MultiSelectMenuItemType: see MultiSelectMenuItemType props below',\n },\n ],\n [\n {\n content: 'MultiSelectMenuGroupType.showSeparator',\n hintText:\n 'When true, displays a visual separator line after this menu group. Separators help visually distinguish different groups of menu items. Useful for grouping related options. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows separator after group, false shows no separator',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.label',\n hintText:\n 'Required string representing the main text label displayed for the menu item. This is the primary text users see. Should be concise and descriptive, clearly identifying the option (e.g., \"React\", \"Node.js\", \"Python\").',\n },\n {\n content: 'string',\n hintText: 'Primary text label displayed for the menu item',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.value',\n hintText:\n \"Required unique string value identifier for the menu item. This value is used to track selections and must be unique within the items array. It's returned in the selectedValues array and passed to onChange. Should match the value used in your data.\",\n },\n {\n content: 'string',\n hintText: 'Unique value identifier for the menu item',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.checked',\n hintText:\n 'Optional boolean that controls whether the item appears checked. When true, the item shows a checked state. When false or undefined, the checked state is determined by whether the value is in selectedValues. Useful for controlled state.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows checked state, false shows unchecked state',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.subLabel',\n hintText:\n 'Optional string representing secondary text displayed below the main label. Provides additional context, description, or clarification about the menu item. Styled with smaller, lighter text. Useful for explaining options.',\n },\n {\n content: 'string',\n hintText:\n 'Secondary descriptive text shown below the main label',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.slot1',\n hintText:\n 'Optional React element displayed in the first content slot, typically before the label. Commonly used for icons (from lucide-react or other icon libraries) that visually represent the menu item. The slot maintains proper spacing.',\n },\n {\n content: 'React.ReactNode',\n hintText:\n 'React element (typically an icon) displayed in the first slot',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.slot2',\n hintText:\n 'Optional React element displayed in the second content slot. Can be used for additional icons, badges, indicators, or other visual elements that complement the menu item. Useful for status indicators or additional actions.',\n },\n {\n content: 'React.ReactNode',\n hintText: 'React element displayed in the second slot',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.slot3',\n hintText:\n 'Optional React element displayed in the third content slot. Provides additional flexibility for custom content placement. Can be used for icons, badges, or other visual elements as needed.',\n },\n {\n content: 'React.ReactNode',\n hintText: 'React element displayed in the third slot',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.slot4',\n hintText:\n 'Optional React element displayed in the fourth content slot, typically for trailing elements. Provides maximum flexibility for custom content placement. Can be used for icons, badges, shortcuts, or other visual elements.',\n },\n {\n content: 'React.ReactNode',\n hintText: 'React element displayed in the fourth slot',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.disabled',\n hintText:\n \"When true, disables the menu item, making it non-selectable and visually muted. Disabled items show reduced opacity and cannot be selected. Useful for preventing selection when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables menu item, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.alwaysSelected',\n hintText:\n 'When true, the item is always selected and cannot be deselected by the user. The item remains checked regardless of user interaction. Useful for required default selections or mandatory options. If not provided, items can be toggled normally.',\n },\n {\n content: 'boolean',\n hintText:\n 'true makes item always selected, false allows normal toggling',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.onClick',\n hintText:\n 'Optional custom click handler function for the menu item. Invoked when the item is clicked, in addition to the default selection behavior. Receives no parameters. Useful for custom actions or side effects when selecting an item.',\n },\n {\n content: '() => void',\n hintText: 'Function called when menu item is clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.subMenu',\n hintText:\n 'Optional array of MultiSelectMenuItemType objects that create a nested submenu. When provided, clicking the menu item opens a submenu with these items. Supports nested menus for hierarchical navigation. The submenu appears to the side of the parent menu item.',\n },\n {\n content: 'MultiSelectMenuItemType[]',\n hintText:\n 'Recursive array of MultiSelectMenuItemType objects for nested submenu',\n },\n {\n content: 'Recursive: MultiSelectMenuItemType[]',\n },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.tooltip',\n hintText:\n \"Optional tooltip content that appears when users hover over the menu item. Can be a string (simple text) or a React element (for rich content). Useful for providing additional information, shortcuts, or explanations that don't fit in the label.\",\n },\n {\n content: 'string | React.ReactNode',\n hintText: 'String text or React element for tooltip content',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.tooltipProps',\n hintText:\n 'Optional object with additional configuration properties for the tooltip component. Allows customization of tooltip positioning (side, align), size, arrow visibility, delay duration, and offset. Useful for fine-tuning tooltip appearance and behavior.',\n },\n {\n content:\n '{ side?: TooltipSide, align?: TooltipAlign, size?: TooltipSize, showArrow?: boolean, delayDuration?: number, offset?: number }',\n hintText:\n 'Object with optional tooltip configuration properties',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.disableTruncation',\n hintText:\n 'When true, disables text truncation for long labels, allowing them to wrap or overflow. When false, long labels are truncated with ellipsis. Useful for items with very long labels that need full visibility. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true disables truncation, false truncates long labels',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the MultiSelect component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with the toggled item value when selection changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.items',\n hintText:\n 'Required array of MultiSelectMenuGroupType objects that define the selectable options. Each group can contain multiple items organized under an optional label. Groups can be separated with visual separators. Each MultiSelectMenuGroupType contains: groupLabel (optional), items (required array), and showSeparator (optional). Defaults to empty array.',\n },\n {\n content: 'MultiSelectMenuGroupType[]',\n hintText:\n 'Array of menu group objects defining selectable options',\n },\n {\n content:\n 'MultiSelectMenuGroupType: see MultiSelectMenuGroupType props below',\n },\n ],\n [\n {\n content: 'MultiSelect.size',\n hintText:\n 'Optional MultiSelectMenuSize enum value that determines the size variant of the select field. Affects padding, height, font size, and overall dimensions. Common values include SMALL, MEDIUM, LARGE. Defaults to MEDIUM.',\n },\n {\n content: 'MultiSelectMenuSize',\n hintText:\n 'Enum that determines the size variant of the select field',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.variant',\n hintText:\n 'Optional MultiSelectVariant enum value that determines the visual styling approach. CONTAINER creates a container-style input with border and background, OUTLINED creates an outlined style. Affects the overall appearance and styling of the trigger. Defaults to CONTAINER.',\n },\n {\n content: 'MultiSelectVariant',\n hintText: 'Enum that determines the visual variant styling',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.selectionTagType',\n hintText:\n 'Optional MultiSelectSelectionTagType enum value that determines how selected items are displayed in the trigger. COUNT shows a badge with the number of selected items, TEXT shows the actual selected item labels. Defaults to COUNT.',\n },\n {\n content: 'MultiSelectSelectionTagType',\n hintText: 'Enum that determines how selections are displayed',\n },\n { content: 'COUNT, TEXT' },\n ],\n [\n {\n content: 'MultiSelect.required',\n hintText:\n 'When true, marks the field as required and displays an asterisk (*) next to the label. Useful for form validation. Required fields should have validation logic to ensure selections are made. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true marks field as required, false allows optional selection',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.disabled',\n hintText:\n \"When true, disables the select field, making it non-interactive and visually muted. Users cannot open the dropdown or make selections. Disabled fields show reduced opacity. Useful for preventing interaction when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables select field, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.helpIconHintText',\n hintText:\n 'Optional tooltip text string displayed when hovering over the help icon next to the label. Provides additional context or guidance about the select field. Should be concise and helpful. If not provided, no help icon is shown.',\n },\n {\n content: 'string',\n hintText: 'Tooltip text shown when hovering over the help icon',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.hintText',\n hintText:\n 'Optional helper text string displayed below the select field. Provides guidance, examples, or additional information about the selection. Styled with smaller, lighter text. Useful for clarifying complex selection requirements or providing examples.',\n },\n {\n content: 'string',\n hintText: 'Helper text shown below the select field',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.enableSearch',\n hintText:\n 'When true, displays a search input at the top of the dropdown menu that allows users to filter options by typing. The search filters items in real-time as the user types. Useful for menus with many items. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows search input, false hides search functionality',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.searchPlaceholder',\n hintText:\n 'Optional placeholder text string displayed in the search input when it\\'s empty. Provides guidance to users about what they can search for. Should be concise and descriptive. Defaults to \"Search options...\".',\n },\n {\n content: 'string',\n hintText: 'Placeholder text shown when search input is empty',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.enableSelectAll',\n hintText:\n 'When true, displays a \"Select All\" option at the top of the dropdown menu that allows users to select or deselect all items at once. Useful for menus with many items. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows select all option, false hides select all',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.selectAllText',\n hintText:\n 'Optional text string displayed for the select all option. Should be concise and action-oriented. Defaults to \"Select All\".',\n },\n {\n content: 'string',\n hintText: 'Text displayed for the select all option',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.slot',\n hintText:\n \"Optional React element displayed inside the trigger button, typically before the selection display. Commonly used for icons (from lucide-react or other icon libraries) that visually represent the select field's purpose. The slot maintains proper spacing and alignment.\",\n },\n {\n content: 'React.ReactNode',\n hintText:\n 'React element (typically an icon) displayed in the trigger',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.name',\n hintText:\n 'Optional name attribute string for the select field. Used for form identification, form submission, and accessibility. Should be unique within the form. Useful for form libraries and native form handling.',\n },\n {\n content: 'string',\n hintText:\n 'Name attribute for form identification and submission',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.customTrigger',\n hintText:\n 'Optional custom React element that completely replaces the default trigger button. Provides full control over the trigger appearance and behavior. When provided, the default trigger with label, placeholder, and selection display is not rendered. Useful for custom designs.',\n },\n {\n content: 'React.ReactNode',\n hintText:\n 'Custom React element that replaces the default trigger',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.useDrawerOnMobile',\n hintText:\n 'When true, renders the dropdown as a full-screen drawer on mobile devices (typically screen width ',\n hintText:\n 'Single element, array of elements, or null for collision boundaries',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.minMenuWidth',\n hintText:\n 'Optional minimum width in pixels for the dropdown menu. The menu will not shrink below this width, ensuring readability of menu items. Useful for preventing menus from becoming too narrow.',\n },\n {\n content: 'number',\n hintText:\n 'Positive number representing minimum menu width in pixels',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.maxMenuWidth',\n hintText:\n 'Optional maximum width in pixels for the dropdown menu. The menu will not grow beyond this width, preventing it from becoming too wide. Useful for maintaining consistent menu sizing.',\n },\n {\n content: 'number',\n hintText:\n 'Positive number representing maximum menu width in pixels',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.maxMenuHeight',\n hintText:\n 'Optional maximum height in pixels for the dropdown menu. When the menu content exceeds this height, a scrollbar appears. Useful for controlling the vertical space taken by the menu.',\n },\n {\n content: 'number',\n hintText:\n 'Positive number representing maximum menu height in pixels',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.inline',\n hintText:\n 'When true, renders the select field inline without a fixed height, allowing it to grow with content. When false, the select has a fixed height. Useful for dynamic content. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true renders inline without fixed height, false uses fixed height',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.onBlur',\n hintText:\n 'Optional callback function invoked when the select field loses focus. Receives no parameters. Useful for validation, cleanup, or tracking user interaction.',\n },\n {\n content: '() => void',\n hintText: 'Function called when select loses focus',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.onFocus',\n hintText:\n 'Optional callback function invoked when the select field gains focus. Receives no parameters. Useful for tracking user interaction or showing additional UI.',\n },\n {\n content: '() => void',\n hintText: 'Function called when select gains focus',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.error',\n hintText:\n 'When true, displays the select field in an error state with red border and error styling. Should be used with errorMessage to provide feedback. Useful for form validation. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows error state, false shows normal state',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.errorMessage',\n hintText:\n 'Optional error message string displayed below the select field when error is true. Should clearly explain what went wrong and how to fix it. Styled with error colors. Useful for form validation feedback.',\n },\n {\n content: 'string',\n hintText: 'Error message text displayed when error is true',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.showActionButtons',\n hintText:\n 'When true, displays action buttons (primary and/or secondary) in the dropdown footer. Buttons allow users to confirm or cancel selections. Useful for multi-step selection workflows. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows action buttons in footer, false hides action buttons',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.primaryAction',\n hintText:\n 'Optional object that configures the primary action button displayed in the dropdown footer. Contains: text (required button label), onClick (required callback receiving selectedValues array), disabled (optional boolean), and loading (optional boolean). Only shown when showActionButtons is true.',\n },\n {\n content:\n '{ text: string, onClick: (selectedValues: string[]) => void, disabled?: boolean, loading?: boolean }',\n hintText: 'Object with button configuration properties',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.secondaryAction',\n hintText:\n 'Optional object that configures the secondary action button displayed in the dropdown footer. Contains: text (required button label), onClick (required callback), disabled (optional boolean), and loading (optional boolean). Only shown when showActionButtons is true.',\n },\n {\n content:\n '{ text: string, onClick: () => void, disabled?: boolean, loading?: boolean }',\n hintText: 'Object with button configuration properties',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.showItemDividers',\n hintText:\n 'When true, displays visual divider lines between menu items. Dividers help visually separate items and create hierarchy. Useful for long lists or grouped items. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows dividers between items, false hides dividers',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.showHeaderBorder',\n hintText:\n 'When true, displays a border line below the dropdown header (search input and select all area). Creates visual separation between header and content. Useful for visual hierarchy. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows border below header, false hides border',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.height',\n hintText:\n 'Optional height in pixels for the select field. When provided, sets a fixed height for the trigger button. If not provided, height adapts to size variant and content. Useful for precise layout control.',\n },\n {\n content: 'number',\n hintText:\n 'Positive number representing select field height in pixels',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.maxSelections',\n hintText:\n 'Optional maximum number of items that can be selected. When the limit is reached, users cannot select additional items. Useful for enforcing business rules or API constraints. If not provided, unlimited selections are allowed.',\n },\n {\n content: 'number',\n hintText:\n 'Positive integer representing maximum number of selections',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.fullWidth',\n hintText:\n 'When true, the select field takes the full width of its container. When false, the select field only takes as much width as needed. Useful for form layouts. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true takes full width, false takes only needed width',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.enableVirtualization',\n hintText:\n 'When true, enables virtual scrolling for large item lists. Virtual scrolling only renders visible items plus an overscan buffer, improving performance for menus with hundreds or thousands of items. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables virtual scrolling, false uses standard rendering',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.virtualListItemHeight',\n hintText:\n 'Optional height in pixels for each virtualized list item. Required when enableVirtualization is true. Should match the actual height of your menu items for accurate scrolling. Defaults to 48 pixels.',\n },\n {\n content: 'number',\n hintText: 'Positive number representing item height in pixels',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.virtualListOverscan',\n hintText:\n 'Optional number of items to render outside the visible area when using virtual scrolling. Higher values provide smoother scrolling but use more memory. Lower values are more memory-efficient. Defaults to 5 items.',\n },\n {\n content: 'number',\n hintText:\n 'Positive integer representing number of items to render outside viewport',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.itemsToRender',\n hintText:\n 'Optional number of items to render initially when using virtualization or infinite scroll. Useful for controlling initial render performance. If not provided, all visible items plus overscan are rendered.',\n },\n {\n content: 'number',\n hintText:\n 'Positive integer representing initial number of items to render',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.onEndReached',\n hintText:\n 'Optional callback function invoked when the virtual list reaches the end (or within endReachedThreshold). Useful for infinite scrolling or lazy loading. Receives no parameters. Use with hasMore to control loading.',\n },\n {\n content: '() => void',\n hintText: 'Function called when list reaches the end',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.endReachedThreshold',\n hintText:\n 'Optional distance in pixels from the end of the list to trigger onEndReached. Allows pre-loading before the user reaches the absolute end. Useful for smooth infinite scrolling. If not provided, triggers at the exact end.',\n },\n {\n content: 'number',\n hintText:\n 'Positive number representing distance in pixels from end',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.hasMore',\n hintText:\n 'Optional boolean indicating whether there are more items to load. Used with onEndReached for infinite scrolling. When true and onEndReached is called, the loadingComponent is shown. If not provided, infinite scroll is not used.',\n },\n {\n content: 'boolean',\n hintText:\n 'true indicates more items available, false indicates all items loaded',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.loadingComponent',\n hintText:\n 'Optional React element displayed at the bottom of the list while loading more items (when hasMore is true and onEndReached is called). Typically a spinner or loading indicator. Useful for providing visual feedback during infinite scroll.',\n },\n {\n content: 'React.ReactNode',\n hintText:\n 'React element (typically a loading indicator) shown while loading',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.skeleton',\n hintText:\n 'Optional skeleton loading configuration object. Controls the appearance and behavior of skeleton loading states while data is being fetched. The skeleton provides visual feedback to users during loading. Contains count (number of skeleton items to show), show (boolean to toggle skeleton visibility), and variant (visual style: \"pulse\" or \"wave\").',\n },\n {\n content:\n '{ count?: number, show?: boolean, variant?: SkeletonVariant }',\n hintText: 'object',\n },\n {\n content:\n 'count: number of skeleton items, show: boolean to display skeleton, variant: \"pulse\" | \"wave\"',\n },\n {\n content: '{ count: 3, show: false, variant: \"pulse\" }',\n },\n ],\n [\n {\n content: 'MultiSelect.maxTriggerWidth',\n hintText:\n 'Optional maximum width constraint for the trigger button in pixels. Prevents the trigger from growing beyond this width even if the content is longer. Useful for maintaining consistent layouts and preventing the select from becoming too wide. Text that exceeds this width will be truncated with ellipsis.',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.minTriggerWidth',\n hintText:\n 'Optional minimum width constraint for the trigger button in pixels. Ensures the trigger maintains at least this width even if the content is shorter. Useful for maintaining consistent button sizes across multiple select fields or ensuring adequate click target area.',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.allowCustomValue',\n hintText:\n 'When true, allows users to enter custom values that are not in the predefined items list. Enables a special menu option that lets users specify their own value. Useful for scenarios where the list cannot cover all possible options. Defaults to false.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n { content: 'false' },\n ],\n [\n {\n content: 'MultiSelect.customValueLabel',\n hintText:\n 'Optional label text for the custom value option in the dropdown menu. This text appears as the label for the menu item that allows users to enter their own custom value. Only visible when allowCustomValue is true. Defaults to \"Specify\".',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n { content: '\"Specify\"' },\n ],\n [\n {\n content: 'MultiSelect.showClearButton',\n hintText:\n 'Optional boolean that controls the visibility of the X icon (clear button) beside the multi-select trigger. When true, the clear button is shown when items are selected. When false, the clear button is hidden. If not provided, defaults to showing the clear button when variant=CONTAINER and items are selected. Useful for analytics filters where API calls should only happen on explicit actions (Apply/Reset buttons).',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n { content: 'undefined (defaults based on variant and selection)' },\n ],\n [\n {\n content: 'MultiSelect.onClearAllClick',\n hintText:\n \"Optional callback function invoked when the X icon (clear button) is clicked. Provides a separate callback from onChange, allowing you to handle clear actions differently (e.g., making API calls). If not provided, clicking the clear button calls onChange('') to clear selections. Useful for analytics filters where clearing should trigger API calls.\",\n },\n {\n content: '() => void',\n hintText: 'Function called when clear button is clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.onOpenChange',\n hintText:\n 'Optional callback function invoked when the dropdown menu or mobile drawer opens or closes. Receives a boolean indicating the new open state. Useful for resetting internal state when the menu is dismissed without applying changes.',\n },\n {\n content: '(open: boolean) => void',\n hintText:\n 'Function called with boolean open state when menu visibility changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelect.multiSelectGroupPosition',\n hintText:\n 'Optional positioning string for when MultiSelect is used within a button group. Controls border radius adjustments for proper visual grouping. \"left\" for the leftmost item, \"right\" for the rightmost item, \"center\" for middle items. If not provided, uses default border radius.',\n },\n {\n content: \"'center' | 'left' | 'right'\",\n hintText:\n 'String indicating position within a button group for border radius adjustments',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuGroupType.groupLabel',\n hintText:\n 'Optional string label displayed above the menu items in this group. Provides a heading or category name for organizing related items (e.g., \"Frontend\", \"Backend\", \"Tools\"). The label is styled distinctly from menu items.',\n },\n {\n content: 'string',\n hintText: 'Group heading text displayed above menu items',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuGroupType.items',\n hintText:\n 'Required array of MultiSelectMenuItemType objects that define the individual selectable items within this group. Each item represents a selectable option with its own label, value, and styling. The items are displayed in order within the group.',\n },\n {\n content: 'MultiSelectMenuItemType[]',\n hintText:\n 'Array of menu item objects defining selectable options in this group',\n },\n {\n content:\n 'MultiSelectMenuItemType: see MultiSelectMenuItemType props below',\n },\n ],\n [\n {\n content: 'MultiSelectMenuGroupType.showSeparator',\n hintText:\n 'When true, displays a visual separator line after this menu group. Separators help visually distinguish different groups of menu items. Useful for grouping related options. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows separator after group, false shows no separator',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.label',\n hintText:\n 'Required string representing the main text label displayed for the menu item. This is the primary text users see. Should be concise and descriptive, clearly identifying the option (e.g., \"React\", \"Node.js\", \"Python\").',\n },\n {\n content: 'string',\n hintText: 'Primary text label displayed for the menu item',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.value',\n hintText:\n \"Required unique string value identifier for the menu item. This value is used to track selections and must be unique within the items array. It's returned in the selectedValues array and passed to onChange. Should match the value used in your data.\",\n },\n {\n content: 'string',\n hintText: 'Unique value identifier for the menu item',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.checked',\n hintText:\n 'Optional boolean that controls whether the item appears checked. When true, the item shows a checked state. When false or undefined, the checked state is determined by whether the value is in selectedValues. Useful for controlled state.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows checked state, false shows unchecked state',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.subLabel',\n hintText:\n 'Optional string representing secondary text displayed below the main label. Provides additional context, description, or clarification about the menu item. Styled with smaller, lighter text. Useful for explaining options.',\n },\n {\n content: 'string',\n hintText:\n 'Secondary descriptive text shown below the main label',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.slot1',\n hintText:\n 'Optional React element displayed in the first content slot, typically before the label. Commonly used for icons (from lucide-react or other icon libraries) that visually represent the menu item. The slot maintains proper spacing.',\n },\n {\n content: 'React.ReactNode',\n hintText:\n 'React element (typically an icon) displayed in the first slot',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.slot2',\n hintText:\n 'Optional React element displayed in the second content slot. Can be used for additional icons, badges, indicators, or other visual elements that complement the menu item. Useful for status indicators or additional actions.',\n },\n {\n content: 'React.ReactNode',\n hintText: 'React element displayed in the second slot',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.slot3',\n hintText:\n 'Optional React element displayed in the third content slot. Provides additional flexibility for custom content placement. Can be used for icons, badges, or other visual elements as needed.',\n },\n {\n content: 'React.ReactNode',\n hintText: 'React element displayed in the third slot',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.slot4',\n hintText:\n 'Optional React element displayed in the fourth content slot, typically for trailing elements. Provides maximum flexibility for custom content placement. Can be used for icons, badges, shortcuts, or other visual elements.',\n },\n {\n content: 'React.ReactNode',\n hintText: 'React element displayed in the fourth slot',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.disabled',\n hintText:\n \"When true, disables the menu item, making it non-selectable and visually muted. Disabled items show reduced opacity and cannot be selected. Useful for preventing selection when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables menu item, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.alwaysSelected',\n hintText:\n 'When true, the item is always selected and cannot be deselected by the user. The item remains checked regardless of user interaction. Useful for required default selections or mandatory options. If not provided, items can be toggled normally.',\n },\n {\n content: 'boolean',\n hintText:\n 'true makes item always selected, false allows normal toggling',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.onClick',\n hintText:\n 'Optional custom click handler function for the menu item. Invoked when the item is clicked, in addition to the default selection behavior. Receives no parameters. Useful for custom actions or side effects when selecting an item.',\n },\n {\n content: '() => void',\n hintText: 'Function called when menu item is clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.subMenu',\n hintText:\n 'Optional array of MultiSelectMenuItemType objects that create a nested submenu. When provided, clicking the menu item opens a submenu with these items. Supports nested menus for hierarchical navigation. The submenu appears to the side of the parent menu item.',\n },\n {\n content: 'MultiSelectMenuItemType[]',\n hintText:\n 'Recursive array of MultiSelectMenuItemType objects for nested submenu',\n },\n {\n content: 'Recursive: MultiSelectMenuItemType[]',\n },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.tooltip',\n hintText:\n \"Optional tooltip content that appears when users hover over the menu item. Can be a string (simple text) or a React element (for rich content). Useful for providing additional information, shortcuts, or explanations that don't fit in the label.\",\n },\n {\n content: 'string | React.ReactNode',\n hintText: 'String text or React element for tooltip content',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.tooltipProps',\n hintText:\n 'Optional object with additional configuration properties for the tooltip component. Allows customization of tooltip positioning (side, align), size, arrow visibility, delay duration, and offset. Useful for fine-tuning tooltip appearance and behavior.',\n },\n {\n content:\n '{ side?: TooltipSide, align?: TooltipAlign, size?: TooltipSize, showArrow?: boolean, delayDuration?: number, offset?: number }',\n hintText:\n 'Object with optional tooltip configuration properties',\n },\n { content: '' },\n ],\n [\n {\n content: 'MultiSelectMenuItemType.disableTruncation',\n hintText:\n 'When true, disables text truncation for long labels, allowing them to wrap or overflow. When false, long labels are truncated with ellipsis. Useful for items with very long labels that need full visibility. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true disables truncation, false truncates long labels',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the MultiSelect component using the following tokens:",
"excerpt": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with the toggled item value when selection changes',...",
"sections": [
{
@@ -815,7 +855,7 @@
"validation",
"controls"
],
- "content": "Usage\n\n\n\nAPI Reference\n\n as a parameter. Use event.target.value to get the new string value, then convert to number if needed. Update your state in this handler.',\n },\n {\n content: '(e: React.ChangeEvent) => void',\n hintText:\n 'Function called with change event when value changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.min',\n hintText:\n 'Optional minimum allowed numeric value. Users cannot enter values below this limit. The stepper buttons and input validation enforce this constraint. Useful for preventing invalid ranges (e.g., negative quantities).',\n },\n {\n content: 'number',\n hintText: 'Number representing the minimum allowed value',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.max',\n hintText:\n 'Optional maximum allowed numeric value. Users cannot enter values above this limit. The stepper buttons and input validation enforce this constraint. Useful for preventing invalid ranges (e.g., stock limits).',\n },\n {\n content: 'number',\n hintText: 'Number representing the maximum allowed value',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.step',\n hintText:\n 'Optional increment/decrement step value used by the stepper buttons and arrow keys. When users click the up/down buttons or press arrow keys, the value changes by this amount. Defaults to 1 if not provided.',\n },\n {\n content: 'number',\n hintText:\n 'Positive number representing the step increment/decrement',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.label',\n hintText:\n 'Optional primary label string displayed above the input field. Provides context and description of what users should input. Should be concise and descriptive. The label is styled prominently and appears above the sublabel (if provided).',\n },\n {\n content: 'string',\n hintText: 'Primary label text displayed above the input field',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.sublabel',\n hintText:\n \"Optional secondary label string displayed below the main label. Provides additional context, instructions, or clarification about the input field's purpose. Styled with smaller, lighter text than the label. Useful for explaining complex input requirements.\",\n },\n {\n content: 'string',\n hintText:\n 'Secondary descriptive text shown below the main label',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.placeholder',\n hintText:\n 'Optional placeholder text string displayed in the input field when it\\'s empty or when value is undefined. Provides guidance to users about what they can input. Should be concise and action-oriented (e.g., \"Enter quantity\").',\n },\n {\n content: 'string',\n hintText: 'Placeholder text shown when input is empty',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.size',\n hintText:\n 'Optional NumberInputSize enum value that determines the size variant of the input field. Affects padding, height, font size, and overall dimensions. MEDIUM is the default size, LARGE provides a prominent input with floating label on mobile. Defaults to MEDIUM.',\n },\n {\n content: 'NumberInputSize',\n hintText:\n 'Enum that determines the size variant of the input field',\n },\n { content: 'MEDIUM, LARGE' },\n ],\n [\n {\n content: 'NumberInput.error',\n hintText:\n 'When true, displays the input field in an error state with red border and error styling. Should be used with errorMessage to provide feedback. Useful for form validation. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows error state, false shows normal state',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.errorMessage',\n hintText:\n 'Optional error message string displayed below the input field when error is true. Should clearly explain what went wrong and how to fix it. Styled with error colors. Useful for form validation feedback.',\n },\n {\n content: 'string',\n hintText: 'Error message text displayed when error is true',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.hintText',\n hintText:\n 'Optional helper text string displayed below the input field. Provides guidance, examples, or additional information about the input. Styled with smaller, lighter text. Useful for clarifying complex input requirements or providing examples.',\n },\n {\n content: 'string',\n hintText: 'Helper text shown below the input field',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.helpIconHintText',\n hintText:\n 'Optional tooltip text string displayed when hovering over the help icon next to the label. Provides additional context or guidance about the input field. Should be concise and helpful. If not provided, no help icon is shown.',\n },\n {\n content: 'string',\n hintText: 'Tooltip text shown when hovering over the help icon',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.disabled',\n hintText:\n \"When true, disables the input field, making it non-interactive and visually muted. Users cannot type, use stepper buttons, or change the value. Disabled fields show reduced opacity. Useful for preventing interaction when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables input field, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.required',\n hintText:\n 'When true, marks the input field as required and displays an asterisk (*) next to the label. Useful for form validation. Required fields should have validation logic to ensure values are provided. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true marks field as required, false allows optional input',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.name',\n hintText:\n 'Optional name attribute string for the input field. Used for form identification, form submission, and accessibility. Should be unique within the form. Useful for form libraries and native form handling.',\n },\n {\n content: 'string',\n hintText:\n 'Name attribute for form identification and submission',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.onFocus',\n hintText:\n 'Optional callback function invoked when the input field receives focus. Receives a React.FocusEvent as a parameter. Useful for tracking user interaction or showing additional UI.',\n },\n {\n content: '(e: React.FocusEvent) => void',\n hintText:\n 'Function called with focus event when input gains focus',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.onBlur',\n hintText:\n 'Optional callback function invoked when the input field loses focus. Receives a React.FocusEvent as a parameter. Useful for validation, cleanup, or tracking user interaction.',\n },\n {\n content: '(e: React.FocusEvent) => void',\n hintText:\n 'Function called with focus event when input loses focus',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the NumberInput component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\n as a parameter. Use event.target.value to get the new string value, then convert to number if needed. Update your state in this handler.',\n },\n {\n content: '(e: React.ChangeEvent) => void',\n hintText:\n 'Function called with change event when value changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.min',\n hintText:\n 'Optional minimum allowed numeric value. Users cannot enter values below this limit. The stepper buttons and input validation enforce this constraint. Useful for preventing invalid ranges (e.g., negative quantities).',\n },\n {\n content: 'number',\n hintText: 'Number representing the minimum allowed value',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.max',\n hintText:\n 'Optional maximum allowed numeric value. Users cannot enter values above this limit. The stepper buttons and input validation enforce this constraint. Useful for preventing invalid ranges (e.g., stock limits).',\n },\n {\n content: 'number',\n hintText: 'Number representing the maximum allowed value',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.step',\n hintText:\n 'Optional increment/decrement step value used by the stepper buttons and arrow keys. When users click the up/down buttons or press arrow keys, the value changes by this amount. Defaults to 1 if not provided.',\n },\n {\n content: 'number',\n hintText:\n 'Positive number representing the step increment/decrement',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.label',\n hintText:\n 'Optional primary label string displayed above the input field. Provides context and description of what users should input. Should be concise and descriptive. The label is styled prominently and appears above the sublabel (if provided).',\n },\n {\n content: 'string',\n hintText: 'Primary label text displayed above the input field',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.sublabel',\n hintText:\n \"Optional secondary label string displayed below the main label. Provides additional context, instructions, or clarification about the input field's purpose. Styled with smaller, lighter text than the label. Useful for explaining complex input requirements.\",\n },\n {\n content: 'string',\n hintText:\n 'Secondary descriptive text shown below the main label',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.placeholder',\n hintText:\n 'Optional placeholder text string displayed in the input field when it\\'s empty or when value is undefined. Provides guidance to users about what they can input. Should be concise and action-oriented (e.g., \"Enter quantity\").',\n },\n {\n content: 'string',\n hintText: 'Placeholder text shown when input is empty',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.size',\n hintText:\n 'Optional NumberInputSize enum value that determines the size variant of the input field. Affects padding, height, font size, and overall dimensions. MEDIUM is the default size, LARGE provides a prominent input with floating label on mobile. Defaults to MEDIUM.',\n },\n {\n content: 'NumberInputSize',\n hintText:\n 'Enum that determines the size variant of the input field',\n },\n { content: 'MEDIUM, LARGE' },\n ],\n [\n {\n content: 'NumberInput.error',\n hintText:\n 'When true, displays the input field in an error state with red border and error styling. Should be used with errorMessage to provide feedback. Useful for form validation. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows error state, false shows normal state',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.errorMessage',\n hintText:\n 'Optional error message string displayed below the input field when error is true. Should clearly explain what went wrong and how to fix it. Styled with error colors. Useful for form validation feedback.',\n },\n {\n content: 'string',\n hintText: 'Error message text displayed when error is true',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.hintText',\n hintText:\n 'Optional helper text string displayed below the input field. Provides guidance, examples, or additional information about the input. Styled with smaller, lighter text. Useful for clarifying complex input requirements or providing examples.',\n },\n {\n content: 'string',\n hintText: 'Helper text shown below the input field',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.helpIconHintText',\n hintText:\n 'Optional tooltip text string displayed when hovering over the help icon next to the label. Provides additional context or guidance about the input field. Should be concise and helpful. If not provided, no help icon is shown.',\n },\n {\n content: 'string',\n hintText: 'Tooltip text shown when hovering over the help icon',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.disabled',\n hintText:\n \"When true, disables the input field, making it non-interactive and visually muted. Users cannot type, use stepper buttons, or change the value. Disabled fields show reduced opacity. Useful for preventing interaction when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables input field, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.required',\n hintText:\n 'When true, marks the input field as required and displays an asterisk (*) next to the label. Useful for form validation. Required fields should have validation logic to ensure values are provided. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true marks field as required, false allows optional input',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.name',\n hintText:\n 'Optional name attribute string for the input field. Used for form identification, form submission, and accessibility. Should be unique within the form. Useful for form libraries and native form handling.',\n },\n {\n content: 'string',\n hintText:\n 'Name attribute for form identification and submission',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.onFocus',\n hintText:\n 'Optional callback function invoked when the input field receives focus. Receives a React.FocusEvent as a parameter. Useful for tracking user interaction or showing additional UI.',\n },\n {\n content: '(e: React.FocusEvent) => void',\n hintText:\n 'Function called with focus event when input gains focus',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.onBlur',\n hintText:\n 'Optional callback function invoked when the input field loses focus. Receives a React.FocusEvent as a parameter. Useful for validation, cleanup, or tracking user interaction.',\n },\n {\n content: '(e: React.FocusEvent) => void',\n hintText:\n 'Function called with focus event when input loses focus',\n },\n { content: '' },\n ],\n [\n {\n content: 'NumberInput.preventNegative',\n hintText:\n 'When true, prevents the input from accepting negative values. The down stepper button is disabled at 0, and negative values cannot be entered via keyboard. Useful for quantities, counts, and other non-negative numeric values. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true prevents negative values, false allows negative values',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the NumberInput component using the following tokens:",
"excerpt": "Usage\n\n\n\nAPI Reference\n\n as a parameter. Use event.target.value to get the new string value, then convert to number if needed. Update your state in th...",
"sections": [
{
@@ -853,7 +893,7 @@
"code",
"2fa"
],
- "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with the complete OTP string when value changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.length',\n hintText:\n 'Optional number of OTP digit inputs to display. Determines how many individual input boxes are rendered. Common values are 4, 6, or 8 digits. The component automatically manages focus movement between inputs. Defaults to 6 if not provided.',\n },\n {\n content: 'number',\n hintText:\n 'Positive integer representing the number of digit inputs',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.autoFocus',\n hintText:\n 'When true, automatically focuses the first input field when the component mounts. Useful for improving UX by allowing users to immediately start typing without clicking. Only works if disabled is false. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true auto-focuses first input on mount, false requires manual focus',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.label',\n hintText:\n 'Optional primary label string displayed above the OTP input group. Provides context and description of what users should enter. Should be concise and descriptive (e.g., \"Verification Code\", \"Enter OTP\"). The label is styled prominently.',\n },\n {\n content: 'string',\n hintText: 'Primary label text displayed above the OTP inputs',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.sublabel',\n hintText:\n 'Optional secondary label string displayed below the main label. Provides additional context, instructions, or clarification about the OTP input. Styled with smaller, lighter text than the label. Useful for explaining where the OTP comes from.',\n },\n {\n content: 'string',\n hintText:\n 'Secondary descriptive text shown below the main label',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.error',\n hintText:\n 'When true, displays all OTP inputs in an error state with red border and error styling. Should be used with errorMessage to provide feedback. Useful for form validation when the OTP is incorrect or invalid. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows error state, false shows normal state',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.errorMessage',\n hintText:\n 'Optional error message string displayed below the OTP inputs when error is true. Should clearly explain what went wrong and how to fix it. Styled with error colors. Useful for form validation feedback (e.g., \"Invalid code. Please try again.\").',\n },\n {\n content: 'string',\n hintText: 'Error message text displayed when error is true',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.hintText',\n hintText:\n 'Optional helper text string displayed below the OTP inputs. Provides guidance, examples, or additional information about the OTP. Styled with smaller, lighter text. Useful for explaining where to find the code (e.g., \"Enter the 6-digit code sent to your phone\").',\n },\n {\n content: 'string',\n hintText: 'Helper text shown below the OTP inputs',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.helpIconHintText',\n hintText:\n 'Optional tooltip text string displayed when hovering over the help icon next to the label. Provides additional context or guidance about the OTP input. Should be concise and helpful. If not provided, no help icon is shown.',\n },\n {\n content: 'string',\n hintText: 'Tooltip text shown when hovering over the help icon',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.disabled',\n hintText:\n \"When true, disables all OTP input fields, making them non-interactive and visually muted. Users cannot type or change values. Disabled inputs show reduced opacity. Useful for preventing interaction when prerequisites aren't met or during submission. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables all inputs, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.required',\n hintText:\n 'When true, marks the OTP input as required and displays an asterisk (*) next to the label. Useful for form validation. Required fields should have validation logic to ensure the OTP is complete. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true marks field as required, false allows optional input',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.name',\n hintText:\n 'Optional name attribute string for the OTP input group. Used for form identification, form submission, and accessibility. Should be unique within the form. Useful for form libraries and native form handling.',\n },\n {\n content: 'string',\n hintText:\n 'Name attribute for form identification and submission',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.form',\n hintText:\n 'Optional form ID string to associate the OTP inputs with a specific form element. When provided, the inputs are part of that form and can be submitted with the form. Useful for native form handling and form validation.',\n },\n {\n content: 'string',\n hintText: 'Form ID to associate the inputs with',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the OTPInput component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with the complete OTP string when value changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.length',\n hintText:\n 'Optional number of OTP digit inputs to display. Determines how many individual input boxes are rendered. Common values are 4, 6, or 8 digits. The component automatically manages focus movement between inputs. Defaults to 6 if not provided.',\n },\n {\n content: 'number',\n hintText:\n 'Positive integer representing the number of digit inputs',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.autoFocus',\n hintText:\n 'When true, automatically focuses the first input field when the component mounts. Useful for improving UX by allowing users to immediately start typing without clicking. Only works if disabled is false. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true auto-focuses first input on mount, false requires manual focus',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.label',\n hintText:\n 'Optional primary label string displayed above the OTP input group. Provides context and description of what users should enter. Should be concise and descriptive (e.g., \"Verification Code\", \"Enter OTP\"). The label is styled prominently.',\n },\n {\n content: 'string',\n hintText: 'Primary label text displayed above the OTP inputs',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.sublabel',\n hintText:\n 'Optional secondary label string displayed below the main label. Provides additional context, instructions, or clarification about the OTP input. Styled with smaller, lighter text than the label. Useful for explaining where the OTP comes from.',\n },\n {\n content: 'string',\n hintText:\n 'Secondary descriptive text shown below the main label',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.error',\n hintText:\n 'When true, displays all OTP inputs in an error state with red border and error styling. Should be used with errorMessage to provide feedback. Useful for form validation when the OTP is incorrect or invalid. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows error state, false shows normal state',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.errorMessage',\n hintText:\n 'Optional error message string displayed below the OTP inputs when error is true. Should clearly explain what went wrong and how to fix it. Styled with error colors. Useful for form validation feedback (e.g., \"Invalid code. Please try again.\").',\n },\n {\n content: 'string',\n hintText: 'Error message text displayed when error is true',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.hintText',\n hintText:\n 'Optional helper text string displayed below the OTP inputs. Provides guidance, examples, or additional information about the OTP. Styled with smaller, lighter text. Useful for explaining where to find the code (e.g., \"Enter the 6-digit code sent to your phone\").',\n },\n {\n content: 'string',\n hintText: 'Helper text shown below the OTP inputs',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.helpIconHintText',\n hintText:\n 'Optional tooltip text string displayed when hovering over the help icon next to the label. Provides additional context or guidance about the OTP input. Should be concise and helpful. If not provided, no help icon is shown.',\n },\n {\n content: 'string',\n hintText: 'Tooltip text shown when hovering over the help icon',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.disabled',\n hintText:\n \"When true, disables all OTP input fields, making them non-interactive and visually muted. Users cannot type or change values. Disabled inputs show reduced opacity. Useful for preventing interaction when prerequisites aren't met or during submission. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables all inputs, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.required',\n hintText:\n 'When true, marks the OTP input as required and displays an asterisk (*) next to the label. Useful for form validation. Required fields should have validation logic to ensure the OTP is complete. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true marks field as required, false allows optional input',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.name',\n hintText:\n 'Optional name attribute string for the OTP input group. Used for form identification, form submission, and accessibility. Should be unique within the form. Useful for form libraries and native form handling.',\n },\n {\n content: 'string',\n hintText:\n 'Name attribute for form identification and submission',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.form',\n hintText:\n 'Optional form ID string to associate the OTP inputs with a specific form element. When provided, the inputs are part of that form and can be submitted with the form. Useful for native form handling and form validation.',\n },\n {\n content: 'string',\n hintText: 'Form ID to associate the inputs with',\n },\n { content: '' },\n ],\n [\n {\n content: 'OTPInput.placeholder',\n hintText:\n 'Optional placeholder text displayed in each digit input when empty. Note that placeholder color is set to transparent by default, so placeholder text may not be visible.',\n },\n {\n content: 'string',\n hintText:\n 'Placeholder text shown in each digit input when empty',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the OTPInput component using the following tokens:",
"excerpt": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with the complete OTP string when value changes',...",
"sections": [
{
@@ -965,7 +1005,7 @@
"keyboard-navigation",
"accessibility"
],
- "content": "Usage\n\n\n\nAPI Reference\n\nRadio Props\n\n void',\n hintText:\n 'Function called with new checked state when radio state changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'Radio.disabled',\n hintText:\n \"When true, disables the radio button, making it non-interactive and visually muted. Disabled radios show reduced opacity and cannot be selected. Useful for preventing selection when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables radio button, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'Radio.required',\n hintText:\n 'When true, marks the radio button as required and displays an asterisk (*) next to the label. Useful for form validation. At least one radio in a required group must be selected. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true marks radio as required, false allows optional selection',\n },\n { content: '' },\n ],\n [\n {\n content: 'Radio.error',\n hintText:\n 'When true, displays the radio button in an error state with red border and error styling. Useful for form validation feedback when the selection is invalid or missing (for required groups). Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows error state, false shows normal state',\n },\n { content: '' },\n ],\n [\n {\n content: 'Radio.size',\n hintText:\n 'Optional RadioSize enum value that determines the size variant of the radio button. Affects the indicator size, label font size, and overall dimensions. SMALL creates a compact radio, MEDIUM creates a standard size. Defaults to MEDIUM.',\n },\n {\n content: 'RadioSize',\n hintText:\n 'Enum that determines the size variant of the radio button',\n },\n { content: 'SMALL, MEDIUM' },\n ],\n [\n {\n content: 'Radio.children',\n hintText:\n 'Required React node representing the label text displayed next to the radio button. Can be a string or React element. The label is clickable and toggles the radio button. Should clearly describe the option (e.g., \"Basic Plan\", \"Professional Plan\").',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React element (typically text) displayed as the radio label',\n },\n { content: '' },\n ],\n [\n {\n content: 'Radio.subtext',\n hintText:\n 'Optional descriptive text string displayed below the main label. Provides additional context, explanation, or clarification about the radio option. Styled with smaller, lighter text. Useful for explaining complex options.',\n },\n {\n content: 'string',\n hintText:\n 'Secondary descriptive text shown below the main label',\n },\n { content: '' },\n ],\n [\n {\n content: 'Radio.slot',\n hintText:\n 'Optional React element displayed alongside the label, typically on the right side. Can be used for badges, prices, icons, or other supplementary content that complements the radio option. Useful for displaying additional information.',\n },\n {\n content: 'ReactNode',\n hintText: 'React element displayed alongside the radio label',\n },\n { content: '' },\n ],\n [\n {\n content: 'Radio.name',\n hintText:\n \"Optional name attribute string for form grouping. When used within a RadioGroup, this is typically inherited from the RadioGroup's name prop. Required for proper form submission and accessibility. Should match the RadioGroup's name.\",\n },\n {\n content: 'string',\n hintText: 'Name attribute for form grouping and identification',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nRadioGroup Props\n\n void',\n hintText:\n 'Function called with new selected value when selection changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'RadioGroup.disabled',\n hintText:\n \"When true, disables all radio buttons within the group, making them non-interactive and visually muted. Disabled radios show reduced opacity and cannot be selected. Useful for preventing interaction when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables all radios in group, false allows normal interaction',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the Radio component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\nRadio Props\n\n void',\n hintText:\n 'Function called with new checked state when radio state changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'Radio.disabled',\n hintText:\n \"When true, disables the radio button, making it non-interactive and visually muted. Disabled radios show reduced opacity and cannot be selected. Useful for preventing selection when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables radio button, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'Radio.required',\n hintText:\n 'When true, marks the radio button as required and displays an asterisk (*) next to the label. Useful for form validation. At least one radio in a required group must be selected. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true marks radio as required, false allows optional selection',\n },\n { content: '' },\n ],\n [\n {\n content: 'Radio.error',\n hintText:\n 'When true, displays the radio button in an error state with red border and error styling. Useful for form validation feedback when the selection is invalid or missing (for required groups). Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows error state, false shows normal state',\n },\n { content: '' },\n ],\n [\n {\n content: 'Radio.size',\n hintText:\n 'Optional RadioSize enum value that determines the size variant of the radio button. Affects the indicator size, label font size, and overall dimensions. SMALL creates a compact radio, MEDIUM creates a standard size. Defaults to MEDIUM.',\n },\n {\n content: 'RadioSize',\n hintText:\n 'Enum that determines the size variant of the radio button',\n },\n { content: 'SMALL, MEDIUM' },\n ],\n [\n {\n content: 'Radio.children',\n hintText:\n 'Required React node representing the label text displayed next to the radio button. Can be a string or React element. The label is clickable and toggles the radio button. Should clearly describe the option (e.g., \"Basic Plan\", \"Professional Plan\").',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React element (typically text) displayed as the radio label',\n },\n { content: '' },\n ],\n [\n {\n content: 'Radio.subtext',\n hintText:\n 'Optional descriptive text string displayed below the main label. Provides additional context, explanation, or clarification about the radio option. Styled with smaller, lighter text. Useful for explaining complex options.',\n },\n {\n content: 'string',\n hintText:\n 'Secondary descriptive text shown below the main label',\n },\n { content: '' },\n ],\n [\n {\n content: 'Radio.slot',\n hintText:\n 'Optional React element displayed alongside the label, typically on the right side. Can be used for badges, prices, icons, or other supplementary content that complements the radio option. Useful for displaying additional information.',\n },\n {\n content: 'ReactNode',\n hintText: 'React element displayed alongside the radio label',\n },\n { content: '' },\n ],\n [\n {\n content: 'Radio.name',\n hintText:\n \"Optional name attribute string for form grouping. When used within a RadioGroup, this is typically inherited from the RadioGroup's name prop. Required for proper form submission and accessibility. Should match the RadioGroup's name.\",\n },\n {\n content: 'string',\n hintText: 'Name attribute for form grouping and identification',\n },\n { content: '' },\n ],\n [\n {\n content: 'Radio.maxLength',\n hintText:\n 'Optional object with label and subtext number properties to limit text length. When text exceeds the limit, it is truncated with an ellipsis and a tooltip shows the full text on hover. Useful for keeping radio options compact.',\n },\n {\n content: '{ label?: number; subtext?: number }',\n hintText:\n 'Object defining max character lengths for label and subtext',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nRadioGroup Props\n\n void',\n hintText:\n 'Function called with new selected value when selection changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'RadioGroup.disabled',\n hintText:\n \"When true, disables all radio buttons within the group, making them non-interactive and visually muted. Disabled radios show reduced opacity and cannot be selected. Useful for preventing interaction when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables all radios in group, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'RadioGroup.required',\n hintText:\n 'When true, marks the radio group as required and displays an asterisk (*) next to the group label. At least one radio option must be selected for form validation. Useful for ensuring users make a selection. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true marks group as required, false allows optional selection',\n },\n { content: '' },\n ],\n [\n {\n content: 'RadioGroup.error',\n hintText:\n 'When true, displays all radio buttons in the group in an error state with red border and error styling. Useful for form validation feedback when the selection is invalid or missing. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows error state for all radios, false shows normal state',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the Radio component using the following tokens:",
"excerpt": "Usage\n\n\n\nAPI Reference\n\nRadio Props\n\n void',\n hintText:\n 'Function called with new checked state when radio state ch...",
"sections": [
{
@@ -1013,7 +1053,7 @@
"find",
"lookup"
],
- "content": "Usage\n\n\n\nAPI Reference\n\n as a parameter. Use event.target.value to get the new string value. Update your state in this handler for controlled input. Required for controlled usage.',\n },\n {\n content: '(e: React.ChangeEvent) => void',\n hintText:\n 'Function called with change event when value changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'SearchInput.placeholder',\n hintText:\n 'Optional placeholder text string displayed in the input field when it\\'s empty. Provides guidance to users about what they can search for. Should be concise and action-oriented (e.g., \"Search products...\", \"Enter search term\"). Defaults to \"Enter\" if not provided.',\n },\n {\n content: 'string',\n hintText: 'Placeholder text shown when input is empty',\n },\n { content: '' },\n ],\n [\n {\n content: 'SearchInput.name',\n hintText:\n 'Optional name attribute string for the input field. Used for form identification, form submission, and accessibility. Should be unique within the form. Useful for form libraries and native form handling.',\n },\n {\n content: 'string',\n hintText:\n 'Name attribute for form identification and submission',\n },\n { content: '' },\n ],\n [\n {\n content: 'SearchInput.disabled',\n hintText:\n \"When true, disables the input field, making it non-interactive and visually muted. Users cannot type or change the value. Disabled fields show reduced opacity and a disabled underline style. Useful for preventing interaction when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables input field, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'SearchInput.autoFocus',\n hintText:\n 'When true, automatically focuses the input field when the component mounts. Useful for improving UX by allowing users to immediately start typing without clicking. Only works if disabled is false. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true auto-focuses input on mount, false requires manual focus',\n },\n { content: '' },\n ],\n [\n {\n content: 'SearchInput.onFocus',\n hintText:\n 'Optional callback function invoked when the input field receives focus. Receives a React.FocusEvent as a parameter. Useful for tracking user interaction, showing additional UI, or triggering search suggestions.',\n },\n {\n content: '(e: React.FocusEvent) => void',\n hintText:\n 'Function called with focus event when input gains focus',\n },\n { content: '' },\n ],\n [\n {\n content: 'SearchInput.onBlur',\n hintText:\n 'Optional callback function invoked when the input field loses focus. Receives a React.FocusEvent as a parameter. Useful for validation, cleanup, or hiding search suggestions when the user clicks away.',\n },\n {\n content: '(e: React.FocusEvent) => void',\n hintText:\n 'Function called with focus event when input loses focus',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the SearchInput component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\n as a parameter. Use event.target.value to get the new string value. Update your state in this handler for controlled input. Required for controlled usage.',\n },\n {\n content: '(e: React.ChangeEvent) => void',\n hintText:\n 'Function called with change event when value changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'SearchInput.placeholder',\n hintText:\n 'Optional placeholder text string displayed in the input field when it\\'s empty. Provides guidance to users about what they can search for. Should be concise and action-oriented (e.g., \"Search products...\", \"Enter search term\"). Defaults to \"Enter\" if not provided.',\n },\n {\n content: 'string',\n hintText: 'Placeholder text shown when input is empty',\n },\n { content: '' },\n ],\n [\n {\n content: 'SearchInput.name',\n hintText:\n 'Optional name attribute string for the input field. Used for form identification, form submission, and accessibility. Should be unique within the form. Useful for form libraries and native form handling.',\n },\n {\n content: 'string',\n hintText:\n 'Name attribute for form identification and submission',\n },\n { content: '' },\n ],\n [\n {\n content: 'SearchInput.disabled',\n hintText:\n \"When true, disables the input field, making it non-interactive and visually muted. Users cannot type or change the value. Disabled fields show reduced opacity and a disabled underline style. Useful for preventing interaction when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables input field, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'SearchInput.autoFocus',\n hintText:\n 'When true, automatically focuses the input field when the component mounts. Useful for improving UX by allowing users to immediately start typing without clicking. Only works if disabled is false. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true auto-focuses input on mount, false requires manual focus',\n },\n { content: '' },\n ],\n [\n {\n content: 'SearchInput.onFocus',\n hintText:\n 'Optional callback function invoked when the input field receives focus. Receives a React.FocusEvent as a parameter. Useful for tracking user interaction, showing additional UI, or triggering search suggestions.',\n },\n {\n content: '(e: React.FocusEvent) => void',\n hintText:\n 'Function called with focus event when input gains focus',\n },\n { content: '' },\n ],\n [\n {\n content: 'SearchInput.onBlur',\n hintText:\n 'Optional callback function invoked when the input field loses focus. Receives a React.FocusEvent as a parameter. Useful for validation, cleanup, or hiding search suggestions when the user clicks away.',\n },\n {\n content: '(e: React.FocusEvent) => void',\n hintText:\n 'Function called with focus event when input loses focus',\n },\n { content: '' },\n ],\n [\n {\n content: 'SearchInput.allowClear',\n hintText:\n 'When true, displays a clear button (X icon) on the right side when the input has a value. Clicking the button clears the input. When false or when rightSlot is provided, the clear button is not shown. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows clear button when input has value, false hides clear button',\n },\n { content: '' },\n ],\n [\n {\n content: 'SearchInput.onClear',\n hintText:\n 'Optional callback function invoked when the clear button is clicked. Receives no parameters. Use this for custom clear logic such as analytics tracking or additional cleanup. If not provided, the component clears the input by calling onChange with an empty string.',\n },\n {\n content: '() => void',\n hintText: 'Function called when clear button is clicked',\n },\n { content: '' },\n ],\n [\n {\n content: 'SearchInput.clearIcon',\n hintText:\n 'Optional React element to use as the clear button icon. Defaults to X icon from lucide-react. Only visible when allowClear is true and rightSlot is not provided. Use this to customize the clear button appearance.',\n },\n {\n content: 'React.ReactNode',\n hintText: 'Custom icon element for the clear button',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the SearchInput component using the following tokens:",
"excerpt": "Usage\n\n\n\nAPI Reference\n\n as a parameter. Use event.target.value to get the new string value. Update your state in this handler for controlled input. R...",
"sections": [
{
@@ -1049,7 +1089,7 @@
"responsive",
"application"
],
- "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new expanded state when sidebar state changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'Sidebar.defaultIsExpanded',\n hintText:\n \"Optional boolean that sets the initial expanded state for uncontrolled usage. When provided, determines the sidebar's initial state. When true, the sidebar starts expanded. When false, it starts collapsed. Only used when isExpanded is not provided. Defaults to true.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true sets initial expanded state, false sets initial collapsed state',\n },\n { content: '' },\n ],\n [\n {\n content: 'Sidebar.isTopbarVisible',\n hintText:\n 'Optional boolean that controls whether the topbar is visible (controlled mode). When true, the topbar is shown. When false, the topbar is hidden. Use this with onTopbarVisibilityChange for controlled state management. When combined with enableTopbarAutoHide, this prop takes precedence and allows parent components to manage topbar visibility state.',\n },\n {\n content: 'boolean',\n hintText: 'true shows topbar, false hides topbar',\n },\n { content: '' },\n ],\n [\n {\n content: 'Sidebar.onTopbarVisibilityChange',\n hintText:\n 'Optional callback function invoked when the topbar visibility should change. Receives a boolean indicating the new visibility state. Required when using controlled mode (providing isTopbarVisible prop). Use this to update your isTopbarVisible state. Also called in uncontrolled mode for visibility change notifications when enableTopbarAutoHide triggers auto-hide behavior.',\n },\n {\n content: '(isVisible: boolean) => void',\n hintText:\n 'Function called with new visibility state when topbar visibility changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'Sidebar.defaultIsTopbarVisible',\n hintText:\n 'Optional boolean that sets the initial topbar visibility for uncontrolled usage. When provided, determines whether the topbar is initially visible. When true, the topbar starts visible. When false, it starts hidden. Only used when isTopbarVisible is not provided. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true sets initial visible state, false sets initial hidden state',\n },\n { content: 'true' },\n ],\n [\n {\n content: 'Sidebar.disableIntermediateState',\n hintText:\n 'When true, disables the intermediate state that appears on hover. When false or undefined, hovering over the collapsed sidebar will temporarily show it in an intermediate/expanded state. The intermediate state allows users to see the sidebar content without fully expanding it. When disabled, the sidebar can only be expanded by clicking the toggle button or using the keyboard shortcut. Useful for preventing accidental expansion on hover or when you want more explicit user control over sidebar visibility. Defaults to false (intermediate state enabled).',\n },\n {\n content: 'boolean',\n hintText:\n 'true disables intermediate state on hover, false enables it',\n },\n { content: 'false' },\n ],\n [\n {\n content: 'Sidebar.iconOnlyMode',\n hintText:\n 'When true, shows only icons (52px width) with tooltips on hover. In this mode: directory items show only their icons, tooltips appear on hover showing the item label, sections render as horizontal dividers, merchant switcher moves to topbar, and intermediate/hover state expansion is disabled. The toggle button appears at the top of the icon-only panel. Clicking the toggle button expands to full sidebar view (or hides the sidebar if hideOnIconOnlyToggle is true). Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables icon-only mode (52px width), false uses normal sidebar',\n },\n { content: 'false' },\n ],\n [\n {\n content: 'Sidebar.hideOnIconOnlyToggle',\n hintText:\n 'When true, clicking the toggle button in icon-only mode will completely hide the sidebar. When false, clicking the toggle button will expand to full sidebar view with tenant panel (if provided) and directory. Only applies when iconOnlyMode is true. Defaults to false (expands to full sidebar).',\n },\n {\n content: 'boolean',\n hintText:\n 'true hides sidebar on toggle, false expands to full sidebar',\n },\n { content: 'false' },\n ],\n [\n {\n content: 'Sidebar.showPrimaryActionButton',\n hintText:\n 'Optional boolean that controls whether to show a primary action button in the mobile navigation. When true, displays a primary action button (typically for CTA actions like \"Create\", \"Add\", etc.) in the mobile navigation drawer. The button appearance and behavior are controlled by primaryActionButtonProps. Useful for providing quick access to primary actions on mobile devices.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows primary action button in mobile nav, false hides it',\n },\n { content: '' },\n ],\n [\n {\n content: 'Sidebar.primaryActionButtonProps',\n hintText:\n 'Optional object containing HTML button attributes for the primary action button in mobile navigation. Extends standard button props (onClick, disabled, className, etc.) except for type. Use this to configure the primary action button behavior, styling, and event handlers. Only applies when showPrimaryActionButton is true. Common use cases include onClick for navigation, className for styling, and children for button content.',\n },\n {\n content:\n 'Omit, \"type\">',\n hintText:\n 'Standard HTML button attributes excluding type property',\n },\n { content: '' },\n ],\n [\n {\n content: 'DirectoryData.id',\n hintText:\n 'Required unique identifier string for the navigation item. Used internally for tracking selection, navigation, and rendering. Must be unique within the data array. The ID is used to determine which navigation item is active or selected.',\n },\n {\n content: 'string',\n hintText: 'Unique identifier for the navigation item',\n },\n { content: '' },\n ],\n [\n {\n content: 'DirectoryData.label',\n hintText:\n 'Required display text string for the navigation item. This is the text users see in the sidebar navigation. Should be concise and descriptive, clearly identifying the navigation destination (e.g., \"Dashboard\", \"Projects\", \"Settings\").',\n },\n {\n content: 'string',\n hintText: 'Display text for the navigation item',\n },\n { content: '' },\n ],\n [\n {\n content: 'DirectoryData.href',\n hintText:\n 'Required URL or route string for navigation. When the navigation item is clicked, the application navigates to this URL. Can be an absolute URL, relative path, or route identifier depending on your routing setup.',\n },\n {\n content: 'string',\n hintText: 'URL or route for navigation',\n },\n { content: '' },\n ],\n [\n {\n content: 'DirectoryData.icon',\n hintText:\n 'Optional React element (typically an icon from lucide-react or other icon libraries) displayed next to the navigation item label. Icons help users quickly identify navigation items. The icon is visible both when expanded and collapsed, making it useful for collapsed sidebar state.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React element (typically an icon) displayed with the navigation item',\n },\n { content: '' },\n ],\n [\n {\n content: 'DirectoryData.children',\n hintText:\n 'Optional array of DirectoryData objects that create nested navigation items (sub-menu). When provided, the navigation item becomes expandable and shows child items when clicked. Supports nested navigation for hierarchical structures. The children appear indented below the parent item.',\n },\n {\n content: 'DirectoryData[]',\n hintText:\n 'Recursive array of DirectoryData objects for nested navigation',\n },\n {\n content: 'Recursive: DirectoryData[]',\n },\n ],\n [\n {\n content: 'LeftPanelInfo.items',\n hintText:\n 'Required array of LeftPanelItem objects that define the selectable items in the left panel. Each item represents an option (typically a merchant, tenant, or context) that users can select. The items are displayed as a vertical list with icons and labels.',\n },\n {\n content: 'LeftPanelItem[]',\n hintText:\n 'Array of panel item objects defining selectable options',\n },\n {\n content: 'LeftPanelItem: see LeftPanelItem props below',\n },\n ],\n [\n {\n content: 'LeftPanelInfo.selected',\n hintText:\n 'Required string representing the currently selected item identifier. Should match the value prop of one of the items in the items array. The selected item is highlighted visually. Use this for controlled state management of the left panel selection.',\n },\n {\n content: 'string',\n hintText: 'Value string of the currently selected panel item',\n },\n { content: '' },\n ],\n [\n {\n content: 'LeftPanelInfo.onSelect',\n hintText:\n \"Required callback function invoked when a panel item is selected. Receives the selected item's value string as a parameter. Use this to update your selected state and perform any necessary actions when the selection changes (e.g., switching context, reloading data).\",\n },\n {\n content: '(value: string) => void',\n hintText:\n 'Function called with selected item value when selection changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'LeftPanelInfo.tenantSlot1',\n hintText:\n 'Optional React element displayed as the first slot above the tenant footer. The slot is rendered with fixed dimensions of 36x36 pixels (same as tenant panel items) and is centered within its container. Useful for adding custom actions or controls that should be positioned above the footer, such as help buttons, notifications, or quick actions.',\n },\n {\n content: 'ReactNode',\n hintText: 'React element displayed as first tenant slot',\n },\n { content: '' },\n ],\n [\n {\n content: 'LeftPanelInfo.tenantSlot2',\n hintText:\n 'Optional React element displayed as the second slot above the tenant footer (and below tenantSlot1 if provided). The slot is rendered with fixed dimensions of 36x36 pixels (same as tenant panel items) and is centered within its container. Useful for adding secondary actions or controls above the footer, providing additional functionality alongside tenantSlot1.',\n },\n {\n content: 'ReactNode',\n hintText: 'React element displayed as second tenant slot',\n },\n { content: '' },\n ],\n [\n {\n content: 'LeftPanelInfo.tenantFooter',\n hintText:\n 'Optional React element displayed at the bottom of the tenant panel. The footer is rendered with fixed dimensions of 36x36 pixels (same as tenant panel items) and is centered within its container. Useful for adding actions or information that should always be accessible at the bottom of the tenant panel, such as settings, help, or additional options.',\n },\n {\n content: 'ReactNode',\n hintText: 'React element displayed in the tenant panel footer',\n },\n { content: '' },\n ],\n [\n {\n content: 'LeftPanelItem.label',\n hintText:\n 'Required display text string for the panel item. This is the text users see for each selectable option. Should be concise and descriptive, clearly identifying the option (e.g., \"Merchant A\", \"Tenant 1\", \"Organization X\").',\n },\n {\n content: 'string',\n hintText: 'Display text for the panel item',\n },\n { content: '' },\n ],\n [\n {\n content: 'LeftPanelItem.icon',\n hintText:\n 'Required React element (typically an icon from lucide-react or other icon libraries) displayed next to the panel item label. Icons help users quickly identify different options. The icon is always visible and provides visual differentiation between items.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React element (typically an icon) displayed with the panel item',\n },\n { content: '' },\n ],\n [\n {\n content: 'LeftPanelItem.value',\n hintText:\n 'Optional custom value string for selection. When provided, this value is used for selection tracking instead of the label. If not provided, the label is used as the value. Useful when you need a different identifier than the display label (e.g., IDs, codes).',\n },\n {\n content: 'string',\n hintText: 'Custom value identifier for the panel item',\n },\n { content: '' },\n ],\n [\n {\n content: 'LeftPanelItem.showInPanel',\n hintText:\n 'Optional boolean that controls whether the item appears directly in the panel or in the overflow menu (three-dot menu). When true, the item is displayed in the main panel. When false or undefined (default), the item appears only in the overflow menu. The selected item is always visible in the panel, regardless of this setting. Useful for managing panel clutter by relegating less frequently used options to the overflow menu.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows item in panel, false/undefined shows in overflow menu',\n },\n { content: 'false' },\n ],\n [\n {\n content: 'Sidebar.onSidebarStateChange',\n hintText:\n 'Optional callback function invoked whenever the sidebar layout state changes. Receives the current sidebar layout state, which can be \"collapsed\", \"expanded\", or \"intermediate\". Useful for tracking sidebar behavior, syncing layout changes with parent components, analytics, or adjusting surrounding content based on sidebar state.',\n },\n {\n content:\n '(state: \"collapsed\" | \"expanded\" | \"intermediate\") => void',\n hintText:\n 'Function called with the current sidebar layout state',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the Sidebar component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new expanded state when sidebar state changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'Sidebar.defaultIsExpanded',\n hintText:\n \"Optional boolean that sets the initial expanded state for uncontrolled usage. When provided, determines the sidebar's initial state. When true, the sidebar starts expanded. When false, it starts collapsed. Only used when isExpanded is not provided. Defaults to true.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true sets initial expanded state, false sets initial collapsed state',\n },\n { content: '' },\n ],\n [\n {\n content: 'Sidebar.isTopbarVisible',\n hintText:\n 'Optional boolean that controls whether the topbar is visible (controlled mode). When true, the topbar is shown. When false, the topbar is hidden. Use this with onTopbarVisibilityChange for controlled state management. When combined with enableTopbarAutoHide, this prop takes precedence and allows parent components to manage topbar visibility state.',\n },\n {\n content: 'boolean',\n hintText: 'true shows topbar, false hides topbar',\n },\n { content: '' },\n ],\n [\n {\n content: 'Sidebar.onTopbarVisibilityChange',\n hintText:\n 'Optional callback function invoked when the topbar visibility should change. Receives a boolean indicating the new visibility state. Required when using controlled mode (providing isTopbarVisible prop). Use this to update your isTopbarVisible state. Also called in uncontrolled mode for visibility change notifications when enableTopbarAutoHide triggers auto-hide behavior.',\n },\n {\n content: '(isVisible: boolean) => void',\n hintText:\n 'Function called with new visibility state when topbar visibility changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'Sidebar.defaultIsTopbarVisible',\n hintText:\n 'Optional boolean that sets the initial topbar visibility for uncontrolled usage. When provided, determines whether the topbar is initially visible. When true, the topbar starts visible. When false, it starts hidden. Only used when isTopbarVisible is not provided. Defaults to true.',\n },\n {\n content: 'boolean',\n hintText:\n 'true sets initial visible state, false sets initial hidden state',\n },\n { content: 'true' },\n ],\n [\n {\n content: 'Sidebar.disableIntermediateState',\n hintText:\n 'When true, disables the intermediate state that appears on hover. When false or undefined, hovering over the collapsed sidebar will temporarily show it in an intermediate/expanded state. The intermediate state allows users to see the sidebar content without fully expanding it. When disabled, the sidebar can only be expanded by clicking the toggle button or using the keyboard shortcut. Useful for preventing accidental expansion on hover or when you want more explicit user control over sidebar visibility. Defaults to false (intermediate state enabled).',\n },\n {\n content: 'boolean',\n hintText:\n 'true disables intermediate state on hover, false enables it',\n },\n { content: 'false' },\n ],\n [\n {\n content: 'Sidebar.iconOnlyMode',\n hintText:\n 'When true, shows only icons (52px width) with tooltips on hover. In this mode: directory items show only their icons, tooltips appear on hover showing the item label, sections render as horizontal dividers, merchant switcher moves to topbar, and intermediate/hover state expansion is disabled. The toggle button appears at the top of the icon-only panel. Clicking the toggle button expands to full sidebar view (or hides the sidebar if hideOnIconOnlyToggle is true). Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true enables icon-only mode (52px width), false uses normal sidebar',\n },\n { content: 'false' },\n ],\n [\n {\n content: 'Sidebar.hideOnIconOnlyToggle',\n hintText:\n 'When true, clicking the toggle button in icon-only mode will completely hide the sidebar. When false, clicking the toggle button will expand to full sidebar view with tenant panel (if provided) and directory. Only applies when iconOnlyMode is true. Defaults to false (expands to full sidebar).',\n },\n {\n content: 'boolean',\n hintText:\n 'true hides sidebar on toggle, false expands to full sidebar',\n },\n { content: 'false' },\n ],\n [\n {\n content: 'Sidebar.showPrimaryActionButton',\n hintText:\n 'Optional boolean that controls whether to show a primary action button in the mobile navigation. When true, displays a primary action button (typically for CTA actions like \"Create\", \"Add\", etc.) in the mobile navigation drawer. The button appearance and behavior are controlled by primaryActionButtonProps. Useful for providing quick access to primary actions on mobile devices.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows primary action button in mobile nav, false hides it',\n },\n { content: '' },\n ],\n [\n {\n content: 'Sidebar.primaryActionButtonProps',\n hintText:\n 'Optional object containing HTML button attributes for the primary action button in mobile navigation. Extends standard button props (onClick, disabled, className, etc.) except for type. Use this to configure the primary action button behavior, styling, and event handlers. Only applies when showPrimaryActionButton is true. Common use cases include onClick for navigation, className for styling, and children for button content.',\n },\n {\n content:\n 'Omit, \"type\">',\n hintText:\n 'Standard HTML button attributes excluding type property',\n },\n { content: '' },\n ],\n [\n {\n content: 'DirectoryData.id',\n hintText:\n 'Required unique identifier string for the navigation item. Used internally for tracking selection, navigation, and rendering. Must be unique within the data array. The ID is used to determine which navigation item is active or selected.',\n },\n {\n content: 'string',\n hintText: 'Unique identifier for the navigation item',\n },\n { content: '' },\n ],\n [\n {\n content: 'DirectoryData.label',\n hintText:\n 'Required display text string for the navigation item. This is the text users see in the sidebar navigation. Should be concise and descriptive, clearly identifying the navigation destination (e.g., \"Dashboard\", \"Projects\", \"Settings\").',\n },\n {\n content: 'string',\n hintText: 'Display text for the navigation item',\n },\n { content: '' },\n ],\n [\n {\n content: 'DirectoryData.href',\n hintText:\n 'Required URL or route string for navigation. When the navigation item is clicked, the application navigates to this URL. Can be an absolute URL, relative path, or route identifier depending on your routing setup.',\n },\n {\n content: 'string',\n hintText: 'URL or route for navigation',\n },\n { content: '' },\n ],\n [\n {\n content: 'DirectoryData.icon',\n hintText:\n 'Optional React element (typically an icon from lucide-react or other icon libraries) displayed next to the navigation item label. Icons help users quickly identify navigation items. The icon is visible both when expanded and collapsed, making it useful for collapsed sidebar state.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React element (typically an icon) displayed with the navigation item',\n },\n { content: '' },\n ],\n [\n {\n content: 'DirectoryData.children',\n hintText:\n 'Optional array of DirectoryData objects that create nested navigation items (sub-menu). When provided, the navigation item becomes expandable and shows child items when clicked. Supports nested navigation for hierarchical structures. The children appear indented below the parent item.',\n },\n {\n content: 'DirectoryData[]',\n hintText:\n 'Recursive array of DirectoryData objects for nested navigation',\n },\n {\n content: 'Recursive: DirectoryData[]',\n },\n ],\n [\n {\n content: 'LeftPanelInfo.items',\n hintText:\n 'Required array of LeftPanelItem objects that define the selectable items in the left panel. Each item represents an option (typically a merchant, tenant, or context) that users can select. The items are displayed as a vertical list with icons and labels.',\n },\n {\n content: 'LeftPanelItem[]',\n hintText:\n 'Array of panel item objects defining selectable options',\n },\n {\n content: 'LeftPanelItem: see LeftPanelItem props below',\n },\n ],\n [\n {\n content: 'LeftPanelInfo.selected',\n hintText:\n 'Required string representing the currently selected item identifier. Should match the value prop of one of the items in the items array. The selected item is highlighted visually. Use this for controlled state management of the left panel selection.',\n },\n {\n content: 'string',\n hintText: 'Value string of the currently selected panel item',\n },\n { content: '' },\n ],\n [\n {\n content: 'LeftPanelInfo.onSelect',\n hintText:\n \"Required callback function invoked when a panel item is selected. Receives the selected item's value string as a parameter. Use this to update your selected state and perform any necessary actions when the selection changes (e.g., switching context, reloading data).\",\n },\n {\n content: '(value: string) => void',\n hintText:\n 'Function called with selected item value when selection changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'LeftPanelInfo.tenantSlot1',\n hintText:\n 'Optional React element displayed as the first slot above the tenant footer. The slot is rendered with fixed dimensions of 36x36 pixels (same as tenant panel items) and is centered within its container. Useful for adding custom actions or controls that should be positioned above the footer, such as help buttons, notifications, or quick actions.',\n },\n {\n content: 'ReactNode',\n hintText: 'React element displayed as first tenant slot',\n },\n { content: '' },\n ],\n [\n {\n content: 'LeftPanelInfo.tenantSlot2',\n hintText:\n 'Optional React element displayed as the second slot above the tenant footer (and below tenantSlot1 if provided). The slot is rendered with fixed dimensions of 36x36 pixels (same as tenant panel items) and is centered within its container. Useful for adding secondary actions or controls above the footer, providing additional functionality alongside tenantSlot1.',\n },\n {\n content: 'ReactNode',\n hintText: 'React element displayed as second tenant slot',\n },\n { content: '' },\n ],\n [\n {\n content: 'LeftPanelInfo.tenantFooter',\n hintText:\n 'Optional React element displayed at the bottom of the tenant panel. The footer is rendered with fixed dimensions of 36x36 pixels (same as tenant panel items) and is centered within its container. Useful for adding actions or information that should always be accessible at the bottom of the tenant panel, such as settings, help, or additional options.',\n },\n {\n content: 'ReactNode',\n hintText: 'React element displayed in the tenant panel footer',\n },\n { content: '' },\n ],\n [\n {\n content: 'LeftPanelItem.label',\n hintText:\n 'Required display text string for the panel item. This is the text users see for each selectable option. Should be concise and descriptive, clearly identifying the option (e.g., \"Merchant A\", \"Tenant 1\", \"Organization X\").',\n },\n {\n content: 'string',\n hintText: 'Display text for the panel item',\n },\n { content: '' },\n ],\n [\n {\n content: 'LeftPanelItem.icon',\n hintText:\n 'Required React element (typically an icon from lucide-react or other icon libraries) displayed next to the panel item label. Icons help users quickly identify different options. The icon is always visible and provides visual differentiation between items.',\n },\n {\n content: 'ReactNode',\n hintText:\n 'React element (typically an icon) displayed with the panel item',\n },\n { content: '' },\n ],\n [\n {\n content: 'LeftPanelItem.value',\n hintText:\n 'Optional custom value string for selection. When provided, this value is used for selection tracking instead of the label. If not provided, the label is used as the value. Useful when you need a different identifier than the display label (e.g., IDs, codes).',\n },\n {\n content: 'string',\n hintText: 'Custom value identifier for the panel item',\n },\n { content: '' },\n ],\n [\n {\n content: 'LeftPanelItem.showInPanel',\n hintText:\n 'Optional boolean that controls whether the item appears directly in the panel or in the overflow menu (three-dot menu). When true, the item is displayed in the main panel. When false or undefined (default), the item appears only in the overflow menu. The selected item is always visible in the panel, regardless of this setting. Useful for managing panel clutter by relegating less frequently used options to the overflow menu.',\n },\n {\n content: 'boolean',\n hintText:\n 'true shows item in panel, false/undefined shows in overflow menu',\n },\n { content: 'false' },\n ],\n [\n {\n content: 'Sidebar.onSidebarStateChange',\n hintText:\n 'Optional callback function invoked whenever the sidebar layout state changes. Receives the current sidebar layout state, which can be \"collapsed\", \"expanded\", or \"intermediate\". Useful for tracking sidebar behavior, syncing layout changes with parent components, analytics, or adjusting surrounding content based on sidebar state.',\n },\n {\n content:\n '(state: \"collapsed\" | \"expanded\" | \"intermediate\") => void',\n hintText:\n 'Function called with the current sidebar layout state',\n },\n { content: '' },\n ],\n [\n {\n content: 'Sidebar.activeItem',\n hintText:\n 'Optional string value representing the currently active/selected navigation item ID. When provided, the component works in controlled mode, highlighting the item with this ID in the directory navigation. Use this with onActiveItemChange for controlled state management. If not provided, the component manages active state internally based on defaultActiveItem.',\n },\n {\n content: 'string | null',\n hintText: 'ID of the currently active directory item',\n },\n { content: '' },\n ],\n [\n {\n content: 'Sidebar.onActiveItemChange',\n hintText:\n 'Optional callback function invoked when the active navigation item changes. Receives the ID of the newly selected item as a parameter. Required when using controlled mode (providing activeItem prop). Use this to update your activeItem state when users click on navigation items.',\n },\n {\n content: '(item: string | null) => void',\n hintText:\n 'Function called with new active item ID when selection changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'Sidebar.defaultActiveItem',\n hintText:\n 'Optional string value that sets the initial active navigation item for uncontrolled usage. When provided, the item with this ID is initially highlighted. Only used when activeItem is not provided. If not provided, no item is initially active.',\n },\n {\n content: 'string | null',\n hintText: 'Initial active item ID for uncontrolled mode',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the Sidebar component using the following tokens:",
"excerpt": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new expanded state when sidebar state changes',...",
"sections": [
{
@@ -1087,7 +1127,7 @@
"mobile-optimized",
"accessibility"
],
- "content": "Usage\n\n\n\nAPI Reference\n\n void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.enableSearch',\n hintText: 'Whether to show search input in the dropdown menu',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.searchPlaceholder',\n hintText: 'Placeholder text for the search input field',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.slot',\n hintText:\n 'Content displayed inside the trigger button (e.g., icons)',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.disabled',\n hintText:\n 'Whether the select field is disabled and non-interactive',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.name',\n hintText:\n 'Name attribute for form identification and submission',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.customTrigger',\n hintText:\n 'Custom React element to replace the default trigger button',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.useDrawerOnMobile',\n hintText: 'Whether to render as drawer on mobile devices',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n { content: 'true' },\n ],\n [\n {\n content: 'SingleSelect.alignment',\n hintText:\n 'How the dropdown menu aligns relative to the trigger',\n },\n { content: 'SelectMenuAlignment', hintText: 'enum' },\n { content: 'START, CENTER, END' },\n { content: 'undefined' },\n ],\n [\n {\n content: 'SingleSelect.side',\n hintText: 'Which side of the trigger the dropdown appears on',\n },\n { content: 'SelectMenuSide', hintText: 'enum' },\n { content: 'TOP, LEFT, RIGHT, BOTTOM' },\n { content: 'undefined' },\n ],\n [\n {\n content: 'SingleSelect.sideOffset',\n hintText: 'Distance in pixels between trigger and dropdown',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.alignOffset',\n hintText: 'Alignment offset in pixels for fine positioning',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.collisionBoundary',\n hintText:\n 'Optional reference to DOM element(s) that define collision boundaries for the dropdown positioning. The dropdown will automatically reposition itself to stay within these boundaries. Can be a single Element, null, or an array of Elements. Useful for keeping dropdowns within specific containers or avoiding overlap with sidebars and other UI elements. When not provided, the viewport is used as the default boundary.',\n },\n {\n content: 'Element | null | Array',\n hintText:\n 'Single element, array of elements, or null for collision boundaries',\n },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.minMenuWidth',\n hintText: 'Minimum width of the dropdown menu in pixels',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.maxMenuWidth',\n hintText: 'Maximum width of the dropdown menu in pixels',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.maxMenuHeight',\n hintText: 'Maximum height of the dropdown menu in pixels',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.inline',\n hintText:\n 'Whether the select renders inline without fixed height',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.onBlur',\n hintText:\n 'Callback function called when the select loses focus',\n },\n { content: '() => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.onFocus',\n hintText:\n 'Callback function called when the select gains focus',\n },\n { content: '() => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.error',\n hintText: 'Whether the select is in an error state',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.errorMessage',\n hintText: 'Error message displayed when error is true',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.fullWidth',\n hintText: 'Whether the select field takes full width',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.enableVirtualization',\n hintText: 'Whether to enable virtual scrolling for large lists',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.virtualListItemHeight',\n hintText: 'Height of each virtualized list item in pixels',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.virtualListOverscan',\n hintText: 'Number of items to render outside visible area',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.onEndReached',\n hintText: 'Callback when virtual list reaches the end',\n },\n { content: '() => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.endReachedThreshold',\n hintText: 'Distance from end to trigger onEndReached',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.hasMore',\n hintText: 'Whether there are more items to load',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.loadingComponent',\n hintText: 'Component to show while loading more items',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.skeleton',\n hintText:\n 'Optional skeleton loading configuration object. Controls the appearance and behavior of skeleton loading states while data is being fetched. The skeleton provides visual feedback to users during loading. Contains count (number of skeleton items to show), show (boolean to toggle skeleton visibility), and variant (visual style: \"pulse\" or \"wave\").',\n },\n {\n content:\n '{ count?: number, show?: boolean, variant?: SkeletonVariant }',\n hintText: 'object',\n },\n {\n content:\n 'count: number of skeleton items, show: boolean to display skeleton, variant: \"pulse\" | \"wave\"',\n },\n {\n content: '{ count: 3, show: false, variant: \"pulse\" }',\n },\n ],\n [\n {\n content: 'SingleSelect.maxTriggerWidth',\n hintText:\n 'Optional maximum width constraint for the trigger button in pixels. Prevents the trigger from growing beyond this width even if the content is longer. Useful for maintaining consistent layouts and preventing the select from becoming too wide. Text that exceeds this width will be truncated with ellipsis.',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.minTriggerWidth',\n hintText:\n 'Optional minimum width constraint for the trigger button in pixels. Ensures the trigger maintains at least this width even if the content is shorter. Useful for maintaining consistent button sizes across multiple select fields or ensuring adequate click target area.',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.allowCustomValue',\n hintText:\n 'When true, allows users to enter custom values that are not in the predefined items list. Enables a special menu option that lets users specify their own value. Useful for scenarios where the list cannot cover all possible options. Defaults to false.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n { content: 'false' },\n ],\n [\n {\n content: 'SingleSelect.customValueLabel',\n hintText:\n 'Optional label text for the custom value option in the dropdown menu. This text appears as the label for the menu item that allows users to enter their own custom value. Only visible when allowCustomValue is true. Defaults to \"Specify\".',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n { content: '\"Specify\"' },\n ],\n [\n {\n content: 'SelectMenuGroupType.groupLabel',\n hintText: 'Optional label text for the group section',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuGroupType.items',\n hintText: 'Array of selectable items within this group',\n },\n { content: 'SelectMenuItemType[]', hintText: 'array' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuGroupType.showSeparator',\n hintText: 'Whether to show separator line after this group',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.label',\n hintText: 'Main display text for the menu item',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.value',\n hintText: 'Unique value identifier for the menu item',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.checked',\n hintText: 'Whether the item is currently selected',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.subLabel',\n hintText: 'Optional secondary text displayed below the label',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.slot1',\n hintText: 'First content slot (typically for leading icons)',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.slot2',\n hintText: 'Second content slot for additional elements',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.slot3',\n hintText: 'Third content slot for additional elements',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.slot4',\n hintText:\n 'Fourth content slot (typically for trailing elements)',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.disabled',\n hintText:\n 'Whether the menu item is disabled and non-selectable',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.onClick',\n hintText: 'Custom click handler for the menu item',\n },\n { content: '() => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.subMenu',\n hintText: 'Nested menu items for creating hierarchical menus',\n },\n { content: 'SelectMenuItemType[]', hintText: 'array' },\n { content: '' },\n {\n content: 'Recursive: SelectMenuItemType[]',\n hintText: 'Recursive type',\n },\n ],\n [\n {\n content: 'SelectMenuItemType.tooltip',\n hintText: 'Tooltip content displayed on hover',\n },\n { content: 'string | React.ReactNode', hintText: 'union type' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.tooltipProps',\n hintText: 'Configuration options for the tooltip display',\n },\n {\n content:\n '{ side?: TooltipSide, align?: TooltipAlign, size?: TooltipSize, showArrow?: boolean, delayDuration?: number, offset?: number }',\n hintText: 'object',\n },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.disableTruncation',\n hintText: 'Whether to disable text truncation for long labels',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the SingleSelect component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\n void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.enableSearch',\n hintText: 'Whether to show search input in the dropdown menu',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.searchPlaceholder',\n hintText: 'Placeholder text for the search input field',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.slot',\n hintText:\n 'Content displayed inside the trigger button (e.g., icons)',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.disabled',\n hintText:\n 'Whether the select field is disabled and non-interactive',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.name',\n hintText:\n 'Name attribute for form identification and submission',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.customTrigger',\n hintText:\n 'Custom React element to replace the default trigger button',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.useDrawerOnMobile',\n hintText: 'Whether to render as drawer on mobile devices',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n { content: 'true' },\n ],\n [\n {\n content: 'SingleSelect.alignment',\n hintText:\n 'How the dropdown menu aligns relative to the trigger',\n },\n { content: 'SelectMenuAlignment', hintText: 'enum' },\n { content: 'START, CENTER, END' },\n { content: 'undefined' },\n ],\n [\n {\n content: 'SingleSelect.side',\n hintText: 'Which side of the trigger the dropdown appears on',\n },\n { content: 'SelectMenuSide', hintText: 'enum' },\n { content: 'TOP, LEFT, RIGHT, BOTTOM' },\n { content: 'undefined' },\n ],\n [\n {\n content: 'SingleSelect.sideOffset',\n hintText: 'Distance in pixels between trigger and dropdown',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.alignOffset',\n hintText: 'Alignment offset in pixels for fine positioning',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.minMenuWidth',\n hintText: 'Minimum width of the dropdown menu in pixels',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.maxMenuWidth',\n hintText: 'Maximum width of the dropdown menu in pixels',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.maxMenuHeight',\n hintText: 'Maximum height of the dropdown menu in pixels',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.inline',\n hintText:\n 'Whether the select renders inline without fixed height',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.onBlur',\n hintText:\n 'Callback function called when the select loses focus',\n },\n { content: '() => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.onFocus',\n hintText:\n 'Callback function called when the select gains focus',\n },\n { content: '() => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.error',\n hintText: 'Whether the select is in an error state',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.errorMessage',\n hintText: 'Error message displayed when error is true',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.fullWidth',\n hintText: 'Whether the select field takes full width',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.enableVirtualization',\n hintText: 'Whether to enable virtual scrolling for large lists',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.virtualListItemHeight',\n hintText: 'Height of each virtualized list item in pixels',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.virtualListOverscan',\n hintText: 'Number of items to render outside visible area',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.onEndReached',\n hintText: 'Callback when virtual list reaches the end',\n },\n { content: '() => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.endReachedThreshold',\n hintText: 'Distance from end to trigger onEndReached',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.hasMore',\n hintText: 'Whether there are more items to load',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.loadingComponent',\n hintText: 'Component to show while loading more items',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.skeleton',\n hintText:\n 'Optional skeleton loading configuration object. Controls the appearance and behavior of skeleton loading states while data is being fetched. The skeleton provides visual feedback to users during loading. Contains count (number of skeleton items to show), show (boolean to toggle skeleton visibility), and variant (visual style: \"pulse\" or \"wave\").',\n },\n {\n content:\n '{ count?: number, show?: boolean, variant?: SkeletonVariant }',\n hintText: 'object',\n },\n {\n content:\n 'count: number of skeleton items, show: boolean to display skeleton, variant: \"pulse\" | \"wave\"',\n },\n {\n content: '{ count: 3, show: false, variant: \"pulse\" }',\n },\n ],\n [\n {\n content: 'SingleSelect.maxTriggerWidth',\n hintText:\n 'Optional maximum width constraint for the trigger button in pixels. Prevents the trigger from growing beyond this width even if the content is longer. Useful for maintaining consistent layouts and preventing the select from becoming too wide. Text that exceeds this width will be truncated with ellipsis.',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.minTriggerWidth',\n hintText:\n 'Optional minimum width constraint for the trigger button in pixels. Ensures the trigger maintains at least this width even if the content is shorter. Useful for maintaining consistent button sizes across multiple select fields or ensuring adequate click target area.',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.allowCustomValue',\n hintText:\n 'When true, allows users to enter custom values that are not in the predefined items list. Enables a special menu option that lets users specify their own value. Useful for scenarios where the list cannot cover all possible options. Defaults to false.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n { content: 'false' },\n ],\n [\n {\n content: 'SingleSelect.customValueLabel',\n hintText:\n 'Optional label text for the custom value option in the dropdown menu. This text appears as the label for the menu item that allows users to enter their own custom value. Only visible when allowCustomValue is true. Defaults to \"Specify\".',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n { content: '\"Specify\"' },\n ],\n [\n {\n content: 'SingleSelect.singleSelectGroupPosition',\n hintText:\n 'Optional positioning string for when SingleSelect is used within a button group. Controls border radius adjustments for the group context. \"left\" removes right border radius, \"center\" removes both left and right border radius, \"right\" removes left border radius. Useful for creating cohesive button group appearances.',\n },\n {\n content: \"'center' | 'left' | 'right'\",\n hintText: 'String indicating position within a button group',\n },\n { content: '' },\n ],\n [\n {\n content: 'SingleSelect.allowDeselect',\n hintText:\n 'When true, allows users to deselect the currently selected option by clicking on it again. When false, clicking the selected option keeps it selected. Useful for scenarios where users may want to clear their selection without choosing another option. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true allows deselecting by clicking again, false prevents deselection',\n },\n { content: '' },\n { content: 'false' },\n ],\n [\n {\n content: 'SelectMenuGroupType.groupLabel',\n hintText: 'Optional label text for the group section',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuGroupType.items',\n hintText: 'Array of selectable items within this group',\n },\n { content: 'SelectMenuItemType[]', hintText: 'array' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuGroupType.showSeparator',\n hintText: 'Whether to show separator line after this group',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.label',\n hintText: 'Main display text for the menu item',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.value',\n hintText: 'Unique value identifier for the menu item',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.checked',\n hintText: 'Whether the item is currently selected',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.subLabel',\n hintText: 'Optional secondary text displayed below the label',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.slot1',\n hintText: 'First content slot (typically for leading icons)',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.slot2',\n hintText: 'Second content slot for additional elements',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.slot3',\n hintText: 'Third content slot for additional elements',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.slot4',\n hintText:\n 'Fourth content slot (typically for trailing elements)',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.disabled',\n hintText:\n 'Whether the menu item is disabled and non-selectable',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.onClick',\n hintText: 'Custom click handler for the menu item',\n },\n { content: '() => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.subMenu',\n hintText: 'Nested menu items for creating hierarchical menus',\n },\n { content: 'SelectMenuItemType[]', hintText: 'array' },\n { content: '' },\n {\n content: 'Recursive: SelectMenuItemType[]',\n hintText: 'Recursive type',\n },\n ],\n [\n {\n content: 'SelectMenuItemType.tooltip',\n hintText: 'Tooltip content displayed on hover',\n },\n { content: 'string | React.ReactNode', hintText: 'union type' },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.tooltipProps',\n hintText: 'Configuration options for the tooltip display',\n },\n {\n content:\n '{ side?: TooltipSide, align?: TooltipAlign, size?: TooltipSize, showArrow?: boolean, delayDuration?: number, offset?: number }',\n hintText: 'object',\n },\n { content: '' },\n ],\n [\n {\n content: 'SelectMenuItemType.disableTruncation',\n hintText: 'Whether to disable text truncation for long labels',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the SingleSelect component using the following tokens:",
"excerpt": "Usage\n\n\n\nAPI Reference\n\n void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'Sing...",
"sections": [
{
@@ -1177,7 +1217,7 @@
"slug": "split-tag",
"category": "components",
"tags": ["split-tag", "component", "label"],
- "content": "Usage\n\n\n\nAPI Reference\n\n',\n hintText:\n 'Object with tag configuration properties (see TagProps below)',\n },\n {\n content: 'TagProps: see Tag component props',\n },\n ],\n [\n {\n content: 'SplitTag.secondaryTag',\n hintText:\n 'Optional TagProps object (excluding splitTagPosition, size, and shape) that configures the secondary tag displayed on the right side of the split tag. The secondary tag typically shows a status or value (e.g., \"Active\", \"Pending\"). The splitTagPosition is automatically set to \"right\" and the variant is set to ATTENTIVE. Contains: text (required string), variant (optional TagVariant), color (optional TagColor), leftSlot (optional ReactNode), rightSlot (optional ReactNode), and onClick (optional callback). If not provided, only the primary tag is displayed.',\n },\n {\n content:\n 'Omit',\n hintText:\n 'Object with tag configuration properties (see TagProps below)',\n },\n {\n content: 'TagProps: see Tag component props',\n },\n ],\n [\n {\n content: 'SplitTag.leadingSlot',\n hintText:\n 'Optional React element displayed before the tags (on the left side). Can be used for icons, badges, or other content that appears before the split tag. The slot maintains proper spacing and alignment with the tags. Useful for adding visual context or additional information.',\n },\n {\n content: 'ReactNode',\n hintText: 'React element displayed before the split tag',\n },\n { content: '' },\n ],\n [\n {\n content: 'SplitTag.trailingSlot',\n hintText:\n 'Optional React element displayed after the tags (on the right side). Can be used for icons, badges, or other content that appears after the split tag. The slot maintains proper spacing and alignment with the tags. Useful for adding visual context or additional information.',\n },\n {\n content: 'ReactNode',\n hintText: 'React element displayed after the split tag',\n },\n { content: '' },\n ],\n [\n {\n content: 'SplitTag.size',\n hintText:\n 'Optional TagSize enum value that determines the size variant affecting dimensions, padding, and font size of both tags in the split tag. XS creates an extra-small split tag, SM creates a small size, MD creates a medium size, LG creates a large size. The size applies to both primary and secondary tags. If not provided, uses default tag size.',\n },\n {\n content: 'TagSize',\n hintText:\n 'Enum that determines the size variant of the split tag',\n },\n { content: 'XS, SM, MD, LG' },\n ],\n [\n {\n content: 'SplitTag.shape',\n hintText:\n 'Optional TagShape enum value that determines the border radius shape of both tags in the split tag. ROUNDED creates tags with rounded corners, SQUARICAL creates tags with square corners. The shape applies to both primary and secondary tags, with the split tag automatically adjusting border radius where the tags meet. If not provided, uses default tag shape.',\n },\n {\n content: 'TagShape',\n hintText:\n 'Enum that determines the shape variant of the split tag',\n },\n { content: 'ROUNDED, SQUARICAL' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the SplitTag component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\n',\n hintText:\n 'Object with tag configuration properties (see TagProps below)',\n },\n {\n content: 'TagProps: see Tag component props',\n },\n ],\n [\n {\n content: 'SplitTag.secondaryTag',\n hintText:\n 'Optional TagProps object (excluding splitTagPosition, size, and shape) that configures the secondary tag displayed on the right side of the split tag. The secondary tag typically shows a status or value (e.g., \"Active\", \"Pending\"). The splitTagPosition is automatically set to \"right\" and the variant is set to ATTENTIVE. Contains: text (required string), variant (optional TagVariant), color (optional TagColor), leftSlot (optional ReactNode), rightSlot (optional ReactNode), and onClick (optional callback). If not provided, only the primary tag is displayed.',\n },\n {\n content:\n 'Omit',\n hintText:\n 'Object with tag configuration properties (see TagProps below)',\n },\n {\n content: 'TagProps: see Tag component props',\n },\n ],\n [\n {\n content: 'SplitTag.size',\n hintText:\n 'Optional TagSize enum value that determines the size variant affecting dimensions, padding, and font size of both tags in the split tag. XS creates an extra-small split tag, SM creates a small size, MD creates a medium size, LG creates a large size. The size applies to both primary and secondary tags. If not provided, uses default tag size.',\n },\n {\n content: 'TagSize',\n hintText:\n 'Enum that determines the size variant of the split tag',\n },\n { content: 'XS, SM, MD, LG' },\n ],\n [\n {\n content: 'SplitTag.shape',\n hintText:\n 'Optional TagShape enum value that determines the border radius shape of both tags in the split tag. ROUNDED creates tags with rounded corners, SQUARICAL creates tags with square corners. The shape applies to both primary and secondary tags, with the split tag automatically adjusting border radius where the tags meet. If not provided, uses default tag shape.',\n },\n {\n content: 'TagShape',\n hintText:\n 'Enum that determines the shape variant of the split tag',\n },\n { content: 'ROUNDED, SQUARICAL' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the SplitTag component using the following tokens:",
"excerpt": "Usage\n\n\n\nAPI Reference\n\n',\n hintText:\n 'Object with tag configuration properties (see TagProps below)',...",
"sections": [
{
@@ -1265,7 +1305,7 @@
"slug": "switch",
"category": "components",
"tags": ["switch", "component", "toggle", "form", "input"],
- "content": "Usage\n\n\n\nAPI Reference\n\nSwitch Props\n\n void',\n hintText:\n 'Function called with new checked state when switch state changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.disabled',\n hintText:\n \"When true, disables the switch, making it non-interactive and visually muted. Users cannot toggle the switch. Disabled switches show reduced opacity and cannot be clicked. Useful for preventing interaction when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables switch, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.required',\n hintText:\n 'When true, marks the switch as required and displays an asterisk (*) next to the label. Useful for form validation. Required switches must be checked before form submission. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true marks switch as required, false allows optional selection',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.error',\n hintText:\n 'When true, displays the switch in an error state with red border and error styling. Useful for form validation feedback when the switch state is invalid or missing (for required switches). Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows error state, false shows normal state',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.size',\n hintText:\n 'Optional SwitchSize enum value that determines the size variant of the switch. Affects the switch container dimensions, thumb size, and label font size. SMALL creates a compact switch, MEDIUM creates a standard size. Defaults to MEDIUM.',\n },\n {\n content: 'SwitchSize',\n hintText: 'Enum that determines the size variant of the switch',\n },\n { content: 'SMALL, MEDIUM' },\n ],\n [\n {\n content: 'Switch.label',\n hintText:\n 'Optional label string displayed next to the switch. Provides context and description of what the switch controls (e.g., \"Enable notifications\", \"Auto-save\"). The label is clickable and toggles the switch. Should be concise and descriptive.',\n },\n {\n content: 'string',\n hintText: 'Label text displayed next to the switch',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.subtext',\n hintText:\n \"Optional React element displayed below the main label. Provides additional context, explanation, or clarification about the switch. Can be a string or React element. Styled with smaller, lighter text. Useful for explaining the switch's purpose or consequences.\",\n },\n {\n content: 'ReactNode',\n hintText: 'React element displayed below the main label',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.slot',\n hintText:\n 'Optional React element displayed alongside the label, typically on the right side. Can be used for badges, icons, or other supplementary content that complements the switch. Useful for displaying additional information or actions.',\n },\n {\n content: 'ReactNode',\n hintText: 'React element displayed alongside the switch label',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.name',\n hintText:\n 'Optional name attribute string for form grouping. Used for form identification, form submission, and accessibility. Should be unique within the form. Useful when switches are part of a form that needs to be submitted.',\n },\n {\n content: 'string',\n hintText: 'Name attribute for form grouping and identification',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.value',\n hintText:\n 'Optional value string associated with the switch. Used for form submission and when the switch is part of a SwitchGroup. The value is included in form data when the switch is checked. Useful for identifying which switch was toggled in a group.',\n },\n {\n content: 'string',\n hintText: 'Value string associated with the switch',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nSwitchGroup Props\n\n void',\n hintText:\n 'Function called with new value array when selection changes',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the Switch component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\nSwitch Props\n\n void',\n hintText:\n 'Function called with new checked state when switch state changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.disabled',\n hintText:\n \"When true, disables the switch, making it non-interactive and visually muted. Users cannot toggle the switch. Disabled switches show reduced opacity and cannot be clicked. Useful for preventing interaction when prerequisites aren't met. Defaults to false.\",\n },\n {\n content: 'boolean',\n hintText:\n 'true disables switch, false allows normal interaction',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.required',\n hintText:\n 'When true, marks the switch as required and displays an asterisk (*) next to the label. Useful for form validation. Required switches must be checked before form submission. Defaults to false.',\n },\n {\n content: 'boolean',\n hintText:\n 'true marks switch as required, false allows optional selection',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.error',\n hintText:\n 'When true, displays the switch in an error state with red border and error styling. Useful for form validation feedback when the switch state is invalid or missing (for required switches). Defaults to false.',\n },\n {\n content: 'boolean',\n hintText: 'true shows error state, false shows normal state',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.size',\n hintText:\n 'Optional SwitchSize enum value that determines the size variant of the switch. Affects the switch container dimensions, thumb size, and label font size. SMALL creates a compact switch, MEDIUM creates a standard size. Defaults to MEDIUM.',\n },\n {\n content: 'SwitchSize',\n hintText: 'Enum that determines the size variant of the switch',\n },\n { content: 'SMALL, MEDIUM' },\n ],\n [\n {\n content: 'Switch.label',\n hintText:\n 'Optional label string displayed next to the switch. Provides context and description of what the switch controls (e.g., \"Enable notifications\", \"Auto-save\"). The label is clickable and toggles the switch. Should be concise and descriptive.',\n },\n {\n content: 'string',\n hintText: 'Label text displayed next to the switch',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.subtext',\n hintText:\n \"Optional React element displayed below the main label. Provides additional context, explanation, or clarification about the switch. Can be a string or React element. Styled with smaller, lighter text. Useful for explaining the switch's purpose or consequences.\",\n },\n {\n content: 'ReactNode',\n hintText: 'React element displayed below the main label',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.slot',\n hintText:\n 'Optional React element displayed alongside the label, typically on the right side. Can be used for badges, icons, or other supplementary content that complements the switch. Useful for displaying additional information or actions.',\n },\n {\n content: 'ReactNode',\n hintText: 'React element displayed alongside the switch label',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.name',\n hintText:\n 'Optional name attribute string for form grouping. Used for form identification, form submission, and accessibility. Should be unique within the form. Useful when switches are part of a form that needs to be submitted.',\n },\n {\n content: 'string',\n hintText: 'Name attribute for form grouping and identification',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.value',\n hintText:\n 'Optional value string associated with the switch. Used for form submission and when the switch is part of a SwitchGroup. The value is included in form data when the switch is checked. Useful for identifying which switch was toggled in a group.',\n },\n {\n content: 'string',\n hintText: 'Value string associated with the switch',\n },\n { content: '' },\n ],\n [\n {\n content: 'Switch.maxLength',\n hintText:\n 'Optional object with label and subtext number properties to limit text length. When text exceeds the limit, it is truncated with an ellipsis and a tooltip shows the full text on hover. Useful for keeping switch labels compact.',\n },\n {\n content: '{ label?: number; subtext?: number }',\n hintText:\n 'Object defining max character lengths for label and subtext',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nSwitchGroup Props\n\n void',\n hintText:\n 'Function called with new value array when selection changes',\n },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the Switch component using the following tokens:",
"excerpt": "Usage\n\n\n\nAPI Reference\n\nSwitch Props\n\n void',\n hintText:\n 'Function called with new checked state when switch state...",
"sections": [
{
@@ -1302,7 +1342,7 @@
"slug": "tabs",
"category": "components",
"tags": ["tabs", "component", "navigation"],
- "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new selected value when tab selection changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.variant',\n hintText:\n 'Optional TabsVariant enum value that determines the visual style variant of the tabs. UNDERLINE creates tabs with an underline indicator below the active tab, BOXED creates tabs in a boxed container, FLOATING creates tabs in a floating container, PILLS creates rounded pill-style tabs. The variant affects the overall appearance and styling. Defaults to UNDERLINE.',\n },\n { content: 'TabsVariant', hintText: 'enum' },\n { content: 'UNDERLINE, BOXED, FLOATING, PILLS' },\n ],\n [\n {\n content: 'Tabs.size',\n hintText:\n 'Optional TabsSize enum value that determines the size variant affecting dimensions, padding, and font size of the tabs. MD creates a medium-sized tab, LG creates a larger tab. The size applies to all tabs in the list. Defaults to MD.',\n },\n { content: 'TabsSize', hintText: 'enum' },\n { content: 'MD, LG' },\n ],\n [\n {\n content: 'Tabs.items',\n hintText:\n 'Array of tab item objects for programmatic rendering. When provided, the component renders tabs dynamically based on this array instead of using the declarative children API. Each item should have a value, label, and content. This approach is ideal for dynamic tab management, where tabs can be added, removed, or modified programmatically. Supports features like newItem tabs, disabled states, and skeleton loading per tab.',\n },\n { content: 'TabItem[]', hintText: 'array' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.onTabClose',\n hintText:\n 'Callback function invoked when a user closes a tab by clicking its close button. Receives the value of the closed tab as a parameter. Use this to update your tab state, remove the tab from your data structure, save user preferences, or perform cleanup operations. The component automatically handles switching to an appropriate tab when the active tab is closed.',\n },\n { content: '(value: string) => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.onTabAdd',\n hintText:\n 'Callback function invoked when the user clicks the add button to create a new tab. Use this to open a modal, show a form, or trigger any logic needed to create a new tab. After creating the tab, update your items array to include the new tab, and the component will automatically render it. This enables dynamic tab management workflows.',\n },\n { content: '() => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.showDropdown',\n hintText:\n 'When true, displays a dropdown menu button that provides access to all tabs, including those that are scrolled out of view or overflow the visible area. This is essential for managing large numbers of tabs where horizontal scrolling would otherwise hide some tabs. The dropdown allows users to navigate to any tab regardless of scroll position, improving accessibility and usability for complex tab interfaces.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.showAddButton',\n hintText:\n 'When true, displays an add button that allows users to create new tabs dynamically. This is useful for interfaces where users need to add tabs on demand, such as multi-document editors, browser-like interfaces, or dynamic content management systems. The button triggers the onTabAdd callback when clicked, allowing you to handle the tab creation logic.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.dropdownTooltip',\n hintText:\n 'Custom tooltip text displayed when hovering over the dropdown button. This provides context and guidance to users about what the dropdown does. Useful for accessibility and user education, especially when the dropdown icon alone might not be immediately clear. Defaults to \"Navigate to tab\" if not provided.',\n },\n { content: 'string', hintText: 'string' },\n { content: 'Navigate to tab' },\n ],\n [\n {\n content: 'Tabs.addButtonTooltip',\n hintText:\n 'Custom tooltip text displayed when hovering over the add button. This provides context and guidance to users about what the add button does. Useful for accessibility and user education, especially when the add icon alone might not be immediately clear. Defaults to \"Add new tab\" if not provided.',\n },\n { content: 'string', hintText: 'string' },\n { content: 'Add new tab' },\n ],\n [\n {\n content: 'Tabs.maxDisplayTabs',\n hintText:\n 'Maximum number of tabs to display before showing dropdown',\n },\n { content: 'number', hintText: 'number' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.disable',\n hintText:\n 'When true, disables all tabs in the tab list, preventing user interaction. All tabs will be visually disabled and non-interactive. This is useful for temporarily disabling the entire tab component during loading states or when certain conditions prevent tab navigation. Individual tabs can still override this with their own disable prop.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.expanded',\n hintText:\n 'When true, tabs expand to fill the full available width of their container. This is useful for creating evenly distributed tabs across the entire width, ensuring consistent spacing and a balanced visual appearance. When false, tabs only take up the space needed for their content.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.fitContent',\n hintText:\n 'When true, tabs automatically size to fit their content width rather than expanding or using default sizing. This is ideal when you want tabs to be compact and only take up the minimum space required. Useful for scenarios with varying tab label lengths where you want each tab to be sized individually based on its content.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.showSkeleton',\n hintText:\n 'When true, displays skeleton loading placeholders instead of the actual tab content. This provides visual feedback during data loading, preventing layout shifts and improving perceived performance. The skeleton state maintains the same dimensions and structure as the actual tabs, creating a smooth transition when content loads.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.skeletonVariant',\n hintText:\n 'Determines the animation style for skeleton loading states. \"pulse\" creates a gentle fade in/out effect, \"wave\" creates a shimmer wave animation that moves across the skeleton, and \"shimmer\" creates a bright shimmer effect. Use this to match your application\\'s loading animation style or to differentiate loading states.',\n },\n {\n content: \"'pulse' | 'wave' | 'shimmer'\",\n hintText: 'SkeletonVariant',\n },\n { content: 'pulse, wave, shimmer' },\n ],\n [\n {\n content: 'Tabs.stickyHeader',\n hintText:\n 'When true, makes the tab listing header stick to the top of its container when scrolling. This keeps the tab navigation visible while scrolling through long tab content. The header will have position: sticky, top: 0, and a z-index to stay above other content. This is particularly useful for documentation pages, settings panels, and dashboards with lengthy content.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: 'false' },\n ],\n [\n {\n content: 'Tabs.offsetTop',\n hintText:\n 'Optional number value that sets the top offset in pixels for the sticky header. This is useful when there are other sticky elements on the page (like a navbar) and the tabs header needs to stick below them. Only applies when stickyHeader is true. Defaults to 0.',\n },\n { content: 'number', hintText: 'number' },\n { content: '0' },\n ],\n [\n {\n content: 'TabsList.variant',\n hintText:\n 'Optional TabsVariant enum value that determines the visual style variant of the tabs list. UNDERLINE creates tabs with an underline indicator, BOXED creates tabs in a boxed container, FLOATING creates tabs in a floating container, PILLS creates rounded pill-style tabs. The variant affects the overall appearance and styling. Defaults to UNDERLINE.',\n },\n { content: 'TabsVariant', hintText: 'enum' },\n { content: 'UNDERLINE, BOXED, FLOATING, PILLS' },\n ],\n [\n {\n content: 'TabsList.size',\n hintText:\n 'Optional TabsSize enum value that determines the size variant affecting dimensions, padding, and font size of the tabs list. MD creates a medium-sized tab list, LG creates a larger tab list. The size applies to all tabs in the list. Defaults to MD.',\n },\n { content: 'TabsSize', hintText: 'enum' },\n { content: 'MD, LG' },\n ],\n [\n {\n content: 'TabsList.expanded',\n hintText:\n 'When true, tabs expand to fill the full available width of their container. This is useful for creating evenly distributed tabs across the entire width, ensuring consistent spacing and a balanced visual appearance. When false, tabs only take up the space needed for their content.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.fitContent',\n hintText:\n 'When true, tabs automatically size to fit their content width rather than expanding or using default sizing. This is ideal when you want tabs to be compact and only take up the minimum space required. Useful for scenarios with varying tab label lengths where you want each tab to be sized individually based on its content.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.items',\n hintText:\n 'Array of tab item objects for programmatic rendering. When provided, the component renders tabs dynamically based on this array instead of using the declarative children API. Each item should have a value, label, and content. This approach is ideal for dynamic tab management, where tabs can be added, removed, or modified programmatically. Supports features like newItem tabs, disabled states, and skeleton loading per tab.',\n },\n { content: 'TabItem[]', hintText: 'array' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.onTabClose',\n hintText:\n 'Callback function invoked when a user closes a tab by clicking its close button. Receives the value of the closed tab as a parameter. Use this to update your tab state, remove the tab from your data structure, save user preferences, or perform cleanup operations. The component automatically handles switching to an appropriate tab when the active tab is closed.',\n },\n { content: '(value: string) => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.onTabAdd',\n hintText:\n 'Callback function invoked when the user clicks the add button to create a new tab. Use this to open a modal, show a form, or trigger any logic needed to create a new tab. After creating the tab, update your items array to include the new tab, and the component will automatically render it. This enables dynamic tab management workflows.',\n },\n { content: '() => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.showDropdown',\n hintText:\n 'When true, displays a dropdown menu button that provides access to all tabs, including those that are scrolled out of view or overflow the visible area. This is essential for managing large numbers of tabs where horizontal scrolling would otherwise hide some tabs. The dropdown allows users to navigate to any tab regardless of scroll position, improving accessibility and usability for complex tab interfaces.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.showAddButton',\n hintText:\n 'When true, displays an add button that allows users to create new tabs dynamically. This is useful for interfaces where users need to add tabs on demand, such as multi-document editors, browser-like interfaces, or dynamic content management systems. The button triggers the onTabAdd callback when clicked, allowing you to handle the tab creation logic.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.addButtonTooltip',\n hintText:\n 'Custom tooltip text displayed when hovering over the add button. This provides context and guidance to users about what the add button does. Useful for accessibility and user education, especially when the add icon alone might not be immediately clear.',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.onTabChange',\n hintText: 'Callback when tab selection changes (for dropdown)',\n },\n { content: '(value: string) => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.activeTab',\n hintText: 'The currently active tab value',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.disable',\n hintText:\n 'When true, disables all tabs in the tab list, preventing user interaction. All tabs will be visually disabled and non-interactive. This is useful for temporarily disabling the entire tab component during loading states or when certain conditions prevent tab navigation. Individual tabs can still override this with their own disable prop.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.showSkeleton',\n hintText:\n 'When true, displays skeleton loading placeholders instead of the actual tab content. This provides visual feedback during data loading, preventing layout shifts and improving perceived performance. The skeleton state maintains the same dimensions and structure as the actual tabs, creating a smooth transition when content loads.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.skeletonVariant',\n hintText:\n 'Determines the animation style for skeleton loading states. \"pulse\" creates a gentle fade in/out effect, \"wave\" creates a shimmer wave animation that moves across the skeleton, and \"shimmer\" creates a bright shimmer effect. Use this to match your application\\'s loading animation style or to differentiate loading states.',\n },\n {\n content: \"'pulse' | 'wave' | 'shimmer'\",\n hintText: 'SkeletonVariant',\n },\n { content: 'pulse, wave, shimmer' },\n ],\n [\n {\n content: 'TabsTrigger.value',\n hintText: 'The unique value for this tab trigger',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TabsTrigger.variant',\n hintText:\n 'Optional TabsVariant enum value that determines the visual style variant of the tab trigger. UNDERLINE creates a tab with an underline indicator, BOXED creates a tab in a boxed container, FLOATING creates a tab in a floating container, PILLS creates a rounded pill-style tab. The variant should match the parent Tabs or TabsList variant. Defaults to UNDERLINE.',\n },\n { content: 'TabsVariant', hintText: 'enum' },\n { content: 'UNDERLINE, BOXED, FLOATING, PILLS' },\n ],\n [\n {\n content: 'TabsTrigger.size',\n hintText:\n 'Optional TabsSize enum value that determines the size variant affecting dimensions, padding, and font size of the tab trigger. MD creates a medium-sized tab, LG creates a larger tab. The size should match the parent Tabs or TabsList size. Defaults to MD.',\n },\n { content: 'TabsSize', hintText: 'enum' },\n { content: 'MD, LG' },\n ],\n [\n {\n content: 'TabsTrigger.leftSlot',\n hintText: 'Icon or element to display before the tab text',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'TabsTrigger.rightSlot',\n hintText: 'Icon or element to display after the tab text',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'TabsTrigger.children',\n hintText: 'The text content of the tab trigger',\n },\n { content: 'string | number', hintText: 'string or number' },\n { content: '' },\n ],\n [\n {\n content: 'TabsTrigger.closable',\n hintText:\n 'Whether the tab shows a close button (maps from TabItem.newItem)',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsTrigger.onClose',\n hintText: 'Callback when close button is clicked',\n },\n { content: '() => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'TabsTrigger.disable',\n hintText:\n 'When true, disables this specific tab trigger, preventing user interaction. The tab will be visually disabled and non-interactive, and keyboard navigation will skip over it. This is useful for conditionally disabling specific tabs based on user permissions, data availability, or application state.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsTrigger.showSkeleton',\n hintText:\n 'When true, displays a skeleton loading placeholder for this specific tab trigger instead of the actual content. This provides visual feedback during data loading for individual tabs, preventing layout shifts and improving perceived performance. The skeleton state maintains the same dimensions and structure as the actual tab.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsTrigger.skeletonVariant',\n hintText:\n 'Determines the animation style for skeleton loading state of this specific tab trigger. \"pulse\" creates a gentle fade in/out effect, \"wave\" creates a shimmer wave animation that moves across the skeleton, and \"shimmer\" creates a bright shimmer effect. Use this to match your application\\'s loading animation style or to differentiate loading states for individual tabs.',\n },\n {\n content: \"'pulse' | 'wave' | 'shimmer'\",\n hintText: 'SkeletonVariant',\n },\n { content: 'pulse, wave, shimmer' },\n ],\n [\n {\n content: 'TabsContent.value',\n hintText:\n 'The value that matches the corresponding TabsTrigger',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TabsContent.children',\n hintText: 'The content to display when this tab is active',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the tabs component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new selected value when tab selection changes',\n },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.variant',\n hintText:\n 'Optional TabsVariant enum value that determines the visual style variant of the tabs. UNDERLINE creates tabs with an underline indicator below the active tab, BOXED creates tabs in a boxed container, FLOATING creates tabs in a floating container, PILLS creates rounded pill-style tabs. The variant affects the overall appearance and styling. Defaults to UNDERLINE.',\n },\n { content: 'TabsVariant', hintText: 'enum' },\n { content: 'UNDERLINE, BOXED, FLOATING, PILLS' },\n ],\n [\n {\n content: 'Tabs.size',\n hintText:\n 'Optional TabsSize enum value that determines the size variant affecting dimensions, padding, and font size of the tabs. MD creates a medium-sized tab, LG creates a larger tab. The size applies to all tabs in the list. Defaults to MD.',\n },\n { content: 'TabsSize', hintText: 'enum' },\n { content: 'MD, LG' },\n ],\n [\n {\n content: 'Tabs.items',\n hintText:\n 'Array of tab item objects for programmatic rendering. When provided, the component renders tabs dynamically based on this array instead of using the declarative children API. Each item should have a value, label, and content. This approach is ideal for dynamic tab management, where tabs can be added, removed, or modified programmatically. Supports features like newItem tabs, disabled states, and skeleton loading per tab.',\n },\n { content: 'TabItem[]', hintText: 'array' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.onTabClose',\n hintText:\n 'Callback function invoked when a user closes a tab by clicking its close button. Receives the value of the closed tab as a parameter. Use this to update your tab state, remove the tab from your data structure, save user preferences, or perform cleanup operations. The component automatically handles switching to an appropriate tab when the active tab is closed.',\n },\n { content: '(value: string) => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.onTabAdd',\n hintText:\n 'Callback function invoked when the user clicks the add button to create a new tab. Use this to open a modal, show a form, or trigger any logic needed to create a new tab. After creating the tab, update your items array to include the new tab, and the component will automatically render it. This enables dynamic tab management workflows.',\n },\n { content: '() => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.showDropdown',\n hintText:\n 'When true, displays a dropdown menu button that provides access to all tabs, including those that are scrolled out of view or overflow the visible area. This is essential for managing large numbers of tabs where horizontal scrolling would otherwise hide some tabs. The dropdown allows users to navigate to any tab regardless of scroll position, improving accessibility and usability for complex tab interfaces.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.showAddButton',\n hintText:\n 'When true, displays an add button that allows users to create new tabs dynamically. This is useful for interfaces where users need to add tabs on demand, such as multi-document editors, browser-like interfaces, or dynamic content management systems. The button triggers the onTabAdd callback when clicked, allowing you to handle the tab creation logic.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.dropdownTooltip',\n hintText:\n 'Custom tooltip text displayed when hovering over the dropdown button. This provides context and guidance to users about what the dropdown does. Useful for accessibility and user education, especially when the dropdown icon alone might not be immediately clear. Defaults to \"Navigate to tab\" if not provided.',\n },\n { content: 'string', hintText: 'string' },\n { content: 'Navigate to tab' },\n ],\n [\n {\n content: 'Tabs.addButtonTooltip',\n hintText:\n 'Custom tooltip text displayed when hovering over the add button. This provides context and guidance to users about what the add button does. Useful for accessibility and user education, especially when the add icon alone might not be immediately clear. Defaults to \"Add new tab\" if not provided.',\n },\n { content: 'string', hintText: 'string' },\n { content: 'Add new tab' },\n ],\n [\n {\n content: 'Tabs.disable',\n hintText:\n 'When true, disables all tabs in the tab list, preventing user interaction. All tabs will be visually disabled and non-interactive. This is useful for temporarily disabling the entire tab component during loading states or when certain conditions prevent tab navigation. Individual tabs can still override this with their own disable prop.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.expanded',\n hintText:\n 'When true, tabs expand to fill the full available width of their container. This is useful for creating evenly distributed tabs across the entire width, ensuring consistent spacing and a balanced visual appearance. When false, tabs only take up the space needed for their content.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.fitContent',\n hintText:\n 'When true, tabs automatically size to fit their content width rather than expanding or using default sizing. This is ideal when you want tabs to be compact and only take up the minimum space required. Useful for scenarios with varying tab label lengths where you want each tab to be sized individually based on its content.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.showSkeleton',\n hintText:\n 'When true, displays skeleton loading placeholders instead of the actual tab content. This provides visual feedback during data loading, preventing layout shifts and improving perceived performance. The skeleton state maintains the same dimensions and structure as the actual tabs, creating a smooth transition when content loads.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'Tabs.skeletonVariant',\n hintText:\n 'Determines the animation style for skeleton loading states. \"pulse\" creates a gentle fade in/out effect, \"wave\" creates a shimmer wave animation that moves across the skeleton, and \"shimmer\" creates a bright shimmer effect. Use this to match your application\\'s loading animation style or to differentiate loading states.',\n },\n {\n content: \"'pulse' | 'wave' | 'shimmer'\",\n hintText: 'SkeletonVariant',\n },\n { content: 'pulse, wave, shimmer' },\n ],\n [\n {\n content: 'Tabs.stickyHeader',\n hintText:\n 'When true, makes the tab listing header stick to the top of its container when scrolling. This keeps the tab navigation visible while scrolling through long tab content. The header will have position: sticky, top: 0, and a z-index to stay above other content. This is particularly useful for documentation pages, settings panels, and dashboards with lengthy content.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: 'false' },\n ],\n [\n {\n content: 'Tabs.offsetTop',\n hintText:\n 'Optional number value that sets the top offset in pixels for the sticky header. This is useful when there are other sticky elements on the page (like a navbar) and the tabs header needs to stick below them. Only applies when stickyHeader is true. Defaults to 0.',\n },\n { content: 'number', hintText: 'number' },\n { content: '0' },\n ],\n [\n {\n content: 'TabsList.variant',\n hintText:\n 'Optional TabsVariant enum value that determines the visual style variant of the tabs list. UNDERLINE creates tabs with an underline indicator, BOXED creates tabs in a boxed container, FLOATING creates tabs in a floating container, PILLS creates rounded pill-style tabs. The variant affects the overall appearance and styling. Defaults to UNDERLINE.',\n },\n { content: 'TabsVariant', hintText: 'enum' },\n { content: 'UNDERLINE, BOXED, FLOATING, PILLS' },\n ],\n [\n {\n content: 'TabsList.size',\n hintText:\n 'Optional TabsSize enum value that determines the size variant affecting dimensions, padding, and font size of the tabs list. MD creates a medium-sized tab list, LG creates a larger tab list. The size applies to all tabs in the list. Defaults to MD.',\n },\n { content: 'TabsSize', hintText: 'enum' },\n { content: 'MD, LG' },\n ],\n [\n {\n content: 'TabsList.expanded',\n hintText:\n 'When true, tabs expand to fill the full available width of their container. This is useful for creating evenly distributed tabs across the entire width, ensuring consistent spacing and a balanced visual appearance. When false, tabs only take up the space needed for their content.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.fitContent',\n hintText:\n 'When true, tabs automatically size to fit their content width rather than expanding or using default sizing. This is ideal when you want tabs to be compact and only take up the minimum space required. Useful for scenarios with varying tab label lengths where you want each tab to be sized individually based on its content.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.items',\n hintText:\n 'Array of tab item objects for programmatic rendering. When provided, the component renders tabs dynamically based on this array instead of using the declarative children API. Each item should have a value, label, and content. This approach is ideal for dynamic tab management, where tabs can be added, removed, or modified programmatically. Supports features like newItem tabs, disabled states, and skeleton loading per tab.',\n },\n { content: 'TabItem[]', hintText: 'array' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.onTabClose',\n hintText:\n 'Callback function invoked when a user closes a tab by clicking its close button. Receives the value of the closed tab as a parameter. Use this to update your tab state, remove the tab from your data structure, save user preferences, or perform cleanup operations. The component automatically handles switching to an appropriate tab when the active tab is closed.',\n },\n { content: '(value: string) => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.onTabAdd',\n hintText:\n 'Callback function invoked when the user clicks the add button to create a new tab. Use this to open a modal, show a form, or trigger any logic needed to create a new tab. After creating the tab, update your items array to include the new tab, and the component will automatically render it. This enables dynamic tab management workflows.',\n },\n { content: '() => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.showDropdown',\n hintText:\n 'When true, displays a dropdown menu button that provides access to all tabs, including those that are scrolled out of view or overflow the visible area. This is essential for managing large numbers of tabs where horizontal scrolling would otherwise hide some tabs. The dropdown allows users to navigate to any tab regardless of scroll position, improving accessibility and usability for complex tab interfaces.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.showAddButton',\n hintText:\n 'When true, displays an add button that allows users to create new tabs dynamically. This is useful for interfaces where users need to add tabs on demand, such as multi-document editors, browser-like interfaces, or dynamic content management systems. The button triggers the onTabAdd callback when clicked, allowing you to handle the tab creation logic.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.addButtonTooltip',\n hintText:\n 'Custom tooltip text displayed when hovering over the add button. This provides context and guidance to users about what the add button does. Useful for accessibility and user education, especially when the add icon alone might not be immediately clear.',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.onTabChange',\n hintText: 'Callback when tab selection changes (for dropdown)',\n },\n { content: '(value: string) => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.activeTab',\n hintText: 'The currently active tab value',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.disable',\n hintText:\n 'When true, disables all tabs in the tab list, preventing user interaction. All tabs will be visually disabled and non-interactive. This is useful for temporarily disabling the entire tab component during loading states or when certain conditions prevent tab navigation. Individual tabs can still override this with their own disable prop.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.showSkeleton',\n hintText:\n 'When true, displays skeleton loading placeholders instead of the actual tab content. This provides visual feedback during data loading, preventing layout shifts and improving perceived performance. The skeleton state maintains the same dimensions and structure as the actual tabs, creating a smooth transition when content loads.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsList.skeletonVariant',\n hintText:\n 'Determines the animation style for skeleton loading states. \"pulse\" creates a gentle fade in/out effect, \"wave\" creates a shimmer wave animation that moves across the skeleton, and \"shimmer\" creates a bright shimmer effect. Use this to match your application\\'s loading animation style or to differentiate loading states.',\n },\n {\n content: \"'pulse' | 'wave' | 'shimmer'\",\n hintText: 'SkeletonVariant',\n },\n { content: 'pulse, wave, shimmer' },\n ],\n [\n {\n content: 'TabsTrigger.value',\n hintText: 'The unique value for this tab trigger',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TabsTrigger.variant',\n hintText:\n 'Optional TabsVariant enum value that determines the visual style variant of the tab trigger. UNDERLINE creates a tab with an underline indicator, BOXED creates a tab in a boxed container, FLOATING creates a tab in a floating container, PILLS creates a rounded pill-style tab. The variant should match the parent Tabs or TabsList variant. Defaults to UNDERLINE.',\n },\n { content: 'TabsVariant', hintText: 'enum' },\n { content: 'UNDERLINE, BOXED, FLOATING, PILLS' },\n ],\n [\n {\n content: 'TabsTrigger.size',\n hintText:\n 'Optional TabsSize enum value that determines the size variant affecting dimensions, padding, and font size of the tab trigger. MD creates a medium-sized tab, LG creates a larger tab. The size should match the parent Tabs or TabsList size. Defaults to MD.',\n },\n { content: 'TabsSize', hintText: 'enum' },\n { content: 'MD, LG' },\n ],\n [\n {\n content: 'TabsTrigger.leftSlot',\n hintText: 'Icon or element to display before the tab text',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'TabsTrigger.rightSlot',\n hintText: 'Icon or element to display after the tab text',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'TabsTrigger.children',\n hintText: 'The text content of the tab trigger',\n },\n { content: 'string | number', hintText: 'string or number' },\n { content: '' },\n ],\n [\n {\n content: 'TabsTrigger.closable',\n hintText:\n 'Whether the tab shows a close button (maps from TabItem.newItem)',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsTrigger.onClose',\n hintText: 'Callback when close button is clicked',\n },\n { content: '() => void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'TabsTrigger.disable',\n hintText:\n 'When true, disables this specific tab trigger, preventing user interaction. The tab will be visually disabled and non-interactive, and keyboard navigation will skip over it. This is useful for conditionally disabling specific tabs based on user permissions, data availability, or application state.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsTrigger.showSkeleton',\n hintText:\n 'When true, displays a skeleton loading placeholder for this specific tab trigger instead of the actual content. This provides visual feedback during data loading for individual tabs, preventing layout shifts and improving perceived performance. The skeleton state maintains the same dimensions and structure as the actual tab.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TabsTrigger.skeletonVariant',\n hintText:\n 'Determines the animation style for skeleton loading state of this specific tab trigger. \"pulse\" creates a gentle fade in/out effect, \"wave\" creates a shimmer wave animation that moves across the skeleton, and \"shimmer\" creates a bright shimmer effect. Use this to match your application\\'s loading animation style or to differentiate loading states for individual tabs.',\n },\n {\n content: \"'pulse' | 'wave' | 'shimmer'\",\n hintText: 'SkeletonVariant',\n },\n { content: 'pulse, wave, shimmer' },\n ],\n [\n {\n content: 'TabsContent.value',\n hintText:\n 'The value that matches the corresponding TabsTrigger',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TabsContent.children',\n hintText: 'The content to display when this tab is active',\n },\n { content: 'React.ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the tabs component using the following tokens:",
"excerpt": "Usage\n\n\n\nAPI Reference\n\n void',\n hintText:\n 'Function called with new selected value when tab selection changes',...",
"sections": [
{
@@ -1329,8 +1369,8 @@
"slug": "tag",
"category": "components",
"tags": ["tag", "component", "label"],
- "content": "Usage\n\n\n\nAPI Reference\n\n void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'splitTagPosition',\n hintText: 'Position for split tag styling',\n },\n { content: \"'left' | 'right'\", hintText: 'string union' },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the Tag component using the following tokens:",
- "excerpt": "Usage\n\n\n\nAPI Reference\n\n void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'spli...",
+ "content": "Usage\n\n\n\nAPI Reference\n\n void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'Tag.splitTagPosition',\n hintText:\n 'Optional position value used when the tag is part of a SplitTag component. LEFT indicates this tag is the left/primary portion of a split tag, RIGHT indicates this tag is the right/secondary portion. Controls border radius adjustments where the tags meet.',\n },\n { content: \"'left' | 'right'\", hintText: 'string union' },\n { content: '' },\n ],\n [\n {\n content: 'Tag.showSkeleton',\n hintText:\n 'When true, displays a skeleton loading placeholder instead of the actual tag content. This provides visual feedback during data loading, preventing layout shifts and improving perceived performance. Defaults to false.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'Tag.skeletonVariant',\n hintText:\n 'Optional SkeletonVariant enum that determines the animation style for skeleton loading states. PULSE creates a gentle fade in/out effect, WAVE creates a shimmer wave animation, SHIMMER creates a bright shimmer effect. Defaults to PULSE.',\n },\n { content: 'SkeletonVariant', hintText: 'enum' },\n { content: 'PULSE, WAVE, SHIMMER' },\n ],\n [\n {\n content: 'Tag.maxWidth',\n hintText:\n 'Optional CSS max-width value for the tag. Limits the maximum width of the tag, useful for preventing tags from growing too wide with long text. Text overflow is handled with ellipsis.',\n },\n { content: 'string | number', hintText: 'CSS max-width value' },\n { content: '' },\n ],\n [\n {\n content: 'Tag.minWidth',\n hintText:\n 'Optional CSS min-width value for the tag. Sets a minimum width for the tag, ensuring consistent sizing even with short text content.',\n },\n { content: 'string | number', hintText: 'CSS min-width value' },\n { content: '' },\n ],\n [\n {\n content: 'Tag.width',\n hintText:\n 'Optional CSS width value for the tag. Sets a fixed width for the tag. If not provided, the tag sizes to fit its content.',\n },\n { content: 'string | number', hintText: 'CSS width value' },\n { content: '' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the Tag component using the following tokens:",
+ "excerpt": "Usage\n\n\n\nAPI Reference\n\n void', hintText: 'function' },\n { content: '' },\n ],\n [\n {\n content: 'Tag....",
"sections": [
{
"title": "Usage",
@@ -1403,7 +1443,7 @@
"validation",
"responsive"
],
- "content": "Usage\n\n\n\nAPI Reference\n\n) => void',\n hintText: 'function',\n },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.label',\n hintText:\n 'Optional primary label string displayed above the input field. Provides context and description of what the input is for (e.g., \"Email Address\", \"Password\", \"Username\"). The label is styled prominently and supports floating label behavior on mobile devices. Should be concise and descriptive.',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.sublabel',\n hintText:\n 'Optional secondary label string displayed below the primary label. Provides additional context, explanation, or clarification about the input (e.g., \"Required for account creation\", \"Must be at least 8 characters\"). Styled with smaller, lighter text than the primary label.',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.placeholder',\n hintText:\n 'Optional placeholder string shown when the input is empty. Provides a hint about what to enter (e.g., \"Enter your email\", \"Type your message\"). The placeholder disappears when the user starts typing or when the input has a value. On mobile devices with floating labels, the placeholder may be hidden when the input is focused. Defaults to \"Enter\" if not provided.',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.size',\n hintText:\n 'Optional TextInputSize enum value that determines the size variant affecting dimensions, padding, font size, and border radius. SMALL creates a compact input, MEDIUM creates a standard size, LARGE creates a larger input. The size affects the overall input dimensions and text size. Defaults to MEDIUM.',\n },\n { content: 'TextInputSize', hintText: 'enum' },\n { content: 'SMALL, MEDIUM, LARGE' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.error',\n hintText:\n 'When true, displays the input in an error state with red border and error styling. Useful for form validation feedback when the input value is invalid or missing (for required inputs). When true, the errorMessage (if provided) is displayed below the input. Defaults to false.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.errorMessage',\n hintText:\n 'Optional error message string displayed below the input when error is true. Provides specific feedback about what went wrong (e.g., \"Email is required\", \"Password must be at least 8 characters\"). The error message is styled with red text and appears below the input, replacing the hintText if both are provided.',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.hintText',\n hintText:\n 'Optional helper text string displayed below the input. Provides guidance, instructions, or additional context about the input (e.g., \"We\\'ll never share your email\", \"Minimum 8 characters\"). The hint text is styled with lighter text and appears below the input. It is hidden when error is true and errorMessage is provided.',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.helpIconHintText',\n hintText:\n 'Optional tooltip text string displayed when hovering over the help icon. The help icon appears next to the label when this prop is provided. Useful for providing additional context or explanation about the input field without cluttering the interface. The tooltip appears on hover or focus of the help icon.',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.disabled',\n hintText:\n \"When true, disables the input, making it non-interactive and visually muted. Users cannot type, select, or interact with the input. Disabled inputs show reduced opacity and cannot receive focus. Useful for preventing interaction when prerequisites aren't met or during form submission. Defaults to false.\",\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.required',\n hintText:\n 'When true, marks the input as required and displays an asterisk (*) next to the label. Useful for form validation. Required inputs must have a value before form submission. The required indicator is visually distinct and helps users understand which fields are mandatory. Defaults to false.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.leftSlot',\n hintText:\n 'Optional React element displayed on the left side of the input field. Can be used for icons, badges, or other content that appears before the input text (e.g., search icon, currency symbol, prefix text). The slot maintains proper spacing and alignment with the input. Useful for adding visual context or additional information.',\n },\n { content: 'ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.rightSlot',\n hintText:\n 'Optional React element displayed on the right side of the input field. Can be used for icons, badges, action buttons, or other content that appears after the input text (e.g., clear button, visibility toggle, suffix text). The slot maintains proper spacing and alignment with the input. Useful for adding actions or visual context.',\n },\n { content: 'ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.name',\n hintText:\n 'Optional name attribute string for the input element. Used for form identification, form submission, and accessibility. Should be unique within the form. Useful when inputs are part of a form that needs to be submitted or when using form libraries. The name is included in form data when the form is submitted.',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.onFocus',\n hintText:\n 'Optional callback function invoked when the input receives focus. Receives a React FocusEvent with the input element. Use this to perform actions when the user focuses the input (e.g., show additional information, highlight related fields, track analytics). The function is called when the user clicks or tabs into the input.',\n },\n {\n content: '(e: React.FocusEvent) => void',\n hintText: 'function',\n },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.onBlur',\n hintText:\n 'Optional callback function invoked when the input loses focus. Receives a React FocusEvent with the input element. Use this to perform actions when the user leaves the input (e.g., validate input, save draft, track analytics). The function is called when the user clicks or tabs away from the input.',\n },\n {\n content: '(e: React.FocusEvent) => void',\n hintText: 'function',\n },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.cursor',\n hintText:\n 'Optional CSS cursor style string that determines the cursor appearance when hovering over the input. TEXT shows a text selection cursor, POINTER shows a pointer hand cursor, DEFAULT shows the default cursor, NOT_ALLOWED shows a disabled cursor. Useful for indicating interactivity or disabled state. Defaults to \"text\".',\n },\n {\n content: \"'text' | 'pointer' | 'default' | 'not-allowed'\",\n hintText: 'union type',\n },\n { content: 'text, pointer, default, not-allowed' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the TextInput component using the following tokens:",
+ "content": "Usage\n\n\n\nAPI Reference\n\n) => void',\n hintText: 'function',\n },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.label',\n hintText:\n 'Optional primary label string displayed above the input field. Provides context and description of what the input is for (e.g., \"Email Address\", \"Password\", \"Username\"). The label is styled prominently and supports floating label behavior on mobile devices. Should be concise and descriptive.',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.sublabel',\n hintText:\n 'Optional secondary label string displayed below the primary label. Provides additional context, explanation, or clarification about the input (e.g., \"Required for account creation\", \"Must be at least 8 characters\"). Styled with smaller, lighter text than the primary label.',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.placeholder',\n hintText:\n 'Optional placeholder string shown when the input is empty. Provides a hint about what to enter (e.g., \"Enter your email\", \"Type your message\"). The placeholder disappears when the user starts typing or when the input has a value. On mobile devices with floating labels, the placeholder may be hidden when the input is focused. Defaults to \"Enter\" if not provided.',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.size',\n hintText:\n 'Optional TextInputSize enum value that determines the size variant affecting dimensions, padding, font size, and border radius. SMALL creates a compact input, MEDIUM creates a standard size, LARGE creates a larger input. The size affects the overall input dimensions and text size. Defaults to MEDIUM.',\n },\n { content: 'TextInputSize', hintText: 'enum' },\n { content: 'SMALL, MEDIUM, LARGE' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.error',\n hintText:\n 'When true, displays the input in an error state with red border and error styling. Useful for form validation feedback when the input value is invalid or missing (for required inputs). When true, the errorMessage (if provided) is displayed below the input. Defaults to false.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.errorMessage',\n hintText:\n 'Optional error message string displayed below the input when error is true. Provides specific feedback about what went wrong (e.g., \"Email is required\", \"Password must be at least 8 characters\"). The error message is styled with red text and appears below the input, replacing the hintText if both are provided.',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.hintText',\n hintText:\n 'Optional helper text string displayed below the input. Provides guidance, instructions, or additional context about the input (e.g., \"We\\'ll never share your email\", \"Minimum 8 characters\"). The hint text is styled with lighter text and appears below the input. It is hidden when error is true and errorMessage is provided.',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.helpIconHintText',\n hintText:\n 'Optional tooltip text string displayed when hovering over the help icon. The help icon appears next to the label when this prop is provided. Useful for providing additional context or explanation about the input field without cluttering the interface. The tooltip appears on hover or focus of the help icon.',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.disabled',\n hintText:\n \"When true, disables the input, making it non-interactive and visually muted. Users cannot type, select, or interact with the input. Disabled inputs show reduced opacity and cannot receive focus. Useful for preventing interaction when prerequisites aren't met or during form submission. Defaults to false.\",\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.required',\n hintText:\n 'When true, marks the input as required and displays an asterisk (*) next to the label. Useful for form validation. Required inputs must have a value before form submission. The required indicator is visually distinct and helps users understand which fields are mandatory. Defaults to false.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.leftSlot',\n hintText:\n 'Optional React element displayed on the left side of the input field. Can be used for icons, badges, or other content that appears before the input text (e.g., search icon, currency symbol, prefix text). The slot maintains proper spacing and alignment with the input. Useful for adding visual context or additional information.',\n },\n { content: 'ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.rightSlot',\n hintText:\n 'Optional React element displayed on the right side of the input field. Can be used for icons, badges, action buttons, or other content that appears after the input text (e.g., clear button, visibility toggle, suffix text). The slot maintains proper spacing and alignment with the input. Useful for adding actions or visual context.',\n },\n { content: 'ReactNode', hintText: 'React node' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.name',\n hintText:\n 'Optional name attribute string for the input element. Used for form identification, form submission, and accessibility. Should be unique within the form. Useful when inputs are part of a form that needs to be submitted or when using form libraries. The name is included in form data when the form is submitted.',\n },\n { content: 'string', hintText: 'string' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.onFocus',\n hintText:\n 'Optional callback function invoked when the input receives focus. Receives a React FocusEvent with the input element. Use this to perform actions when the user focuses the input (e.g., show additional information, highlight related fields, track analytics). The function is called when the user clicks or tabs into the input.',\n },\n {\n content: '(e: React.FocusEvent) => void',\n hintText: 'function',\n },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.onBlur',\n hintText:\n 'Optional callback function invoked when the input loses focus. Receives a React FocusEvent with the input element. Use this to perform actions when the user leaves the input (e.g., validate input, save draft, track analytics). The function is called when the user clicks or tabs away from the input.',\n },\n {\n content: '(e: React.FocusEvent) => void',\n hintText: 'function',\n },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.cursor',\n hintText:\n 'Optional CSS cursor style string that determines the cursor appearance when hovering over the input. TEXT shows a text selection cursor, POINTER shows a pointer hand cursor, DEFAULT shows the default cursor, NOT_ALLOWED shows a disabled cursor. Useful for indicating interactivity or disabled state. Defaults to \"text\".',\n },\n {\n content: \"'text' | 'pointer' | 'default' | 'not-allowed'\",\n hintText: 'union type',\n },\n { content: 'text, pointer, default, not-allowed' },\n ],\n [\n {\n content: 'TextInput.passwordToggle',\n hintText:\n 'When true, displays a password visibility toggle button (eye icon) that allows users to show or hide the password text. The button includes proper accessibility attributes (aria-label, aria-pressed) and keyboard support. Useful for password inputs where users may want to verify their entry. Defaults to false.',\n },\n { content: 'boolean', hintText: 'boolean' },\n { content: '' },\n ],\n [\n {\n content: 'TextInput.textInputGroupPosition',\n hintText:\n 'Optional TextInputGroupPosition enum value that determines the border radius styling when this input is part of a text input group. CENTER removes border radius on left and right sides, LEFT removes border radius on the right side only, RIGHT removes border radius on the left side only. Useful for creating connected input groups.',\n },\n { content: 'TextInputGroupPosition', hintText: 'enum' },\n { content: 'CENTER, LEFT, RIGHT' },\n ],\n ]}\n className=\"mb-8\"\n emptyMessage=\"No props available\"\n loadingMessage=\"Loading props...\"\n/>\n\nComponent Tokens\n\nYou can style the TextInput component using the following tokens:",
"excerpt": "Usage\n\n\n\nAPI Reference\n\n) => void',\n hintText: 'function',\n },\n { content: '' },\n ],\n [...",
"sections": [
{
diff --git a/apps/backend/.env.example b/apps/backend/.env.example
new file mode 100644
index 000000000..2810de7a8
--- /dev/null
+++ b/apps/backend/.env.example
@@ -0,0 +1,27 @@
+# Server Configuration
+PORT=3001
+NODE_ENV=development
+
+# Database (PostgreSQL) β matches docker-compose.yml
+DATABASE_URL=postgresql://blend:blend_secret@localhost:5432/blend_studio
+
+# Database connection tuning (optional)
+DB_CONNECTION_LIMIT=3
+DB_POOL_TIMEOUT_SECONDS=10
+DB_CONNECT_RETRY_DELAY_MS=5000
+DB_CONNECT_MAX_ATTEMPTS=6
+
+# Google OAuth 2.0
+GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
+GOOGLE_CLIENT_SECRET=your-google-client-secret
+GOOGLE_REDIRECT_URI=http://localhost:3001/api/auth/google/callback
+
+# JWT Configuration
+JWT_SECRET=dev-jwt-secret-change-in-production-min-32-chars
+JWT_EXPIRES_IN=7d
+JWT_REFRESH_EXPIRES_IN=30d
+# Short-lived token for Studio β paste into `blend-studio login` (default 10m)
+JWT_CLI_EXPORT_EXPIRES_IN=10m
+
+# Frontend URL
+FRONTEND_URL=http://localhost:5173
diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile
new file mode 100644
index 000000000..b45509c99
--- /dev/null
+++ b/apps/backend/Dockerfile
@@ -0,0 +1,111 @@
+# =============================================================================
+# Blend Backend β multi-stage production image
+# -----------------------------------------------------------------------------
+# Monorepo layout: pnpm workspace at repo root, backend at apps/backend.
+# Goals of this Dockerfile:
+# 1. Deterministic installs (frozen lockfile, pinned pnpm).
+# 2. Only install what the backend actually needs (filtered install).
+# 3. Build with tsc + tsc-alias so compiled `@/*` imports resolve natively
+# in Node β no runtime path-alias tricks.
+# 4. Ship a minimal, self-contained runtime image built by `pnpm deploy`:
+# no workspace symlinks, no dev deps, no source code, non-root user.
+# 5. Proper PID 1 (tini) + Cloud Run healthcheck.
+# =============================================================================
+
+ARG NODE_VERSION=20-alpine
+ARG PNPM_VERSION=10.21.0
+
+# -----------------------------------------------------------------------------
+# 1. base β shared toolchain layer
+# -----------------------------------------------------------------------------
+FROM node:${NODE_VERSION} AS base
+RUN apk add --no-cache libc6-compat openssl
+ENV CI=true \
+ PNPM_HOME=/pnpm \
+ PATH=/pnpm:$PATH
+RUN corepack enable \
+ && corepack prepare pnpm@${PNPM_VERSION} --activate
+WORKDIR /repo
+
+# -----------------------------------------------------------------------------
+# 2. builder β install + compile backend inside the monorepo
+# -----------------------------------------------------------------------------
+FROM base AS builder
+
+# Workspace metadata first so the dep layer caches across source-only edits.
+COPY pnpm-workspace.yaml pnpm-lock.yaml package.json turbo.json ./
+COPY apps/backend/package.json ./apps/backend/package.json
+COPY packages/cli/package.json ./packages/cli/package.json
+COPY packages/blend/package.json ./packages/blend/package.json
+COPY packages/token-engine/package.json ./packages/token-engine/package.json
+
+# Allow native post-install scripts only for the packages that actually
+# need them. Everything else stays locked down.
+RUN pnpm config set onlyBuiltDependencies "@prisma/client,@prisma/engines,esbuild,prisma"
+
+# Filtered install: only the backend and its transitive deps. We keep devDeps
+# because tsc / tsc-alias / prisma CLI are needed to build.
+RUN pnpm install \
+ --frozen-lockfile \
+ --filter "@blend-design/justbackend..."
+
+# Backend source (after deps, so this layer busts on code changes only).
+COPY apps/backend ./apps/backend
+
+WORKDIR /repo/apps/backend
+RUN pnpm exec prisma generate
+RUN pnpm run build
+
+# -----------------------------------------------------------------------------
+# 3. deployer β `pnpm deploy` produces a self-contained prod tree in /deploy.
+# We re-run `prisma generate` against the pruned node_modules so the
+# generated client is present in the final image (pnpm deploy re-installs
+# @prisma/client fresh, so the builder-generated client is discarded).
+# -----------------------------------------------------------------------------
+FROM builder AS deployer
+WORKDIR /repo
+# `--legacy` is required in pnpm 10 because this workspace does not set
+# `inject-workspace-packages=true`; legacy deploy is the fully-supported path
+# for packages without injected workspace deps (the backend is standalone).
+RUN pnpm --filter "@blend-design/justbackend" deploy --prod --legacy /deploy
+WORKDIR /deploy
+# Avoid `pnpm exec` here: in some CI runners Corepack may resolve a newer
+# pnpm (v11+) requiring Node 22+, while this image intentionally uses Node 20.
+# Calling the local binary directly is deterministic and Node-version agnostic.
+RUN ./node_modules/.bin/prisma generate --schema=./prisma/schema.prisma
+
+# -----------------------------------------------------------------------------
+# 4. runner β minimal runtime image
+# -----------------------------------------------------------------------------
+FROM node:${NODE_VERSION} AS runner
+
+RUN apk add --no-cache libc6-compat openssl tini wget \
+ && addgroup -S appgroup -g 1001 \
+ && adduser -S appuser -u 1001 -G appgroup
+
+WORKDIR /app
+
+# Self-contained prod deps (no symlinks, no workspace leaks).
+COPY --from=deployer --chown=appuser:appgroup /deploy/package.json ./package.json
+COPY --from=deployer --chown=appuser:appgroup /deploy/node_modules ./node_modules
+
+# Compiled output + prisma schema/migrations.
+COPY --from=builder --chown=appuser:appgroup /repo/apps/backend/dist ./dist
+COPY --from=builder --chown=appuser:appgroup /repo/apps/backend/prisma ./prisma
+
+# Entrypoint script.
+COPY --chown=appuser:appgroup apps/backend/entrypoint.sh ./entrypoint.sh
+RUN chmod +x ./entrypoint.sh
+
+USER appuser
+
+ENV NODE_ENV=production \
+ PORT=8080
+
+EXPOSE 8080
+
+HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider "http://127.0.0.1:${PORT}/health" || exit 1
+
+ENTRYPOINT ["/sbin/tini", "--"]
+CMD ["./entrypoint.sh"]
diff --git a/apps/backend/SETUP.md b/apps/backend/SETUP.md
new file mode 100644
index 000000000..73d929cd5
--- /dev/null
+++ b/apps/backend/SETUP.md
@@ -0,0 +1,409 @@
+# Blend Studio Backend - Complete Setup Guide
+
+## Prerequisites
+
+- Node.js 18+ (20 recommended)
+- PostgreSQL 16
+- Google Cloud account with Firebase project
+- pnpm package manager
+
+---
+
+## Step 1: Database Setup (PostgreSQL)
+
+### Local Development
+
+```bash
+# Install PostgreSQL (macOS)
+brew install postgresql@16
+brew services start postgresql@16
+
+# Create database
+createdb blend_studio
+
+# Grant permissions to your user
+psql -d blend_studio -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO $(whoami);"
+```
+
+### Configure Database Connection
+
+Edit `/Users/vinit.khandal/Desktop/blend-design-system/apps/backend/.env`:
+
+```env
+DATABASE_URL=postgresql://$(whoami)@localhost:5432/blend_studio
+```
+
+### Run Database Setup
+
+```bash
+cd /Users/vinit.khandal/Desktop/blend-design-system/apps/backend
+
+# Install dependencies
+pnpm install
+
+# Generate Prisma client
+npx prisma generate
+
+# Verify database connection
+psql blend_studio -c "\dt"
+```
+
+---
+
+## Step 2: Google OAuth Setup
+
+### Create OAuth Credentials
+
+1. Go to [Google Cloud Console](https://console.cloud.google.com/)
+2. Select project: `storybook-452807`
+3. Navigate to **APIs & Services** β **Credentials**
+4. Click **Create Credentials** β **OAuth 2.0 Client ID**
+5. Application type: **Web application**
+6. Name: `Blend Studio Backend`
+7. **Authorized redirect URIs**:
+ - `http://localhost:3001/api/auth/google/callback`
+ - `http://localhost:3001/auth/google/callback`
+8. Click **Create**
+9. Copy **Client ID** and **Client Secret**
+
+### Configure OAuth Consent Screen
+
+1. Go to **OAuth consent screen** (left sidebar)
+2. Publishing Status: **Testing** (for development)
+3. **Test users**: Add your Google email address
+4. Save changes
+
+---
+
+## Step 3: Firebase Setup
+
+### Create Service Account
+
+1. Go to [Firebase Console](https://console.firebase.google.com/)
+2. Select project: `storybook-452807`
+3. Click gear icon βοΈ β **Project settings**
+4. Go to **Service accounts** tab
+5. Click **Generate new private key**
+6. Download the JSON file
+
+### Extract Credentials
+
+Open the downloaded JSON file and extract these values:
+
+```json
+{
+ "project_id": "storybook-452807",
+ "client_email": "firebase-adminsdk-xxxxx@storybook-452807.iam.gserviceaccount.com",
+ "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
+}
+```
+
+### Enable Firestore
+
+1. In Firebase Console, go to **Firestore Database** (left sidebar)
+2. Click **Create database**
+3. Choose **Start in test mode** (for development)
+4. Select region: `asia-south1` (Mumbai) or closest to you
+5. Click **Enable**
+
+---
+
+## Step 4: Environment Configuration
+
+### Generate JWT Secrets
+
+```bash
+# Run this twice to get two different secrets
+node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
+```
+
+### Create .env File
+
+```bash
+cd /Users/vinit.khandal/Desktop/blend-design-system/apps/backend
+cp .env.example .env
+```
+
+Edit `.env` with your actual values:
+
+```env
+# Server
+PORT=3001
+NODE_ENV=development
+
+# Database
+DATABASE_URL=postgresql://your_username@localhost:5432/blend_studio
+
+# Google OAuth
+GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
+GOOGLE_CLIENT_SECRET=your-client-secret
+GOOGLE_REDIRECT_URI=http://localhost:3001/api/auth/google/callback
+
+# JWT
+JWT_SECRET=paste-64-char-hex-from-step-above
+JWT_REFRESH_SECRET=paste-second-64-char-hex
+JWT_EXPIRES_IN=7d
+JWT_REFRESH_EXPIRES_IN=30d
+
+# Frontend
+FRONTEND_URL=http://localhost:5173
+
+# Firebase (from the downloaded JSON)
+FIREBASE_PROJECT_ID=storybook-452807
+FIREBASE_CLIENT_EMAIL=firebase-adminsdk-xxxxx@storybook-452807.iam.gserviceaccount.com
+FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----\n"
+
+# Cookies
+COOKIE_SECURE=false
+COOKIE_HTTP_ONLY=true
+```
+
+**Note**: For `FIREBASE_PRIVATE_KEY`, copy the entire key including newlines (as `\n`)
+
+---
+
+## Step 5: Start the Server
+
+```bash
+cd /Users/vinit.khandal/Desktop/blend-design-system/apps/backend
+
+# Install dependencies
+pnpm install
+
+# Generate Prisma client
+npx prisma generate
+
+# Start development server
+pnpm dev
+```
+
+You should see:
+
+```
+Server running on http://localhost:3001
+Swagger docs available at http://localhost:3001/docs
+Environment: development
+```
+
+---
+
+## Step 6: Test Everything
+
+### Test 1: Health Check
+
+```bash
+curl http://localhost:3001/health
+```
+
+Expected response:
+
+```json
+{
+ "status": "ok",
+ "timestamp": "2025-01-XXT...",
+ "version": "0.1.0"
+}
+```
+
+### Test 2: Swagger UI
+
+Open http://localhost:3001/docs
+
+You should see:
+
+- **Health** endpoints
+- **Authentication** endpoints (Google OAuth, JWT)
+- **Branches** endpoints (CRUD operations)
+
+### Test 3: Google Authentication
+
+#### Method A: Via Swagger UI
+
+1. Go to http://localhost:3001/docs
+2. Find `GET /api/auth/google`
+3. Click **Try it out** β **Execute**
+4. You'll be redirected to Google login
+5. After login, you'll get a token in the URL
+
+#### Method B: Direct Browser
+
+1. Go to: http://localhost:3001/api/auth/google
+2. Complete Google OAuth flow
+3. You'll be redirected to: `http://localhost:5173/auth/callback?token=eyJhbG...`
+4. Copy the token (everything after `token=`)
+
+#### Method C: Test Protected Endpoint
+
+```bash
+# Replace YOUR_JWT_TOKEN with the token from above
+curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
+ http://localhost:3001/api/auth/me
+```
+
+Expected response:
+
+```json
+{
+ "success": true,
+ "data": {
+ "user": {
+ "id": "uuid",
+ "email": "your@email.com",
+ "displayName": "Your Name",
+ "role": "viewer"
+ }
+ }
+}
+```
+
+### Test 4: Create a Branch (Requires Auth)
+
+```bash
+# First get a JWT token (follow Test 3)
+TOKEN="your-jwt-token"
+
+# Create a new branch
+curl -X POST http://localhost:3001/api/branches \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $TOKEN" \
+ -d '{
+ "name": "My First Brand",
+ "brandConfig": {
+ "colors": {
+ "primary": {
+ "500": "#E31837"
+ }
+ }
+ }
+ }'
+```
+
+Expected response:
+
+```json
+{
+ "success": true,
+ "data": {
+ "branch": {
+ "id": "uuid",
+ "name": "My First Brand",
+ "status": "draft",
+ ...
+ }
+ }
+}
+```
+
+### Test 5: List Branches
+
+```bash
+curl -H "Authorization: Bearer $TOKEN" \
+ http://localhost:3001/api/branches
+```
+
+### Test 6: Resolve Tokens
+
+```bash
+# Replace BRANCH_ID with the ID from the create response
+curl -X POST http://localhost:3001/api/branches/BRANCH_ID/resolve \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $TOKEN" \
+ -d '{"theme": "light"}'
+```
+
+---
+
+## Available API Endpoints
+
+### Health
+
+- `GET /health` - Health check
+- `GET /api/health` - Health check (alternative)
+
+### Authentication
+
+- `GET /api/auth/google` - Initiate Google OAuth
+- `GET /api/auth/google/callback` - OAuth callback
+- `POST /api/auth/refresh` - Refresh access token
+- `POST /api/auth/logout` - Logout (requires auth)
+- `POST /api/auth/logout-all` - Logout all devices (requires auth)
+- `GET /api/auth/me` - Get current user (requires auth)
+
+### Branches
+
+- `GET /api/branches` - List branches (requires auth)
+- `POST /api/branches` - Create branch (requires auth)
+- `GET /api/branches/:id` - Get branch (requires auth)
+- `PATCH /api/branches/:id` - Update branch (requires auth)
+- `DELETE /api/branches/:id` - Delete branch (requires auth)
+- `POST /api/branches/:id/fork` - Fork branch (requires auth)
+- `POST /api/branches/:id/publish` - Publish version (requires auth)
+- `GET /api/branches/:id/versions` - List versions (requires auth)
+- `POST /api/branches/:id/resolve` - Resolve tokens (requires auth)
+
+---
+
+## Troubleshooting
+
+### "User was denied access on the database"
+
+```bash
+# Grant database permissions
+psql blend_studio -c 'GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO "$(whoami)";'
+psql blend_studio -c 'GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO "$(whoami)";'
+```
+
+### "Firebase initialization failed"
+
+- Verify `FIREBASE_PROJECT_ID`, `FIREBASE_CLIENT_EMAIL`, and `FIREBASE_PRIVATE_KEY`
+- Ensure Firestore is enabled in Firebase Console
+- Check that the private key has proper formatting (with `\n` for newlines)
+
+### "Cannot find module '@/config/firebase'"
+
+```bash
+# Regenerate Prisma client and check TypeScript compilation
+npx prisma generate
+pnpm typecheck
+```
+
+### Google OAuth "unauthorized_client" error
+
+- Add your email to **Test users** in OAuth consent screen
+- Verify redirect URI matches exactly (including protocol and path)
+- Check that Client ID and Secret are correct
+
+---
+
+## Next Steps
+
+After setup is complete:
+
+1. **Frontend Integration**: Connect blend-studio frontend to use these APIs
+2. **Token Engine**: Implement actual token resolution from brand config
+3. **CLI Tool**: Create the `blend-studio` CLI package
+4. **Production Deployment**: Deploy to GCP Cloud Run
+
+---
+
+## Useful Commands
+
+```bash
+# Development
+pnpm dev # Start with hot reload
+pnpm build # Build for production
+pnpm start # Start production build
+
+# Database
+npx prisma generate # Generate Prisma client
+npx prisma studio # Open Prisma Studio GUI
+npx prisma migrate dev # Create migration
+npx prisma migrate deploy # Apply migrations
+
+# Testing
+curl http://localhost:3001/health
+curl -H "Authorization: Bearer TOKEN" http://localhost:3001/api/auth/me
+
+# Logs
+# View logs in terminal where pnpm dev is running
+```
diff --git a/apps/backend/docker-compose.yml b/apps/backend/docker-compose.yml
new file mode 100644
index 000000000..2bdfac4e4
--- /dev/null
+++ b/apps/backend/docker-compose.yml
@@ -0,0 +1,49 @@
+# ---------------------------------------------------------------------------
+# Blend Token Studio β Local Staging Environment (OrbStack)
+#
+# OrbStack runs containers natively on macOS. No Docker Desktop needed.
+#
+# Usage:
+# docker compose up -d # Start PostgreSQL
+# npm run db:migrate # Run Prisma migrations
+# npm run db:seed # Seed test data
+# npm run dev # Start backend
+#
+# Connect to PostgreSQL:
+# Host: localhost
+# Port: 5432
+# User: blend
+# Password: blend_secret
+# Database: blend_studio
+#
+# Prisma Studio (visual DB browser):
+# npm run db:studio
+# β opens http://localhost:5555
+#
+# Stop:
+# docker compose down # Stop containers (data preserved)
+# docker compose down -v # Stop + delete data (fresh start)
+# ---------------------------------------------------------------------------
+
+services:
+ postgres:
+ image: postgres:16-alpine
+ container_name: blend_studio_postgres
+ restart: unless-stopped
+ ports:
+ - '5432:5432'
+ environment:
+ POSTGRES_USER: blend
+ POSTGRES_PASSWORD: blend_secret
+ POSTGRES_DB: blend_studio
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ['CMD-SHELL', 'pg_isready -U blend -d blend_studio']
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+volumes:
+ postgres_data:
+ driver: local
diff --git a/apps/backend/entrypoint.sh b/apps/backend/entrypoint.sh
new file mode 100644
index 000000000..73997479f
--- /dev/null
+++ b/apps/backend/entrypoint.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+set -e
+
+echo "Starting Blend Backend (NODE_ENV=${NODE_ENV:-production})..."
+
+if [ -z "${DATABASE_URL:-}" ]; then
+ echo "ERROR: DATABASE_URL is not set β refusing to start without database config."
+ exit 1
+fi
+
+if [ "${MIGRATE_ON_STARTUP:-false}" = "true" ]; then
+ echo "MIGRATE_ON_STARTUP=true β applying database migrations..."
+ ./node_modules/.bin/prisma migrate deploy --schema=./prisma/schema.prisma
+else
+ echo "Skipping startup migrations (MIGRATE_ON_STARTUP=${MIGRATE_ON_STARTUP:-false})."
+ echo "Expected path: run migrations in CI/CD before deployment."
+fi
+
+echo "Launching server on port ${PORT:-8080}..."
+exec node dist/server.js
diff --git a/apps/backend/package.json b/apps/backend/package.json
new file mode 100644
index 000000000..f71b8d6ce
--- /dev/null
+++ b/apps/backend/package.json
@@ -0,0 +1,55 @@
+{
+ "name": "@blend-design/justbackend",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Blend Token Studio API - Express backend with clean architecture",
+ "type": "module",
+ "main": "dist/server.js",
+ "scripts": {
+ "dev": "tsx watch src/server.ts",
+ "build": "tsc && tsc-alias",
+ "start": "node dist/server.js",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint src/**/*.ts",
+ "db:generate": "prisma generate",
+ "db:migrate": "prisma migrate dev",
+ "db:deploy": "prisma migrate deploy",
+ "db:studio": "prisma studio",
+ "db:seed": "tsx prisma/seed.ts"
+ },
+ "dependencies": {
+ "@prisma/client": "^6.2.1",
+ "cookie-parser": "^1.4.7",
+ "cors": "^2.8.5",
+ "dotenv": "^16.4.5",
+ "express": "^4.21.2",
+ "google-auth-library": "^9.15.1",
+ "helmet": "^8.0.0",
+ "jsonwebtoken": "^9.0.2",
+ "multer": "^1.4.5-lts.1",
+ "pg": "^8.13.1",
+ "pino": "^9.6.0",
+ "pino-pretty": "^13.0.0",
+ "prisma": "^6.2.1",
+ "swagger-jsdoc": "^6.2.8",
+ "swagger-ui-express": "^5.0.1",
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "@types/cookie-parser": "^1.4.8",
+ "@types/cors": "^2.8.17",
+ "@types/express": "^4.17.21",
+ "@types/jsonwebtoken": "^9.0.7",
+ "@types/node": "^20",
+ "@types/pg": "^8.11.10",
+ "@types/multer": "^1.4.12",
+ "@types/swagger-jsdoc": "^6.0.4",
+ "@types/swagger-ui-express": "^4.1.7",
+ "tsc-alias": "^1.8.10",
+ "tsx": "^4.19.2",
+ "typescript": "~5.8.3"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+}
diff --git a/apps/backend/prisma/migrations/20260415173323_init_org_optional/migration.sql b/apps/backend/prisma/migrations/20260415173323_init_org_optional/migration.sql
new file mode 100644
index 000000000..a88e6f7ca
--- /dev/null
+++ b/apps/backend/prisma/migrations/20260415173323_init_org_optional/migration.sql
@@ -0,0 +1,355 @@
+-- CreateEnum
+CREATE TYPE "UserRole" AS ENUM ('admin', 'editor', 'viewer');
+
+-- CreateEnum
+CREATE TYPE "BranchStatus" AS ENUM ('draft', 'published', 'archived');
+
+-- CreateEnum
+CREATE TYPE "BranchVisibility" AS ENUM ('private', 'team', 'public');
+
+-- CreateEnum
+CREATE TYPE "UploadStatus" AS ENUM ('pending', 'processing', 'valid', 'invalid');
+
+-- CreateEnum
+CREATE TYPE "AuditAction" AS ENUM ('branch_created', 'branch_updated', 'branch_deleted', 'branch_published', 'branch_archived', 'branch_forked', 'version_created', 'snapshot_created', 'token_uploaded', 'user_created', 'user_role_changed', 'api_key_created', 'api_key_revoked');
+
+-- CreateTable
+CREATE TABLE "organizations" (
+ "id" UUID NOT NULL,
+ "name" VARCHAR(255) NOT NULL,
+ "slug" VARCHAR(100) NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "members" (
+ "id" UUID NOT NULL,
+ "organization_id" UUID NOT NULL,
+ "user_id" UUID NOT NULL,
+ "role" "UserRole" NOT NULL DEFAULT 'viewer',
+ "joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "members_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "users" (
+ "id" UUID NOT NULL,
+ "google_id" VARCHAR(255),
+ "email" VARCHAR(255) NOT NULL,
+ "display_name" VARCHAR(255),
+ "photo_url" TEXT,
+ "role" "UserRole" NOT NULL DEFAULT 'viewer',
+ "is_active" BOOLEAN NOT NULL DEFAULT true,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+ "last_login" TIMESTAMP(3),
+ "deleted_at" TIMESTAMP(3),
+
+ CONSTRAINT "users_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "refresh_tokens" (
+ "id" UUID NOT NULL,
+ "user_id" UUID NOT NULL,
+ "token_hash" VARCHAR(255) NOT NULL,
+ "expires_at" TIMESTAMP(3) NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "api_keys" (
+ "id" UUID NOT NULL,
+ "organization_id" UUID NOT NULL,
+ "user_id" UUID NOT NULL,
+ "name" VARCHAR(255) NOT NULL,
+ "key_hash" VARCHAR(255) NOT NULL,
+ "key_prefix" VARCHAR(8) NOT NULL,
+ "last_used_at" TIMESTAMP(3),
+ "expires_at" TIMESTAMP(3),
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "revoked_at" TIMESTAMP(3),
+
+ CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "branches" (
+ "id" UUID NOT NULL,
+ "organization_id" UUID,
+ "brand_id" VARCHAR(255) NOT NULL,
+ "name" VARCHAR(255) NOT NULL,
+ "description" TEXT,
+ "parent_branch_id" UUID,
+ "status" "BranchStatus" NOT NULL DEFAULT 'draft',
+ "visibility" "BranchVisibility" NOT NULL DEFAULT 'private',
+ "brand_config" JSONB NOT NULL,
+ "published_versions" INTEGER NOT NULL DEFAULT 0,
+ "latest_version" VARCHAR(50),
+ "created_by" UUID NOT NULL,
+ "created_by_name" VARCHAR(255) NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+ "deleted_at" TIMESTAMP(3),
+
+ CONSTRAINT "branches_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "branch_versions" (
+ "id" UUID NOT NULL,
+ "branch_id" UUID NOT NULL,
+ "version" VARCHAR(50) NOT NULL,
+ "brand_config" JSONB NOT NULL,
+ "changelog" TEXT,
+ "is_breaking" BOOLEAN NOT NULL DEFAULT false,
+ "is_prerelease" BOOLEAN NOT NULL DEFAULT false,
+ "published_by" UUID NOT NULL,
+ "published_by_name" VARCHAR(255) NOT NULL,
+ "published_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "branch_versions_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "branch_snapshots" (
+ "id" UUID NOT NULL,
+ "branch_id" UUID NOT NULL,
+ "brand_config" JSONB NOT NULL,
+ "label" VARCHAR(255),
+ "is_auto_save" BOOLEAN NOT NULL DEFAULT false,
+ "saved_by" UUID NOT NULL,
+ "saved_by_name" VARCHAR(255) NOT NULL,
+ "saved_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "branch_snapshots_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "tags" (
+ "id" UUID NOT NULL,
+ "name" VARCHAR(100) NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "tags_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "branch_tags" (
+ "branch_id" UUID NOT NULL,
+ "tag_id" UUID NOT NULL,
+
+ CONSTRAINT "branch_tags_pkey" PRIMARY KEY ("branch_id","tag_id")
+);
+
+-- CreateTable
+CREATE TABLE "token_uploads" (
+ "id" UUID NOT NULL,
+ "branch_id" UUID NOT NULL,
+ "file_name" VARCHAR(255) NOT NULL,
+ "file_size" INTEGER NOT NULL,
+ "description" TEXT,
+ "parsed_config" JSONB,
+ "status" "UploadStatus" NOT NULL DEFAULT 'pending',
+ "uploaded_by" UUID NOT NULL,
+ "uploaded_by_name" VARCHAR(255) NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "token_uploads_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "audit_logs" (
+ "id" UUID NOT NULL,
+ "organization_id" UUID NOT NULL,
+ "action" "AuditAction" NOT NULL,
+ "actor_id" UUID NOT NULL,
+ "actor_email" VARCHAR(255) NOT NULL,
+ "target_type" VARCHAR(50) NOT NULL,
+ "target_id" UUID NOT NULL,
+ "metadata" JSONB,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "organizations_slug_key" ON "organizations"("slug");
+
+-- CreateIndex
+CREATE INDEX "members_user_id_idx" ON "members"("user_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "members_organization_id_user_id_key" ON "members"("organization_id", "user_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "users_google_id_key" ON "users"("google_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
+
+-- CreateIndex
+CREATE INDEX "users_email_idx" ON "users"("email");
+
+-- CreateIndex
+CREATE INDEX "users_deleted_at_idx" ON "users"("deleted_at");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash");
+
+-- CreateIndex
+CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id");
+
+-- CreateIndex
+CREATE INDEX "refresh_tokens_token_hash_idx" ON "refresh_tokens"("token_hash");
+
+-- CreateIndex
+CREATE INDEX "refresh_tokens_expires_at_idx" ON "refresh_tokens"("expires_at");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "api_keys_key_hash_key" ON "api_keys"("key_hash");
+
+-- CreateIndex
+CREATE INDEX "api_keys_key_hash_idx" ON "api_keys"("key_hash");
+
+-- CreateIndex
+CREATE INDEX "api_keys_organization_id_idx" ON "api_keys"("organization_id");
+
+-- CreateIndex
+CREATE INDEX "api_keys_user_id_idx" ON "api_keys"("user_id");
+
+-- CreateIndex
+CREATE INDEX "api_keys_revoked_at_idx" ON "api_keys"("revoked_at");
+
+-- CreateIndex
+CREATE INDEX "branches_organization_id_idx" ON "branches"("organization_id");
+
+-- CreateIndex
+CREATE INDEX "branches_created_by_idx" ON "branches"("created_by");
+
+-- CreateIndex
+CREATE INDEX "branches_status_idx" ON "branches"("status");
+
+-- CreateIndex
+CREATE INDEX "branches_visibility_idx" ON "branches"("visibility");
+
+-- CreateIndex
+CREATE INDEX "branches_updated_at_idx" ON "branches"("updated_at");
+
+-- CreateIndex
+CREATE INDEX "branches_deleted_at_idx" ON "branches"("deleted_at");
+
+-- CreateIndex
+CREATE INDEX "branches_brand_id_idx" ON "branches"("brand_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "branches_brand_id_key" ON "branches"("brand_id");
+
+-- CreateIndex
+CREATE INDEX "branch_versions_branch_id_idx" ON "branch_versions"("branch_id");
+
+-- CreateIndex
+CREATE INDEX "branch_versions_published_at_idx" ON "branch_versions"("published_at");
+
+-- CreateIndex
+CREATE INDEX "branch_versions_branch_id_published_at_idx" ON "branch_versions"("branch_id", "published_at");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "branch_versions_branch_id_version_key" ON "branch_versions"("branch_id", "version");
+
+-- CreateIndex
+CREATE INDEX "branch_snapshots_branch_id_idx" ON "branch_snapshots"("branch_id");
+
+-- CreateIndex
+CREATE INDEX "branch_snapshots_saved_at_idx" ON "branch_snapshots"("saved_at");
+
+-- CreateIndex
+CREATE INDEX "branch_snapshots_branch_id_saved_at_idx" ON "branch_snapshots"("branch_id", "saved_at");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "tags_name_key" ON "tags"("name");
+
+-- CreateIndex
+CREATE INDEX "branch_tags_tag_id_idx" ON "branch_tags"("tag_id");
+
+-- CreateIndex
+CREATE INDEX "token_uploads_branch_id_idx" ON "token_uploads"("branch_id");
+
+-- CreateIndex
+CREATE INDEX "token_uploads_uploaded_by_idx" ON "token_uploads"("uploaded_by");
+
+-- CreateIndex
+CREATE INDEX "token_uploads_status_idx" ON "token_uploads"("status");
+
+-- CreateIndex
+CREATE INDEX "token_uploads_created_at_idx" ON "token_uploads"("created_at");
+
+-- CreateIndex
+CREATE INDEX "audit_logs_organization_id_idx" ON "audit_logs"("organization_id");
+
+-- CreateIndex
+CREATE INDEX "audit_logs_action_idx" ON "audit_logs"("action");
+
+-- CreateIndex
+CREATE INDEX "audit_logs_actor_id_idx" ON "audit_logs"("actor_id");
+
+-- CreateIndex
+CREATE INDEX "audit_logs_target_type_target_id_idx" ON "audit_logs"("target_type", "target_id");
+
+-- CreateIndex
+CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs"("created_at");
+
+-- CreateIndex
+CREATE INDEX "audit_logs_organization_id_created_at_idx" ON "audit_logs"("organization_id", "created_at");
+
+-- AddForeignKey
+ALTER TABLE "members" ADD CONSTRAINT "members_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "members" ADD CONSTRAINT "members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "branches" ADD CONSTRAINT "branches_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "branches" ADD CONSTRAINT "branches_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "branch_versions" ADD CONSTRAINT "branch_versions_branch_id_fkey" FOREIGN KEY ("branch_id") REFERENCES "branches"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "branch_snapshots" ADD CONSTRAINT "branch_snapshots_branch_id_fkey" FOREIGN KEY ("branch_id") REFERENCES "branches"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "branch_tags" ADD CONSTRAINT "branch_tags_branch_id_fkey" FOREIGN KEY ("branch_id") REFERENCES "branches"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "branch_tags" ADD CONSTRAINT "branch_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "token_uploads" ADD CONSTRAINT "token_uploads_branch_id_fkey" FOREIGN KEY ("branch_id") REFERENCES "branches"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "token_uploads" ADD CONSTRAINT "token_uploads_uploaded_by_fkey" FOREIGN KEY ("uploaded_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/apps/backend/prisma/migrations/20260417000000_rename_actor_email_hash/migration.sql b/apps/backend/prisma/migrations/20260417000000_rename_actor_email_hash/migration.sql
new file mode 100644
index 000000000..1631df4ee
--- /dev/null
+++ b/apps/backend/prisma/migrations/20260417000000_rename_actor_email_hash/migration.sql
@@ -0,0 +1,4 @@
+-- Rename actor_email to actor_email_hash and shrink column size
+-- The field stores SHA-256 hashes (64 hex chars), not plaintext emails
+ALTER TABLE "audit_logs" RENAME COLUMN "actor_email" TO "actor_email_hash";
+ALTER TABLE "audit_logs" ALTER COLUMN "actor_email_hash" TYPE VARCHAR(64);
diff --git a/apps/backend/prisma/migrations/20260417190514_add_merge_requests_table/migration.sql b/apps/backend/prisma/migrations/20260417190514_add_merge_requests_table/migration.sql
new file mode 100644
index 000000000..5e7b816f9
--- /dev/null
+++ b/apps/backend/prisma/migrations/20260417190514_add_merge_requests_table/migration.sql
@@ -0,0 +1,86 @@
+-- AlterEnum
+-- This migration adds more than one value to an enum.
+-- With PostgreSQL versions 11 and earlier, this is not possible
+-- in a single migration. This can be worked around by creating
+-- multiple migrations, each migration adding only one value to
+-- the enum.
+
+
+ALTER TYPE "AuditAction" ADD VALUE 'snapshot_restored';
+ALTER TYPE "AuditAction" ADD VALUE 'user_deactivated';
+ALTER TYPE "AuditAction" ADD VALUE 'member_invited';
+ALTER TYPE "AuditAction" ADD VALUE 'member_removed';
+ALTER TYPE "AuditAction" ADD VALUE 'token_locked';
+ALTER TYPE "AuditAction" ADD VALUE 'token_unlocked';
+ALTER TYPE "AuditAction" ADD VALUE 'merge_request_created';
+ALTER TYPE "AuditAction" ADD VALUE 'merge_request_approved';
+ALTER TYPE "AuditAction" ADD VALUE 'merge_request_rejected';
+ALTER TYPE "AuditAction" ADD VALUE 'merge_request_merged';
+
+-- AlterTable
+ALTER TABLE "organizations" ADD COLUMN "blend_version" VARCHAR(50),
+ADD COLUMN "default_branch_id" VARCHAR(255),
+ADD COLUMN "wcag_enforcement" VARCHAR(20) NOT NULL DEFAULT 'warn';
+
+-- CreateTable
+CREATE TABLE "token_locks" (
+ "id" UUID NOT NULL,
+ "organization_id" UUID NOT NULL,
+ "token_path" VARCHAR(500) NOT NULL,
+ "reason" TEXT,
+ "locked_by" UUID NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "token_locks_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "merge_requests" (
+ "id" UUID NOT NULL,
+ "organization_id" UUID NOT NULL,
+ "source_branch_id" UUID NOT NULL,
+ "source_branch_name" VARCHAR(255) NOT NULL,
+ "target_branch_id" UUID NOT NULL,
+ "target_branch_name" VARCHAR(255) NOT NULL,
+ "title" VARCHAR(255) NOT NULL,
+ "description" TEXT,
+ "status" VARCHAR(50) NOT NULL DEFAULT 'pending',
+ "diff" JSONB NOT NULL,
+ "lock_violations" JSONB,
+ "requested_by" UUID NOT NULL,
+ "reviewed_by" UUID,
+ "reviewed_at" TIMESTAMP(3),
+ "review_comment" TEXT,
+ "merged_at" TIMESTAMP(3),
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "merge_requests_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "token_locks_organization_id_idx" ON "token_locks"("organization_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "token_locks_organization_id_token_path_key" ON "token_locks"("organization_id", "token_path");
+
+-- CreateIndex
+CREATE INDEX "merge_requests_organization_id_idx" ON "merge_requests"("organization_id");
+
+-- CreateIndex
+CREATE INDEX "merge_requests_status_idx" ON "merge_requests"("status");
+
+-- CreateIndex
+CREATE INDEX "merge_requests_source_branch_id_idx" ON "merge_requests"("source_branch_id");
+
+-- CreateIndex
+CREATE INDEX "merge_requests_target_branch_id_idx" ON "merge_requests"("target_branch_id");
+
+-- CreateIndex
+CREATE INDEX "merge_requests_requested_by_idx" ON "merge_requests"("requested_by");
+
+-- AddForeignKey
+ALTER TABLE "token_locks" ADD CONSTRAINT "token_locks_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "merge_requests" ADD CONSTRAINT "merge_requests_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/backend/prisma/migrations/migration_lock.toml b/apps/backend/prisma/migrations/migration_lock.toml
new file mode 100644
index 000000000..044d57cdb
--- /dev/null
+++ b/apps/backend/prisma/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (e.g., Git)
+provider = "postgresql"
diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma
new file mode 100644
index 000000000..96a09d9bc
--- /dev/null
+++ b/apps/backend/prisma/schema.prisma
@@ -0,0 +1,451 @@
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+// ===========================================================================
+// Enums β PostgreSQL-native, enforced at the DB level
+// ===========================================================================
+
+/// User roles. Admin can manage users and see Monitor. Editor can create/edit
+/// branches. Viewer can only view published branches.
+enum UserRole {
+ admin
+ editor
+ viewer
+}
+
+/// Branch lifecycle: draft β published β archived. Only published branches
+/// can be pulled via CLI.
+enum BranchStatus {
+ draft
+ published
+ archived
+}
+
+/// Controls who can see a branch. Private = creator only. Team = organization
+/// members. Public = anyone with the branch ID.
+enum BranchVisibility {
+ private
+ team
+ public
+}
+
+/// Token upload processing state.
+enum UploadStatus {
+ pending
+ processing
+ valid
+ invalid
+}
+
+/// Audit log action types β every mutation in the system is tracked.
+enum AuditAction {
+ branch_created
+ branch_updated
+ branch_deleted
+ branch_published
+ branch_archived
+ branch_forked
+ version_created
+ snapshot_created
+ snapshot_restored
+ token_uploaded
+ user_created
+ user_role_changed
+ user_deactivated
+ api_key_created
+ api_key_revoked
+ member_invited
+ member_removed
+ token_locked
+ token_unlocked
+ merge_request_created
+ merge_request_approved
+ merge_request_rejected
+ merge_request_merged
+}
+
+// ===========================================================================
+// Organizations β multi-tenant isolation from day one
+// ===========================================================================
+
+/// An organization (company, team). All resources belong to an org.
+/// For now, users can be in one org. Later, support multi-org.
+model Organization {
+ id String @id @default(uuid()) @db.Uuid
+ name String @db.VarChar(255)
+ slug String @unique @db.VarChar(100)
+ /// Firestore branch ID of the org's master theme (e.g. "acme/default")
+ defaultBranchId String? @map("default_branch_id") @db.VarChar(255)
+ /// Blend component library version the org is currently using
+ blendVersion String? @map("blend_version") @db.VarChar(50)
+ /// WCAG enforcement policy: none = no checks, warn = show warnings, block = prevent saving
+ wcagEnforcement String @default("warn") @map("wcag_enforcement") @db.VarChar(20)
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ members Member[]
+ branches Branch[]
+ apiKeys ApiKey[]
+ auditLogs AuditLog[]
+ tokenLocks TokenLock[]
+ mergeRequests MergeRequest[]
+
+ @@map("organizations")
+}
+
+/// Links a user to an organization with a role within that org.
+model Member {
+ id String @id @default(uuid()) @db.Uuid
+ organizationId String @map("organization_id") @db.Uuid
+ userId String @map("user_id") @db.Uuid
+ role UserRole @default(viewer)
+ joinedAt DateTime @default(now()) @map("joined_at")
+
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([organizationId, userId])
+ @@index([userId])
+ @@map("members")
+}
+
+// ===========================================================================
+// Users & Auth
+// ===========================================================================
+
+model User {
+ id String @id @default(uuid()) @db.Uuid
+ googleId String? @unique @map("google_id") @db.VarChar(255)
+ email String @unique @db.VarChar(255)
+ displayName String? @map("display_name") @db.VarChar(255)
+ photoUrl String? @map("photo_url") @db.Text
+ role UserRole @default(viewer)
+ isActive Boolean @default(true) @map("is_active")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+ lastLogin DateTime? @map("last_login")
+ deletedAt DateTime? @map("deleted_at")
+
+ refreshTokens RefreshToken[]
+ tokenUploads TokenUpload[]
+ memberships Member[]
+ apiKeys ApiKey[]
+ createdBranches Branch[] @relation("BranchCreator")
+ auditLogs AuditLog[]
+
+ @@index([email])
+ @@index([deletedAt])
+ @@map("users")
+}
+
+model RefreshToken {
+ id String @id @default(uuid()) @db.Uuid
+ userId String @map("user_id") @db.Uuid
+ tokenHash String @unique @map("token_hash") @db.VarChar(255)
+ expiresAt DateTime @map("expires_at")
+ createdAt DateTime @default(now()) @map("created_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId])
+ @@index([tokenHash])
+ @@index([expiresAt])
+ @@map("refresh_tokens")
+}
+
+/// API keys for CLI access. Users run `blend-studio login` to create one.
+/// This avoids passing user JWTs through the terminal.
+model ApiKey {
+ id String @id @default(uuid()) @db.Uuid
+ organizationId String @map("organization_id") @db.Uuid
+ userId String @map("user_id") @db.Uuid
+ name String @db.VarChar(255)
+ keyHash String @unique @map("key_hash") @db.VarChar(255)
+ keyPrefix String @map("key_prefix") @db.VarChar(8)
+ lastUsedAt DateTime? @map("last_used_at")
+ expiresAt DateTime? @map("expires_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ revokedAt DateTime? @map("revoked_at")
+
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([keyHash])
+ @@index([organizationId])
+ @@index([userId])
+ @@index([revokedAt])
+ @@map("api_keys")
+}
+
+// ===========================================================================
+// Branches β core token data
+// ===========================================================================
+
+/// A branch is a versioned brand configuration. It holds the full BrandConfig
+/// as JSONB and tracks its lifecycle (draft β published β archived).
+///
+/// BrandConfig JSONB shape (enforced at application level):
+/// {
+/// "brandId": string, // e.g. "my-brand/default"
+/// "name": string, // e.g. "My Brand"
+/// "version": string, // e.g. "1.0.0"
+/// "colors": { // optional color groups
+/// "primary": { "50": "#EFF6FF", ..., "950": "#172554" },
+/// "gray": { ... },
+/// "red": { ... },
+/// "green": { ... },
+/// "yellow": { ... },
+/// "orange": { ... },
+/// "purple": { ... }
+/// },
+/// "radius": { "8": "8px", ... }, // optional border radius overrides
+/// "shadows": { "md": "...", ... }, // optional shadow overrides
+/// "font": { "family": "Inter" }, // optional font override
+/// "componentOverrides": { // optional per-component overrides
+/// "BUTTONV2": {
+/// "colors": { "primary": { "500": "#DC2626" } }
+/// }
+/// }
+/// }
+model Branch {
+ id String @id @default(uuid()) @db.Uuid
+ organizationId String? @map("organization_id") @db.Uuid
+ brandId String @map("brand_id") @db.VarChar(255)
+ name String @db.VarChar(255)
+ description String? @db.Text
+ parentBranchId String? @map("parent_branch_id") @db.Uuid
+ status BranchStatus @default(draft)
+ visibility BranchVisibility @default(private)
+ brandConfig Json @map("brand_config") @db.JsonB
+ publishedVersions Int @default(0) @map("published_versions")
+ latestVersion String? @map("latest_version") @db.VarChar(50)
+ createdBy String @map("created_by") @db.Uuid
+ createdByName String @map("created_by_name") @db.VarChar(255)
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+ deletedAt DateTime? @map("deleted_at")
+
+ organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ creator User @relation("BranchCreator", fields: [createdBy], references: [id], onDelete: Cascade)
+ versions BranchVersion[]
+ snapshots BranchSnapshot[]
+ tokenUploads TokenUpload[]
+ tags BranchTag[]
+
+ @@unique([brandId])
+ @@index([organizationId])
+ @@index([createdBy])
+ @@index([status])
+ @@index([visibility])
+ @@index([updatedAt])
+ @@index([deletedAt])
+ @@index([brandId])
+ @@map("branches")
+}
+
+/// A published snapshot of a branch's brand config. Immutable once created.
+/// Used by `npx blend-studio pull @`.
+model BranchVersion {
+ id String @id @default(uuid()) @db.Uuid
+ branchId String @map("branch_id") @db.Uuid
+ version String @db.VarChar(50)
+ brandConfig Json @map("brand_config") @db.JsonB
+ changelog String? @db.Text
+ isBreaking Boolean @default(false) @map("is_breaking")
+ isPrerelease Boolean @default(false) @map("is_prerelease")
+ publishedBy String @map("published_by") @db.Uuid
+ publishedByName String @map("published_by_name") @db.VarChar(255)
+ publishedAt DateTime @default(now()) @map("published_at")
+
+ branch Branch @relation(fields: [branchId], references: [id], onDelete: Cascade)
+
+ @@unique([branchId, version])
+ @@index([branchId])
+ @@index([publishedAt])
+ @@index([branchId, publishedAt])
+ @@map("branch_versions")
+}
+
+/// A save point (manual or auto). Used for undo/restore in the editor.
+/// Auto-saves are created on every edit (debounced). Manual saves have labels.
+model BranchSnapshot {
+ id String @id @default(uuid()) @db.Uuid
+ branchId String @map("branch_id") @db.Uuid
+ brandConfig Json @map("brand_config") @db.JsonB
+ label String? @db.VarChar(255)
+ isAutoSave Boolean @default(false) @map("is_auto_save")
+ savedBy String @map("saved_by") @db.Uuid
+ savedByName String @map("saved_by_name") @db.VarChar(255)
+ savedAt DateTime @default(now()) @map("saved_at")
+
+ branch Branch @relation(fields: [branchId], references: [id], onDelete: Cascade)
+
+ @@index([branchId])
+ @@index([savedAt])
+ @@index([branchId, savedAt])
+ @@map("branch_snapshots")
+}
+
+// ===========================================================================
+// Tags β for organizing and filtering branches
+// ===========================================================================
+
+/// A tag that can be applied to branches (e.g. "banking", "dark-mode", "v2").
+model Tag {
+ id String @id @default(uuid()) @db.Uuid
+ name String @unique @db.VarChar(100)
+ createdAt DateTime @default(now()) @map("created_at")
+
+ branches BranchTag[]
+
+ @@map("tags")
+}
+
+/// Many-to-many join between branches and tags.
+model BranchTag {
+ branchId String @map("branch_id") @db.Uuid
+ tagId String @map("tag_id") @db.Uuid
+
+ branch Branch @relation(fields: [branchId], references: [id], onDelete: Cascade)
+ tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
+
+ @@id([branchId, tagId])
+ @@index([tagId])
+ @@map("branch_tags")
+}
+
+// ===========================================================================
+// Token Uploads
+// ===========================================================================
+
+/// Uploaded brand.json files. Parsed, validated, and linked to a branch.
+model TokenUpload {
+ id String @id @default(uuid()) @db.Uuid
+ branchId String @map("branch_id") @db.Uuid
+ fileName String @map("file_name") @db.VarChar(255)
+ fileSize Int @map("file_size")
+ description String? @db.Text
+ parsedConfig Json? @map("parsed_config") @db.JsonB
+ status UploadStatus @default(pending)
+ uploadedBy String @map("uploaded_by") @db.Uuid
+ uploadedByName String @map("uploaded_by_name") @db.VarChar(255)
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ branch Branch @relation(fields: [branchId], references: [id], onDelete: Cascade)
+ user User @relation(fields: [uploadedBy], references: [id], onDelete: SetNull)
+
+ @@index([branchId])
+ @@index([uploadedBy])
+ @@index([status])
+ @@index([createdAt])
+ @@map("token_uploads")
+}
+
+// ===========================================================================
+// Token Governance β locked tokens that downstream branches cannot override
+// ===========================================================================
+
+/// A token path locked by org admin as a non-negotiable brand rule.
+/// Child branches cannot override these paths when resolving tokens.
+model TokenLock {
+ id String @id @default(uuid()) @db.Uuid
+ organizationId String @map("organization_id") @db.Uuid
+ /// Dot-path to the locked token, e.g. "colors.primary.500", "radius.8"
+ tokenPath String @map("token_path") @db.VarChar(500)
+ /// Human-readable reason for locking (shown to editors who try to change it)
+ reason String? @db.Text
+ lockedBy String @map("locked_by") @db.Uuid
+ createdAt DateTime @default(now()) @map("created_at")
+
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+ @@unique([organizationId, tokenPath])
+ @@index([organizationId])
+ @@map("token_locks")
+}
+
+// ===========================================================================
+// Merge Requests β approval workflow for promoting branch changes
+// ===========================================================================
+
+/// A merge request (change request) to promote a branch's config into
+/// the default branch. Editors must submit one; admins can self-merge.
+model MergeRequest {
+ id String @id @default(uuid()) @db.Uuid
+ organizationId String @map("organization_id") @db.Uuid
+ /// Source branch ID (the branch with changes)
+ sourceBranchId String @map("source_branch_id") @db.Uuid
+ sourceBranchName String @map("source_branch_name") @db.VarChar(255)
+ /// Target branch ID (usually the org default)
+ targetBranchId String @map("target_branch_id") @db.Uuid
+ targetBranchName String @map("target_branch_name") @db.VarChar(255)
+ title String @db.VarChar(255)
+ description String? @db.Text
+ /// pending | approved | rejected | merged | cancelled
+ status String @default("pending") @db.VarChar(50)
+ /// Computed diff between source and target brand configs
+ diff Json @db.JsonB
+ /// Any token lock violations found during diff
+ lockViolations Json? @map("lock_violations") @db.JsonB
+ requestedBy String @map("requested_by") @db.Uuid
+ reviewedBy String? @map("reviewed_by") @db.Uuid
+ reviewedAt DateTime? @map("reviewed_at")
+ reviewComment String? @map("review_comment") @db.Text
+ mergedAt DateTime? @map("merged_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+ @@index([organizationId])
+ @@index([status])
+ @@index([sourceBranchId])
+ @@index([targetBranchId])
+ @@index([requestedBy])
+ @@map("merge_requests")
+}
+
+// ===========================================================================
+// Audit Log β immutable record of every mutation
+// ===========================================================================
+
+/// Tracks every create/update/delete action in the system. Required for
+/// compliance (banking clients), debugging, and analytics.
+///
+/// The `metadata` JSONB stores action-specific data, e.g.:
+/// - branch_published: { version: "1.0.0", isBreaking: false }
+/// - user_role_changed: { from: "viewer", to: "editor" }
+/// - branch_updated: { fieldsChanged: ["colors.primary.500"] }
+///
+/// IMPORTANT: `actorEmailHash` stores the SHA-256 hash of the actor's email,
+/// NOT the plaintext email. Use hashPII() before writing. The same applies
+/// to any PII in `metadata` β emails must be hashed, names masked.
+model AuditLog {
+ id String @id @default(uuid()) @db.Uuid
+ organizationId String @map("organization_id") @db.Uuid
+ action AuditAction
+ actorId String @map("actor_id") @db.Uuid
+ actorEmailHash String @map("actor_email_hash") @db.VarChar(64)
+ targetType String @map("target_type") @db.VarChar(50)
+ targetId String @map("target_id") @db.Uuid
+ metadata Json? @db.JsonB
+ createdAt DateTime @default(now()) @map("created_at")
+
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ actor User @relation(fields: [actorId], references: [id], onDelete: SetNull)
+
+ @@index([organizationId])
+ @@index([action])
+ @@index([actorId])
+ @@index([targetType, targetId])
+ @@index([createdAt])
+ @@index([organizationId, createdAt])
+ @@map("audit_logs")
+}
diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts
new file mode 100644
index 000000000..7d82d370e
--- /dev/null
+++ b/apps/backend/prisma/seed.ts
@@ -0,0 +1,406 @@
+import { PrismaClient } from '@prisma/client'
+import { randomUUID } from 'crypto'
+
+const prisma = new PrismaClient()
+
+async function main() {
+ console.log('Seeding database...')
+
+ // -----------------------------------------------------------------------
+ // Organization
+ // -----------------------------------------------------------------------
+ const org = await prisma.organization.upsert({
+ where: { slug: 'blend-studio' },
+ update: {},
+ create: {
+ name: 'Blend Design Studio',
+ slug: 'blend-studio',
+ },
+ })
+ const orgId = org.id
+ console.log(` Created organization: ${org.name}`)
+
+ // -----------------------------------------------------------------------
+ // Users
+ // -----------------------------------------------------------------------
+ const admin = await prisma.user.upsert({
+ where: { email: 'admin@blend.dev' },
+ update: {},
+ create: {
+ email: 'admin@blend.dev',
+ displayName: 'Admin User',
+ role: 'admin',
+ isActive: true,
+ },
+ })
+
+ const designer = await prisma.user.upsert({
+ where: { email: 'designer@blend.dev' },
+ update: {},
+ create: {
+ email: 'designer@blend.dev',
+ displayName: 'Design Team',
+ role: 'editor',
+ isActive: true,
+ },
+ })
+
+ const viewer = await prisma.user.upsert({
+ where: { email: 'viewer@blend.dev' },
+ update: {},
+ create: {
+ email: 'viewer@blend.dev',
+ displayName: 'View Only User',
+ role: 'viewer',
+ isActive: true,
+ },
+ })
+
+ console.log(
+ ` Created users: ${admin.email}, ${designer.email}, ${viewer.email}`
+ )
+
+ // -----------------------------------------------------------------------
+ // Memberships
+ // -----------------------------------------------------------------------
+ await prisma.member.createMany({
+ data: [
+ { organizationId: orgId, userId: admin.id, role: 'admin' },
+ { organizationId: orgId, userId: designer.id, role: 'editor' },
+ { organizationId: orgId, userId: viewer.id, role: 'viewer' },
+ ],
+ skipDuplicates: true,
+ })
+ console.log(' Created memberships')
+
+ // -----------------------------------------------------------------------
+ // Tags
+ // -----------------------------------------------------------------------
+ const tags = await Promise.all([
+ prisma.tag.upsert({
+ where: { name: 'default' },
+ update: {},
+ create: { name: 'default' },
+ }),
+ prisma.tag.upsert({
+ where: { name: 'dark-mode' },
+ update: {},
+ create: { name: 'dark-mode' },
+ }),
+ prisma.tag.upsert({
+ where: { name: 'starter' },
+ update: {},
+ create: { name: 'starter' },
+ }),
+ prisma.tag.upsert({
+ where: { name: 'v2' },
+ update: {},
+ create: { name: 'v2' },
+ }),
+ prisma.tag.upsert({
+ where: { name: 'production' },
+ update: {},
+ create: { name: 'production' },
+ }),
+ ])
+ console.log(` Created ${tags.length} tags`)
+
+ // -----------------------------------------------------------------------
+ // Branches β generic, open-source friendly
+ // -----------------------------------------------------------------------
+ const branchIds = {
+ blendDefault: randomUUID(),
+ juspayDefault: randomUUID(),
+ starterPurple: randomUUID(),
+ starterGreen: randomUUID(),
+ }
+
+ const branches = await Promise.all([
+ prisma.branch.create({
+ data: {
+ id: branchIds.blendDefault,
+ organizationId: orgId,
+ brandId: 'blend/default',
+ name: 'Blend Default',
+ description: 'Default Blend Design System theme β no overrides',
+ status: 'published',
+ visibility: 'public',
+ createdBy: admin.id,
+ createdByName: admin.displayName || 'Admin',
+ brandConfig: {
+ brandId: 'blend/default',
+ name: 'Blend Default',
+ version: '1.0.0',
+ colors: {},
+ },
+ publishedVersions: 1,
+ latestVersion: '1.0.0',
+ },
+ }),
+ prisma.branch.create({
+ data: {
+ id: branchIds.juspayDefault,
+ organizationId: orgId,
+ brandId: 'juspay/default',
+ name: 'Juspay Default',
+ description:
+ 'Juspay brand theme with blue primary β the default preset',
+ status: 'published',
+ visibility: 'team',
+ createdBy: admin.id,
+ createdByName: admin.displayName || 'Admin',
+ brandConfig: {
+ brandId: 'juspay/default',
+ name: 'Juspay',
+ version: '1.0.0',
+ colors: {
+ primary: {
+ '300': '#93C5FD',
+ '400': '#60A5FA',
+ '500': '#3B82F6',
+ '600': '#2563EB',
+ '700': '#1D4ED8',
+ '800': '#1E40AF',
+ },
+ },
+ },
+ publishedVersions: 2,
+ latestVersion: '1.2.0',
+ },
+ }),
+ prisma.branch.create({
+ data: {
+ id: branchIds.starterPurple,
+ organizationId: orgId,
+ brandId: 'starter/purple',
+ name: 'Starter Purple',
+ description:
+ 'Purple SaaS theme with rounded corners β great for dashboards',
+ status: 'published',
+ visibility: 'public',
+ createdBy: designer.id,
+ createdByName: designer.displayName || 'Designer',
+ brandConfig: {
+ brandId: 'starter/purple',
+ name: 'Purple',
+ version: '1.0.0',
+ colors: {
+ primary: {
+ '300': '#DAB2FF',
+ '400': '#C27AFF',
+ '500': '#AD46FF',
+ '600': '#9810FA',
+ '700': '#8200DB',
+ '800': '#6E11B0',
+ },
+ },
+ radius: { '10': '20px', '12': '24px' },
+ },
+ publishedVersions: 1,
+ latestVersion: '1.0.0',
+ },
+ }),
+ prisma.branch.create({
+ data: {
+ id: branchIds.starterGreen,
+ organizationId: orgId,
+ brandId: 'starter/green',
+ name: 'Starter Green',
+ description: 'Green theme β still in draft',
+ status: 'draft',
+ visibility: 'private',
+ createdBy: designer.id,
+ createdByName: designer.displayName || 'Designer',
+ brandConfig: {
+ brandId: 'starter/green',
+ name: 'Green',
+ version: '1.0.0',
+ colors: {
+ primary: {
+ '300': '#7BF1A8',
+ '400': '#00D492',
+ '500': '#00C951',
+ '600': '#00A63E',
+ '700': '#008236',
+ '800': '#016630',
+ },
+ },
+ },
+ publishedVersions: 0,
+ },
+ }),
+ ])
+ console.log(` Created ${branches.length} branches`)
+
+ // -----------------------------------------------------------------------
+ // Branch Tags
+ // -----------------------------------------------------------------------
+ await Promise.all([
+ prisma.branchTag.create({
+ data: { branchId: branchIds.blendDefault, tagId: tags[0].id },
+ }),
+ prisma.branchTag.create({
+ data: { branchId: branchIds.juspayDefault, tagId: tags[4].id },
+ }),
+ prisma.branchTag.create({
+ data: { branchId: branchIds.starterPurple, tagId: tags[2].id },
+ }),
+ prisma.branchTag.create({
+ data: { branchId: branchIds.starterGreen, tagId: tags[2].id },
+ }),
+ ])
+ console.log(' Tagged branches')
+
+ // -----------------------------------------------------------------------
+ // Versions
+ // -----------------------------------------------------------------------
+ const versions = await Promise.all([
+ prisma.branchVersion.create({
+ data: {
+ branchId: branchIds.blendDefault,
+ version: '1.0.0',
+ brandConfig: {
+ brandId: 'blend/default',
+ name: 'Blend Default',
+ version: '1.0.0',
+ colors: {},
+ },
+ changelog: 'Initial Blend default theme',
+ publishedBy: admin.id,
+ publishedByName: admin.displayName || 'Admin',
+ },
+ }),
+ prisma.branchVersion.create({
+ data: {
+ branchId: branchIds.juspayDefault,
+ version: '1.0.0',
+ brandConfig: {
+ brandId: 'juspay/default',
+ name: 'Juspay',
+ version: '1.0.0',
+ colors: { primary: { '500': '#3B82F6' } },
+ },
+ changelog: 'Initial Juspay branding',
+ publishedBy: admin.id,
+ publishedByName: admin.displayName || 'Admin',
+ },
+ }),
+ prisma.branchVersion.create({
+ data: {
+ branchId: branchIds.juspayDefault,
+ version: '1.2.0',
+ brandConfig: {
+ brandId: 'juspay/default',
+ name: 'Juspay',
+ version: '1.2.0',
+ colors: { primary: { '500': '#3B82F6' } },
+ },
+ changelog: 'Updated full color scale',
+ publishedBy: admin.id,
+ publishedByName: admin.displayName || 'Admin',
+ },
+ }),
+ prisma.branchVersion.create({
+ data: {
+ branchId: branchIds.starterPurple,
+ version: '1.0.0',
+ brandConfig: {
+ brandId: 'starter/purple',
+ name: 'Purple',
+ version: '1.0.0',
+ colors: { primary: { '500': '#AD46FF' } },
+ },
+ changelog: 'Initial purple theme',
+ publishedBy: designer.id,
+ publishedByName: designer.displayName || 'Designer',
+ },
+ }),
+ ])
+ console.log(` Created ${versions.length} versions`)
+
+ // -----------------------------------------------------------------------
+ // Snapshots
+ // -----------------------------------------------------------------------
+ const snapshots = await Promise.all([
+ prisma.branchSnapshot.create({
+ data: {
+ branchId: branchIds.starterGreen,
+ brandConfig: {
+ brandId: 'starter/green',
+ name: 'Green',
+ colors: { primary: { '500': '#00C951' } },
+ },
+ label: 'Manual save',
+ isAutoSave: false,
+ savedBy: designer.id,
+ savedByName: designer.displayName || 'Designer',
+ },
+ }),
+ prisma.branchSnapshot.create({
+ data: {
+ branchId: branchIds.starterGreen,
+ brandConfig: {
+ brandId: 'starter/green',
+ name: 'Green',
+ colors: { primary: { '500': '#00D492' } },
+ },
+ label: 'Auto-save',
+ isAutoSave: true,
+ savedBy: designer.id,
+ savedByName: designer.displayName || 'Designer',
+ },
+ }),
+ ])
+ console.log(` Created ${snapshots.length} snapshots`)
+
+ // -----------------------------------------------------------------------
+ // Audit Logs
+ // -----------------------------------------------------------------------
+ await Promise.all([
+ prisma.auditLog.create({
+ data: {
+ organizationId: orgId,
+ action: 'branch_created',
+ actorId: admin.id,
+ actorEmail: admin.email,
+ targetType: 'branch',
+ targetId: branchIds.blendDefault,
+ metadata: { name: 'Blend Default' },
+ },
+ }),
+ prisma.auditLog.create({
+ data: {
+ organizationId: orgId,
+ action: 'branch_created',
+ actorId: admin.id,
+ actorEmail: admin.email,
+ targetType: 'branch',
+ targetId: branchIds.juspayDefault,
+ metadata: { name: 'Juspay Default' },
+ },
+ }),
+ prisma.auditLog.create({
+ data: {
+ organizationId: orgId,
+ action: 'branch_published',
+ actorId: admin.id,
+ actorEmail: admin.email,
+ targetType: 'branch',
+ targetId: branchIds.juspayDefault,
+ metadata: { version: '1.2.0' },
+ },
+ }),
+ ])
+ console.log(' Created audit logs')
+
+ console.log('\nSeed complete! Run `npm run db:studio` to browse data.')
+}
+
+main()
+ .catch((e) => {
+ console.error('Seed failed:', e)
+ process.exit(1)
+ })
+ .finally(async () => {
+ await prisma.$disconnect()
+ })
diff --git a/apps/backend/setup-database.sql b/apps/backend/setup-database.sql
new file mode 100644
index 000000000..5cdbf97d2
--- /dev/null
+++ b/apps/backend/setup-database.sql
@@ -0,0 +1,9 @@
+-- Add google_id column to existing users table
+ALTER TABLE users ALTER COLUMN firebase_uid DROP NOT NULL;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS google_id VARCHAR(255);
+
+-- Create unique index for google_id (only for non-null values)
+CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS users_google_id_key ON users(google_id) WHERE google_id IS NOT NULL;
+
+-- Verify changes
+\d users
diff --git a/apps/backend/src/config/database.ts b/apps/backend/src/config/database.ts
new file mode 100644
index 000000000..a1b32d022
--- /dev/null
+++ b/apps/backend/src/config/database.ts
@@ -0,0 +1,154 @@
+/**
+ * Database Configuration β PostgreSQL Only
+ *
+ * Single database. No Firestore. No Firebase.
+ * PostgreSQL stores everything: users, branches, versions, snapshots, uploads.
+ * JWT handles authentication. Google OAuth handles login.
+ */
+
+import prismaClientModule from '@prisma/client'
+import { logger } from '@/utils/logger.js'
+
+const { PrismaClient } = prismaClientModule as any
+
+const getPositiveIntegerFromEnv = (
+ value: string | undefined,
+ fallback: number
+): number => {
+ if (!value) return fallback
+
+ const parsed = Number.parseInt(value, 10)
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback
+
+ return parsed
+}
+
+const dbConnectionLimit = getPositiveIntegerFromEnv(
+ process.env.DB_CONNECTION_LIMIT,
+ 3
+)
+const dbPoolTimeoutSeconds = getPositiveIntegerFromEnv(
+ process.env.DB_POOL_TIMEOUT_SECONDS,
+ 10
+)
+const dbConnectRetryDelayMs = getPositiveIntegerFromEnv(
+ process.env.DB_CONNECT_RETRY_DELAY_MS,
+ 5000
+)
+const dbConnectMaxAttempts = getPositiveIntegerFromEnv(
+ process.env.DB_CONNECT_MAX_ATTEMPTS,
+ 6
+)
+
+const withPrismaPoolSettings = (
+ rawDatabaseUrl?: string
+): string | undefined => {
+ if (!rawDatabaseUrl) return rawDatabaseUrl
+
+ // Handle Cloud SQL Unix socket paths - don't parse URL, just append params
+ if (rawDatabaseUrl.includes('/cloudsql/')) {
+ const separator = rawDatabaseUrl.includes('?') ? '&' : '?'
+ let result = rawDatabaseUrl
+ if (!rawDatabaseUrl.includes('connection_limit')) {
+ result += `${separator}connection_limit=${dbConnectionLimit}`
+ }
+ if (!rawDatabaseUrl.includes('pool_timeout')) {
+ result += `&pool_timeout=${dbPoolTimeoutSeconds}`
+ }
+ return result
+ }
+
+ try {
+ const parsedUrl = new URL(rawDatabaseUrl)
+ const searchParams = parsedUrl.searchParams
+
+ // Cloud Run creates many concurrent requests; keep per-instance
+ // Prisma pool small and wait a bit longer before timing out.
+ if (!searchParams.has('connection_limit')) {
+ searchParams.set('connection_limit', String(dbConnectionLimit))
+ }
+ if (!searchParams.has('pool_timeout')) {
+ searchParams.set('pool_timeout', String(dbPoolTimeoutSeconds))
+ }
+
+ return parsedUrl.toString()
+ } catch (error) {
+ logger.warn({ err: error }, 'Invalid DATABASE_URL; using raw value')
+ return rawDatabaseUrl
+ }
+}
+
+// ---------------------------------------------------------------------------
+// PostgreSQL (Prisma)
+// ---------------------------------------------------------------------------
+
+const globalForPrisma = globalThis as unknown as {
+ prisma: any | undefined
+}
+
+const prismaClientOptions = process.env.DATABASE_URL
+ ? {
+ datasources: {
+ db: {
+ url: withPrismaPoolSettings(process.env.DATABASE_URL),
+ },
+ },
+ }
+ : undefined
+
+export const prisma =
+ globalForPrisma.prisma ?? new PrismaClient(prismaClientOptions)
+
+if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
+
+let databaseReady = false
+
+// ---------------------------------------------------------------------------
+// Lifecycle
+// ---------------------------------------------------------------------------
+
+export const connectDatabase = async (): Promise => {
+ try {
+ await prisma.$connect()
+ databaseReady = true
+ logger.info('PostgreSQL connected successfully')
+ } catch (error) {
+ databaseReady = false
+ logger.error(error, 'Failed to connect to PostgreSQL')
+ throw error
+ }
+}
+
+export const disconnectDatabase = async (): Promise => {
+ await prisma.$disconnect()
+ databaseReady = false
+ logger.info('PostgreSQL disconnected')
+}
+
+export const isDatabaseReady = (): boolean => databaseReady
+
+export const connectDatabaseWithRetry = async (
+ retryDelayMs = dbConnectRetryDelayMs,
+ maxAttempts = dbConnectMaxAttempts
+): Promise => {
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+ try {
+ await connectDatabase()
+ return
+ } catch (error) {
+ if (attempt === maxAttempts) {
+ logger.error(
+ { err: error, attempt, maxAttempts },
+ 'Database connection failed after max retries'
+ )
+ throw error
+ }
+
+ logger.warn(
+ { err: error, attempt, maxAttempts, retryDelayMs },
+ 'Database connection retry scheduled'
+ )
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs))
+ }
+ }
+}
diff --git a/apps/backend/src/config/index.ts b/apps/backend/src/config/index.ts
new file mode 100644
index 000000000..3a77205b3
--- /dev/null
+++ b/apps/backend/src/config/index.ts
@@ -0,0 +1,86 @@
+import { config } from 'dotenv'
+import { z } from 'zod'
+
+config()
+
+const envSchema = z.object({
+ PORT: z.string().default('8080'),
+ NODE_ENV: z
+ .enum(['development', 'production', 'test'])
+ .default('development'),
+
+ DATABASE_URL: z.string(),
+
+ GOOGLE_CLIENT_ID: z.string(),
+ GOOGLE_CLIENT_SECRET: z.string(),
+ GOOGLE_REDIRECT_URI: z
+ .string()
+ .default('http://localhost:3001/auth/google/callback'),
+
+ JWT_SECRET: z.string().min(32, 'JWT secret must be at least 32 characters'),
+ JWT_EXPIRES_IN: z.string().default('7d'),
+ JWT_REFRESH_EXPIRES_IN: z.string().default('30d'),
+ /** Short-lived token minted for CLI paste from Studio (default 10 minutes). */
+ JWT_CLI_EXPORT_EXPIRES_IN: z.string().default('10m'),
+
+ FRONTEND_URL: z.string().default('http://localhost:5173'),
+ STUDIO_URL: z.string().default(''),
+})
+
+const isValidUrl = (value: string): boolean => {
+ try {
+ new URL(value)
+ return true
+ } catch {
+ return false
+ }
+}
+
+const hasNonStandardHttpsPort = (value: string): boolean => {
+ const parsed = new URL(value)
+ return (
+ parsed.protocol === 'https:' &&
+ parsed.port !== '' &&
+ parsed.port !== '443'
+ )
+}
+
+const parsedEnv = envSchema
+ .superRefine((data, ctx) => {
+ if (!isValidUrl(data.GOOGLE_REDIRECT_URI)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['GOOGLE_REDIRECT_URI'],
+ message: 'GOOGLE_REDIRECT_URI must be a valid absolute URL',
+ })
+ }
+
+ if (data.NODE_ENV === 'production') {
+ if (!isValidUrl(data.FRONTEND_URL)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['FRONTEND_URL'],
+ message:
+ 'FRONTEND_URL must be a valid absolute URL in production',
+ })
+ } else if (hasNonStandardHttpsPort(data.FRONTEND_URL)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['FRONTEND_URL'],
+ message:
+ 'FRONTEND_URL must not use a non-443 HTTPS port in production',
+ })
+ }
+ }
+ })
+ .safeParse(process.env)
+
+if (!parsedEnv.success) {
+ console.error('Environment validation failed:', parsedEnv.error.errors)
+ process.exit(1)
+}
+
+export const env = parsedEnv.data
+
+export const isDevelopment = env.NODE_ENV === 'development'
+export const isProduction = env.NODE_ENV === 'production'
diff --git a/apps/backend/src/config/swagger.ts b/apps/backend/src/config/swagger.ts
new file mode 100644
index 000000000..83ab0748c
--- /dev/null
+++ b/apps/backend/src/config/swagger.ts
@@ -0,0 +1,169 @@
+import swaggerJsdoc from 'swagger-jsdoc'
+import swaggerUi from 'swagger-ui-express'
+import { env } from './index.js'
+
+const options: swaggerJsdoc.Options = {
+ definition: {
+ openapi: '3.0.0',
+ info: {
+ title: 'Blend Studio API',
+ version: '0.1.0',
+ description:
+ 'Production-ready Backend API for Blend Token Studio with Google OAuth, RBAC, Teams, and Token Management',
+ contact: {
+ name: 'Blend Team',
+ url: 'https://github.com/juspay/blend-design-system',
+ },
+ license: {
+ name: 'Apache-2.0',
+ url: 'https://opensource.org/licenses/Apache-2.0',
+ },
+ },
+ servers: [
+ {
+ url: `http://localhost:${env.PORT}`,
+ description: 'Local Development',
+ },
+ {
+ url: 'https://api.blend.example.com',
+ description: 'Production (configure via FRONTEND_URL env)',
+ },
+ ],
+ components: {
+ securitySchemes: {
+ bearerAuth: {
+ type: 'http',
+ scheme: 'bearer',
+ bearerFormat: 'JWT',
+ description:
+ 'Enter your JWT token in the format: Bearer ',
+ },
+ },
+ schemas: {
+ Error: {
+ type: 'object',
+ properties: {
+ success: {
+ type: 'boolean',
+ example: false,
+ },
+ error: {
+ type: 'object',
+ properties: {
+ code: {
+ type: 'string',
+ },
+ message: {
+ type: 'string',
+ },
+ },
+ },
+ },
+ },
+ User: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ format: 'uuid',
+ },
+ email: {
+ type: 'string',
+ format: 'email',
+ },
+ displayName: {
+ type: 'string',
+ nullable: true,
+ },
+ photoUrl: {
+ type: 'string',
+ nullable: true,
+ },
+ role: {
+ type: 'string',
+ enum: ['viewer', 'editor', 'admin', 'superadmin'],
+ },
+ },
+ },
+ Branch: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ format: 'uuid',
+ },
+ brandId: {
+ type: 'string',
+ },
+ name: {
+ type: 'string',
+ },
+ parentBranch: {
+ type: 'string',
+ nullable: true,
+ },
+ status: {
+ type: 'string',
+ enum: ['draft', 'published'],
+ },
+ brandConfig: {
+ type: 'object',
+ properties: {
+ brandId: { type: 'string' },
+ name: { type: 'string' },
+ version: { type: 'string' },
+ colors: { type: 'object' },
+ radius: { type: 'object' },
+ },
+ },
+ createdBy: {
+ type: 'string',
+ },
+ createdAt: {
+ type: 'string',
+ format: 'date-time',
+ },
+ updatedAt: {
+ type: 'string',
+ format: 'date-time',
+ },
+ publishedVersions: {
+ type: 'integer',
+ },
+ },
+ },
+ },
+ },
+ tags: [
+ {
+ name: 'Health',
+ description: 'Health check endpoints',
+ },
+ {
+ name: 'Authentication',
+ description: 'Authentication and authorization endpoints',
+ },
+ {
+ name: 'Branches',
+ description: 'Token branch management and version control',
+ },
+ ],
+ },
+ apis: ['./src/**/*routes.ts', './src/**/*controller.ts', './src/server.ts'],
+}
+
+import type { Handler, RequestHandler } from 'express'
+
+export const swaggerSpec = swaggerJsdoc(options)
+export const swaggerUiHandler: Handler[] = swaggerUi.serve
+export const swaggerUiSetup: RequestHandler = swaggerUi.setup(swaggerSpec, {
+ explorer: true,
+ customCss: '.swagger-ui .topbar { display: none }',
+ customSiteTitle: 'Blend Studio API Docs',
+ swaggerOptions: {
+ persistAuthorization: true,
+ docExpansion: 'list',
+ filter: true,
+ showRequestDuration: true,
+ },
+})
diff --git a/apps/backend/src/domains/apikeys/data-access/apikey.repository.ts b/apps/backend/src/domains/apikeys/data-access/apikey.repository.ts
new file mode 100644
index 000000000..44be39b17
--- /dev/null
+++ b/apps/backend/src/domains/apikeys/data-access/apikey.repository.ts
@@ -0,0 +1,130 @@
+import crypto from 'crypto'
+import { prisma } from '@/config/database.js'
+import { logger } from '@/utils/logger.js'
+
+export interface ApiKeyRow {
+ id: string
+ organizationId: string
+ userId: string
+ name: string
+ keyHash: string
+ keyPrefix: string
+ lastUsedAt: Date | null
+ expiresAt: Date | null
+ createdAt: Date
+ revokedAt: Date | null
+}
+
+const KEY_PREFIX = 'bts_'
+const KEY_BYTES = 32
+
+export const generateRawKey = (): string => {
+ return KEY_PREFIX + crypto.randomBytes(KEY_BYTES).toString('hex')
+}
+
+export const hashKey = (rawKey: string): string => {
+ return crypto.createHash('sha256').update(rawKey).digest('hex')
+}
+
+export const createApiKey = async (data: {
+ organizationId: string
+ userId: string
+ name: string
+ expiresAt?: Date
+}): Promise<{ apiKey: ApiKeyRow; rawKey: string }> => {
+ const rawKey = generateRawKey()
+ const keyHash = hashKey(rawKey)
+ const keyPrefix = rawKey.substring(0, 8)
+
+ const apiKey = await prisma.apiKey.create({
+ data: {
+ organizationId: data.organizationId,
+ userId: data.userId,
+ name: data.name,
+ keyHash,
+ keyPrefix,
+ expiresAt: data.expiresAt || null,
+ },
+ })
+
+ logger.info({ apiKeyId: apiKey.id, prefix: keyPrefix }, 'API key created')
+
+ return {
+ apiKey: apiKey as unknown as ApiKeyRow,
+ rawKey,
+ }
+}
+
+export const findApiKeyByHash = async (
+ keyHash: string
+): Promise => {
+ const apiKey = await prisma.apiKey.findUnique({
+ where: { keyHash },
+ })
+ return apiKey as unknown as ApiKeyRow | null
+}
+
+export const validateApiKey = async (
+ rawKey: string
+): Promise => {
+ const keyHash = hashKey(rawKey)
+ const apiKey = await findApiKeyByHash(keyHash)
+
+ if (!apiKey) return null
+ if (apiKey.revokedAt) return null
+ if (apiKey.expiresAt && apiKey.expiresAt < new Date()) return null
+
+ await prisma.apiKey.update({
+ where: { id: apiKey.id },
+ data: { lastUsedAt: new Date() },
+ })
+
+ return apiKey
+}
+
+export const revokeApiKey = async (
+ id: string,
+ userId: string
+): Promise => {
+ const apiKey = await prisma.apiKey.findFirst({
+ where: { id, userId, revokedAt: null },
+ })
+
+ if (!apiKey) return null
+
+ const revoked = await prisma.apiKey.update({
+ where: { id },
+ data: { revokedAt: new Date() },
+ })
+
+ logger.info({ apiKeyId: id }, 'API key revoked')
+ return revoked as unknown as ApiKeyRow
+}
+
+export const listApiKeys = async (
+ userId: string,
+ options: { organizationId?: string } = {}
+): Promise => {
+ const where: any = { userId }
+ if (options.organizationId) {
+ where.organizationId = options.organizationId
+ }
+
+ const keys = await prisma.apiKey.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ select: {
+ id: true,
+ organizationId: true,
+ userId: true,
+ name: true,
+ keyPrefix: true,
+ lastUsedAt: true,
+ expiresAt: true,
+ createdAt: true,
+ revokedAt: true,
+ },
+ })
+
+ return keys as unknown as ApiKeyRow[]
+}
diff --git a/apps/backend/src/domains/apikeys/entry-points/apikey.routes.ts b/apps/backend/src/domains/apikeys/entry-points/apikey.routes.ts
new file mode 100644
index 000000000..817799219
--- /dev/null
+++ b/apps/backend/src/domains/apikeys/entry-points/apikey.routes.ts
@@ -0,0 +1,96 @@
+import { Router, type IRouter, type Request, type Response } from 'express'
+import { authenticate } from '@/middlewares/auth.js'
+import { asyncHandler } from '@/middlewares/errorHandler.js'
+import { validate, createApiKeySchema } from '@/middlewares/validate.js'
+import * as apiKeyRepo from '../data-access/apikey.repository.js'
+import * as auditLogRepo from '@/domains/audit/data-access/auditlog.repository.js'
+
+const router: IRouter = Router()
+
+router.post(
+ '/',
+ authenticate,
+ validate({ body: createApiKeySchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const { apiKey, rawKey } = await apiKeyRepo.createApiKey({
+ organizationId: req.body.organizationId,
+ userId: req.user!.id,
+ name: req.body.name,
+ expiresAt: req.body.expiresAt
+ ? new Date(req.body.expiresAt)
+ : undefined,
+ })
+
+ await auditLogRepo.createAuditLog({
+ organizationId: req.body.organizationId,
+ action: 'api_key_created',
+ actorId: req.user!.id,
+ actorEmail: req.user!.email,
+ targetType: 'api_key',
+ targetId: apiKey.id,
+ metadata: { name: req.body.name, prefix: apiKey.keyPrefix },
+ })
+
+ res.status(201).json({
+ success: true,
+ data: {
+ apiKey: {
+ id: apiKey.id,
+ name: apiKey.name,
+ keyPrefix: apiKey.keyPrefix,
+ expiresAt: apiKey.expiresAt,
+ createdAt: apiKey.createdAt,
+ },
+ rawKey,
+ },
+ })
+ })
+)
+
+router.get(
+ '/',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const keys = await apiKeyRepo.listApiKeys(req.user!.id, {
+ organizationId: req.query.organizationId as string,
+ })
+ res.json({ success: true, data: { apiKeys: keys } })
+ })
+)
+
+router.delete(
+ '/:id',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const revoked = await apiKeyRepo.revokeApiKey(
+ req.params.id,
+ req.user!.id
+ )
+ if (!revoked) {
+ res.status(404).json({
+ success: false,
+ error: {
+ code: 'NOT_FOUND',
+ message: 'API key not found or already revoked',
+ },
+ })
+ return
+ }
+
+ await auditLogRepo.createAuditLog({
+ organizationId: revoked.organizationId,
+ action: 'api_key_revoked',
+ actorId: req.user!.id,
+ actorEmail: req.user!.email,
+ targetType: 'api_key',
+ targetId: revoked.id,
+ metadata: {
+ name: revoked.name,
+ prefix: revoked.keyPrefix,
+ },
+ })
+ res.json({ success: true, message: 'API key revoked' })
+ })
+)
+
+export default router
diff --git a/apps/backend/src/domains/audit/data-access/auditlog.repository.ts b/apps/backend/src/domains/audit/data-access/auditlog.repository.ts
new file mode 100644
index 000000000..a1a479d7f
--- /dev/null
+++ b/apps/backend/src/domains/audit/data-access/auditlog.repository.ts
@@ -0,0 +1,301 @@
+import { prisma } from '@/config/database.js'
+import { logger } from '@/utils/logger.js'
+import { hashPII } from '@/utils/crypto.js'
+
+// ===========================================================================
+// Audit Actions β every mutation tracked, enum enforced at DB level
+// ===========================================================================
+
+export type AuditAction =
+ | 'branch_created'
+ | 'branch_updated'
+ | 'branch_deleted'
+ | 'branch_published'
+ | 'branch_archived'
+ | 'branch_forked'
+ | 'version_created'
+ | 'snapshot_created'
+ | 'snapshot_restored'
+ | 'token_uploaded'
+ | 'user_created'
+ | 'user_role_changed'
+ | 'user_deactivated'
+ | 'api_key_created'
+ | 'api_key_revoked'
+ | 'member_invited'
+ | 'member_removed'
+ | 'token_locked'
+ | 'token_unlocked'
+ | 'merge_request_created'
+ | 'merge_request_approved'
+ | 'merge_request_rejected'
+ | 'merge_request_merged'
+
+// ===========================================================================
+// Structured Metadata β typed per action so callers can't pass random shapes
+// ===========================================================================
+
+export interface BranchCreatedMeta {
+ name: string
+ brandId: string
+ visibility: string
+}
+
+export interface BranchUpdatedMeta {
+ fieldsChanged: string[]
+ previousValues?: Record
+}
+
+export interface BranchDeletedMeta {
+ name: string
+ brandId: string
+ softDelete: true
+}
+
+export interface BranchPublishedMeta {
+ version: string
+ isBreaking: boolean
+ isPrerelease: boolean
+}
+
+export interface BranchArchivedMeta {
+ previousStatus: string
+}
+
+export interface BranchForkedMeta {
+ sourceBranchId: string
+ sourceBranchName: string
+}
+
+export interface VersionCreatedMeta {
+ version: string
+ isBreaking: boolean
+ isPrerelease: boolean
+}
+
+export interface SnapshotCreatedMeta {
+ label: string | null
+ isAutoSave: boolean
+}
+
+export interface SnapshotRestoredMeta {
+ snapshotId: string
+ snapshotLabel: string | null
+}
+
+export interface TokenUploadedMeta {
+ fileName: string
+ fileSize: number
+ status: string
+}
+
+export interface UserCreatedMeta {
+ emailHash: string
+ role: string
+ provider: string
+}
+
+export interface UserRoleChangedMeta {
+ previousRole: string
+ newRole: string
+ changedBy: string
+}
+
+export interface UserDeactivatedMeta {
+ previousRole: string
+}
+
+export interface ApiKeyCreatedMeta {
+ name: string
+ prefix: string
+ expiresAt: string | null
+}
+
+export interface ApiKeyRevokedMeta {
+ name: string
+ prefix: string
+}
+
+export interface MemberInvitedMeta {
+ emailHash: string
+ role: string
+}
+
+export interface MemberRemovedMeta {
+ emailHash: string
+ previousRole: string
+}
+
+export interface TokenLockedMeta {
+ tokenPath: string
+ reason: string | null
+}
+
+export interface TokenUnlockedMeta {
+ tokenPath: string
+}
+
+export interface MergeRequestCreatedMeta {
+ sourceBranchId: string
+ sourceBranchName: string
+ targetBranchId: string
+ targetBranchName: string
+}
+
+export interface MergeRequestReviewedMeta {
+ reviewComment: string | null
+}
+
+export interface MergeRequestMergedMeta {
+ sourceBranchId: string
+ targetBranchId: string
+}
+
+export type AuditMetadata =
+ | BranchCreatedMeta
+ | BranchUpdatedMeta
+ | BranchDeletedMeta
+ | BranchPublishedMeta
+ | BranchArchivedMeta
+ | BranchForkedMeta
+ | VersionCreatedMeta
+ | SnapshotCreatedMeta
+ | SnapshotRestoredMeta
+ | TokenUploadedMeta
+ | UserCreatedMeta
+ | UserRoleChangedMeta
+ | UserDeactivatedMeta
+ | ApiKeyCreatedMeta
+ | ApiKeyRevokedMeta
+ | MemberInvitedMeta
+ | MemberRemovedMeta
+ | TokenLockedMeta
+ | TokenUnlockedMeta
+ | MergeRequestCreatedMeta
+ | MergeRequestReviewedMeta
+ | MergeRequestMergedMeta
+
+// ===========================================================================
+// Target types β constrained set of entities that can be audit-logged
+// ===========================================================================
+
+export type AuditTargetType =
+ | 'branch'
+ | 'version'
+ | 'snapshot'
+ | 'token_upload'
+ | 'user'
+ | 'api_key'
+ | 'member'
+ | 'organization'
+ | 'token_lock'
+ | 'merge_request'
+
+// ===========================================================================
+// Create input β typed, no `any` leaks
+// ===========================================================================
+
+export interface CreateAuditLogInput {
+ organizationId: string
+ action: AuditAction
+ actorId: string
+ actorEmail: string
+ targetType: AuditTargetType
+ targetId: string
+ metadata: AuditMetadata
+}
+
+export interface AuditLogRow {
+ id: string
+ organizationId: string
+ action: AuditAction
+ actorId: string
+ actorEmailHash: string
+ targetType: string
+ targetId: string
+ metadata: AuditMetadata | null
+ createdAt: Date
+}
+
+// ===========================================================================
+// Row shape from DB
+// ===========================================================================
+
+export interface AuditLogRow {
+ id: string
+ organizationId: string
+ action: AuditAction
+ actorId: string
+ actorEmail: string
+ targetType: string
+ targetId: string
+ metadata: AuditMetadata | null
+ createdAt: Date
+}
+
+// ===========================================================================
+// Repository functions
+// ===========================================================================
+
+export const createAuditLog = async (
+ data: CreateAuditLogInput
+): Promise => {
+ const emailHash = data.actorEmail ? hashPII(data.actorEmail) : ''
+
+ const log = await prisma.auditLog.create({
+ data: {
+ organizationId: data.organizationId,
+ action: data.action as any,
+ actorId: data.actorId,
+ actorEmailHash: emailHash,
+ targetType: data.targetType,
+ targetId: data.targetId,
+ metadata: data.metadata as any,
+ },
+ })
+
+ logger.debug(
+ {
+ auditLogId: log.id,
+ action: data.action,
+ targetType: data.targetType,
+ },
+ 'Audit log created'
+ )
+ return log as unknown as AuditLogRow
+}
+
+export const listAuditLogs = async (options: {
+ organizationId: string
+ action?: AuditAction
+ targetType?: AuditTargetType
+ targetId?: string
+ actorId?: string
+ limit?: number
+ cursor?: string
+}): Promise<{ logs: AuditLogRow[]; nextCursor?: string }> => {
+ const limit = options.limit || 50
+
+ const where: any = {
+ organizationId: options.organizationId,
+ }
+ if (options.action) where.action = options.action
+ if (options.targetType) where.targetType = options.targetType
+ if (options.targetId) where.targetId = options.targetId
+ if (options.actorId) where.actorId = options.actorId
+ if (options.cursor) where.id = { lt: options.cursor }
+
+ const logs = await prisma.auditLog.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ take: limit + 1,
+ })
+
+ let nextCursor: string | undefined
+ if (logs.length > limit) {
+ nextCursor = logs[limit - 1].id
+ logs.pop()
+ }
+
+ return { logs: logs as unknown as AuditLogRow[], nextCursor }
+}
diff --git a/apps/backend/src/domains/auth/data-access/auth.repository.ts b/apps/backend/src/domains/auth/data-access/auth.repository.ts
new file mode 100644
index 000000000..d8b1f6356
--- /dev/null
+++ b/apps/backend/src/domains/auth/data-access/auth.repository.ts
@@ -0,0 +1,25 @@
+import {
+ findUserByEmail,
+ findUserByGoogleId,
+ findUserById,
+ createUser,
+ updateUserLogin,
+ storeRefreshToken,
+ findRefreshToken,
+ revokeRefreshToken,
+ revokeAllUserRefreshTokens,
+ cleanupExpiredTokens,
+} from '@/domains/users/data-access/user.repository.js'
+
+export {
+ findUserByEmail,
+ findUserByGoogleId,
+ findUserById,
+ createUser as findOrCreateUser,
+ updateUserLogin,
+ storeRefreshToken,
+ findRefreshToken,
+ revokeRefreshToken,
+ revokeAllUserRefreshTokens,
+ cleanupExpiredTokens,
+}
diff --git a/apps/backend/src/domains/auth/domain/auth.service.ts b/apps/backend/src/domains/auth/domain/auth.service.ts
new file mode 100644
index 000000000..ce703b074
--- /dev/null
+++ b/apps/backend/src/domains/auth/domain/auth.service.ts
@@ -0,0 +1,124 @@
+import { OAuth2Client } from 'google-auth-library'
+import jwt from 'jsonwebtoken'
+import crypto from 'crypto'
+import { env } from '@/config/index.js'
+import { UnauthorizedError } from '@/errors/AppError.js'
+import { logger } from '@/utils/logger.js'
+import type {
+ GoogleUserInfo,
+ JwtPayload,
+ TokenPayload,
+ AuthTokens,
+} from './auth.types.js'
+
+const oauth2Client = new OAuth2Client(
+ env.GOOGLE_CLIENT_ID,
+ env.GOOGLE_CLIENT_SECRET,
+ env.GOOGLE_REDIRECT_URI
+)
+
+export const generateAuthUrl = (): string => {
+ const scopes = [
+ 'https://www.googleapis.com/auth/userinfo.profile',
+ 'https://www.googleapis.com/auth/userinfo.email',
+ ]
+
+ return oauth2Client.generateAuthUrl({
+ access_type: 'offline',
+ scope: scopes,
+ prompt: 'consent',
+ })
+}
+
+export const exchangeCodeForTokens = async (
+ code: string
+): Promise => {
+ try {
+ const { tokens } = await oauth2Client.getToken(code)
+ oauth2Client.setCredentials(tokens)
+
+ const ticket = await oauth2Client.verifyIdToken({
+ idToken: tokens.id_token!,
+ audience: env.GOOGLE_CLIENT_ID,
+ })
+
+ const payload = ticket.getPayload()
+ if (!payload) {
+ throw new UnauthorizedError('Invalid Google token')
+ }
+
+ return {
+ googleId: payload.sub,
+ email: payload.email!,
+ displayName: payload.name,
+ photoUrl: payload.picture,
+ }
+ } catch (error) {
+ logger.error(error, 'Failed to exchange Google code')
+ throw new UnauthorizedError('Failed to authenticate with Google')
+ }
+}
+
+export const generateAccessToken = (payload: TokenPayload): string => {
+ const tokenPayload: JwtPayload = {
+ ...payload,
+ type: 'access',
+ }
+
+ return jwt.sign(tokenPayload, env.JWT_SECRET, {
+ expiresIn: (env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
+ })
+}
+
+export const generateRefreshToken = (payload: TokenPayload): string => {
+ const tokenPayload: JwtPayload = {
+ ...payload,
+ type: 'refresh',
+ }
+
+ return jwt.sign(tokenPayload, env.JWT_SECRET, {
+ expiresIn: (env.JWT_REFRESH_EXPIRES_IN ||
+ '30d') as jwt.SignOptions['expiresIn'],
+ })
+}
+
+/**
+ * Short-lived JWT for pasting into the Blend CLI (`login --token`).
+ * Narrower blast radius than exporting the long-lived browser access token.
+ */
+export const generateCliExportToken = (payload: TokenPayload): string => {
+ const tokenPayload: JwtPayload = {
+ ...payload,
+ type: 'cli_export',
+ }
+
+ return jwt.sign(tokenPayload, env.JWT_SECRET, {
+ expiresIn:
+ env.JWT_CLI_EXPORT_EXPIRES_IN as jwt.SignOptions['expiresIn'],
+ })
+}
+
+export const verifyJwtToken = (token: string): JwtPayload => {
+ try {
+ return jwt.verify(token, env.JWT_SECRET) as JwtPayload
+ } catch {
+ throw new UnauthorizedError('Invalid or expired token')
+ }
+}
+
+export const generateTokens = (payload: TokenPayload): AuthTokens => {
+ const accessToken = generateAccessToken(payload)
+ const refreshToken = generateRefreshToken(payload)
+
+ const decoded = jwt.decode(accessToken) as { exp: number }
+
+ return {
+ accessToken,
+ refreshToken,
+ expiresIn: decoded.exp,
+ }
+}
+
+export const hashToken = (token: string): string => {
+ return crypto.createHash('sha256').update(token).digest('hex')
+}
diff --git a/apps/backend/src/domains/auth/domain/auth.types.ts b/apps/backend/src/domains/auth/domain/auth.types.ts
new file mode 100644
index 000000000..38285df6d
--- /dev/null
+++ b/apps/backend/src/domains/auth/domain/auth.types.ts
@@ -0,0 +1,44 @@
+export interface GoogleUserInfo {
+ googleId: string
+ email: string
+ displayName?: string
+ photoUrl?: string
+}
+
+export interface AuthTokens {
+ accessToken: string
+ refreshToken: string
+ expiresIn: number
+}
+
+export interface JwtPayload {
+ userId: string
+ email: string
+ role: string
+ /** access / refresh = OAuth session; cli_export = short-lived token for `blend-studio login` */
+ type?: 'access' | 'refresh' | 'cli_export'
+}
+
+export interface TokenPayload {
+ userId: string
+ email: string
+ role: string
+}
+
+export interface AuthResponse {
+ success: boolean
+ data: {
+ user: {
+ id: string
+ email: string
+ displayName?: string | null
+ photoUrl?: string | null
+ role: string
+ }
+ tokens: {
+ accessToken: string
+ refreshToken: string
+ expiresIn: number
+ }
+ }
+}
diff --git a/apps/backend/src/domains/auth/entry-points/auth.controller.ts b/apps/backend/src/domains/auth/entry-points/auth.controller.ts
new file mode 100644
index 000000000..cf0855ec3
--- /dev/null
+++ b/apps/backend/src/domains/auth/entry-points/auth.controller.ts
@@ -0,0 +1,449 @@
+import type { Request, Response } from 'express'
+import jwt from 'jsonwebtoken'
+import { env } from '@/config/index.js'
+import { logger } from '@/utils/logger.js'
+import { maskEmail, hashPII } from '@/utils/crypto.js'
+import { UnauthorizedError, NotFoundError } from '@/errors/AppError.js'
+import {
+ generateAuthUrl,
+ exchangeCodeForTokens,
+ generateTokens,
+ generateCliExportToken,
+ verifyJwtToken,
+ hashToken,
+} from '../domain/auth.service.js'
+import {
+ findUserByEmail,
+ findUserByGoogleId,
+ findUserById,
+ findOrCreateUser,
+ updateUserLogin,
+ storeRefreshToken,
+ findRefreshToken,
+ revokeRefreshToken,
+ revokeAllUserRefreshTokens,
+} from '../data-access/auth.repository.js'
+import * as auditLogRepo from '@/domains/audit/data-access/auditlog.repository.js'
+
+const REFRESH_TOKEN_EXPIRES_DAYS = 30
+const cookieSameSite = env.NODE_ENV === 'production' ? 'none' : 'lax'
+
+export const getGoogleAuthUrl = async (_req: Request, res: Response) => {
+ const url = generateAuthUrl()
+ res.json({ success: true, data: { url } })
+}
+
+export const googleCallback = async (req: Request, res: Response) => {
+ const { code } = req.query as { code: string }
+ const callbackContext = {
+ requestHost: req.get('host'),
+ requestOrigin: req.get('origin'),
+ requestReferer: req.get('referer'),
+ frontendUrl: env.FRONTEND_URL,
+ googleRedirectUri: env.GOOGLE_REDIRECT_URI,
+ }
+
+ if (!code) {
+ logger.warn(
+ { ...callbackContext, reason: 'missing_code' },
+ 'Google callback missing authorization code'
+ )
+ return res.redirect(`${env.FRONTEND_URL}/login?error=no_code`)
+ }
+
+ try {
+ logger.info(
+ {
+ ...callbackContext,
+ codeLength: code.length,
+ },
+ 'Processing Google OAuth callback'
+ )
+ const googleUser = await exchangeCodeForTokens(code)
+ let user = await findUserByGoogleId(googleUser.googleId)
+ let isNewUser = false
+
+ if (!user) {
+ const existingUser = await findUserByEmail(googleUser.email)
+ if (existingUser) {
+ user = existingUser
+ } else {
+ user = await findOrCreateUser({
+ email: googleUser.email,
+ displayName: googleUser.displayName,
+ photoUrl: googleUser.photoUrl,
+ googleId: googleUser.googleId,
+ })
+ isNewUser = true
+ logger.info(
+ { userId: user.id, email: maskEmail(user.email) },
+ 'New user registered'
+ )
+
+ const memberships = await (
+ await import('@/domains/users/data-access/user.repository.js')
+ ).findUserMemberships(user.id)
+ const orgId = memberships?.[0]?.organizationId
+ if (orgId) {
+ await auditLogRepo.createAuditLog({
+ organizationId: orgId,
+ action: 'user_created',
+ actorId: user.id,
+ actorEmail: user.email,
+ targetType: 'user',
+ targetId: user.id,
+ metadata: {
+ emailHash: hashPII(user.email),
+ role: user.role,
+ provider: 'google',
+ },
+ })
+ }
+ }
+ } else {
+ const updated = await updateUserLogin(user.id)
+ if (updated) user = updated
+ }
+
+ if (!user) {
+ return res.redirect(
+ `${env.FRONTEND_URL}/login?error=user_creation_failed`
+ )
+ }
+
+ const tokens = generateTokens({
+ userId: user.id,
+ email: user.email,
+ role: user.role,
+ })
+
+ const refreshTokenHash = hashToken(tokens.refreshToken)
+ const expiresAt = new Date()
+ expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_EXPIRES_DAYS)
+ await storeRefreshToken(user.id, refreshTokenHash, expiresAt)
+
+ res.cookie('refreshToken', tokens.refreshToken, {
+ httpOnly: true,
+ secure: env.NODE_ENV === 'production',
+ sameSite: cookieSameSite,
+ maxAge: REFRESH_TOKEN_EXPIRES_DAYS * 24 * 60 * 60 * 1000,
+ path: '/api/auth',
+ })
+
+ // Set access token as httpOnly cookie for secure browser requests.
+ // The redirect still passes the token in the URL for the frontend
+ // auth-callback page to do the initial setup, but subsequent requests
+ // use the cookie automatically via credentials: 'include'.
+ const accessTokenMaxDays = 7
+ res.cookie('accessToken', tokens.accessToken, {
+ httpOnly: true,
+ secure: env.NODE_ENV === 'production',
+ sameSite: cookieSameSite,
+ maxAge: accessTokenMaxDays * 24 * 60 * 60 * 1000,
+ path: '/api',
+ })
+
+ logger.info(
+ {
+ ...callbackContext,
+ userId: user.id,
+ email: maskEmail(user.email),
+ isNewUser,
+ },
+ 'Google OAuth callback completed successfully'
+ )
+ res.redirect(`${env.FRONTEND_URL}/auth-callback?newUser=${isNewUser}`)
+ } catch (error: any) {
+ logger.error(
+ {
+ ...callbackContext,
+ error: error.message || error,
+ stack: error.stack,
+ name: error.name,
+ },
+ 'Google auth callback failed'
+ )
+ res.redirect(`${env.FRONTEND_URL}/login?error=auth_failed`)
+ }
+}
+
+export const refreshAccessToken = async (req: Request, res: Response) => {
+ const refreshToken = req.cookies?.refreshToken || req.body?.refreshToken
+
+ if (!refreshToken) {
+ throw new UnauthorizedError('Refresh token required')
+ }
+
+ const tokenHash = hashToken(refreshToken)
+ const storedToken = await findRefreshToken(tokenHash)
+
+ if (!storedToken || storedToken.expiresAt < new Date()) {
+ throw new UnauthorizedError('Invalid refresh token')
+ }
+
+ try {
+ const decoded = verifyJwtToken(refreshToken)
+
+ if (decoded.type !== 'refresh') {
+ throw new UnauthorizedError('Invalid refresh token')
+ }
+
+ const newTokens = generateTokens({
+ userId: decoded.userId,
+ email: decoded.email,
+ role: decoded.role,
+ })
+
+ await revokeRefreshToken(tokenHash)
+
+ const newRefreshTokenHash = hashToken(newTokens.refreshToken)
+
+ const rawRefresh = jwt.decode(newTokens.refreshToken) as {
+ exp?: number
+ } | null
+ const refreshExpSec = rawRefresh?.exp
+ const refreshExpMs =
+ typeof refreshExpSec === 'number' ? refreshExpSec * 1000 : null
+
+ if (!refreshExpMs) {
+ throw new UnauthorizedError('Invalid refresh token')
+ }
+
+ await storeRefreshToken(
+ decoded.userId,
+ newRefreshTokenHash,
+ new Date(refreshExpMs)
+ )
+
+ res.cookie('refreshToken', newTokens.refreshToken, {
+ httpOnly: true,
+ secure: env.NODE_ENV === 'production',
+ sameSite: cookieSameSite,
+ maxAge: REFRESH_TOKEN_EXPIRES_DAYS * 24 * 60 * 60 * 1000,
+ path: '/api/auth',
+ })
+
+ res.cookie('accessToken', newTokens.accessToken, {
+ httpOnly: true,
+ secure: env.NODE_ENV === 'production',
+ sameSite: cookieSameSite,
+ maxAge: 7 * 24 * 60 * 60 * 1000,
+ path: '/api',
+ })
+
+ res.json({
+ success: true,
+ data: {
+ accessToken: newTokens.accessToken,
+ expiresAt: (() => {
+ const rawAccess = jwt.decode(newTokens.accessToken) as {
+ exp?: number
+ } | null
+ const accessExpSec = rawAccess?.exp
+ return typeof accessExpSec === 'number'
+ ? accessExpSec * 1000
+ : null
+ })(),
+ // Remaining seconds to expiry (computed from exp).
+ expiresInSeconds: (() => {
+ const rawAccess = jwt.decode(newTokens.accessToken) as {
+ exp?: number
+ } | null
+ const accessExpSec = rawAccess?.exp
+ return typeof accessExpSec === 'number'
+ ? Math.max(
+ 0,
+ accessExpSec - Math.floor(Date.now() / 1000)
+ )
+ : null
+ })(),
+ // Return new refresh token so CLI can keep refreshing.
+ refreshToken: newTokens.refreshToken,
+ refreshExpiresAt: refreshExpMs,
+ },
+ })
+ } catch {
+ await revokeRefreshToken(tokenHash)
+ throw new UnauthorizedError('Invalid refresh token')
+ }
+}
+
+export const logout = async (req: Request, res: Response) => {
+ const refreshToken = req.cookies?.refreshToken
+
+ if (refreshToken) {
+ const tokenHash = hashToken(refreshToken)
+ await revokeRefreshToken(tokenHash)
+
+ res.clearCookie('refreshToken', {
+ httpOnly: true,
+ secure: env.NODE_ENV === 'production',
+ sameSite: cookieSameSite,
+ path: '/api/auth',
+ })
+ }
+
+ res.clearCookie('accessToken', {
+ httpOnly: true,
+ secure: env.NODE_ENV === 'production',
+ sameSite: cookieSameSite,
+ path: '/api',
+ })
+
+ res.json({ success: true, message: 'Logged out successfully' })
+}
+
+export const logoutAllDevices = async (req: Request, res: Response) => {
+ const userId = (req as any).user?.id
+
+ if (userId) {
+ await revokeAllUserRefreshTokens(userId)
+ }
+
+ res.clearCookie('refreshToken', {
+ httpOnly: true,
+ secure: env.NODE_ENV === 'production',
+ sameSite: cookieSameSite,
+ path: '/api/auth',
+ })
+
+ res.clearCookie('accessToken', {
+ httpOnly: true,
+ secure: env.NODE_ENV === 'production',
+ sameSite: cookieSameSite,
+ path: '/api',
+ })
+
+ res.json({ success: true, message: 'Logged out from all devices' })
+}
+
+/**
+ * Mint a **short-lived CLI-only** JWT for `blend-studio login --token`.
+ *
+ * Requires a valid browser session (`authenticate`). Does **not** return the long-lived
+ * httpOnly access cookie; instead signs a new JWT with `type: cli_export` and TTL
+ * `JWT_CLI_EXPORT_EXPIRES_IN` (default **10 minutes**).
+ *
+ * **Security:** Narrower blast radius than exporting the 7-day access token. XSS on the
+ * Studio origin can still exfiltrate a freshly minted tokenβuse CSP and dependency hygiene.
+ */
+export const getCliAccessToken = async (req: Request, res: Response) => {
+ const user = (req as any).user
+
+ if (!user?.id || !user?.email) {
+ throw new UnauthorizedError('User not authenticated')
+ }
+
+ // Short-lived token (10m) intended for `blend-studio login --token`.
+ const token = generateCliExportToken({
+ userId: user.id,
+ email: user.email,
+ role: user.role,
+ })
+
+ const rawCli = jwt.decode(token) as { exp?: number } | null
+ const cliExpSec = rawCli?.exp
+ const cliExpMs = typeof cliExpSec === 'number' ? cliExpSec * 1000 : null
+ const cliExpiresInSeconds =
+ typeof cliExpSec === 'number'
+ ? Math.max(0, cliExpSec - Math.floor(Date.now() / 1000))
+ : null
+
+ // Long-lived refresh token (days) for CLI auto-refresh.
+ const tokens = generateTokens({
+ userId: user.id,
+ email: user.email,
+ role: user.role,
+ })
+
+ const rawRefresh = jwt.decode(tokens.refreshToken) as {
+ exp?: number
+ } | null
+ const refreshExpSec = rawRefresh?.exp
+ const refreshExpMs =
+ typeof refreshExpSec === 'number' ? refreshExpSec * 1000 : null
+ const refreshExpiresInSeconds =
+ typeof refreshExpSec === 'number'
+ ? Math.max(0, refreshExpSec - Math.floor(Date.now() / 1000))
+ : null
+
+ // Persist refresh token so `/api/auth/refresh` can validate it.
+ if (refreshExpMs) {
+ const refreshTokenHash = hashToken(tokens.refreshToken)
+ await storeRefreshToken(
+ user.id,
+ refreshTokenHash,
+ new Date(refreshExpMs)
+ )
+ }
+
+ const rawAccess = jwt.decode(tokens.accessToken) as { exp?: number } | null
+ const accessExpSec = rawAccess?.exp
+ const accessExpMs =
+ typeof accessExpSec === 'number' ? accessExpSec * 1000 : null
+ const accessExpiresInSeconds =
+ typeof accessExpSec === 'number'
+ ? Math.max(0, accessExpSec - Math.floor(Date.now() / 1000))
+ : null
+
+ res.setHeader(
+ 'Cache-Control',
+ 'no-store, no-cache, must-revalidate, private'
+ )
+ res.setHeader('Pragma', 'no-cache')
+
+ res.json({
+ success: true,
+ data: {
+ token,
+ expiresAt: cliExpMs,
+ expiresInSeconds: cliExpiresInSeconds,
+ tokenType: 'cli_export' as const,
+
+ // Extra fields for CLI auto-refresh (Option 2).
+ accessToken: tokens.accessToken,
+ accessExpiresAt: accessExpMs,
+ accessExpiresInSeconds: accessExpiresInSeconds,
+
+ refreshToken: tokens.refreshToken,
+ refreshExpiresAt: refreshExpMs,
+ refreshExpiresInSeconds,
+ },
+ })
+}
+
+export const getCurrentUser = async (req: Request, res: Response) => {
+ const userId = (req as any).user?.id
+
+ if (!userId) {
+ throw new UnauthorizedError('User not authenticated')
+ }
+
+ const user = await findUserById(userId)
+
+ if (!user) {
+ throw new NotFoundError('User')
+ }
+
+ const { findUserMemberships } =
+ await import('@/domains/users/data-access/user.repository.js')
+ const memberships = await findUserMemberships(userId)
+
+ res.json({
+ success: true,
+ data: {
+ user: {
+ id: user.id,
+ email: user.email,
+ displayName: user.displayName,
+ photoUrl: user.photoUrl,
+ role: user.role,
+ isActive: user.isActive,
+ organizations: memberships.map((m: any) => ({
+ organizationId: m.organizationId,
+ role: m.role,
+ })),
+ },
+ },
+ })
+}
diff --git a/apps/backend/src/domains/auth/entry-points/auth.routes.ts b/apps/backend/src/domains/auth/entry-points/auth.routes.ts
new file mode 100644
index 000000000..c78205021
--- /dev/null
+++ b/apps/backend/src/domains/auth/entry-points/auth.routes.ts
@@ -0,0 +1,181 @@
+import { Router, type IRouter } from 'express'
+import { authenticate } from '@/middlewares/auth.js'
+import { asyncHandler } from '@/middlewares/errorHandler.js'
+import {
+ getGoogleAuthUrl,
+ googleCallback,
+ refreshAccessToken,
+ logout,
+ logoutAllDevices,
+ getCliAccessToken,
+ getCurrentUser,
+} from './auth.controller.js'
+
+const router: IRouter = Router()
+
+/**
+ * @openapi
+ * /api/auth/google:
+ * get:
+ * summary: Initiate Google OAuth login
+ * description: Returns the Google OAuth URL for authentication
+ * tags:
+ * - Authentication
+ * responses:
+ * 200:
+ * description: Google OAuth URL
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * data:
+ * type: object
+ * properties:
+ * url:
+ * type: string
+ * description: Google OAuth authorization URL
+ */
+router.get('/google', asyncHandler(getGoogleAuthUrl))
+
+/**
+ * @openapi
+ * /api/auth/google/callback:
+ * get:
+ * summary: Google OAuth callback
+ * description: Handles the OAuth callback from Google and creates/logs in the user
+ * tags:
+ * - Authentication
+ * parameters:
+ * - in: query
+ * name: code
+ * required: true
+ * schema:
+ * type: string
+ * description: Authorization code from Google
+ * responses:
+ * 302:
+ * description: Redirects to frontend with JWT token
+ * 400:
+ * description: Missing or invalid authorization code
+ */
+router.get('/google/callback', asyncHandler(googleCallback))
+
+/**
+ * @openapi
+ * /api/auth/refresh:
+ * post:
+ * summary: Refresh access token
+ * description: Uses refresh token to generate a new access token
+ * tags:
+ * - Authentication
+ * responses:
+ * 200:
+ * description: New access token generated
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * data:
+ * type: object
+ * properties:
+ * accessToken:
+ * type: string
+ * expiresIn:
+ * type: number
+ * 401:
+ * description: Invalid or expired refresh token
+ */
+router.post('/refresh', asyncHandler(refreshAccessToken))
+
+/**
+ * @openapi
+ * /api/auth/logout:
+ * post:
+ * summary: Logout user
+ * description: Revokes the current refresh token
+ * tags:
+ * - Authentication
+ * security:
+ * - bearerAuth: []
+ * responses:
+ * 200:
+ * description: Successfully logged out
+ * 401:
+ * description: Unauthorized
+ */
+router.post('/logout', authenticate, asyncHandler(logout))
+
+/**
+ * @openapi
+ * /api/auth/logout-all:
+ * post:
+ * summary: Logout from all devices
+ * description: Revokes all refresh tokens for the user
+ * tags:
+ * - Authentication
+ * security:
+ * - bearerAuth: []
+ * responses:
+ * 200:
+ * description: Successfully logged out from all devices
+ * 401:
+ * description: Unauthorized
+ */
+router.post('/logout-all', authenticate, asyncHandler(logoutAllDevices))
+
+/**
+ * @openapi
+ * /api/auth/me:
+ * get:
+ * summary: Get current user profile
+ * description: Returns the authenticated user's information
+ * tags:
+ * - Authentication
+ * security:
+ * - bearerAuth: []
+ * responses:
+ * 200:
+ * description: User profile retrieved successfully
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * data:
+ * type: object
+ * properties:
+ * user:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * email:
+ * type: string
+ * displayName:
+ * type: string
+ * photoUrl:
+ * type: string
+ * role:
+ * type: string
+ * teams:
+ * type: array
+ * 401:
+ * description: Unauthorized - Invalid or missing token
+ */
+router.get('/me', authenticate, asyncHandler(getCurrentUser))
+
+/**
+ * CLI: exposes current access JWT when the user has a valid browser session (cookie).
+ * POST avoids treating this as a cacheable GET; client must send credentials (cookies).
+ */
+router.post('/cli-token', authenticate, asyncHandler(getCliAccessToken))
+
+export default router
diff --git a/apps/backend/src/domains/branches/data-access/branch.repository.ts b/apps/backend/src/domains/branches/data-access/branch.repository.ts
new file mode 100644
index 000000000..5009face8
--- /dev/null
+++ b/apps/backend/src/domains/branches/data-access/branch.repository.ts
@@ -0,0 +1,342 @@
+import { prisma } from '@/config/database.js'
+import { logger } from '@/utils/logger.js'
+import type {
+ BrandConfig,
+ TagRow,
+ BranchStatus,
+ BranchVisibility,
+} from '../domain/branch.types.js'
+
+export interface BranchRow {
+ id: string
+ organizationId: string | null
+ brandId: string
+ name: string
+ description: string | null
+ parentBranchId: string | null
+ status: BranchStatus
+ visibility: BranchVisibility
+ brandConfig: BrandConfig
+ publishedVersions: number
+ latestVersion: string | null
+ createdBy: string
+ createdByName: string
+ createdAt: Date
+ updatedAt: Date
+ deletedAt: Date | null
+ tags?: TagRow[]
+}
+
+export interface BranchVersionRow {
+ id: string
+ branchId: string
+ version: string
+ brandConfig: BrandConfig
+ changelog: string | null
+ isBreaking: boolean
+ isPrerelease: boolean
+ publishedBy: string
+ publishedByName: string
+ publishedAt: Date
+}
+
+export interface BranchSnapshotRow {
+ id: string
+ branchId: string
+ brandConfig: BrandConfig
+ label: string | null
+ isAutoSave: boolean
+ savedBy: string
+ savedByName: string
+ savedAt: Date
+}
+
+export const createBranch = async (
+ data: Omit<
+ BranchRow,
+ | 'id'
+ | 'createdAt'
+ | 'updatedAt'
+ | 'publishedVersions'
+ | 'latestVersion'
+ | 'deletedAt'
+ | 'tags'
+ > & { organizationId: string | null }
+): Promise => {
+ const branch = await prisma.branch.create({
+ data: {
+ organizationId: data.organizationId,
+ brandId: data.brandId,
+ name: data.name,
+ description: data.description,
+ parentBranchId: data.parentBranchId,
+ status: data.status || 'draft',
+ visibility: data.visibility || 'private',
+ brandConfig: data.brandConfig as any,
+ createdBy: data.createdBy,
+ createdByName: data.createdByName,
+ },
+ })
+
+ logger.info({ branchId: branch.id }, 'Branch created')
+ return branch as unknown as BranchRow
+}
+
+export const getBranchById = async (
+ branchId: string
+): Promise => {
+ const branch = await prisma.branch.findUnique({
+ where: { id: branchId, deletedAt: null },
+ include: { tags: { include: { tag: true } } },
+ })
+
+ if (!branch) return null
+
+ return {
+ ...(branch as any),
+ tags: branch.tags?.map((bt: any) => bt.tag) ?? [],
+ } as unknown as BranchRow
+}
+
+export const listBranches = async (
+ options: {
+ organizationId?: string
+ limit?: number
+ cursor?: string
+ createdBy?: string
+ status?: string
+ visibility?: string
+ search?: string
+ tag?: string
+ } = {}
+): Promise<{ branches: BranchRow[]; nextCursor?: string }> => {
+ const limit = options.limit || 20
+
+ const where: any = { deletedAt: null }
+
+ if (options.organizationId) where.organizationId = options.organizationId
+ if (options.createdBy) where.createdBy = options.createdBy
+ if (options.status) where.status = options.status
+ if (options.visibility) where.visibility = options.visibility
+ if (options.search) {
+ where.OR = [
+ { name: { contains: options.search, mode: 'insensitive' } },
+ { brandId: { contains: options.search, mode: 'insensitive' } },
+ { description: { contains: options.search, mode: 'insensitive' } },
+ ]
+ }
+ if (options.tag) {
+ where.tags = { some: { tag: { name: options.tag } } }
+ }
+ if (options.cursor) {
+ where.id = { lt: options.cursor }
+ }
+
+ const branches = await prisma.branch.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ take: limit + 1,
+ include: { tags: { include: { tag: true } } },
+ })
+
+ let nextCursor: string | undefined
+ if (branches.length > limit) {
+ nextCursor = branches[limit - 1].id
+ branches.pop()
+ }
+
+ return {
+ branches: branches.map((b: any) => ({
+ ...b,
+ tags: b.tags?.map((bt: any) => bt.tag) ?? [],
+ })) as unknown as BranchRow[],
+ nextCursor,
+ }
+}
+
+export const updateBranch = async (
+ branchId: string,
+ updates: Partial<
+ Pick<
+ BranchRow,
+ | 'name'
+ | 'description'
+ | 'brandConfig'
+ | 'status'
+ | 'visibility'
+ | 'latestVersion'
+ >
+ >
+): Promise => {
+ const branch = await prisma.branch.update({
+ where: { id: branchId },
+ data: updates as any,
+ })
+
+ return branch ? (branch as unknown as BranchRow) : null
+}
+
+export const softDeleteBranch = async (branchId: string): Promise => {
+ await prisma.branch.update({
+ where: { id: branchId },
+ data: { deletedAt: new Date() },
+ })
+ logger.info({ branchId }, 'Branch soft-deleted')
+ return true
+}
+
+export const forkBranch = async (
+ sourceBranchId: string,
+ data: {
+ name: string
+ brandId: string
+ organizationId: string | null
+ createdBy: string
+ createdByName: string
+ description?: string
+ visibility?: BranchVisibility
+ }
+): Promise => {
+ const source = await getBranchById(sourceBranchId)
+ if (!source) return null
+
+ return createBranch({
+ organizationId: data.organizationId,
+ brandId: data.brandId,
+ name: data.name,
+ description: data.description || null,
+ parentBranchId: sourceBranchId,
+ status: 'draft',
+ visibility: data.visibility || 'private',
+ brandConfig: source.brandConfig,
+ createdBy: data.createdBy,
+ createdByName: data.createdByName,
+ })
+}
+
+export const addTagToBranch = async (
+ branchId: string,
+ tagId: string
+): Promise => {
+ await prisma.branchTag.upsert({
+ where: { branchId_tagId: { branchId, tagId } },
+ update: {},
+ create: { branchId, tagId },
+ })
+}
+
+export const removeTagFromBranch = async (
+ branchId: string,
+ tagId: string
+): Promise => {
+ await prisma.branchTag.deleteMany({
+ where: { branchId, tagId },
+ })
+}
+
+export const createVersion = async (
+ branchId: string,
+ data: Omit
+): Promise => {
+ const version = await prisma.branchVersion.create({
+ data: {
+ branchId,
+ version: data.version,
+ brandConfig: data.brandConfig as any,
+ changelog: data.changelog,
+ isBreaking: data.isBreaking,
+ isPrerelease: data.isPrerelease,
+ publishedBy: data.publishedBy,
+ publishedByName: data.publishedByName,
+ },
+ })
+
+ await prisma.branch.update({
+ where: { id: branchId },
+ data: {
+ status: 'published',
+ publishedVersions: { increment: 1 },
+ latestVersion: data.version,
+ },
+ })
+
+ logger.info({ branchId, version: data.version }, 'Branch published')
+ return version as unknown as BranchVersionRow
+}
+
+export const listVersions = async (
+ branchId: string,
+ limit: number = 20
+): Promise => {
+ const versions = await prisma.branchVersion.findMany({
+ where: { branchId },
+ orderBy: { publishedAt: 'desc' },
+ take: limit,
+ })
+
+ return versions as unknown as BranchVersionRow[]
+}
+
+export const getVersion = async (
+ branchId: string,
+ versionId: string
+): Promise => {
+ const version = await prisma.branchVersion.findFirst({
+ where: { id: versionId, branchId },
+ })
+
+ return version ? (version as unknown as BranchVersionRow) : null
+}
+
+export const getVersionByNumber = async (
+ branchId: string,
+ versionNumber: string
+): Promise => {
+ const version = await prisma.branchVersion.findUnique({
+ where: { branchId_version: { branchId, version: versionNumber } },
+ })
+
+ return version ? (version as unknown as BranchVersionRow) : null
+}
+
+export const createSnapshot = async (
+ branchId: string,
+ data: Omit
+): Promise => {
+ const snapshot = await prisma.branchSnapshot.create({
+ data: {
+ branchId,
+ brandConfig: data.brandConfig as any,
+ label: data.label,
+ isAutoSave: data.isAutoSave,
+ savedBy: data.savedBy,
+ savedByName: data.savedByName,
+ },
+ })
+
+ return snapshot as unknown as BranchSnapshotRow
+}
+
+export const listSnapshots = async (
+ branchId: string,
+ limit: number = 20
+): Promise => {
+ const snapshots = await prisma.branchSnapshot.findMany({
+ where: { branchId },
+ orderBy: { savedAt: 'desc' },
+ take: limit,
+ })
+
+ return snapshots as unknown as BranchSnapshotRow[]
+}
+
+export const getLatestSnapshot = async (
+ branchId: string
+): Promise => {
+ const snapshot = await prisma.branchSnapshot.findFirst({
+ where: { branchId },
+ orderBy: { savedAt: 'desc' },
+ })
+
+ return snapshot ? (snapshot as unknown as BranchSnapshotRow) : null
+}
diff --git a/apps/backend/src/domains/branches/domain/branch.service.ts b/apps/backend/src/domains/branches/domain/branch.service.ts
new file mode 100644
index 000000000..40dc9c444
--- /dev/null
+++ b/apps/backend/src/domains/branches/domain/branch.service.ts
@@ -0,0 +1,598 @@
+import {
+ NotFoundError,
+ ValidationError,
+ ForbiddenError,
+} from '@/errors/AppError.js'
+import type {
+ Branch,
+ CreateBranchDTO,
+ UpdateBranchDTO,
+ PublishBranchDTO,
+ BrandConfig,
+ BranchVersion,
+} from './branch.types.js'
+import * as branchRepo from '../data-access/branch.repository.js'
+import * as auditLogRepo from '@/domains/audit/data-access/auditlog.repository.js'
+import * as tagRepo from '@/domains/tags/data-access/tag.repository.js'
+import * as userRepo from '@/domains/users/data-access/user.repository.js'
+import * as lockRepo from '@/domains/locks/data-access/lock.repository.js'
+import * as orgRepo from '@/domains/organizations/data-access/organization.repository.js'
+import {
+ resolveWithInheritance,
+ validateAgainstLocks,
+ type TokenLockEntry,
+} from './inheritance.js'
+
+function deepMergeRecords(
+ base: Record,
+ override: Record
+): Record {
+ const result: Record = { ...base }
+ for (const [key, value] of Object.entries(override)) {
+ const baseValue = result[key]
+ if (
+ value &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ baseValue &&
+ typeof baseValue === 'object' &&
+ !Array.isArray(baseValue)
+ ) {
+ result[key] = deepMergeRecords(
+ baseValue as Record,
+ value as Record
+ )
+ } else {
+ result[key] = value
+ }
+ }
+ return result
+}
+
+function mergeBrandConfig(
+ base: BrandConfig,
+ override?: Partial
+): BrandConfig {
+ if (!override) {
+ return base
+ }
+
+ return {
+ ...base,
+ ...override,
+ colors: {
+ ...(base.colors ?? {}),
+ ...(override.colors ?? {}),
+ },
+ radius: {
+ ...(base.radius ?? {}),
+ ...(override.radius ?? {}),
+ },
+ shadows: {
+ ...(base.shadows ?? {}),
+ ...(override.shadows ?? {}),
+ },
+ font: {
+ ...(base.font ?? {}),
+ ...(override.font ?? {}),
+ ...(base.font?.weight || override.font?.weight
+ ? {
+ weight: {
+ ...(base.font?.weight ?? {}),
+ ...(override.font?.weight ?? {}),
+ },
+ }
+ : {}),
+ },
+ componentOverrides: deepMergeRecords(
+ (base.componentOverrides ?? {}) as Record,
+ (override.componentOverrides ?? {}) as Record
+ ),
+ darkModeOverrides:
+ base.darkModeOverrides || override.darkModeOverrides
+ ? {
+ colors: deepMergeRecords(
+ (base.darkModeOverrides?.colors ?? {}) as Record<
+ string,
+ unknown
+ >,
+ (override.darkModeOverrides?.colors ?? {}) as Record<
+ string,
+ unknown
+ >
+ ),
+ radius: {
+ ...(base.darkModeOverrides?.radius ?? {}),
+ ...(override.darkModeOverrides?.radius ?? {}),
+ },
+ shadows: {
+ ...(base.darkModeOverrides?.shadows ?? {}),
+ ...(override.darkModeOverrides?.shadows ?? {}),
+ },
+ font: {
+ ...(base.darkModeOverrides?.font ?? {}),
+ ...(override.darkModeOverrides?.font ?? {}),
+ ...(base.darkModeOverrides?.font?.weight ||
+ override.darkModeOverrides?.font?.weight
+ ? {
+ weight: {
+ ...(base.darkModeOverrides?.font
+ ?.weight ?? {}),
+ ...(override.darkModeOverrides?.font
+ ?.weight ?? {}),
+ },
+ }
+ : {}),
+ },
+ }
+ : undefined,
+ }
+}
+
+const validateLocksAgainstParent = (
+ parentConfig: BrandConfig,
+ candidateConfig: BrandConfig,
+ lockedPaths: TokenLockEntry[]
+) => {
+ const violations = validateAgainstLocks(
+ parentConfig,
+ candidateConfig,
+ lockedPaths
+ )
+
+ if (violations.length > 0) {
+ throw new ValidationError(
+ `Token lock violations: ${violations
+ .map(
+ (violation) =>
+ `${violation.path} (${violation.reason || 'locked by org'})`
+ )
+ .join(', ')}`
+ )
+ }
+}
+
+const getOrgInheritanceContext = async (branch: Branch) => {
+ if (!branch.organizationId) {
+ return null
+ }
+
+ const organization = await orgRepo.getOrganizationById(
+ branch.organizationId
+ )
+ const resolvedParentBranchId =
+ branch.parentBranchId || organization?.defaultBranchId || null
+ if (!resolvedParentBranchId) {
+ return null
+ }
+
+ if (resolvedParentBranchId === branch.id) {
+ return null
+ }
+
+ const parentBranch = await branchRepo.getBranchById(resolvedParentBranchId)
+ if (!parentBranch) {
+ return null
+ }
+
+ const locks = await lockRepo.listLocks(branch.organizationId)
+ const lockedPaths: TokenLockEntry[] = locks.map((lock) => ({
+ path: lock.tokenPath,
+ reason: lock.reason || undefined,
+ }))
+
+ return { parentBranch, lockedPaths }
+}
+
+const getOrgParentContext = async (
+ organizationId: string,
+ parentBranchId?: string
+) => {
+ const organization = await orgRepo.getOrganizationById(organizationId)
+ const resolvedParentBranchId =
+ parentBranchId || organization?.defaultBranchId
+ if (!resolvedParentBranchId) {
+ return null
+ }
+
+ const parentBranch = await branchRepo.getBranchById(resolvedParentBranchId)
+ if (!parentBranch) {
+ throw new NotFoundError('Parent branch')
+ }
+
+ const locks = await lockRepo.listLocks(organizationId)
+ const lockedPaths: TokenLockEntry[] = locks.map((lock) => ({
+ path: lock.tokenPath,
+ reason: lock.reason || undefined,
+ }))
+
+ return {
+ parentBranchId: resolvedParentBranchId,
+ parentBranch,
+ lockedPaths,
+ }
+}
+
+export const resolveEffectiveBrandConfig = async (
+ branch: Branch,
+ baseConfig: BrandConfig
+): Promise => {
+ const inheritanceContext = await getOrgInheritanceContext(branch)
+ if (!inheritanceContext) {
+ return baseConfig
+ }
+
+ const inheritanceResult = resolveWithInheritance(
+ inheritanceContext.parentBranch.brandConfig,
+ baseConfig,
+ inheritanceContext.lockedPaths
+ )
+
+ return inheritanceResult.mergedConfig
+}
+
+export const createBranch = async (
+ dto: CreateBranchDTO,
+ userId: string,
+ userName: string,
+ userEmail: string,
+ organizationId?: string | null
+): Promise => {
+ if (!dto.name?.trim()) {
+ throw new ValidationError('Branch name is required')
+ }
+
+ let orgId: string | null = organizationId || dto.organizationId || null
+ if (!orgId) {
+ const membership = await userRepo.findUserMembership(userId)
+ orgId = membership?.organizationId || null
+ }
+
+ const brandId = dto.brandId || dto.name.toLowerCase().replace(/\s+/g, '-')
+
+ const defaultConfig: BrandConfig = {
+ brandId,
+ name: dto.name,
+ version: '1.0.0',
+ colors: {
+ primary: {
+ '50': '#EFF6FF',
+ '100': '#DBEAFE',
+ '200': '#BFDBFE',
+ '300': '#93C5FD',
+ '400': '#60A5FA',
+ '500': '#3B82F6',
+ '600': '#2563EB',
+ '700': '#1D4ED8',
+ '800': '#1E40AF',
+ '900': '#1E3A8A',
+ '950': '#172554',
+ },
+ },
+ radius: {
+ '6': '6px',
+ '8': '8px',
+ '10': '10px',
+ '12': '12px',
+ },
+ }
+
+ const branchConfig = mergeBrandConfig(defaultConfig, dto.brandConfig)
+ let resolvedParentBranchId = dto.parentBranchId || null
+
+ if (orgId) {
+ const parentContext = await getOrgParentContext(
+ orgId,
+ dto.parentBranchId
+ )
+ if (parentContext) {
+ resolvedParentBranchId = parentContext.parentBranchId
+ if (parentContext.lockedPaths.length > 0) {
+ validateLocksAgainstParent(
+ parentContext.parentBranch.brandConfig,
+ branchConfig,
+ parentContext.lockedPaths
+ )
+ }
+ }
+ }
+
+ const branch = await branchRepo.createBranch({
+ organizationId: orgId,
+ brandId,
+ name: dto.name,
+ description: dto.description || null,
+ parentBranchId: resolvedParentBranchId,
+ status: 'draft',
+ visibility: dto.visibility || 'private',
+ brandConfig: branchConfig,
+ createdBy: userId,
+ createdByName: userName,
+ })
+
+ if (dto.tags && dto.tags.length > 0) {
+ for (const tagName of dto.tags) {
+ const tag = await tagRepo.createTag(tagName)
+ await branchRepo.addTagToBranch(branch.id, tag.id)
+ }
+ }
+
+ if (orgId) {
+ await auditLogRepo.createAuditLog({
+ organizationId: orgId,
+ action: 'branch_created',
+ actorId: userId,
+ actorEmail: userEmail,
+ targetType: 'branch',
+ targetId: branch.id,
+ metadata: {
+ name: dto.name,
+ brandId,
+ visibility: dto.visibility || 'private',
+ },
+ })
+ }
+
+ return branch as unknown as Branch
+}
+
+export const getBranch = async (branchId: string): Promise => {
+ const branch = await branchRepo.getBranchById(branchId)
+ if (!branch) {
+ throw new NotFoundError('Branch')
+ }
+ return branch as unknown as Branch
+}
+
+export const listBranches = async (
+ options: {
+ organizationId?: string
+ limit?: number
+ cursor?: string
+ createdBy?: string
+ status?: string
+ visibility?: string
+ search?: string
+ tag?: string
+ } = {}
+) => {
+ return branchRepo.listBranches(options)
+}
+
+export const updateBranch = async (
+ branchId: string,
+ dto: UpdateBranchDTO,
+ userId: string,
+ userEmail: string
+): Promise => {
+ const branch = await getBranch(branchId)
+
+ if (branch.createdBy !== userId) {
+ throw new ForbiddenError('Only the creator can update this branch')
+ }
+
+ const mergedBrandConfig = dto.brandConfig
+ ? mergeBrandConfig(branch.brandConfig, dto.brandConfig)
+ : undefined
+
+ // Validate against org token locks when brandConfig changes
+ if (dto.brandConfig && branch.organizationId) {
+ const inheritanceContext = await getOrgInheritanceContext(branch)
+ if (inheritanceContext && inheritanceContext.lockedPaths.length > 0) {
+ validateLocksAgainstParent(
+ inheritanceContext.parentBranch.brandConfig,
+ mergedBrandConfig!,
+ inheritanceContext.lockedPaths
+ )
+ }
+ }
+
+ const updates: any = {}
+
+ if (dto.name) updates.name = dto.name
+ if (dto.description !== undefined) updates.description = dto.description
+ if (dto.visibility) updates.visibility = dto.visibility
+
+ if (mergedBrandConfig) {
+ updates.brandConfig = mergedBrandConfig
+ }
+
+ const previousValues: Record = {}
+ const fieldsChanged = Object.keys(dto)
+ if (dto.name) previousValues.name = branch.name
+ if (dto.description !== undefined)
+ previousValues.description = branch.description
+ if (dto.visibility) previousValues.visibility = branch.visibility
+
+ const updated = await branchRepo.updateBranch(branchId, updates)
+ if (!updated) {
+ throw new NotFoundError('Branch')
+ }
+
+ if (branch.organizationId) {
+ await auditLogRepo.createAuditLog({
+ organizationId: branch.organizationId,
+ action: 'branch_updated',
+ actorId: userId,
+ actorEmail: userEmail,
+ targetType: 'branch',
+ targetId: branchId,
+ metadata: {
+ fieldsChanged,
+ previousValues,
+ },
+ })
+ }
+
+ return updated as unknown as Branch
+}
+
+export const deleteBranch = async (
+ branchId: string,
+ userId: string,
+ userEmail: string
+): Promise => {
+ const branch = await getBranch(branchId)
+
+ if (branch.createdBy !== userId) {
+ throw new ForbiddenError('Only the creator can delete this branch')
+ }
+
+ await branchRepo.softDeleteBranch(branchId)
+
+ if (branch.organizationId) {
+ await auditLogRepo.createAuditLog({
+ organizationId: branch.organizationId,
+ action: 'branch_deleted',
+ actorId: userId,
+ actorEmail: userEmail,
+ targetType: 'branch',
+ targetId: branchId,
+ metadata: {
+ name: branch.name,
+ brandId: branch.brandId,
+ softDelete: true,
+ },
+ })
+ }
+}
+
+export const forkBranch = async (
+ sourceBranchId: string,
+ newName: string,
+ userId: string,
+ userName: string,
+ userEmail: string,
+ organizationId?: string | null
+): Promise => {
+ if (!newName?.trim()) {
+ throw new ValidationError('New branch name is required')
+ }
+
+ const source = await branchRepo.getBranchById(sourceBranchId)
+ if (!source) {
+ throw new NotFoundError('Source branch')
+ }
+
+ let orgId: string | null = organizationId || null
+ if (!orgId) {
+ const membership = await userRepo.findUserMembership(userId)
+ orgId = membership?.organizationId || null
+ }
+
+ const forked = await branchRepo.forkBranch(sourceBranchId, {
+ name: newName,
+ brandId: newName.toLowerCase().replace(/\s+/g, '-'),
+ organizationId: orgId,
+ createdBy: userId,
+ createdByName: userName,
+ })
+ if (!forked) {
+ throw new NotFoundError('Source branch')
+ }
+
+ if (orgId) {
+ await auditLogRepo.createAuditLog({
+ organizationId: orgId,
+ action: 'branch_forked',
+ actorId: userId,
+ actorEmail: userEmail,
+ targetType: 'branch',
+ targetId: forked.id,
+ metadata: {
+ sourceBranchId,
+ sourceBranchName: source.name,
+ },
+ })
+ }
+
+ return forked as unknown as Branch
+}
+
+export const publishBranch = async (
+ branchId: string,
+ dto: PublishBranchDTO,
+ userId: string,
+ userName: string,
+ userEmail: string
+): Promise => {
+ const branch = await getBranch(branchId)
+
+ if (branch.createdBy !== userId) {
+ throw new ForbiddenError('Only the creator can publish this branch')
+ }
+
+ if (!dto.version?.match(/^\d+\.\d+\.\d+$/)) {
+ throw new ValidationError(
+ 'Version must be in format: x.x.x (e.g., 1.0.0)'
+ )
+ }
+
+ const version = await branchRepo.createVersion(branchId, {
+ version: dto.version,
+ brandConfig: branch.brandConfig,
+ changelog: dto.changelog || null,
+ isBreaking: dto.isBreaking || false,
+ isPrerelease: dto.isPrerelease || false,
+ publishedBy: userId,
+ publishedByName: userName,
+ })
+
+ if (branch.organizationId) {
+ await auditLogRepo.createAuditLog({
+ organizationId: branch.organizationId,
+ action: 'branch_published',
+ actorId: userId,
+ actorEmail: userEmail,
+ targetType: 'branch',
+ targetId: branchId,
+ metadata: {
+ version: dto.version,
+ isBreaking: dto.isBreaking || false,
+ isPrerelease: dto.isPrerelease || false,
+ },
+ })
+ }
+
+ return version as unknown as BranchVersion
+}
+
+export const listVersions = async (branchId: string) => {
+ await getBranch(branchId)
+ return branchRepo.listVersions(branchId)
+}
+
+export const resolveTokens = async (
+ branchId: string,
+ theme: 'light' | 'dark' = 'light'
+): Promise<{ branch: Branch; theme: string; brandConfig: BrandConfig }> => {
+ const branch = await getBranch(branchId)
+ const effectiveBrandConfig = await resolveEffectiveBrandConfig(
+ branch,
+ branch.brandConfig
+ )
+
+ return {
+ branch,
+ theme,
+ brandConfig: effectiveBrandConfig,
+ }
+}
+
+export const addTag = async (
+ branchId: string,
+ tagId: string,
+ _userId: string
+): Promise => {
+ await getBranch(branchId)
+ await branchRepo.addTagToBranch(branchId, tagId)
+}
+
+export const removeTag = async (
+ branchId: string,
+ tagId: string,
+ _userId: string
+): Promise => {
+ await getBranch(branchId)
+ await branchRepo.removeTagFromBranch(branchId, tagId)
+}
diff --git a/apps/backend/src/domains/branches/domain/branch.types.ts b/apps/backend/src/domains/branches/domain/branch.types.ts
new file mode 100644
index 000000000..0722b0660
--- /dev/null
+++ b/apps/backend/src/domains/branches/domain/branch.types.ts
@@ -0,0 +1,117 @@
+export type BranchStatus = 'draft' | 'published' | 'archived'
+export type BranchVisibility = 'private' | 'team' | 'public'
+
+export interface BrandConfig {
+ brandId: string
+ name: string
+ version: string
+ colors?: {
+ primary?: Record
+ gray?: Record
+ red?: Record
+ green?: Record
+ yellow?: Record
+ orange?: Record
+ purple?: Record
+ }
+ radius?: Record
+ shadows?: Record
+ font?: {
+ family?: string
+ weight?: Record
+ }
+ componentOverrides?: Record
+ darkModeOverrides?: {
+ colors?: Record
+ radius?: Record
+ shadows?: Record
+ font?: {
+ family?: string
+ weight?: Record
+ }
+ }
+}
+
+export interface Branch {
+ id: string
+ organizationId: string | null
+ brandId: string
+ name: string
+ description: string | null
+ parentBranchId: string | null
+ status: BranchStatus
+ visibility: BranchVisibility
+ brandConfig: BrandConfig
+ publishedVersions: number
+ latestVersion: string | null
+ createdBy: string
+ createdByName: string
+ createdAt: Date
+ updatedAt: Date
+ deletedAt: Date | null
+ tags?: TagRow[]
+}
+
+export interface BranchVersion {
+ id: string
+ branchId: string
+ version: string
+ brandConfig: BrandConfig
+ changelog: string | null
+ isBreaking: boolean
+ isPrerelease: boolean
+ publishedBy: string
+ publishedByName: string
+ publishedAt: Date
+}
+
+export interface BranchSnapshot {
+ id: string
+ branchId: string
+ brandConfig: BrandConfig
+ label: string | null
+ isAutoSave: boolean
+ savedBy: string
+ savedByName: string
+ savedAt: Date
+}
+
+export interface TagRow {
+ id: string
+ name: string
+}
+
+export interface CreateBranchDTO {
+ name: string
+ brandId?: string
+ description?: string
+ parentBranchId?: string
+ brandConfig?: Partial
+ visibility?: BranchVisibility
+ tags?: string[]
+ organizationId?: string
+}
+
+export interface UpdateBranchDTO {
+ name?: string
+ description?: string
+ brandConfig?: Partial
+ status?: BranchStatus
+ visibility?: BranchVisibility
+}
+
+export interface PublishBranchDTO {
+ version: string
+ changelog?: string
+ isBreaking?: boolean
+ isPrerelease?: boolean
+}
+
+export interface ResolvedTokensResponse {
+ success: boolean
+ data: {
+ branchId: string
+ theme: string
+ componentTokens: unknown
+ }
+}
diff --git a/apps/backend/src/domains/branches/domain/inheritance.ts b/apps/backend/src/domains/branches/domain/inheritance.ts
new file mode 100644
index 000000000..689643992
--- /dev/null
+++ b/apps/backend/src/domains/branches/domain/inheritance.ts
@@ -0,0 +1,195 @@
+import type { BrandConfig } from './branch.types.js'
+
+export interface TokenLockEntry {
+ path: string
+ reason?: string
+}
+
+export interface LockViolation {
+ path: string
+ parentValue: string
+ childValue: string
+ reason?: string
+}
+
+export interface InheritanceResult {
+ mergedConfig: BrandConfig
+ violations: LockViolation[]
+ isClean: boolean
+}
+
+function getByPath(object: unknown, path: string): unknown {
+ return path.split('.').reduce((current, key) => {
+ if (!current || typeof current !== 'object') {
+ return undefined
+ }
+ return (current as Record)[key]
+ }, object)
+}
+
+function setByPath(object: unknown, path: string, value: unknown): void {
+ if (!object || typeof object !== 'object') {
+ return
+ }
+
+ const keys = path.split('.')
+ if (keys.length === 0) return
+
+ let current = object as Record
+
+ for (let index = 0; index < keys.length - 1; index++) {
+ const key = keys[index]
+ const existing = current[key]
+ if (!existing || typeof existing !== 'object') {
+ current[key] = {}
+ }
+ current = current[key] as Record
+ }
+
+ current[keys[keys.length - 1]] = value
+}
+
+function deepMerge>(
+ base: T,
+ override: Partial
+): T {
+ const result: Record = { ...base }
+ for (const [key, value] of Object.entries(override)) {
+ const baseValue = result[key]
+ if (
+ value &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ baseValue &&
+ typeof baseValue === 'object' &&
+ !Array.isArray(baseValue)
+ ) {
+ result[key] = deepMerge(
+ baseValue as Record,
+ value as Partial>
+ )
+ } else {
+ result[key] = value
+ }
+ }
+ return result as T
+}
+
+export function validateAgainstLocks(
+ parentConfig: BrandConfig,
+ childConfig: BrandConfig,
+ lockedPaths: TokenLockEntry[]
+): LockViolation[] {
+ return lockedPaths.reduce((violations, lock) => {
+ const parentValue = getByPath(parentConfig, lock.path)
+ const childValue = getByPath(childConfig, lock.path)
+ if (childValue !== undefined && childValue !== parentValue) {
+ violations.push({
+ path: lock.path,
+ parentValue: String(parentValue ?? '(default)'),
+ childValue: String(childValue),
+ reason: lock.reason,
+ })
+ }
+ return violations
+ }, [])
+}
+
+export function resolveWithInheritance(
+ parentConfig: BrandConfig,
+ childConfig: BrandConfig,
+ lockedPaths: TokenLockEntry[] = []
+): InheritanceResult {
+ const mergedDarkModeOverrides =
+ parentConfig.darkModeOverrides || childConfig.darkModeOverrides
+ ? {
+ colors: deepMerge(
+ (parentConfig.darkModeOverrides?.colors ?? {}) as Record<
+ string,
+ unknown
+ >,
+ (childConfig.darkModeOverrides?.colors ?? {}) as Record<
+ string,
+ unknown
+ >
+ ) as Record,
+ radius: {
+ ...(parentConfig.darkModeOverrides?.radius ?? {}),
+ ...(childConfig.darkModeOverrides?.radius ?? {}),
+ },
+ shadows: {
+ ...(parentConfig.darkModeOverrides?.shadows ?? {}),
+ ...(childConfig.darkModeOverrides?.shadows ?? {}),
+ },
+ font: {
+ ...(parentConfig.darkModeOverrides?.font ?? {}),
+ ...(childConfig.darkModeOverrides?.font ?? {}),
+ ...(parentConfig.darkModeOverrides?.font?.weight ||
+ childConfig.darkModeOverrides?.font?.weight
+ ? {
+ weight: {
+ ...(parentConfig.darkModeOverrides?.font
+ ?.weight ?? {}),
+ ...(childConfig.darkModeOverrides?.font
+ ?.weight ?? {}),
+ },
+ }
+ : {}),
+ },
+ }
+ : undefined
+
+ const mergedConfig: BrandConfig = {
+ brandId: childConfig.brandId,
+ name: childConfig.name,
+ version: childConfig.version,
+ colors: deepMerge(
+ (parentConfig.colors ?? {}) as Record,
+ (childConfig.colors ?? {}) as Record
+ ) as BrandConfig['colors'],
+ radius: {
+ ...(parentConfig.radius ?? {}),
+ ...(childConfig.radius ?? {}),
+ },
+ shadows: {
+ ...(parentConfig.shadows ?? {}),
+ ...(childConfig.shadows ?? {}),
+ },
+ font: {
+ ...(parentConfig.font ?? {}),
+ ...(childConfig.font ?? {}),
+ ...(parentConfig.font?.weight || childConfig.font?.weight
+ ? {
+ weight: {
+ ...(parentConfig.font?.weight ?? {}),
+ ...(childConfig.font?.weight ?? {}),
+ },
+ }
+ : {}),
+ },
+ componentOverrides: deepMerge(
+ (parentConfig.componentOverrides ?? {}) as Record,
+ (childConfig.componentOverrides ?? {}) as Record
+ ),
+ darkModeOverrides: mergedDarkModeOverrides,
+ }
+
+ const violations = validateAgainstLocks(
+ parentConfig,
+ childConfig,
+ lockedPaths
+ )
+
+ for (const lock of lockedPaths) {
+ const parentValue = getByPath(parentConfig, lock.path)
+ if (parentValue !== undefined) {
+ setByPath(mergedConfig, lock.path, parentValue)
+ }
+ }
+
+ return {
+ mergedConfig,
+ violations,
+ isClean: violations.length === 0,
+ }
+}
diff --git a/apps/backend/src/domains/branches/entry-points/branch.routes.ts b/apps/backend/src/domains/branches/entry-points/branch.routes.ts
new file mode 100644
index 000000000..dc2cfe850
--- /dev/null
+++ b/apps/backend/src/domains/branches/entry-points/branch.routes.ts
@@ -0,0 +1,348 @@
+import { Router, type IRouter, type Request, type Response } from 'express'
+import { authenticate } from '@/middlewares/auth.js'
+import { asyncHandler } from '@/middlewares/errorHandler.js'
+import {
+ validate,
+ createBranchSchema,
+ updateBranchSchema,
+ publishBranchSchema,
+ resolveTokensSchema,
+ createSnapshotSchema,
+ forkBranchSchema,
+} from '@/middlewares/validate.js'
+import * as branchService from '../domain/branch.service.js'
+import * as branchRepo from '../data-access/branch.repository.js'
+import * as auditLogRepo from '@/domains/audit/data-access/auditlog.repository.js'
+
+const router: IRouter = Router()
+
+// ---------------------------------------------------------------------------
+// List Branches
+// ---------------------------------------------------------------------------
+router.get(
+ '/',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const { branches, nextCursor } = await branchService.listBranches({
+ limit: parseInt(req.query.limit as string) || 20,
+ cursor: req.query.cursor as string,
+ createdBy: req.query.createdBy as string,
+ organizationId: req.query.organizationId as string,
+ status: req.query.status as string,
+ visibility: req.query.visibility as string,
+ search: req.query.search as string,
+ tag: req.query.tag as string,
+ })
+
+ res.json({
+ success: true,
+ data: { branches, nextCursor },
+ })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Create Branch
+// ---------------------------------------------------------------------------
+router.post(
+ '/',
+ authenticate,
+ validate({ body: createBranchSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const branch = await branchService.createBranch(
+ req.body,
+ req.user!.id,
+ req.user!.displayName || req.user!.email,
+ req.user!.email,
+ req.user!.organizationId || req.body.organizationId
+ )
+ res.status(201).json({
+ success: true,
+ data: { branch },
+ })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Get Branch
+// ---------------------------------------------------------------------------
+router.get(
+ '/:branchId',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const branch = await branchService.getBranch(req.params.branchId)
+ res.json({
+ success: true,
+ data: { branch },
+ })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Pull Branch (CLI)
+// ---------------------------------------------------------------------------
+router.get(
+ '/:branchId/pull',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const branch = await branchService.getBranch(req.params.branchId)
+
+ const versionParam =
+ typeof req.query.version === 'string'
+ ? req.query.version
+ : undefined
+
+ const version = versionParam
+ ? await branchRepo.getVersionByNumber(branch.id, versionParam)
+ : null
+
+ const baseBrandConfig = version?.brandConfig ?? branch.brandConfig
+ const brandConfig = await branchService.resolveEffectiveBrandConfig(
+ branch,
+ baseBrandConfig
+ )
+
+ res.json({
+ success: true,
+ data: {
+ branch,
+ version: version
+ ? {
+ id: version.id,
+ branchId: version.branchId,
+ version: version.version,
+ changelog: version.changelog,
+ isBreaking: version.isBreaking,
+ isPrerelease: version.isPrerelease,
+ publishedBy: version.publishedBy,
+ publishedByName: version.publishedByName,
+ publishedAt: version.publishedAt,
+ }
+ : null,
+ brandConfig,
+ },
+ })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Update Branch
+// ---------------------------------------------------------------------------
+router.patch(
+ '/:branchId',
+ authenticate,
+ validate({ body: updateBranchSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const branch = await branchService.updateBranch(
+ req.params.branchId,
+ req.body,
+ req.user!.id,
+ req.user!.email
+ )
+ res.json({
+ success: true,
+ data: { branch },
+ })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Delete Branch
+// ---------------------------------------------------------------------------
+router.delete(
+ '/:branchId',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ await branchService.deleteBranch(
+ req.params.branchId,
+ req.user!.id,
+ req.user!.email
+ )
+ res.json({
+ success: true,
+ message: 'Branch deleted successfully',
+ })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Fork Branch
+// ---------------------------------------------------------------------------
+router.post(
+ '/:branchId/fork',
+ authenticate,
+ validate({ body: forkBranchSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const branch = await branchService.forkBranch(
+ req.params.branchId,
+ req.body.name,
+ req.user!.id,
+ req.user!.displayName || req.user!.email,
+ req.user!.email,
+ req.user!.organizationId || req.body.organizationId
+ )
+ res.status(201).json({
+ success: true,
+ data: { branch },
+ })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Publish Branch
+// ---------------------------------------------------------------------------
+router.post(
+ '/:branchId/publish',
+ authenticate,
+ validate({ body: publishBranchSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const version = await branchService.publishBranch(
+ req.params.branchId,
+ req.body,
+ req.user!.id,
+ req.user!.displayName || req.user!.email,
+ req.user!.email
+ )
+ res.json({
+ success: true,
+ data: { version },
+ message: 'Branch published successfully',
+ })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// List Versions
+// ---------------------------------------------------------------------------
+router.get(
+ '/:branchId/versions',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const versions = await branchService.listVersions(req.params.branchId)
+ res.json({
+ success: true,
+ data: { versions },
+ })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Resolve Tokens
+// ---------------------------------------------------------------------------
+router.post(
+ '/:branchId/resolve',
+ authenticate,
+ validate({ body: resolveTokensSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const { branch, theme, brandConfig } =
+ await branchService.resolveTokens(
+ req.params.branchId,
+ req.body.theme || 'light'
+ )
+ res.json({
+ success: true,
+ data: {
+ branchId: branch.id,
+ theme,
+ brandConfig,
+ },
+ })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// List Snapshots
+// ---------------------------------------------------------------------------
+router.get(
+ '/:branchId/snapshots',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const { getBranch } = await import('../domain/branch.service.js')
+ await getBranch(req.params.branchId)
+ const { listSnapshots } =
+ await import('../data-access/branch.repository.js')
+ const snapshots = await listSnapshots(req.params.branchId)
+ res.json({
+ success: true,
+ data: { snapshots },
+ })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Create Snapshot
+// ---------------------------------------------------------------------------
+router.post(
+ '/:branchId/snapshots',
+ authenticate,
+ validate({ body: createSnapshotSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const { getBranch } = await import('../domain/branch.service.js')
+ const branch = await getBranch(req.params.branchId)
+ const { createSnapshot } =
+ await import('../data-access/branch.repository.js')
+ const snapshot = await createSnapshot(req.params.branchId, {
+ brandConfig: req.body.brandConfig || branch.brandConfig,
+ label: req.body.label || null,
+ isAutoSave: req.body.isAutoSave || false,
+ savedBy: req.user!.id,
+ savedByName: req.user!.displayName || req.user!.email,
+ })
+
+ if (branch.organizationId) {
+ await auditLogRepo.createAuditLog({
+ organizationId: branch.organizationId,
+ action: 'snapshot_created',
+ actorId: req.user!.id,
+ actorEmail: req.user!.email,
+ targetType: 'snapshot',
+ targetId: snapshot.id,
+ metadata: {
+ label: req.body.label || null,
+ isAutoSave: req.body.isAutoSave || false,
+ },
+ })
+ }
+ res.status(201).json({
+ success: true,
+ data: { snapshot },
+ })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Add / Remove Tags
+// ---------------------------------------------------------------------------
+router.post(
+ '/:branchId/tags/:tagId',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ await branchService.addTag(
+ req.params.branchId,
+ req.params.tagId,
+ req.user!.id
+ )
+ res.json({
+ success: true,
+ message: 'Tag added to branch',
+ })
+ })
+)
+
+router.delete(
+ '/:branchId/tags/:tagId',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ await branchService.removeTag(
+ req.params.branchId,
+ req.params.tagId,
+ req.user!.id
+ )
+ res.json({
+ success: true,
+ message: 'Tag removed from branch',
+ })
+ })
+)
+
+export default router
diff --git a/apps/backend/src/domains/locks/data-access/lock.repository.ts b/apps/backend/src/domains/locks/data-access/lock.repository.ts
new file mode 100644
index 000000000..e618b7811
--- /dev/null
+++ b/apps/backend/src/domains/locks/data-access/lock.repository.ts
@@ -0,0 +1,105 @@
+import { prisma } from '@/config/database.js'
+import { logger } from '@/utils/logger.js'
+
+export interface TokenLockRow {
+ id: string
+ organizationId: string
+ tokenPath: string
+ reason: string | null
+ lockedBy: string
+ createdAt: Date
+}
+
+export const createLock = async (data: {
+ organizationId: string
+ tokenPath: string
+ reason?: string
+ lockedBy: string
+}): Promise => {
+ const lock = await prisma.tokenLock.upsert({
+ where: {
+ organizationId_tokenPath: {
+ organizationId: data.organizationId,
+ tokenPath: data.tokenPath,
+ },
+ },
+ update: {
+ reason: data.reason || null,
+ lockedBy: data.lockedBy,
+ },
+ create: {
+ organizationId: data.organizationId,
+ tokenPath: data.tokenPath,
+ reason: data.reason || null,
+ lockedBy: data.lockedBy,
+ },
+ })
+ logger.info(
+ { orgId: data.organizationId, path: data.tokenPath },
+ 'Token locked'
+ )
+ return lock as unknown as TokenLockRow
+}
+
+export const listLocks = async (
+ organizationId: string
+): Promise => {
+ const locks = await prisma.tokenLock.findMany({
+ where: { organizationId },
+ orderBy: { tokenPath: 'asc' },
+ })
+ return locks as unknown as TokenLockRow[]
+}
+
+export const deleteLock = async (
+ organizationId: string,
+ tokenPath: string
+): Promise => {
+ const result = await prisma.tokenLock.deleteMany({
+ where: { organizationId, tokenPath },
+ })
+ const deleted = result.count > 0
+ if (deleted) {
+ logger.info(
+ { orgId: organizationId, path: tokenPath },
+ 'Token unlocked'
+ )
+ }
+ return deleted
+}
+
+export const getLocksByOrg = async (
+ organizationId: string
+): Promise => {
+ return listLocks(organizationId)
+}
+
+export const isTokenLocked = async (
+ organizationId: string,
+ tokenPath: string
+): Promise => {
+ const lock = await prisma.tokenLock.findUnique({
+ where: {
+ organizationId_tokenPath: { organizationId, tokenPath },
+ },
+ })
+ return lock !== null
+}
+
+export const bulkCreateLocks = async (
+ organizationId: string,
+ locks: Array<{ tokenPath: string; reason?: string }>,
+ lockedBy: string
+): Promise => {
+ const results: TokenLockRow[] = []
+ for (const lock of locks) {
+ const row = await createLock({
+ organizationId,
+ tokenPath: lock.tokenPath,
+ reason: lock.reason,
+ lockedBy,
+ })
+ results.push(row)
+ }
+ return results
+}
diff --git a/apps/backend/src/domains/locks/domain/lock.service.ts b/apps/backend/src/domains/locks/domain/lock.service.ts
new file mode 100644
index 000000000..4e411c0d0
--- /dev/null
+++ b/apps/backend/src/domains/locks/domain/lock.service.ts
@@ -0,0 +1,104 @@
+import { NotFoundError, ValidationError } from '@/errors/AppError.js'
+import * as lockRepo from '../data-access/lock.repository.js'
+import * as orgRepo from '@/domains/organizations/data-access/organization.repository.js'
+import * as auditLogRepo from '@/domains/audit/data-access/auditlog.repository.js'
+import {
+ validateAgainstLocks,
+ type TokenLockEntry,
+} from '@/domains/branches/domain/inheritance.js'
+import type { BrandConfig } from '@/domains/branches/domain/branch.types.js'
+import { requireOrganizationRole } from '@/domains/organizations/domain/org-permissions.service.js'
+
+export const lockToken = async (
+ organizationId: string,
+ tokenPath: string,
+ reason: string | undefined,
+ userId: string,
+ userEmail: string
+) => {
+ const org = await orgRepo.getOrganizationById(organizationId)
+ if (!org) throw new NotFoundError('Organization')
+ await requireOrganizationRole(
+ organizationId,
+ userId,
+ ['admin'],
+ 'Only admins can lock tokens'
+ )
+
+ if (!tokenPath || tokenPath.trim().length === 0) {
+ throw new ValidationError('Token path is required')
+ }
+
+ const lock = await lockRepo.createLock({
+ organizationId,
+ tokenPath: tokenPath.trim(),
+ reason,
+ lockedBy: userId,
+ })
+
+ await auditLogRepo.createAuditLog({
+ organizationId,
+ action: 'token_locked',
+ actorId: userId,
+ actorEmail: userEmail,
+ targetType: 'token_lock',
+ targetId: lock.id,
+ metadata: { tokenPath, reason: reason || null },
+ })
+
+ return lock
+}
+
+export const unlockToken = async (
+ organizationId: string,
+ tokenPath: string,
+ userId: string,
+ userEmail: string
+) => {
+ await requireOrganizationRole(
+ organizationId,
+ userId,
+ ['admin'],
+ 'Only admins can unlock tokens'
+ )
+ const deleted = await lockRepo.deleteLock(organizationId, tokenPath)
+ if (!deleted) throw new NotFoundError('Token lock')
+
+ await auditLogRepo.createAuditLog({
+ organizationId,
+ action: 'token_unlocked',
+ actorId: userId,
+ actorEmail: userEmail,
+ targetType: 'token_lock',
+ targetId: tokenPath,
+ metadata: { tokenPath },
+ })
+
+ return { tokenPath, unlocked: true }
+}
+
+export const listLocks = async (organizationId: string, userId: string) => {
+ const org = await orgRepo.getOrganizationById(organizationId)
+ if (!org) throw new NotFoundError('Organization')
+ await requireOrganizationRole(
+ organizationId,
+ userId,
+ ['admin', 'editor', 'viewer'],
+ 'Only organization members can view token locks'
+ )
+ return lockRepo.listLocks(organizationId)
+}
+
+export const validateBranchAgainstLocks = async (
+ organizationId: string,
+ brandConfig: BrandConfig,
+ parentConfig: BrandConfig
+) => {
+ const locks = await lockRepo.listLocks(organizationId)
+ const lockedPaths: TokenLockEntry[] = locks.map((l) => ({
+ path: l.tokenPath,
+ reason: l.reason || undefined,
+ }))
+
+ return validateAgainstLocks(parentConfig, brandConfig, lockedPaths)
+}
diff --git a/apps/backend/src/domains/locks/entry-points/lock.routes.ts b/apps/backend/src/domains/locks/entry-points/lock.routes.ts
new file mode 100644
index 000000000..5d41991b7
--- /dev/null
+++ b/apps/backend/src/domains/locks/entry-points/lock.routes.ts
@@ -0,0 +1,69 @@
+import { Router, type IRouter, type Request, type Response } from 'express'
+import { authenticate } from '@/middlewares/auth.js'
+import { asyncHandler } from '@/middlewares/errorHandler.js'
+import { validate } from '@/middlewares/validate.js'
+import { z } from 'zod'
+import * as lockService from '../domain/lock.service.js'
+
+const router: IRouter = Router()
+
+const createLockSchema = z.object({
+ tokenPath: z
+ .string()
+ .min(1, 'Token path is required')
+ .max(500, 'Token path must be 500 characters or fewer'),
+ reason: z.string().max(1000).optional(),
+})
+
+// ---------------------------------------------------------------------------
+// List Token Locks
+// ---------------------------------------------------------------------------
+router.get(
+ '/:organizationId/locks',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const locks = await lockService.listLocks(
+ req.params.organizationId,
+ req.user!.id
+ )
+ res.json({ success: true, data: { locks } })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Lock a Token
+// ---------------------------------------------------------------------------
+router.post(
+ '/:organizationId/locks',
+ authenticate,
+ validate({ body: createLockSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const lock = await lockService.lockToken(
+ req.params.organizationId,
+ req.body.tokenPath,
+ req.body.reason,
+ req.user!.id,
+ req.user!.email
+ )
+ res.status(201).json({ success: true, data: { lock } })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Unlock a Token
+// ---------------------------------------------------------------------------
+router.delete(
+ '/:organizationId/locks/:tokenPath',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const result = await lockService.unlockToken(
+ req.params.organizationId,
+ decodeURIComponent(req.params.tokenPath),
+ req.user!.id,
+ req.user!.email
+ )
+ res.json({ success: true, data: result })
+ })
+)
+
+export default router
diff --git a/apps/backend/src/domains/mergerequests/data-access/merge-request.repository.ts b/apps/backend/src/domains/mergerequests/data-access/merge-request.repository.ts
new file mode 100644
index 000000000..6fe3dba0c
--- /dev/null
+++ b/apps/backend/src/domains/mergerequests/data-access/merge-request.repository.ts
@@ -0,0 +1,144 @@
+import { prisma } from '@/config/database.js'
+import { logger } from '@/utils/logger.js'
+
+export interface MergeRequestRow {
+ id: string
+ organizationId: string
+ sourceBranchId: string
+ sourceBranchName: string
+ targetBranchId: string
+ targetBranchName: string
+ title: string
+ description: string | null
+ status: string
+ diff: any
+ lockViolations: any
+ requestedBy: string
+ reviewedBy: string | null
+ reviewedAt: Date | null
+ reviewComment: string | null
+ mergedAt: Date | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+export const createMergeRequest = async (data: {
+ organizationId: string
+ sourceBranchId: string
+ sourceBranchName: string
+ targetBranchId: string
+ targetBranchName: string
+ title: string
+ description?: string
+ diff: any
+ lockViolations?: any
+ requestedBy: string
+}): Promise => {
+ const mr = await prisma.mergeRequest.create({
+ data: {
+ organizationId: data.organizationId,
+ sourceBranchId: data.sourceBranchId,
+ sourceBranchName: data.sourceBranchName,
+ targetBranchId: data.targetBranchId,
+ targetBranchName: data.targetBranchName,
+ title: data.title,
+ description: data.description || null,
+ diff: data.diff,
+ lockViolations: data.lockViolations || null,
+ requestedBy: data.requestedBy,
+ },
+ })
+ logger.info(
+ { mrId: mr.id, orgId: data.organizationId },
+ 'Merge request created'
+ )
+ return mr as unknown as MergeRequestRow
+}
+
+export const getMergeRequest = async (
+ id: string
+): Promise => {
+ const mr = await prisma.mergeRequest.findUnique({ where: { id } })
+ return mr as unknown as MergeRequestRow | null
+}
+
+export const listMergeRequests = async (
+ options: {
+ organizationId?: string
+ status?: string
+ requestedBy?: string
+ limit?: number
+ cursor?: string
+ } = {}
+): Promise<{ mergeRequests: MergeRequestRow[]; nextCursor?: string }> => {
+ const limit = options.limit || 20
+ const where: any = {}
+
+ if (options.organizationId) where.organizationId = options.organizationId
+ if (options.status) where.status = options.status
+ if (options.requestedBy) where.requestedBy = options.requestedBy
+ if (options.cursor) where.id = { lt: options.cursor }
+
+ const mergeRequests = await prisma.mergeRequest.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ take: limit + 1,
+ })
+
+ let nextCursor: string | undefined
+ if (mergeRequests.length > limit) {
+ nextCursor = mergeRequests[limit - 1].id
+ mergeRequests.pop()
+ }
+
+ return {
+ mergeRequests: mergeRequests as unknown as MergeRequestRow[],
+ nextCursor,
+ }
+}
+
+export const updateMergeRequestStatus = async (
+ id: string,
+ data: {
+ status: string
+ reviewedBy?: string
+ reviewComment?: string
+ }
+): Promise => {
+ const updateData: any = { status: data.status }
+
+ if (data.reviewedBy) updateData.reviewedBy = data.reviewedBy
+ if (data.reviewComment !== undefined)
+ updateData.reviewComment = data.reviewComment
+
+ if (data.status === 'approved' || data.status === 'rejected') {
+ updateData.reviewedAt = new Date()
+ }
+
+ if (data.status === 'merged') {
+ updateData.mergedAt = new Date()
+ updateData.reviewedAt = new Date()
+ }
+
+ const mr = await prisma.mergeRequest.update({
+ where: { id },
+ data: updateData,
+ })
+
+ logger.info(
+ { mrId: id, status: data.status },
+ 'Merge request status updated'
+ )
+ return mr as unknown as MergeRequestRow | null
+}
+
+export const cancelMergeRequest = async (
+ id: string
+): Promise => {
+ const mr = await prisma.mergeRequest.update({
+ where: { id },
+ data: { status: 'cancelled' },
+ })
+ logger.info({ mrId: id }, 'Merge request cancelled')
+ return mr as unknown as MergeRequestRow | null
+}
diff --git a/apps/backend/src/domains/mergerequests/domain/merge-request.service.ts b/apps/backend/src/domains/mergerequests/domain/merge-request.service.ts
new file mode 100644
index 000000000..33c22b1b0
--- /dev/null
+++ b/apps/backend/src/domains/mergerequests/domain/merge-request.service.ts
@@ -0,0 +1,418 @@
+import {
+ NotFoundError,
+ ValidationError,
+ ForbiddenError,
+} from '@/errors/AppError.js'
+import * as mrRepo from '../data-access/merge-request.repository.js'
+import * as branchRepo from '@/domains/branches/data-access/branch.repository.js'
+import * as lockService from '@/domains/locks/domain/lock.service.js'
+import * as auditLogRepo from '@/domains/audit/data-access/auditlog.repository.js'
+import * as orgRepo from '@/domains/organizations/data-access/organization.repository.js'
+import type { BrandConfig } from '@/domains/branches/domain/branch.types.js'
+import {
+ requireOrganizationMember,
+ requireOrganizationRole,
+} from '@/domains/organizations/domain/org-permissions.service.js'
+
+function diffBrandConfigs(
+ configA: BrandConfig,
+ configB: BrandConfig
+): Array<{ path: string; oldValue: string; newValue: string }> {
+ const diffs: Array<{ path: string; oldValue: string; newValue: string }> =
+ []
+
+ function diffObj(
+ prefix: string,
+ a: Record | undefined,
+ b: Record | undefined
+ ) {
+ const aObj = a ?? {}
+ const bObj = b ?? {}
+ const allKeys = new Set([...Object.keys(aObj), ...Object.keys(bObj)])
+
+ for (const key of allKeys) {
+ const path = prefix ? `${prefix}.${key}` : key
+ const oldVal = aObj[key]
+ const newVal = bObj[key]
+
+ if (
+ oldVal &&
+ typeof oldVal === 'object' &&
+ !Array.isArray(oldVal) &&
+ newVal &&
+ typeof newVal === 'object' &&
+ !Array.isArray(newVal)
+ ) {
+ diffObj(path, oldVal, newVal)
+ } else if (oldVal !== newVal) {
+ diffs.push({
+ path,
+ oldValue: oldVal ?? '(default)',
+ newValue: newVal ?? '(default)',
+ })
+ }
+ }
+ }
+
+ diffObj('colors', configA.colors, configB.colors)
+ diffObj('radius', configA.radius, configB.radius)
+ diffObj('shadows', configA.shadows, configB.shadows)
+ diffObj('font', configA.font, configB.font)
+
+ return diffs
+}
+
+function mergeConfigForTargetBranch(
+ targetConfig: BrandConfig,
+ sourceConfig: BrandConfig
+): BrandConfig {
+ return {
+ ...targetConfig,
+ colors: {
+ ...(targetConfig.colors ?? {}),
+ ...(sourceConfig.colors ?? {}),
+ },
+ radius: {
+ ...(targetConfig.radius ?? {}),
+ ...(sourceConfig.radius ?? {}),
+ },
+ shadows: {
+ ...(targetConfig.shadows ?? {}),
+ ...(sourceConfig.shadows ?? {}),
+ },
+ font: {
+ ...(targetConfig.font ?? {}),
+ ...(sourceConfig.font ?? {}),
+ ...(targetConfig.font?.weight || sourceConfig.font?.weight
+ ? {
+ weight: {
+ ...(targetConfig.font?.weight ?? {}),
+ ...(sourceConfig.font?.weight ?? {}),
+ },
+ }
+ : {}),
+ },
+ componentOverrides: {
+ ...(targetConfig.componentOverrides ?? {}),
+ ...(sourceConfig.componentOverrides ?? {}),
+ },
+ darkModeOverrides:
+ targetConfig.darkModeOverrides || sourceConfig.darkModeOverrides
+ ? {
+ colors: {
+ ...(targetConfig.darkModeOverrides?.colors ?? {}),
+ ...(sourceConfig.darkModeOverrides?.colors ?? {}),
+ },
+ radius: {
+ ...(targetConfig.darkModeOverrides?.radius ?? {}),
+ ...(sourceConfig.darkModeOverrides?.radius ?? {}),
+ },
+ shadows: {
+ ...(targetConfig.darkModeOverrides?.shadows ?? {}),
+ ...(sourceConfig.darkModeOverrides?.shadows ?? {}),
+ },
+ font: {
+ ...(targetConfig.darkModeOverrides?.font ?? {}),
+ ...(sourceConfig.darkModeOverrides?.font ?? {}),
+ ...(targetConfig.darkModeOverrides?.font?.weight ||
+ sourceConfig.darkModeOverrides?.font?.weight
+ ? {
+ weight: {
+ ...(targetConfig.darkModeOverrides?.font
+ ?.weight ?? {}),
+ ...(sourceConfig.darkModeOverrides?.font
+ ?.weight ?? {}),
+ },
+ }
+ : {}),
+ },
+ }
+ : undefined,
+ }
+}
+
+export const createMR = async (
+ organizationId: string,
+ sourceBranchId: string,
+ targetBranchId: string,
+ title: string,
+ description: string | undefined,
+ userId: string,
+ userEmail: string
+) => {
+ await requireOrganizationRole(
+ organizationId,
+ userId,
+ ['admin', 'editor'],
+ 'Only admins and editors can create merge requests'
+ )
+
+ const source = await branchRepo.getBranchById(sourceBranchId)
+ if (!source) throw new NotFoundError('Source branch')
+
+ const target = await branchRepo.getBranchById(targetBranchId)
+ if (!target) throw new NotFoundError('Target branch')
+
+ if (source.organizationId !== organizationId) {
+ throw new ValidationError(
+ 'Source branch does not belong to organization'
+ )
+ }
+ if (target.organizationId !== organizationId) {
+ throw new ValidationError(
+ 'Target branch does not belong to organization'
+ )
+ }
+
+ const organization = await orgRepo.getOrganizationById(organizationId)
+ if (
+ organization?.defaultBranchId &&
+ target.id !== organization.defaultBranchId
+ ) {
+ throw new ValidationError(
+ 'Merge requests can only target the organization default branch'
+ )
+ }
+
+ if (source.id === target.id) {
+ throw new ValidationError(
+ 'Source and target branches must be different'
+ )
+ }
+
+ const diff = diffBrandConfigs(target.brandConfig, source.brandConfig)
+
+ let lockViolations = null
+ try {
+ const violations = await lockService.validateBranchAgainstLocks(
+ organizationId,
+ source.brandConfig,
+ target.brandConfig
+ )
+ if (violations.length > 0) {
+ lockViolations = violations
+ }
+ } catch {
+ // Lock validation may fail if no locks exist yet
+ }
+
+ const mr = await mrRepo.createMergeRequest({
+ organizationId,
+ sourceBranchId: source.id,
+ sourceBranchName: source.name,
+ targetBranchId: target.id,
+ targetBranchName: target.name,
+ title,
+ description,
+ diff,
+ lockViolations,
+ requestedBy: userId,
+ })
+
+ await auditLogRepo.createAuditLog({
+ organizationId,
+ action: 'merge_request_created',
+ actorId: userId,
+ actorEmail: userEmail,
+ targetType: 'merge_request',
+ targetId: mr.id,
+ metadata: {
+ sourceBranchId: source.id,
+ sourceBranchName: source.name,
+ targetBranchId: target.id,
+ targetBranchName: target.name,
+ },
+ })
+
+ return mr
+}
+
+export const getMR = async (id: string, requesterUserId?: string) => {
+ const mr = await mrRepo.getMergeRequest(id)
+ if (!mr) throw new NotFoundError('Merge request')
+ if (requesterUserId) {
+ await requireOrganizationMember(mr.organizationId, requesterUserId)
+ }
+ return mr
+}
+
+export const listMRs = async (
+ options: {
+ organizationId?: string
+ status?: string
+ requestedBy?: string
+ limit?: number
+ cursor?: string
+ },
+ requesterUserId?: string
+) => {
+ if (options.organizationId && requesterUserId) {
+ await requireOrganizationMember(options.organizationId, requesterUserId)
+ }
+ return mrRepo.listMergeRequests(options)
+}
+
+export const approveMR = async (
+ id: string,
+ reviewComment: string | undefined,
+ userId: string,
+ userEmail: string
+) => {
+ const mr = await getMR(id, userId)
+ await requireOrganizationRole(
+ mr.organizationId,
+ userId,
+ ['admin'],
+ 'Only admins can approve merge requests'
+ )
+ if (mr.status !== 'pending') {
+ throw new ValidationError('Only pending merge requests can be approved')
+ }
+
+ const updated = await mrRepo.updateMergeRequestStatus(id, {
+ status: 'approved',
+ reviewedBy: userId,
+ reviewComment,
+ })
+
+ await auditLogRepo.createAuditLog({
+ organizationId: mr.organizationId,
+ action: 'merge_request_approved',
+ actorId: userId,
+ actorEmail: userEmail,
+ targetType: 'merge_request',
+ targetId: id,
+ metadata: { reviewComment: reviewComment || null },
+ })
+
+ return updated
+}
+
+export const rejectMR = async (
+ id: string,
+ reviewComment: string | undefined,
+ userId: string,
+ userEmail: string
+) => {
+ const mr = await getMR(id, userId)
+ await requireOrganizationRole(
+ mr.organizationId,
+ userId,
+ ['admin'],
+ 'Only admins can reject merge requests'
+ )
+ if (mr.status !== 'pending') {
+ throw new ValidationError('Only pending merge requests can be rejected')
+ }
+
+ const updated = await mrRepo.updateMergeRequestStatus(id, {
+ status: 'rejected',
+ reviewedBy: userId,
+ reviewComment,
+ })
+
+ await auditLogRepo.createAuditLog({
+ organizationId: mr.organizationId,
+ action: 'merge_request_rejected',
+ actorId: userId,
+ actorEmail: userEmail,
+ targetType: 'merge_request',
+ targetId: id,
+ metadata: { reviewComment: reviewComment || null },
+ })
+
+ return updated
+}
+
+export const mergeMR = async (
+ id: string,
+ userId: string,
+ userEmail: string
+) => {
+ const mr = await getMR(id, userId)
+ const membership = await requireOrganizationRole(
+ mr.organizationId,
+ userId,
+ ['admin'],
+ 'Only admins can merge merge requests'
+ )
+
+ if (mr.status !== 'approved') {
+ if (!(mr.status === 'pending' && membership.role === 'admin')) {
+ throw new ValidationError(
+ 'Only approved merge requests can be merged'
+ )
+ }
+ }
+
+ const source = await branchRepo.getBranchById(mr.sourceBranchId)
+ if (!source) throw new NotFoundError('Source branch')
+
+ const target = await branchRepo.getBranchById(mr.targetBranchId)
+ if (!target) throw new NotFoundError('Target branch')
+
+ const lockViolations = await lockService.validateBranchAgainstLocks(
+ mr.organizationId,
+ source.brandConfig,
+ target.brandConfig
+ )
+ if (lockViolations.length > 0) {
+ throw new ValidationError(
+ 'Cannot merge: lock violations must be resolved first'
+ )
+ }
+
+ const mergedTargetConfig = mergeConfigForTargetBranch(
+ target.brandConfig,
+ source.brandConfig
+ )
+
+ await branchRepo.updateBranch(mr.targetBranchId, {
+ brandConfig: mergedTargetConfig,
+ })
+
+ const updated = await mrRepo.updateMergeRequestStatus(id, {
+ status: 'merged',
+ reviewedBy: userId,
+ })
+
+ await auditLogRepo.createAuditLog({
+ organizationId: mr.organizationId,
+ action: 'merge_request_merged',
+ actorId: userId,
+ actorEmail: userEmail,
+ targetType: 'merge_request',
+ targetId: id,
+ metadata: {
+ sourceBranchId: mr.sourceBranchId,
+ targetBranchId: mr.targetBranchId,
+ },
+ })
+
+ return updated
+}
+
+export const cancelMR = async (
+ id: string,
+ userId: string,
+ _userEmail: string
+) => {
+ const mr = await getMR(id, userId)
+ if (mr.status !== 'pending') {
+ throw new ValidationError(
+ 'Only pending merge requests can be cancelled'
+ )
+ }
+
+ const membership = await requireOrganizationMember(
+ mr.organizationId,
+ userId
+ )
+ const isAdmin = membership.role === 'admin'
+
+ if (mr.requestedBy !== userId && !isAdmin) {
+ throw new ForbiddenError('Only the requestor or an admin can cancel')
+ }
+
+ return mrRepo.cancelMergeRequest(id)
+}
diff --git a/apps/backend/src/domains/mergerequests/entry-points/merge-request.routes.ts b/apps/backend/src/domains/mergerequests/entry-points/merge-request.routes.ts
new file mode 100644
index 000000000..62f47f0e8
--- /dev/null
+++ b/apps/backend/src/domains/mergerequests/entry-points/merge-request.routes.ts
@@ -0,0 +1,154 @@
+import { Router, type IRouter, type Request, type Response } from 'express'
+import { authenticate } from '@/middlewares/auth.js'
+import { asyncHandler } from '@/middlewares/errorHandler.js'
+import {
+ validate,
+ createMergeRequestSchema,
+ reviewMergeRequestSchema,
+} from '@/middlewares/validate.js'
+import * as mrService from '../domain/merge-request.service.js'
+
+const router: IRouter = Router()
+
+// ---------------------------------------------------------------------------
+// List Merge Requests
+// ---------------------------------------------------------------------------
+router.get(
+ '/',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const organizationId =
+ (req.query.organizationId as string) || req.user?.organizationId
+
+ const { mergeRequests, nextCursor } = await mrService.listMRs(
+ {
+ organizationId,
+ status: req.query.status as string,
+ requestedBy: req.query.requestedBy as string,
+ limit: parseInt(req.query.limit as string) || 20,
+ cursor: req.query.cursor as string,
+ },
+ req.user!.id
+ )
+ res.json({ success: true, data: { mergeRequests, nextCursor } })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Get Merge Request
+// ---------------------------------------------------------------------------
+router.get(
+ '/:id',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const mr = await mrService.getMR(req.params.id, req.user!.id)
+ res.json({ success: true, data: { mergeRequest: mr } })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Create Merge Request
+// ---------------------------------------------------------------------------
+router.post(
+ '/',
+ authenticate,
+ validate({ body: createMergeRequestSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const orgId =
+ (req.query.organizationId as string) ||
+ req.body.organizationId ||
+ req.user!.organizationId
+
+ if (!orgId) {
+ res.status(400).json({
+ success: false,
+ error: {
+ code: 'VALIDATION_ERROR',
+ message: 'Organization ID is required',
+ },
+ })
+ return
+ }
+
+ const mr = await mrService.createMR(
+ orgId,
+ req.body.sourceBranchId,
+ req.body.targetBranchId,
+ req.body.title,
+ req.body.description,
+ req.user!.id,
+ req.user!.email
+ )
+ res.status(201).json({ success: true, data: { mergeRequest: mr } })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Approve Merge Request
+// ---------------------------------------------------------------------------
+router.post(
+ '/:id/approve',
+ authenticate,
+ validate({ body: reviewMergeRequestSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const mr = await mrService.approveMR(
+ req.params.id,
+ req.body.reviewComment,
+ req.user!.id,
+ req.user!.email
+ )
+ res.json({ success: true, data: { mergeRequest: mr } })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Reject Merge Request
+// ---------------------------------------------------------------------------
+router.post(
+ '/:id/reject',
+ authenticate,
+ validate({ body: reviewMergeRequestSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const mr = await mrService.rejectMR(
+ req.params.id,
+ req.body.reviewComment,
+ req.user!.id,
+ req.user!.email
+ )
+ res.json({ success: true, data: { mergeRequest: mr } })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Merge (execute the merge after approval)
+// ---------------------------------------------------------------------------
+router.post(
+ '/:id/merge',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const mr = await mrService.mergeMR(
+ req.params.id,
+ req.user!.id,
+ req.user!.email
+ )
+ res.json({ success: true, data: { mergeRequest: mr } })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Cancel Merge Request
+// ---------------------------------------------------------------------------
+router.post(
+ '/:id/cancel',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const mr = await mrService.cancelMR(
+ req.params.id,
+ req.user!.id,
+ req.user!.email
+ )
+ res.json({ success: true, data: { mergeRequest: mr } })
+ })
+)
+
+export default router
diff --git a/apps/backend/src/domains/organizations/data-access/organization.repository.ts b/apps/backend/src/domains/organizations/data-access/organization.repository.ts
new file mode 100644
index 000000000..df72a14d4
--- /dev/null
+++ b/apps/backend/src/domains/organizations/data-access/organization.repository.ts
@@ -0,0 +1,169 @@
+import { prisma } from '@/config/database.js'
+import { logger } from '@/utils/logger.js'
+
+export interface OrganizationRow {
+ id: string
+ name: string
+ slug: string
+ defaultBranchId: string | null
+ blendVersion: string | null
+ wcagEnforcement: string
+ createdAt: Date
+ updatedAt: Date
+}
+
+export interface MemberRow {
+ id: string
+ organizationId: string
+ userId: string
+ role: string
+ joinedAt: Date
+}
+
+export const createOrganization = async (data: {
+ name: string
+ slug: string
+}): Promise => {
+ const org = await prisma.organization.create({
+ data: { name: data.name, slug: data.slug },
+ })
+ logger.info({ orgId: org.id, slug: data.slug }, 'Organization created')
+ return org as unknown as OrganizationRow
+}
+
+export const getOrganizationById = async (
+ id: string
+): Promise => {
+ const org = await prisma.organization.findUnique({ where: { id } })
+ return org as unknown as OrganizationRow | null
+}
+
+export const getOrganizationBySlug = async (
+ slug: string
+): Promise => {
+ const org = await prisma.organization.findUnique({ where: { slug } })
+ return org as unknown as OrganizationRow | null
+}
+
+export const listOrganizations = async (
+ options: { limit?: number; cursor?: string } = {}
+): Promise<{ organizations: OrganizationRow[]; nextCursor?: string }> => {
+ const limit = options.limit || 20
+ const where: any = {}
+ if (options.cursor) where.id = { lt: options.cursor }
+
+ const organizations = await prisma.organization.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ take: limit + 1,
+ })
+
+ let nextCursor: string | undefined
+ if (organizations.length > limit) {
+ nextCursor = organizations[limit - 1].id
+ organizations.pop()
+ }
+
+ return {
+ organizations: organizations as unknown as OrganizationRow[],
+ nextCursor,
+ }
+}
+
+export const updateOrganization = async (
+ id: string,
+ data: {
+ name?: string
+ slug?: string
+ defaultBranchId?: string | null
+ blendVersion?: string | null
+ wcagEnforcement?: string
+ }
+): Promise => {
+ const org = await prisma.organization.update({
+ where: { id },
+ data,
+ })
+ return org as unknown as OrganizationRow | null
+}
+
+export const addMember = async (data: {
+ organizationId: string
+ userId: string
+ role?: string
+}): Promise => {
+ const member = await prisma.member.upsert({
+ where: {
+ organizationId_userId: {
+ organizationId: data.organizationId,
+ userId: data.userId,
+ },
+ },
+ update: {},
+ create: {
+ organizationId: data.organizationId,
+ userId: data.userId,
+ role: (data.role as any) || 'viewer',
+ },
+ })
+ logger.info(
+ { orgId: data.organizationId, userId: data.userId },
+ 'Member added to organization'
+ )
+ return member as unknown as MemberRow
+}
+
+export const removeMember = async (
+ organizationId: string,
+ userId: string
+): Promise => {
+ await prisma.member.deleteMany({
+ where: { organizationId, userId },
+ })
+ logger.info({ orgId: organizationId, userId }, 'Member removed')
+}
+
+export const updateMemberRole = async (
+ organizationId: string,
+ userId: string,
+ role: string
+): Promise => {
+ const member = await prisma.member.update({
+ where: {
+ organizationId_userId: { organizationId, userId },
+ },
+ data: { role: role as any },
+ })
+ return member as unknown as MemberRow | null
+}
+
+export const listMembers = async (
+ organizationId: string
+): Promise => {
+ const members = await prisma.member.findMany({
+ where: { organizationId },
+ include: {
+ user: {
+ select: {
+ id: true,
+ email: true,
+ displayName: true,
+ photoUrl: true,
+ },
+ },
+ },
+ })
+ return members as unknown as MemberRow[]
+}
+
+export const getMemberOrganizations = async (
+ userId: string
+): Promise => {
+ const memberships = await prisma.member.findMany({
+ where: { userId },
+ include: { organization: true },
+ })
+ return memberships.map(
+ (m: any) => m.organization
+ ) as unknown as OrganizationRow[]
+}
diff --git a/apps/backend/src/domains/organizations/domain/org-permissions.service.ts b/apps/backend/src/domains/organizations/domain/org-permissions.service.ts
new file mode 100644
index 000000000..b9387db40
--- /dev/null
+++ b/apps/backend/src/domains/organizations/domain/org-permissions.service.ts
@@ -0,0 +1,33 @@
+import { ForbiddenError } from '@/errors/AppError.js'
+import * as userRepo from '@/domains/users/data-access/user.repository.js'
+
+export type OrgRole = 'admin' | 'editor' | 'viewer'
+
+export const requireOrganizationMember = async (
+ organizationId: string,
+ userId: string
+) => {
+ const membership = await userRepo.findUserMembershipInOrganization(
+ userId,
+ organizationId
+ )
+ if (!membership) {
+ throw new ForbiddenError(
+ 'You must be a member of this organization to perform this action'
+ )
+ }
+ return membership
+}
+
+export const requireOrganizationRole = async (
+ organizationId: string,
+ userId: string,
+ allowedRoles: OrgRole[],
+ forbiddenMessage: string
+) => {
+ const membership = await requireOrganizationMember(organizationId, userId)
+ if (!allowedRoles.includes(membership.role as OrgRole)) {
+ throw new ForbiddenError(forbiddenMessage)
+ }
+ return membership
+}
diff --git a/apps/backend/src/domains/organizations/entry-points/organization.routes.ts b/apps/backend/src/domains/organizations/entry-points/organization.routes.ts
new file mode 100644
index 000000000..98c862d81
--- /dev/null
+++ b/apps/backend/src/domains/organizations/entry-points/organization.routes.ts
@@ -0,0 +1,231 @@
+import { Router, type IRouter, type Request, type Response } from 'express'
+import { authenticate, requireRole } from '@/middlewares/auth.js'
+import { asyncHandler } from '@/middlewares/errorHandler.js'
+import {
+ validate,
+ createOrgSchema,
+ updateOrgSchema,
+ addMemberSchema,
+ updateMemberRoleSchema,
+} from '@/middlewares/validate.js'
+import * as orgRepo from '../data-access/organization.repository.js'
+import * as auditLogRepo from '@/domains/audit/data-access/auditlog.repository.js'
+import { maskEmail, maskDisplayName } from '@/utils/crypto.js'
+
+const router: IRouter = Router()
+
+// ---------------------------------------------------------------------------
+// List Organizations (admin only)
+// ---------------------------------------------------------------------------
+router.get(
+ '/',
+ authenticate,
+ requireRole('admin'),
+ asyncHandler(async (req: Request, res: Response) => {
+ const { organizations, nextCursor } = await orgRepo.listOrganizations({
+ limit: parseInt(req.query.limit as string) || 20,
+ cursor: req.query.cursor as string,
+ })
+ res.json({ success: true, data: { organizations, nextCursor } })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Get Organization by ID
+// ---------------------------------------------------------------------------
+router.get(
+ '/:id',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const org = await orgRepo.getOrganizationById(req.params.id)
+ if (!org) {
+ res.status(404).json({
+ success: false,
+ error: { code: 'NOT_FOUND', message: 'Organization not found' },
+ })
+ return
+ }
+ res.json({ success: true, data: { organization: org } })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Create Organization (admin only)
+// ---------------------------------------------------------------------------
+router.post(
+ '/',
+ authenticate,
+ requireRole('admin'),
+ validate({ body: createOrgSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const org = await orgRepo.createOrganization({
+ name: req.body.name,
+ slug: req.body.slug,
+ })
+ res.status(201).json({ success: true, data: { organization: org } })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Self-Service Org Creation (onboarding)
+//
+// Any authenticated user who is NOT yet in an org can create one.
+// They automatically become the org admin. This enables smooth onboarding
+// without needing a superadmin to manually create orgs for new users.
+// ---------------------------------------------------------------------------
+router.post(
+ '/onboarding',
+ authenticate,
+ validate({ body: createOrgSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ // Check if user is already a member of an organization
+ const existingOrgs = await orgRepo.getMemberOrganizations(req.user!.id)
+ if (existingOrgs.length > 0) {
+ res.status(409).json({
+ success: false,
+ error: {
+ code: 'ALREADY_IN_ORG',
+ message:
+ 'You are already a member of an organization. Use the admin panel to create additional orgs.',
+ },
+ })
+ return
+ }
+
+ // Create the org and add the user as admin
+ const org = await orgRepo.createOrganization({
+ name: req.body.name,
+ slug: req.body.slug,
+ })
+
+ await orgRepo.addMember({
+ organizationId: org.id,
+ userId: req.user!.id,
+ role: 'admin',
+ })
+
+ res.status(201).json({
+ success: true,
+ data: {
+ organization: org,
+ message: 'Organization created. You are now the admin.',
+ },
+ })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Update Organization
+// ---------------------------------------------------------------------------
+router.patch(
+ '/:id',
+ authenticate,
+ requireRole('admin'),
+ validate({ body: updateOrgSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const org = await orgRepo.updateOrganization(req.params.id, req.body)
+ if (!org) {
+ res.status(404).json({
+ success: false,
+ error: { code: 'NOT_FOUND', message: 'Organization not found' },
+ })
+ return
+ }
+ res.json({ success: true, data: { organization: org } })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// List Members
+// ---------------------------------------------------------------------------
+router.get(
+ '/:id/members',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const members = await orgRepo.listMembers(req.params.id)
+ const maskedMembers = members.map((m: any) => ({
+ ...m,
+ user: m.user
+ ? {
+ ...m.user,
+ email: maskEmail(m.user.email),
+ displayName: maskDisplayName(m.user.displayName),
+ }
+ : m.user,
+ }))
+ res.json({ success: true, data: { members: maskedMembers } })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Add Member
+// ---------------------------------------------------------------------------
+router.post(
+ '/:id/members',
+ authenticate,
+ requireRole('admin'),
+ validate({ body: addMemberSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const member = await orgRepo.addMember({
+ organizationId: req.params.id,
+ userId: req.body.userId,
+ role: req.body.role,
+ })
+ res.status(201).json({ success: true, data: { member } })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Remove Member
+// ---------------------------------------------------------------------------
+router.delete(
+ '/:id/members/:userId',
+ authenticate,
+ requireRole('admin'),
+ asyncHandler(async (req: Request, res: Response) => {
+ await orgRepo.removeMember(req.params.id, req.params.userId)
+ res.json({ success: true, message: 'Member removed' })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Update Member Role
+// ---------------------------------------------------------------------------
+router.patch(
+ '/:id/members/:userId/role',
+ authenticate,
+ requireRole('admin'),
+ validate({ body: updateMemberRoleSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const member = await orgRepo.updateMemberRole(
+ req.params.id,
+ req.params.userId,
+ req.body.role
+ )
+ res.json({ success: true, data: { member } })
+ })
+)
+
+// ---------------------------------------------------------------------------
+// Audit Logs
+// ---------------------------------------------------------------------------
+router.get(
+ '/:id/audit-logs',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const { logs, nextCursor } = await auditLogRepo.listAuditLogs({
+ organizationId: req.params.id,
+ action: req.query.action as any,
+ targetType: req.query.targetType as
+ | auditLogRepo.AuditTargetType
+ | undefined,
+ targetId: req.query.targetId as string,
+ actorId: req.query.actorId as string,
+ limit: parseInt(req.query.limit as string) || 50,
+ cursor: req.query.cursor as string,
+ })
+ res.json({ success: true, data: { logs, nextCursor } })
+ })
+)
+
+export default router
diff --git a/apps/backend/src/domains/tags/data-access/tag.repository.ts b/apps/backend/src/domains/tags/data-access/tag.repository.ts
new file mode 100644
index 000000000..4e3683917
--- /dev/null
+++ b/apps/backend/src/domains/tags/data-access/tag.repository.ts
@@ -0,0 +1,50 @@
+import { prisma } from '@/config/database.js'
+import { logger } from '@/utils/logger.js'
+
+export interface TagRow {
+ id: string
+ name: string
+ createdAt: Date
+}
+
+export const createTag = async (name: string): Promise => {
+ const tag = await prisma.tag.upsert({
+ where: { name },
+ update: {},
+ create: { name },
+ })
+ return tag as unknown as TagRow
+}
+
+export const getTagById = async (id: string): Promise => {
+ const tag = await prisma.tag.findUnique({ where: { id } })
+ return tag as unknown as TagRow | null
+}
+
+export const getTagByName = async (name: string): Promise => {
+ const tag = await prisma.tag.findUnique({ where: { name } })
+ return tag as unknown as TagRow | null
+}
+
+export const listTags = async (
+ options: { search?: string; limit?: number } = {}
+): Promise => {
+ const where: any = {}
+ if (options.search) {
+ where.name = { contains: options.search, mode: 'insensitive' }
+ }
+
+ const tags = await prisma.tag.findMany({
+ where,
+ orderBy: { name: 'asc' },
+ take: options.limit || 100,
+ })
+
+ return tags as unknown as TagRow[]
+}
+
+export const deleteTag = async (id: string): Promise => {
+ await prisma.tag.delete({ where: { id } })
+ logger.info({ tagId: id }, 'Tag deleted')
+ return true
+}
diff --git a/apps/backend/src/domains/tags/entry-points/tag.routes.ts b/apps/backend/src/domains/tags/entry-points/tag.routes.ts
new file mode 100644
index 000000000..f8043eddb
--- /dev/null
+++ b/apps/backend/src/domains/tags/entry-points/tag.routes.ts
@@ -0,0 +1,41 @@
+import { Router, type IRouter, type Request, type Response } from 'express'
+import { authenticate, requireRole } from '@/middlewares/auth.js'
+import { asyncHandler } from '@/middlewares/errorHandler.js'
+import { validate, createTagSchema } from '@/middlewares/validate.js'
+import * as tagRepo from '../data-access/tag.repository.js'
+
+const router: IRouter = Router()
+
+router.get(
+ '/',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const tags = await tagRepo.listTags({
+ search: req.query.search as string,
+ limit: parseInt(req.query.limit as string) || 100,
+ })
+ res.json({ success: true, data: { tags } })
+ })
+)
+
+router.post(
+ '/',
+ authenticate,
+ validate({ body: createTagSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const tag = await tagRepo.createTag(req.body.name)
+ res.status(201).json({ success: true, data: { tag } })
+ })
+)
+
+router.delete(
+ '/:id',
+ authenticate,
+ requireRole('admin'),
+ asyncHandler(async (req: Request, res: Response) => {
+ await tagRepo.deleteTag(req.params.id)
+ res.json({ success: true, message: 'Tag deleted' })
+ })
+)
+
+export default router
diff --git a/apps/backend/src/domains/tokens/domain/token.service.ts b/apps/backend/src/domains/tokens/domain/token.service.ts
new file mode 100644
index 000000000..0f4a7d56e
--- /dev/null
+++ b/apps/backend/src/domains/tokens/domain/token.service.ts
@@ -0,0 +1,212 @@
+import { prisma } from '@/config/database.js'
+import { logger } from '@/utils/logger.js'
+import { NotFoundError, ValidationError } from '@/errors/AppError.js'
+
+export interface TokenUploadMetadata {
+ branchId: string
+ uploadedBy: string
+ uploadedByName: string
+ uploadedAt: Date
+ fileName: string
+ fileSize: number
+ description?: string
+}
+
+export interface TokenUploadResult {
+ success: boolean
+ id: string
+ message?: string
+ brandConfig?: Record
+ validationErrors?: string[]
+}
+
+export interface StoredToken {
+ id: string
+ branchId: string
+ metadata: TokenUploadMetadata
+ parsedConfig?: Record
+ status: 'pending' | 'processing' | 'valid' | 'invalid'
+ createdAt: Date
+ updatedAt: Date
+}
+
+export interface TokenUploadInput {
+ branchId: string
+ fileBuffer: Buffer
+ fileName: string
+ description?: string
+ uploadedBy: string
+ uploadedByName: string
+}
+
+function validateJson(data: unknown): { valid: boolean; errors: string[] } {
+ const errors: string[] = []
+
+ if (!data || typeof data !== 'object') {
+ errors.push('Must be a valid object')
+ return { valid: false, errors }
+ }
+
+ const obj = data as Record
+
+ if (!obj.brandId || typeof obj.brandId !== 'string') {
+ errors.push('Missing or invalid brandId')
+ }
+
+ if (!obj.name || typeof obj.name !== 'string') {
+ errors.push('Missing or invalid name')
+ }
+
+ if (obj.colors && typeof obj.colors === 'object') {
+ const colors = obj.colors as Record
+ if (!colors.primary || typeof colors.primary !== 'object') {
+ errors.push('Missing colors.primary configuration')
+ }
+ } else {
+ errors.push('Missing colors configuration')
+ }
+
+ return { valid: errors.length === 0, errors }
+}
+
+export const uploadToken = async (
+ input: TokenUploadInput
+): Promise => {
+ if (!input.fileName.endsWith('.json')) {
+ throw new ValidationError('Only JSON files are supported')
+ }
+
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(input.fileBuffer.toString('utf-8'))
+ } catch {
+ throw new ValidationError('Failed to parse JSON file')
+ }
+
+ const validation = validateJson(parsed)
+ if (!validation.valid) {
+ throw new ValidationError(
+ `Token validation failed: ${validation.errors.join(', ')}`
+ )
+ }
+
+ const brandConfig = parsed as Record
+
+ const upload = await prisma.tokenUpload.create({
+ data: {
+ branchId: input.branchId,
+ fileName: input.fileName,
+ fileSize: input.fileBuffer.length,
+ description: input.description || null,
+ parsedConfig: brandConfig as any,
+ status: 'valid',
+ uploadedBy: input.uploadedBy,
+ uploadedByName: input.uploadedByName,
+ },
+ })
+
+ logger.info(
+ {
+ tokenId: upload.id,
+ branchId: input.branchId,
+ fileName: input.fileName,
+ },
+ 'Token uploaded successfully'
+ )
+
+ return {
+ success: true,
+ id: upload.id,
+ message: 'Token file uploaded successfully',
+ brandConfig: brandConfig as any,
+ }
+}
+
+export const listTokensByBranch = async (
+ branchId: string
+): Promise => {
+ const uploads = await prisma.tokenUpload.findMany({
+ where: { branchId },
+ orderBy: { createdAt: 'desc' },
+ })
+
+ return uploads.map((u: any) => ({
+ id: u.id,
+ branchId: u.branchId,
+ metadata: {
+ branchId: u.branchId,
+ uploadedBy: u.uploadedBy,
+ uploadedByName: u.uploadedByName,
+ uploadedAt: u.createdAt,
+ fileName: u.fileName,
+ fileSize: u.fileSize,
+ description: u.description || undefined,
+ },
+ parsedConfig: u.parsedConfig as Record | undefined,
+ status: u.status as 'pending' | 'processing' | 'valid' | 'invalid',
+ createdAt: u.createdAt,
+ updatedAt: u.updatedAt,
+ }))
+}
+
+export const getTokenById = async (
+ branchId: string,
+ tokenId: string
+): Promise => {
+ const upload = await prisma.tokenUpload.findFirst({
+ where: { id: tokenId, branchId },
+ })
+
+ if (!upload) return null
+
+ return {
+ id: upload.id,
+ branchId: upload.branchId,
+ metadata: {
+ branchId: upload.branchId,
+ uploadedBy: upload.uploadedBy,
+ uploadedByName: upload.uploadedByName,
+ uploadedAt: upload.createdAt,
+ fileName: upload.fileName,
+ fileSize: upload.fileSize,
+ description: upload.description || undefined,
+ },
+ parsedConfig: upload.parsedConfig as
+ | Record
+ | undefined,
+ status: upload.status as 'pending' | 'processing' | 'valid' | 'invalid',
+ createdAt: upload.createdAt,
+ updatedAt: upload.updatedAt,
+ }
+}
+
+export const deleteToken = async (
+ branchId: string,
+ tokenId: string
+): Promise => {
+ const token = await getTokenById(branchId, tokenId)
+ if (!token) {
+ throw new NotFoundError('Token')
+ }
+
+ await prisma.tokenUpload.delete({ where: { id: tokenId } })
+
+ logger.info({ tokenId, branchId }, 'Token deleted')
+}
+
+export const readTokenFile = async (
+ branchId: string,
+ tokenId: string
+): Promise => {
+ const token = await getTokenById(branchId, tokenId)
+ if (!token) {
+ throw new NotFoundError('Token')
+ }
+
+ const config = token.parsedConfig
+ if (!config) {
+ throw new NotFoundError('Token file content')
+ }
+
+ return Buffer.from(JSON.stringify(config, null, 2))
+}
diff --git a/apps/backend/src/domains/tokens/entry-points/token.routes.ts b/apps/backend/src/domains/tokens/entry-points/token.routes.ts
new file mode 100644
index 000000000..a53c5e4bf
--- /dev/null
+++ b/apps/backend/src/domains/tokens/entry-points/token.routes.ts
@@ -0,0 +1,269 @@
+// Token Routes - Express router for token endpoints
+
+import { Router, type IRouter, type Request, type Response } from 'express'
+import multer from 'multer'
+import { authenticate } from '@/middlewares/auth.js'
+import { asyncHandler } from '@/middlewares/errorHandler.js'
+import * as tokenService from '../domain/token.service.js'
+
+const router: IRouter = Router()
+const upload = multer({ storage: multer.memoryStorage() })
+
+/**
+ * @openapi
+ * /api/branches/{branchId}/tokens/upload:
+ * post:
+ * summary: Upload a token JSON file
+ * description: Upload and validate a token configuration JSON file
+ * tags:
+ * - Tokens
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: branchId
+ * required: true
+ * schema:
+ * type: string
+ * description: Branch UUID
+ * requestBody:
+ * required: true
+ * content:
+ * multipart/form-data:
+ * schema:
+ * type: object
+ * required:
+ * - file
+ * properties:
+ * file:
+ * type: string
+ * format: binary
+ * description: JSON token file
+ * description:
+ * type: string
+ * description: Optional description of the upload
+ * responses:
+ * 201:
+ * description: Token uploaded successfully
+ * 400:
+ * description: Invalid JSON or validation failed
+ * 401:
+ * description: Unauthorized
+ */
+router.post(
+ '/branches/:branchId/tokens/upload',
+ authenticate,
+ upload.single('file'),
+ asyncHandler(async (req: Request, res: Response) => {
+ const { branchId } = req.params
+
+ if (!req.file) {
+ throw new Error('No file uploaded')
+ }
+
+ const { buffer, originalname } = req.file
+ const description = req.body.description as string | undefined
+
+ const result = await tokenService.uploadToken({
+ branchId,
+ fileBuffer: buffer,
+ fileName: originalname,
+ description,
+ uploadedBy: req.user!.id,
+ uploadedByName: '', // req.user doesn't have displayName in current auth setup
+ })
+
+ res.status(201).json({
+ success: true,
+ data: result,
+ })
+ })
+)
+
+/**
+ * @openapi
+ * /api/branches/{branchId}/tokens:
+ * get:
+ * summary: List all tokens for a branch
+ * description: Get all uploaded token files for a specific branch
+ * tags:
+ * - Tokens
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: branchId
+ * required: true
+ * schema:
+ * type: string
+ * description: Branch UUID
+ * responses:
+ * 200:
+ * description: List of tokens
+ * 401:
+ * description: Unauthorized
+ */
+router.get(
+ '/branches/:branchId/tokens',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const { branchId } = req.params
+
+ const tokens = await tokenService.listTokensByBranch(branchId)
+
+ res.json({
+ success: true,
+ data: tokens,
+ })
+ })
+)
+
+/**
+ * @openapi
+ * /api/branches/{branchId}/tokens/{tokenId}:
+ * get:
+ * summary: Get a specific token by ID
+ * description: Retrieve token metadata by ID
+ * tags:
+ * - Tokens
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: branchId
+ * required: true
+ * schema:
+ * type: string
+ * description: Branch UUID
+ * - in: path
+ * name: tokenId
+ * required: true
+ * schema:
+ * type: string
+ * description: Token UUID
+ * responses:
+ * 200:
+ * description: Token details
+ * 404:
+ * description: Token not found
+ * 401:
+ * description: Unauthorized
+ */
+router.get(
+ '/branches/:branchId/tokens/:tokenId',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const { branchId, tokenId } = req.params
+
+ const token = await tokenService.getTokenById(branchId, tokenId)
+
+ if (!token) {
+ res.status(404).json({
+ success: false,
+ message: 'Token not found',
+ })
+ return
+ }
+
+ res.json({
+ success: true,
+ data: token,
+ })
+ })
+)
+
+/**
+ * @openapi
+ * /api/branches/{branchId}/tokens/{tokenId}/download:
+ * get:
+ * summary: Download token file content
+ * description: Download the raw JSON token file
+ * tags:
+ * - Tokens
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: branchId
+ * required: true
+ * schema:
+ * type: string
+ * description: Branch UUID
+ * - in: path
+ * name: tokenId
+ * required: true
+ * schema:
+ * type: string
+ * description: Token UUID
+ * responses:
+ * 200:
+ * description: Token file content
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * 404:
+ * description: Token not found
+ * 401:
+ * description: Unauthorized
+ */
+router.get(
+ '/branches/:branchId/tokens/:tokenId/download',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const { branchId, tokenId } = req.params
+
+ const buffer = await tokenService.readTokenFile(branchId, tokenId)
+
+ res.setHeader('Content-Type', 'application/json')
+ res.send(buffer)
+ })
+)
+
+/**
+ * @openapi
+ * /api/branches/{branchId}/tokens/{tokenId}:
+ * delete:
+ * summary: Delete a token
+ * description: Delete a token file and its metadata
+ * tags:
+ * - Tokens
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: branchId
+ * required: true
+ * schema:
+ * type: string
+ * description: Branch UUID
+ * - in: path
+ * name: tokenId
+ * required: true
+ * schema:
+ * type: string
+ * description: Token UUID
+ * responses:
+ * 200:
+ * description: Token deleted successfully
+ * 404:
+ * description: Token not found
+ * 401:
+ * description: Unauthorized
+ */
+router.delete(
+ '/branches/:branchId/tokens/:tokenId',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const { branchId, tokenId } = req.params
+
+ await tokenService.deleteToken(branchId, tokenId)
+
+ res.json({
+ success: true,
+ message: 'Token deleted successfully',
+ })
+ })
+)
+
+export default router
diff --git a/apps/backend/src/domains/users/data-access/user.repository.ts b/apps/backend/src/domains/users/data-access/user.repository.ts
new file mode 100644
index 000000000..bf2ffd3e2
--- /dev/null
+++ b/apps/backend/src/domains/users/data-access/user.repository.ts
@@ -0,0 +1,218 @@
+import { prisma } from '@/config/database.js'
+import { logger } from '@/utils/logger.js'
+import { maskEmail } from '@/utils/crypto.js'
+
+export interface UserRow {
+ id: string
+ googleId: string | null
+ email: string
+ displayName: string | null
+ photoUrl: string | null
+ role: string
+ isActive: boolean
+ createdAt: Date
+ updatedAt: Date
+ lastLogin: Date | null
+ deletedAt: Date | null
+}
+
+export interface MembershipRow {
+ id: string
+ organizationId: string
+ userId: string
+ role: string
+ joinedAt: Date
+}
+
+export const findUserByEmail = async (
+ email: string
+): Promise => {
+ const user = await prisma.user.findUnique({
+ where: { email, deletedAt: null },
+ })
+ return user as unknown as UserRow | null
+}
+
+export const findUserByGoogleId = async (
+ googleId: string
+): Promise => {
+ const user = await prisma.user.findUnique({
+ where: { googleId, deletedAt: null },
+ })
+ return user as unknown as UserRow | null
+}
+
+export const findUserById = async (id: string): Promise => {
+ const user = await prisma.user.findUnique({
+ where: { id, deletedAt: null },
+ })
+ return user as unknown as UserRow | null
+}
+
+export const findUserMembership = async (
+ userId: string
+): Promise => {
+ const membership = await prisma.member.findFirst({
+ where: { userId },
+ })
+ return membership as unknown as MembershipRow | null
+}
+
+export const findUserMembershipInOrganization = async (
+ userId: string,
+ organizationId: string
+): Promise => {
+ const membership = await prisma.member.findUnique({
+ where: {
+ organizationId_userId: {
+ organizationId,
+ userId,
+ },
+ },
+ })
+ return membership as unknown as MembershipRow | null
+}
+
+export const findUserMemberships = async (
+ userId: string
+): Promise => {
+ const memberships = await prisma.member.findMany({
+ where: { userId },
+ })
+ return memberships as unknown as MembershipRow[]
+}
+
+export const createUser = async (data: {
+ email: string
+ displayName?: string
+ photoUrl?: string
+ googleId?: string
+ role?: string
+}): Promise => {
+ const user = await prisma.user.create({
+ data: {
+ email: data.email,
+ displayName: data.displayName || null,
+ photoUrl: data.photoUrl || null,
+ googleId: data.googleId || null,
+ role: (data.role as any) || 'viewer',
+ isActive: true,
+ lastLogin: new Date(),
+ },
+ })
+
+ logger.info(
+ { userId: user.id, email: maskEmail(user.email) },
+ 'User created'
+ )
+ return user as unknown as UserRow
+}
+
+export const updateUserLogin = async (
+ userId: string
+): Promise => {
+ const user = await prisma.user.update({
+ where: { id: userId },
+ data: { lastLogin: new Date() },
+ })
+ return user as unknown as UserRow
+}
+
+export const updateUserProfile = async (
+ userId: string,
+ data: { displayName?: string; photoUrl?: string }
+): Promise => {
+ const user = await prisma.user.update({
+ where: { id: userId },
+ data: {
+ displayName: data.displayName || undefined,
+ photoUrl: data.photoUrl || undefined,
+ },
+ })
+ return user as unknown as UserRow
+}
+
+export const updateUserRole = async (
+ userId: string,
+ role: string
+): Promise => {
+ const user = await prisma.user.update({
+ where: { id: userId },
+ data: { role: role as any },
+ })
+ return user as unknown as UserRow
+}
+
+export const softDeleteUser = async (userId: string): Promise => {
+ await prisma.user.update({
+ where: { id: userId },
+ data: { deletedAt: new Date(), isActive: false },
+ })
+ logger.info({ userId }, 'User soft-deleted')
+ return true
+}
+
+export const listUsers = async (
+ options: { page?: number; limit?: number; organizationId?: string } = {}
+): Promise<{ users: UserRow[]; total: number }> => {
+ const page = options.page || 1
+ const limit = Math.min(options.limit || 20, 100)
+ const skip = (page - 1) * limit
+
+ const where: any = { deletedAt: null }
+ if (options.organizationId) {
+ where.memberships = {
+ some: { organizationId: options.organizationId },
+ }
+ }
+
+ const [users, total] = await Promise.all([
+ prisma.user.findMany({
+ where,
+ skip,
+ take: limit,
+ select: {
+ id: true,
+ email: true,
+ displayName: true,
+ photoUrl: true,
+ role: true,
+ isActive: true,
+ createdAt: true,
+ lastLogin: true,
+ },
+ orderBy: { createdAt: 'desc' },
+ }),
+ prisma.user.count({ where }),
+ ])
+
+ return { users: users as unknown as UserRow[], total }
+}
+
+export const storeRefreshToken = async (
+ userId: string,
+ tokenHash: string,
+ expiresAt: Date
+) => {
+ return prisma.refreshToken.create({
+ data: { userId, tokenHash, expiresAt },
+ })
+}
+
+export const findRefreshToken = async (tokenHash: string) => {
+ return prisma.refreshToken.findUnique({ where: { tokenHash } })
+}
+
+export const revokeRefreshToken = async (tokenHash: string) => {
+ return prisma.refreshToken.deleteMany({ where: { tokenHash } })
+}
+
+export const revokeAllUserRefreshTokens = async (userId: string) => {
+ return prisma.refreshToken.deleteMany({ where: { userId } })
+}
+
+export const cleanupExpiredTokens = async () => {
+ return prisma.refreshToken.deleteMany({
+ where: { expiresAt: { lt: new Date() } },
+ })
+}
diff --git a/apps/backend/src/domains/users/entry-points/users.routes.ts b/apps/backend/src/domains/users/entry-points/users.routes.ts
new file mode 100644
index 000000000..cacedc595
--- /dev/null
+++ b/apps/backend/src/domains/users/entry-points/users.routes.ts
@@ -0,0 +1,169 @@
+import { Router, type IRouter, type Request, type Response } from 'express'
+import { authenticate, requireRole } from '@/middlewares/auth.js'
+import { asyncHandler } from '@/middlewares/errorHandler.js'
+import { validate, updateUserSchema } from '@/middlewares/validate.js'
+import {
+ listUsers,
+ findUserById,
+ updateUserProfile,
+ updateUserRole,
+ softDeleteUser,
+} from '../data-access/user.repository.js'
+import * as auditLogRepo from '@/domains/audit/data-access/auditlog.repository.js'
+import { maskEmail, maskDisplayName } from '@/utils/crypto.js'
+
+const router: IRouter = Router()
+
+router.get(
+ '/',
+ authenticate,
+ requireRole('admin'),
+ asyncHandler(async (req: Request, res: Response) => {
+ const page = parseInt(req.query.page as string) || 1
+ const limit = Math.min(parseInt(req.query.limit as string) || 20, 100)
+ const organizationId = req.query.organizationId as string
+
+ const { users, total } = await listUsers({
+ page,
+ limit,
+ organizationId,
+ })
+
+ const maskedUsers = users.map((u: any) => ({
+ ...u,
+ email: maskEmail(u.email),
+ displayName: maskDisplayName(u.displayName),
+ }))
+
+ res.json({
+ success: true,
+ data: {
+ users: maskedUsers,
+ pagination: {
+ page,
+ limit,
+ total,
+ pages: Math.ceil(total / limit),
+ },
+ },
+ })
+ })
+)
+
+router.get(
+ '/:id',
+ authenticate,
+ asyncHandler(async (req: Request, res: Response) => {
+ const { id } = req.params
+
+ if (id !== req.user?.id && req.user?.role !== 'admin') {
+ res.status(403).json({
+ success: false,
+ error: {
+ code: 'FORBIDDEN',
+ message: 'Can only view your own profile',
+ },
+ })
+ return
+ }
+
+ const user = await findUserById(id)
+
+ if (!user) {
+ res.status(404).json({
+ success: false,
+ error: { code: 'NOT_FOUND', message: 'User not found' },
+ })
+ return
+ }
+
+ res.json({ success: true, data: { user } })
+ })
+)
+
+router.patch(
+ '/:id',
+ authenticate,
+ validate({ body: updateUserSchema }),
+ asyncHandler(async (req: Request, res: Response) => {
+ const { id } = req.params
+
+ if (id !== req.user?.id && req.user?.role !== 'admin') {
+ res.status(403).json({
+ success: false,
+ error: {
+ code: 'FORBIDDEN',
+ message: 'Can only update your own profile',
+ },
+ })
+ return
+ }
+
+ const { displayName, photoUrl, role } = req.body
+
+ if (role && req.user?.role !== 'admin') {
+ res.status(403).json({
+ success: false,
+ error: {
+ code: 'FORBIDDEN',
+ message: 'Only admins can change roles',
+ },
+ })
+ return
+ }
+
+ let user
+ if (role && req.user?.role === 'admin') {
+ const previousUser = await findUserById(id)
+ user = await updateUserRole(id, role)
+
+ if (previousUser && req.user!.organizationId) {
+ await auditLogRepo.createAuditLog({
+ organizationId: req.user!.organizationId,
+ action: 'user_role_changed',
+ actorId: req.user!.id,
+ actorEmail: req.user!.email,
+ targetType: 'user',
+ targetId: id,
+ metadata: {
+ previousRole: previousUser.role,
+ newRole: role,
+ changedBy: req.user!.id,
+ },
+ })
+ }
+ } else {
+ user = await updateUserProfile(id, { displayName, photoUrl })
+ }
+
+ res.json({ success: true, data: { user } })
+ })
+)
+
+router.delete(
+ '/:id',
+ authenticate,
+ requireRole('admin'),
+ asyncHandler(async (req: Request, res: Response) => {
+ const previousUser = await findUserById(req.params.id)
+ await softDeleteUser(req.params.id)
+
+ if (previousUser && req.user!.organizationId) {
+ await auditLogRepo.createAuditLog({
+ organizationId: req.user!.organizationId,
+ action: 'user_deactivated',
+ actorId: req.user!.id,
+ actorEmail: req.user!.email,
+ targetType: 'user',
+ targetId: req.params.id,
+ metadata: {
+ previousRole: previousUser.role,
+ },
+ })
+ }
+
+ res.json({ success: true, message: 'User deactivated' })
+ })
+)
+
+export default router
diff --git a/apps/backend/src/errors/AppError.ts b/apps/backend/src/errors/AppError.ts
new file mode 100644
index 000000000..14fe85044
--- /dev/null
+++ b/apps/backend/src/errors/AppError.ts
@@ -0,0 +1,44 @@
+export class AppError extends Error {
+ public readonly statusCode: number
+ public readonly isOperational: boolean
+ public readonly code: string
+
+ constructor(
+ message: string,
+ statusCode: number = 500,
+ code: string = 'INTERNAL_ERROR',
+ isOperational: boolean = true
+ ) {
+ super(message)
+ this.statusCode = statusCode
+ this.code = code
+ this.isOperational = isOperational
+
+ Object.setPrototypeOf(this, AppError.prototype)
+ Error.captureStackTrace(this, this.constructor)
+ }
+}
+
+export class ValidationError extends AppError {
+ constructor(message: string) {
+ super(message, 400, 'VALIDATION_ERROR')
+ }
+}
+
+export class UnauthorizedError extends AppError {
+ constructor(message: string = 'Unauthorized') {
+ super(message, 401, 'UNAUTHORIZED')
+ }
+}
+
+export class ForbiddenError extends AppError {
+ constructor(message: string = 'Forbidden') {
+ super(message, 403, 'FORBIDDEN')
+ }
+}
+
+export class NotFoundError extends AppError {
+ constructor(resource: string) {
+ super(`${resource} not found`, 404, 'NOT_FOUND')
+ }
+}
diff --git a/apps/backend/src/middlewares/auth.ts b/apps/backend/src/middlewares/auth.ts
new file mode 100644
index 000000000..b572cc78b
--- /dev/null
+++ b/apps/backend/src/middlewares/auth.ts
@@ -0,0 +1,141 @@
+import { verifyJwtToken } from '@/domains/auth/domain/auth.service.js'
+import { UnauthorizedError, ForbiddenError } from '@/errors/AppError.js'
+import * as apiKeyRepo from '@/domains/apikeys/data-access/apikey.repository.js'
+import * as userRepo from '@/domains/users/data-access/user.repository.js'
+
+declare global {
+ namespace Express {
+ interface Request {
+ user?: {
+ id: string
+ email: string
+ role: string
+ displayName: string
+ organizationId?: string
+ authMethod: 'jwt' | 'api_key'
+ }
+ }
+ }
+}
+
+export const authenticate = async (
+ req: any,
+ _res: any,
+ next: any
+): Promise => {
+ try {
+ // 1. Check Authorization header (Bearer token or API key)
+ const authHeader = req.headers.authorization
+ let token: string | undefined
+
+ if (authHeader?.startsWith('Bearer ')) {
+ token = authHeader.substring(7)
+ }
+
+ // 2. Fallback to httpOnly cookie for browser requests
+ if (!token && req.cookies?.accessToken) {
+ token = req.cookies.accessToken
+ }
+
+ if (!token) {
+ throw new UnauthorizedError('No token provided')
+ }
+
+ // 3. API key path
+ if (token.startsWith('bts_')) {
+ const apiKey = await apiKeyRepo.validateApiKey(token)
+ if (!apiKey) {
+ throw new UnauthorizedError('Invalid or revoked API key')
+ }
+
+ const user = await userRepo.findUserById(apiKey.userId)
+ if (!user) {
+ throw new UnauthorizedError('API key user not found')
+ }
+
+ const membership = await userRepo.findUserMembership(apiKey.userId)
+
+ req.user = {
+ id: user.id,
+ email: user.email,
+ role: user.role,
+ displayName: user.displayName || '',
+ organizationId: membership?.organizationId,
+ authMethod: 'api_key',
+ }
+
+ next()
+ return
+ }
+
+ // 4. JWT path
+ const decoded = verifyJwtToken(token)
+
+ req.user = {
+ id: decoded.userId,
+ email: decoded.email,
+ role: decoded.role,
+ displayName: (decoded as any).displayName || '',
+ authMethod: 'jwt',
+ }
+
+ next()
+ } catch (error) {
+ next(error)
+ }
+}
+
+export const requireRole = (...allowedRoles: string[]) => {
+ return (req: any, _res: any, next: any): void => {
+ if (!req.user) {
+ next(new UnauthorizedError('Authentication required'))
+ return
+ }
+
+ if (!allowedRoles.includes(req.user.role)) {
+ next(
+ new ForbiddenError(
+ `Required role: ${allowedRoles.join(' or ')}`
+ )
+ )
+ return
+ }
+
+ next()
+ }
+}
+
+export const optionalAuth = async (
+ req: any,
+ _res: any,
+ next: any
+): Promise => {
+ try {
+ let token: string | undefined
+
+ const authHeader = req.headers.authorization
+ if (authHeader?.startsWith('Bearer ')) {
+ token = authHeader.substring(7)
+ }
+
+ if (!token && req.cookies?.accessToken) {
+ token = req.cookies.accessToken
+ }
+
+ if (token && !token.startsWith('bts_')) {
+ const decoded = verifyJwtToken(token)
+
+ req.user = {
+ id: decoded.userId,
+ email: decoded.email,
+ role: decoded.role,
+ displayName: (decoded as any).displayName || '',
+ authMethod: 'jwt',
+ }
+ }
+
+ next()
+ } catch {
+ next()
+ }
+}
diff --git a/apps/backend/src/middlewares/errorHandler.ts b/apps/backend/src/middlewares/errorHandler.ts
new file mode 100644
index 000000000..def5259f9
--- /dev/null
+++ b/apps/backend/src/middlewares/errorHandler.ts
@@ -0,0 +1,52 @@
+import type { Request, Response, NextFunction } from 'express'
+import { AppError } from '@/errors/AppError.js'
+import { logger } from '@/utils/logger.js'
+import { isDevelopment } from '@/config/index.js'
+
+export const errorHandler = (
+ err: Error,
+ _req: Request,
+ res: Response,
+ _next: NextFunction
+): void => {
+ let statusCode = 500
+ let message = 'Internal Server Error'
+ let code = 'INTERNAL_ERROR'
+
+ if (err instanceof AppError) {
+ statusCode = err.statusCode
+ message = err.message
+ code = err.code
+
+ if (!err.isOperational) {
+ logger.error(err, 'Unexpected error occurred')
+ }
+ } else {
+ logger.error(err, 'Unhandled error occurred')
+ }
+
+ res.status(statusCode).json({
+ success: false,
+ error: {
+ code,
+ message,
+ ...(isDevelopment && { stack: err.stack }),
+ },
+ })
+}
+
+export const asyncHandler = (fn: Function) => {
+ return (req: Request, res: Response, next: NextFunction) => {
+ Promise.resolve(fn(req, res, next)).catch(next)
+ }
+}
+
+export const notFoundHandler = (req: Request, res: Response) => {
+ res.status(404).json({
+ success: false,
+ error: {
+ code: 'NOT_FOUND',
+ message: `Route ${req.originalUrl} not found`,
+ },
+ })
+}
diff --git a/apps/backend/src/middlewares/rateLimit.ts b/apps/backend/src/middlewares/rateLimit.ts
new file mode 100644
index 000000000..f30a3e2f6
--- /dev/null
+++ b/apps/backend/src/middlewares/rateLimit.ts
@@ -0,0 +1,94 @@
+/**
+ * Rate Limiting Middleware
+ *
+ * In-memory sliding-window rate limiter. For production at scale,
+ * replace with Redis-backed solution (e.g. `rate-limit-redis`).
+ *
+ * Usage:
+ * app.use('/api', rateLimit({ windowMs: 60_000, max: 100 }))
+ * app.use('/api/auth', rateLimit({ windowMs: 60_000, max: 10 }))
+ */
+
+import type { Request, Response, NextFunction } from 'express'
+
+interface RateLimitOptions {
+ /** Time window in milliseconds. Default: 60 seconds. */
+ windowMs?: number
+ /** Maximum requests per window per IP. Default: 100. */
+ max?: number
+ /** Response message when rate limited. */
+ message?: string
+}
+
+interface RequestRecord {
+ count: number
+ resetAt: number
+}
+
+export function rateLimit(options: RateLimitOptions = {}) {
+ const windowMs = options.windowMs ?? 60_000
+ const max = options.max ?? 100
+ const message =
+ options.message ?? 'Too many requests, please try again later'
+
+ const store = new Map()
+
+ // Cleanup expired entries every 5 minutes
+ const cleanupInterval = setInterval(() => {
+ const now = Date.now()
+ for (const [key, record] of store) {
+ if (record.resetAt <= now) {
+ store.delete(key)
+ }
+ }
+ }, 5 * 60_000)
+
+ // Allow the timer to not prevent Node from exiting
+ if (cleanupInterval.unref) {
+ cleanupInterval.unref()
+ }
+
+ return (req: Request, res: Response, next: NextFunction): void => {
+ const key = req.ip || req.socket.remoteAddress || 'unknown'
+ const now = Date.now()
+ const record = store.get(key)
+
+ if (!record || record.resetAt <= now) {
+ // First request in this window or window has expired
+ store.set(key, { count: 1, resetAt: now + windowMs })
+ res.setHeader('X-RateLimit-Limit', max)
+ res.setHeader('X-RateLimit-Remaining', max - 1)
+ res.setHeader(
+ 'X-RateLimit-Reset',
+ Math.ceil((now + windowMs) / 1000)
+ )
+ next()
+ return
+ }
+
+ record.count++
+
+ if (record.count > max) {
+ res.setHeader('X-RateLimit-Limit', max)
+ res.setHeader('X-RateLimit-Remaining', 0)
+ res.setHeader('X-RateLimit-Reset', Math.ceil(record.resetAt / 1000))
+ res.setHeader(
+ 'Retry-After',
+ Math.ceil((record.resetAt - now) / 1000)
+ )
+ res.status(429).json({
+ success: false,
+ error: {
+ code: 'RATE_LIMITED',
+ message,
+ },
+ })
+ return
+ }
+
+ res.setHeader('X-RateLimit-Limit', max)
+ res.setHeader('X-RateLimit-Remaining', max - record.count)
+ res.setHeader('X-RateLimit-Reset', Math.ceil(record.resetAt / 1000))
+ next()
+ }
+}
diff --git a/apps/backend/src/middlewares/validate.ts b/apps/backend/src/middlewares/validate.ts
new file mode 100644
index 000000000..9251a22b4
--- /dev/null
+++ b/apps/backend/src/middlewares/validate.ts
@@ -0,0 +1,279 @@
+/**
+ * Request Validation Middleware
+ *
+ * Uses Zod schemas to validate request body, params, and query.
+ * Returns standardized 400 errors with details on validation failures.
+ *
+ * Usage:
+ * router.post('/', validate(createBranchSchema), handler)
+ */
+
+import type { Request, Response, NextFunction } from 'express'
+import { z, type ZodSchema } from 'zod'
+
+interface ValidationSchemas {
+ body?: ZodSchema
+ params?: ZodSchema
+ query?: ZodSchema
+}
+
+/**
+ * Middleware factory that validates request data against Zod schemas.
+ * Validated data is written back to `req.body`, `req.params`, `req.query`
+ * so downstream handlers receive clean, typed data.
+ */
+export function validate(schemas: ValidationSchemas) {
+ return (req: Request, res: Response, next: NextFunction): void => {
+ const errors: Array<{ field: string; message: string }> = []
+
+ if (schemas.body) {
+ const result = schemas.body.safeParse(req.body)
+ if (!result.success) {
+ result.error.issues.forEach((issue) => {
+ errors.push({
+ field: `body.${issue.path.join('.')}`,
+ message: issue.message,
+ })
+ })
+ } else {
+ req.body = result.data
+ }
+ }
+
+ if (schemas.params) {
+ const result = schemas.params.safeParse(req.params)
+ if (!result.success) {
+ result.error.issues.forEach((issue) => {
+ errors.push({
+ field: `params.${issue.path.join('.')}`,
+ message: issue.message,
+ })
+ })
+ }
+ }
+
+ if (schemas.query) {
+ const result = schemas.query.safeParse(req.query)
+ if (!result.success) {
+ result.error.issues.forEach((issue) => {
+ errors.push({
+ field: `query.${issue.path.join('.')}`,
+ message: issue.message,
+ })
+ })
+ } else {
+ req.query = result.data
+ }
+ }
+
+ if (errors.length > 0) {
+ res.status(400).json({
+ success: false,
+ error: {
+ code: 'VALIDATION_ERROR',
+ message: 'Request validation failed',
+ details: errors,
+ },
+ })
+ return
+ }
+
+ next()
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Reusable Schemas
+// ---------------------------------------------------------------------------
+
+/** UUID string validator. */
+export const uuidParam = z.object({
+ id: z.string().uuid('Invalid UUID format'),
+})
+
+/** Pagination query validator. */
+export const paginationQuery = z.object({
+ limit: z
+ .string()
+ .optional()
+ .transform((v) => (v ? Math.min(parseInt(v, 10) || 20, 100) : 20)),
+ cursor: z.string().uuid().optional(),
+ page: z
+ .string()
+ .optional()
+ .transform((v) => (v ? parseInt(v, 10) || 1 : 1)),
+})
+
+// ---------------------------------------------------------------------------
+// Organization Schemas
+// ---------------------------------------------------------------------------
+
+export const createOrgSchema = z.object({
+ name: z
+ .string()
+ .min(1, 'Name is required')
+ .max(255, 'Name must be 255 characters or fewer'),
+ slug: z
+ .string()
+ .min(1, 'Slug is required')
+ .max(100, 'Slug must be 100 characters or fewer')
+ .regex(
+ /^[a-z0-9-]+$/,
+ 'Slug must be lowercase alphanumeric with hyphens only'
+ ),
+})
+
+export const updateOrgSchema = z.object({
+ name: z.string().min(1).max(255).optional(),
+ slug: z
+ .string()
+ .min(1)
+ .max(100)
+ .regex(/^[a-z0-9-]+$/)
+ .optional(),
+ defaultBranchId: z.string().max(255).nullable().optional(),
+ blendVersion: z.string().max(50).nullable().optional(),
+ wcagEnforcement: z.enum(['none', 'warn', 'block']).optional(),
+})
+
+export const addMemberSchema = z.object({
+ userId: z.string().uuid('Invalid user ID'),
+ role: z.enum(['viewer', 'editor', 'admin']).optional().default('viewer'),
+})
+
+export const updateMemberRoleSchema = z.object({
+ role: z.enum(['viewer', 'editor', 'admin']),
+})
+
+// ---------------------------------------------------------------------------
+// Branch Schemas
+// ---------------------------------------------------------------------------
+
+export const createBranchSchema = z.object({
+ brandId: z
+ .string()
+ .min(1, 'Brand ID is required')
+ .max(255)
+ .regex(
+ /^[a-z0-9][a-z0-9_/-]*$/,
+ 'Brand ID must be lowercase alphanumeric with hyphens, underscores, or slashes'
+ ),
+ name: z.string().min(1, 'Name is required').max(255),
+ slug: z.string().min(1).max(100).optional(),
+ description: z.string().max(1000).optional(),
+ visibility: z
+ .enum(['private', 'team', 'public'])
+ .optional()
+ .default('private'),
+ brandConfig: z.record(z.unknown()).optional(),
+ organizationId: z.string().uuid().optional(),
+ clientName: z.string().max(255).optional(),
+ projectName: z.string().max(255).optional(),
+ tags: z.array(z.string()).optional(),
+})
+
+export const updateBranchSchema = z.object({
+ name: z.string().min(1).max(255).optional(),
+ description: z.string().max(1000).optional(),
+ brandConfig: z.record(z.unknown()).optional(),
+ visibility: z.enum(['private', 'team', 'public']).optional(),
+ status: z.enum(['draft', 'published', 'archived']).optional(),
+})
+
+export const publishBranchSchema = z.object({
+ version: z
+ .string()
+ .regex(
+ /^\d+\.\d+\.\d+/,
+ 'Version must follow semver format (e.g. 1.0.0)'
+ )
+ .optional(),
+ changelog: z.string().max(5000).optional(),
+ isBreaking: z.boolean().optional().default(false),
+ isPrerelease: z.boolean().optional().default(false),
+})
+
+export const resolveTokensSchema = z.object({
+ theme: z.enum(['light', 'dark']).optional().default('light'),
+})
+
+export const createSnapshotSchema = z.object({
+ brandConfig: z.record(z.unknown()).optional(),
+ label: z.string().max(255).optional(),
+ isAutoSave: z.boolean().optional().default(false),
+})
+
+// ---------------------------------------------------------------------------
+// User Schemas
+// ---------------------------------------------------------------------------
+
+export const updateUserSchema = z.object({
+ displayName: z.string().max(255).optional(),
+ photoUrl: z.string().url().optional(),
+ role: z.enum(['viewer', 'editor', 'admin']).optional(),
+})
+
+// ---------------------------------------------------------------------------
+// Tag Schemas
+// ---------------------------------------------------------------------------
+
+export const createTagSchema = z.object({
+ name: z
+ .string()
+ .min(1, 'Tag name is required')
+ .max(100, 'Tag name must be 100 characters or fewer')
+ .regex(
+ /^[a-z0-9-]+$/,
+ 'Tag name must be lowercase alphanumeric with hyphens only'
+ ),
+})
+
+// ---------------------------------------------------------------------------
+// API Key Schemas
+// ---------------------------------------------------------------------------
+
+export const createApiKeySchema = z.object({
+ name: z
+ .string()
+ .min(1, 'API key name is required')
+ .max(255, 'Name must be 255 characters or fewer'),
+ organizationId: z.string().uuid('Invalid organization ID'),
+ expiresAt: z.string().datetime('Invalid date format').optional(),
+})
+
+// ---------------------------------------------------------------------------
+// Fork Branch Schema
+// ---------------------------------------------------------------------------
+
+export const forkBranchSchema = z.object({
+ name: z.string().min(1, 'Name is required').max(255),
+ slug: z.string().min(1).max(100).optional(),
+})
+
+// ---------------------------------------------------------------------------
+// Token Lock Schemas
+// ---------------------------------------------------------------------------
+
+export const createTokenLockSchema = z.object({
+ tokenPath: z
+ .string()
+ .min(1, 'Token path is required')
+ .max(500, 'Token path must be 500 characters or fewer'),
+ reason: z.string().max(1000).optional(),
+})
+
+// ---------------------------------------------------------------------------
+// Merge Request Schemas
+// ---------------------------------------------------------------------------
+
+export const createMergeRequestSchema = z.object({
+ sourceBranchId: z.string().uuid('Invalid source branch ID'),
+ targetBranchId: z.string().uuid('Invalid target branch ID'),
+ title: z.string().min(1, 'Title is required').max(255),
+ description: z.string().max(5000).optional(),
+ organizationId: z.string().uuid().optional(),
+})
+
+export const reviewMergeRequestSchema = z.object({
+ reviewComment: z.string().max(5000).optional(),
+})
diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts
new file mode 100644
index 000000000..0808b5cf9
--- /dev/null
+++ b/apps/backend/src/server.ts
@@ -0,0 +1,163 @@
+import express from 'express'
+import cors from 'cors'
+import helmet from 'helmet'
+import cookieParser from 'cookie-parser'
+import { env, isDevelopment } from '@/config/index.js'
+import { logger } from '@/utils/logger.js'
+import { errorHandler, notFoundHandler } from '@/middlewares/errorHandler.js'
+import { rateLimit } from '@/middlewares/rateLimit.js'
+import { swaggerUiHandler, swaggerUiSetup } from '@/config/swagger.js'
+import { connectDatabaseWithRetry, isDatabaseReady } from '@/config/database.js'
+import authRoutes from '@/domains/auth/entry-points/auth.routes.js'
+import branchRoutes from '@/domains/branches/entry-points/branch.routes.js'
+import tokenRoutes from '@/domains/tokens/entry-points/token.routes.js'
+import userRoutes from '@/domains/users/entry-points/users.routes.js'
+import orgRoutes from '@/domains/organizations/entry-points/organization.routes.js'
+import tagRoutes from '@/domains/tags/entry-points/tag.routes.js'
+import apiKeyRoutes from '@/domains/apikeys/entry-points/apikey.routes.js'
+import lockRoutes from '@/domains/locks/entry-points/lock.routes.js'
+import mergeRequestRoutes from '@/domains/mergerequests/entry-points/merge-request.routes.js'
+import { googleCallback } from '@/domains/auth/entry-points/auth.controller.js'
+
+const app = express()
+
+app.use(
+ helmet({
+ contentSecurityPolicy: isDevelopment ? false : undefined,
+ })
+)
+
+const allowedOrigins = isDevelopment
+ ? [
+ 'http://localhost:5173',
+ 'http://localhost:3000',
+ 'http://127.0.0.1:5173',
+ 'http://127.0.0.1:3000',
+ ]
+ : [
+ ...env.FRONTEND_URL.split(',').map((url) => url.trim()),
+ ...(env.STUDIO_URL
+ ? env.STUDIO_URL.split(',').map((url) => url.trim())
+ : []),
+ ]
+
+app.use(
+ cors({
+ origin: (origin, callback) => {
+ if (!origin) return callback(null, true)
+
+ if (allowedOrigins.indexOf(origin) !== -1 || isDevelopment) {
+ callback(null, true)
+ } else {
+ callback(new Error('Not allowed by CORS'))
+ }
+ },
+ credentials: true,
+ methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
+ allowedHeaders: ['Content-Type', 'Authorization'],
+ })
+)
+
+app.use(express.json({ limit: '1mb' }))
+app.use(express.urlencoded({ extended: true, limit: '1mb' }))
+app.use(cookieParser())
+
+// ---------------------------------------------------------------------------
+// Rate Limiting β prevent abuse and brute-force attacks
+// ---------------------------------------------------------------------------
+// Auth endpoints get stricter limits (20/min) to prevent credential brute-force
+app.use('/api/auth', rateLimit({ windowMs: 60_000, max: 20 }))
+// General API endpoints get standard limits (100/min per IP)
+app.use('/api', rateLimit({ windowMs: 60_000, max: 100 }))
+
+app.use((req, _res, next) => {
+ logger.debug(
+ {
+ method: req.method,
+ path: req.path,
+ },
+ 'Incoming request'
+ )
+ next()
+})
+
+app.get('/health', (_req, res) => {
+ res.json({
+ status: 'ok',
+ database: isDatabaseReady() ? 'connected' : 'connecting',
+ timestamp: new Date().toISOString(),
+ version: '0.1.0',
+ })
+})
+
+app.get('/api/health', (_req, res) => {
+ res.json({
+ status: 'ok',
+ database: isDatabaseReady() ? 'connected' : 'connecting',
+ timestamp: new Date().toISOString(),
+ version: '0.1.0',
+ })
+})
+
+app.use('/api', (req, res, next) => {
+ // Allow health and OAuth bootstrap endpoints even while DB is connecting.
+ // OAuth callback still depends on DB and should remain guarded.
+ const isReadinessBypassPath =
+ req.path === '/health' || req.path === '/auth/google'
+
+ if (isReadinessBypassPath || isDatabaseReady()) {
+ return next()
+ }
+
+ return res.status(503).json({
+ success: false,
+ message: 'Database connection is still initializing. Please retry.',
+ })
+})
+
+app.use('/docs', swaggerUiHandler, swaggerUiSetup)
+
+app.get('/auth/google/callback', googleCallback)
+
+app.use('/api/auth', authRoutes)
+app.use('/api/branches', branchRoutes)
+app.use('/api/users', userRoutes)
+app.use('/api/organizations', orgRoutes)
+app.use('/api/organizations', lockRoutes)
+app.use('/api/merge-requests', mergeRequestRoutes)
+app.use('/api/tags', tagRoutes)
+app.use('/api/api-keys', apiKeyRoutes)
+app.use('/api', tokenRoutes)
+
+app.use(notFoundHandler)
+app.use(errorHandler)
+
+const PORT = parseInt(env.PORT, 10)
+
+/**
+ * Cloud Run requires the process to listen on PORT within a bounded startup
+ * window (~240s). Prisma retries can exceed that if we await DB before listen.
+ * Bind the HTTP server first, then connect in the background; `/api/*` stays
+ * 503 until `isDatabaseReady()` except health/OAuth bootstrap paths.
+ */
+const start = () => {
+ app.listen(PORT, () => {
+ logger.info(`Server running on http://localhost:${PORT}`)
+ logger.info(`Swagger docs available at http://localhost:${PORT}/docs`)
+ logger.info(`Environment: ${env.NODE_ENV}`)
+
+ void (async () => {
+ try {
+ await connectDatabaseWithRetry()
+ } catch (error) {
+ logger.error(
+ { err: error },
+ 'Failed to establish database connection after startup'
+ )
+ process.exit(1)
+ }
+ })()
+ })
+}
+
+start()
diff --git a/apps/backend/src/types/express.d.ts b/apps/backend/src/types/express.d.ts
new file mode 100644
index 000000000..bb58b56e8
--- /dev/null
+++ b/apps/backend/src/types/express.d.ts
@@ -0,0 +1,15 @@
+///
+
+declare global {
+ namespace Express {
+ interface Request {
+ user?: {
+ id: string
+ email: string
+ role: string
+ }
+ }
+ }
+}
+
+export {}
diff --git a/apps/backend/src/utils/crypto.ts b/apps/backend/src/utils/crypto.ts
new file mode 100644
index 000000000..077df62f5
--- /dev/null
+++ b/apps/backend/src/utils/crypto.ts
@@ -0,0 +1,59 @@
+/**
+ * Cryptographic utilities for hashing and masking sensitive user data.
+ *
+ * Email and other PII should be hashed before storage in logs and
+ * audit trails. The original value is needed for lookups (stored in DB),
+ * but logs and API responses should use masked/hashed versions.
+ */
+
+import crypto from 'crypto'
+
+/**
+ * Hash a string (e.g. email) using SHA-256.
+ * Used for audit logs and anywhere PII should not be stored in plaintext.
+ */
+export function hashPII(value: string): string {
+ return crypto
+ .createHash('sha256')
+ .update(value.toLowerCase().trim())
+ .digest('hex')
+}
+
+/**
+ * Mask an email address for display in API responses and logs.
+ * Example: "vinit.khandal@juspay.in" β "v***l@j***y.in"
+ */
+export function maskEmail(email: string): string {
+ const [local, domain] = email.split('@')
+ if (!local || !domain) return '***@***'
+
+ const maskedLocal =
+ local.length <= 2
+ ? `${local[0]}***`
+ : `${local[0]}***${local[local.length - 1]}`
+
+ const domainParts = domain.split('.')
+ const domainName = domainParts[0]
+ const tld = domainParts.slice(1).join('.')
+
+ const maskedDomain =
+ domainName.length <= 2
+ ? `${domainName[0]}***`
+ : `${domainName[0]}***${domainName[domainName.length - 1]}`
+
+ return `${maskedLocal}@${maskedDomain}${tld ? '.' + tld : ''}`
+}
+
+/**
+ * Mask a display name for partial anonymization.
+ * Example: "Vinit Khandal" β "V*** K***"
+ */
+export function maskDisplayName(
+ name: string | null | undefined
+): string | null {
+ if (!name) return null
+ return name
+ .split(' ')
+ .map((part) => (part.length <= 1 ? part : `${part[0]}***`))
+ .join(' ')
+}
diff --git a/apps/backend/src/utils/logger.ts b/apps/backend/src/utils/logger.ts
new file mode 100644
index 000000000..2687a8f81
--- /dev/null
+++ b/apps/backend/src/utils/logger.ts
@@ -0,0 +1,16 @@
+import pino from 'pino'
+import { isDevelopment } from '@/config/index.js'
+
+export const logger = pino({
+ level: isDevelopment ? 'debug' : 'info',
+ ...(isDevelopment && {
+ transport: {
+ target: 'pino-pretty',
+ options: {
+ colorize: true,
+ translateTime: 'HH:MM:ss Z',
+ ignore: 'pid,hostname',
+ },
+ },
+ }),
+})
diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json
new file mode 100644
index 000000000..f492d31ce
--- /dev/null
+++ b/apps/backend/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2022"],
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/apps/blend-monitor/.env.example b/apps/blend-monitor/.env.example
deleted file mode 100644
index 67e54f05a..000000000
--- a/apps/blend-monitor/.env.example
+++ /dev/null
@@ -1,78 +0,0 @@
-# ===================================
-# Blend Monitor - Environment Variables Template
-# ===================================
-# Copy this file to .env.local for local development
-# Replace placeholder values with actual credentials
-# NEVER commit .env.local or .env.production to git!
-# ===================================
-
-# ===================================
-# Firebase Client Configuration (Public - Safe to expose in frontend)
-# ===================================
-NEXT_PUBLIC_FIREBASE_API_KEY=your-firebase-api-key-here
-NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project-id.firebaseapp.com
-NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project-id
-NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your-project-id.firebasestorage.app
-NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your-sender-id
-NEXT_PUBLIC_FIREBASE_APP_ID=1:your-sender-id:web:your-app-id
-NEXT_PUBLIC_FIREBASE_DATABASE_URL=https://your-project-id-default-rtdb.region.firebasedatabase.app
-
-# ===================================
-# Firebase Admin Configuration (Private - Server-side only)
-# ===================================
-FIREBASE_PROJECT_ID=your-project-id
-FIREBASE_DATABASE_URL=https://your-project-id-default-rtdb.region.firebasedatabase.app
-FIREBASE_CLIENT_EMAIL=your-service-account@your-project-id.iam.gserviceaccount.com
-# FIREBASE_PRIVATE_KEY should come from Secret Manager in production
-# For local development, use Cloud SQL Proxy and Secret Manager
-# DO NOT hardcode the private key here!
-
-# ===================================
-# Database Configuration (Local Development via Cloud SQL Proxy)
-# ===================================
-DATABASE_HOST=localhost
-DATABASE_PORT=5433
-DATABASE_NAME=blend_monitor
-DATABASE_USER=admin
-# DATABASE_PASSWORD should come from Secret Manager
-# For local development:
-# 1. Start Cloud SQL Proxy: ./cloud_sql_proxy -instances=PROJECT:REGION:INSTANCE=tcp:5433
-# 2. Set password from Secret Manager: gcloud secrets versions access latest --secret="blend-monitor-db-password"
-# DO NOT hardcode the password here!
-
-# Database URL (constructed from above values)
-# DATABASE_URL=postgresql://admin:PASSWORD_FROM_SECRET_MANAGER@localhost:5433/blend_monitor
-
-# ===================================
-# Application Settings
-# ===================================
-NODE_ENV=development
-
-# ===================================
-# Production Configuration
-# ===================================
-# In production (Cloud Run), these are set via:
-# - Environment variables in cloudbuild.yaml
-# - Secrets from Secret Manager
-# - Cloud SQL Unix socket connection
-#
-# DO NOT use .env files in production!
-# All sensitive values MUST come from Secret Manager
-#
-# Production secrets:
-# - DATABASE_PASSWORD β blend-monitor-db-password (Secret Manager)
-# - FIREBASE_PRIVATE_KEY β blend-monitor-firebase-key (Secret Manager)
-# ===================================
-
-# ===================================
-# Local Development Setup Instructions
-# ===================================
-# 1. Copy this file: cp .env.example .env.local
-# 2. Get Firebase config from Firebase Console
-# 3. Set up Cloud SQL Proxy for database access
-# 4. Get database password from Secret Manager:
-# gcloud secrets versions access latest --secret="blend-monitor-db-password" --project=PROJECT_ID
-# 5. Get Firebase private key from Secret Manager:
-# gcloud secrets versions access latest --secret="blend-monitor-firebase-key" --project=PROJECT_ID
-# 6. Update .env.local with actual values
-# ===================================
diff --git a/apps/blend-monitor/Dockerfile b/apps/blend-monitor/Dockerfile
deleted file mode 100644
index 6da334c1e..000000000
--- a/apps/blend-monitor/Dockerfile
+++ /dev/null
@@ -1,73 +0,0 @@
-# Build stage
-FROM node:20-alpine AS builder
-
-# Install dependencies for native modules and pnpm
-RUN apk add --no-cache libc6-compat
-RUN corepack enable && corepack prepare pnpm@latest --activate
-
-WORKDIR /app
-
-# Copy monorepo files
-COPY pnpm-workspace.yaml ./
-COPY pnpm-lock.yaml ./
-COPY package.json ./
-COPY turbo.json ./
-
-# Copy blend-monitor app
-COPY apps/blend-monitor ./apps/blend-monitor
-
-# Copy shared packages if needed
-COPY packages ./packages
-
-# Install dependencies
-RUN pnpm install --frozen-lockfile
-
-# Build arguments for Firebase configuration
-ARG NEXT_PUBLIC_FIREBASE_API_KEY
-ARG NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN
-ARG NEXT_PUBLIC_FIREBASE_PROJECT_ID
-ARG NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET
-ARG NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
-ARG NEXT_PUBLIC_FIREBASE_APP_ID
-ARG NEXT_PUBLIC_FIREBASE_DATABASE_URL
-
-# Set build-time environment variables from build arguments
-ENV NEXT_PUBLIC_FIREBASE_API_KEY=${NEXT_PUBLIC_FIREBASE_API_KEY}
-ENV NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=${NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN}
-ENV NEXT_PUBLIC_FIREBASE_PROJECT_ID=${NEXT_PUBLIC_FIREBASE_PROJECT_ID}
-ENV NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=${NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET}
-ENV NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID}
-ENV NEXT_PUBLIC_FIREBASE_APP_ID=${NEXT_PUBLIC_FIREBASE_APP_ID}
-ENV NEXT_PUBLIC_FIREBASE_DATABASE_URL=${NEXT_PUBLIC_FIREBASE_DATABASE_URL}
-
-# Build the application
-ENV NEXT_TELEMETRY_DISABLED 1
-WORKDIR /app/apps/blend-monitor
-RUN pnpm run build
-
-# Production stage
-FROM node:20-alpine AS runner
-
-WORKDIR /app
-
-ENV NODE_ENV production
-ENV NEXT_TELEMETRY_DISABLED 1
-
-# Create non-root user
-RUN addgroup --system --gid 1001 nodejs
-RUN adduser --system --uid 1001 nextjs
-
-# Copy necessary files from builder
-COPY --from=builder --chown=nextjs:nodejs /app/apps/blend-monitor/.next/standalone ./
-COPY --from=builder --chown=nextjs:nodejs /app/apps/blend-monitor/.next/static ./apps/blend-monitor/.next/static
-COPY --from=builder --chown=nextjs:nodejs /app/apps/blend-monitor/public ./apps/blend-monitor/public
-
-USER nextjs
-
-# Expose port
-EXPOSE 8080
-ENV PORT 8080
-ENV HOSTNAME="0.0.0.0"
-
-# Start the application
-CMD ["node", "apps/blend-monitor/server.js"]
diff --git a/apps/blend-monitor/app/api/activity/recent/route.ts b/apps/blend-monitor/app/api/activity/recent/route.ts
deleted file mode 100644
index d7c723f53..000000000
--- a/apps/blend-monitor/app/api/activity/recent/route.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { NextResponse } from 'next/server'
-import { databaseService } from '@/backend/lib/database-service'
-import { initializeDatabase } from '@/backend/lib/database'
-
-// GET /api/activity/recent - Get recent activity
-export async function GET(request: Request) {
- try {
- await initializeDatabase()
-
- const { searchParams } = new URL(request.url)
- const limit = parseInt(searchParams.get('limit') || '10')
-
- const activities = await databaseService.getRecentActivity(limit)
-
- return NextResponse.json(activities)
- } catch (error) {
- console.error('Error fetching recent activity:', error)
- return NextResponse.json(
- { error: 'Failed to fetch recent activity' },
- { status: 500 }
- )
- }
-}
diff --git a/apps/blend-monitor/app/api/components/components-pg/route.ts b/apps/blend-monitor/app/api/components/components-pg/route.ts
deleted file mode 100644
index 051fd726b..000000000
--- a/apps/blend-monitor/app/api/components/components-pg/route.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// Re-export from backend API
-export { GET, POST } from '@/backend/api/components/components-pg/route'
diff --git a/apps/blend-monitor/app/api/components/route.ts b/apps/blend-monitor/app/api/components/route.ts
deleted file mode 100644
index cb488db4c..000000000
--- a/apps/blend-monitor/app/api/components/route.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// Re-export from backend API
-export { GET, POST } from '@/backend/api/components/route'
diff --git a/apps/blend-monitor/app/api/health/route.ts b/apps/blend-monitor/app/api/health/route.ts
deleted file mode 100644
index f77cc1cab..000000000
--- a/apps/blend-monitor/app/api/health/route.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { NextResponse } from 'next/server'
-import { checkDatabaseHealth } from '@/backend/lib/database'
-
-// GET /api/health - Check system health including database connectivity
-export async function GET() {
- const health = {
- status: 'unknown',
- timestamp: new Date().toISOString(),
- services: {
- database: {
- status: 'unknown',
- latency: null as number | null,
- error: null as string | null,
- },
- npm: {
- status: 'unknown',
- error: null as string | null,
- },
- },
- }
-
- // Test database connection
- try {
- const dbHealth = await checkDatabaseHealth()
- if (dbHealth.healthy) {
- health.services.database.status = 'healthy'
- health.services.database.latency = dbHealth.latency || null
- } else {
- health.services.database.status = 'unhealthy'
- health.services.database.error =
- dbHealth.error || 'Unknown database error'
- }
- } catch (error) {
- health.services.database.status = 'error'
- health.services.database.error =
- error instanceof Error
- ? error.message
- : 'Database connection failed'
- }
-
- // Test NPM API
- try {
- const npmResponse = await fetch(
- 'https://registry.npmjs.org/@juspay/blend-design-system',
- {
- method: 'HEAD',
- signal: AbortSignal.timeout(5000), // 5 second timeout
- }
- )
- if (npmResponse.ok) {
- health.services.npm.status = 'healthy'
- } else {
- health.services.npm.status = 'unhealthy'
- health.services.npm.error = `HTTP ${npmResponse.status}`
- }
- } catch (error) {
- health.services.npm.status = 'error'
- health.services.npm.error =
- error instanceof Error ? error.message : 'NPM registry unreachable'
- }
-
- // Overall health status
- const allHealthy = Object.values(health.services).every(
- (service) => service.status === 'healthy'
- )
- const anyError = Object.values(health.services).some(
- (service) => service.status === 'error'
- )
-
- health.status = allHealthy ? 'healthy' : anyError ? 'error' : 'degraded'
-
- const statusCode =
- health.status === 'healthy'
- ? 200
- : health.status === 'error'
- ? 503
- : 200
-
- return NextResponse.json(health, { status: statusCode })
-}
diff --git a/apps/blend-monitor/app/api/npm/route.ts b/apps/blend-monitor/app/api/npm/route.ts
deleted file mode 100644
index f51fe04f0..000000000
--- a/apps/blend-monitor/app/api/npm/route.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// Re-export from backend API
-export { GET, POST } from '@/backend/api/npm/route'
diff --git a/apps/blend-monitor/app/api/npm/stats/route.ts b/apps/blend-monitor/app/api/npm/stats/route.ts
deleted file mode 100644
index efb6672dd..000000000
--- a/apps/blend-monitor/app/api/npm/stats/route.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { NextResponse } from 'next/server'
-import { databaseService } from '@/backend/lib/database-service'
-import { initializeDatabase } from '@/backend/lib/database'
-import { NPMClient } from '@/backend/external/npm-client'
-
-// GET /api/npm/stats - Get NPM package statistics
-export async function GET() {
- let dbError: string | null = null
- let npmError: string | null = null
-
- // Try database first
- try {
- await initializeDatabase()
- const packageStats = await databaseService.getPackageStats()
-
- if (packageStats) {
- console.log('Returning package stats from database')
- return NextResponse.json(packageStats)
- }
- console.log('Database connected but no stats found')
- } catch (error) {
- dbError =
- error instanceof Error
- ? error.message
- : 'Database connection failed'
- console.error('Database error:', dbError)
- }
-
- // Try NPM API as fallback
- try {
- console.log('Trying NPM API for package stats...')
- const npmClient = new NPMClient('@juspay/blend-design-system')
- const stats = await npmClient.getPackageStats()
-
- if (stats) {
- // Try to save to database if it's available
- if (!dbError) {
- try {
- await databaseService.savePackageStats(stats)
- console.log('Fetched and saved package stats from NPM')
- } catch (saveError) {
- console.warn('Could not save to database:', saveError)
- }
- }
-
- console.log('Returning package stats from NPM API')
- return NextResponse.json(stats)
- }
- npmError = 'No package stats available from NPM'
- } catch (error) {
- npmError = error instanceof Error ? error.message : 'NPM API failed'
- console.error('NPM API error:', npmError)
- }
-
- // Both sources failed - return error with details
- return NextResponse.json(
- {
- error: 'Unable to fetch package statistics',
- details: {
- database: dbError || 'Connected but no data found',
- npm: npmError || 'Unknown error',
- suggestion:
- 'Check database connection and NPM API availability',
- },
- },
- { status: 503 } // Service Unavailable
- )
-}
diff --git a/apps/blend-monitor/app/api/npm/sync/route.ts b/apps/blend-monitor/app/api/npm/sync/route.ts
deleted file mode 100644
index 39807469b..000000000
--- a/apps/blend-monitor/app/api/npm/sync/route.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import { NextResponse } from 'next/server'
-import { databaseService } from '@/backend/lib/database-service'
-import { initializeDatabase } from '@/backend/lib/database'
-
-// POST /api/npm/sync - Manually trigger NPM data synchronization
-export async function POST() {
- try {
- console.log('Starting NPM data synchronization...')
- await initializeDatabase()
-
- const startTime = Date.now()
- const syncResult = await databaseService.syncNPMData()
- const duration = Date.now() - startTime
-
- const summary = {
- success: true,
- duration: `${duration}ms`,
- results: {
- versions: {
- total:
- syncResult.versions.saved + syncResult.versions.updated,
- new: syncResult.versions.saved,
- updated: syncResult.versions.updated,
- },
- trends: {
- saved: syncResult.trends.saved,
- },
- stats: {
- updated: syncResult.stats.updated,
- },
- },
- errors: syncResult.errors,
- hasErrors: syncResult.errors.length > 0,
- timestamp: new Date().toISOString(),
- }
-
- console.log('NPM sync completed:', summary)
-
- return NextResponse.json(summary)
- } catch (error) {
- console.error('NPM sync failed:', error)
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to sync NPM data',
- details:
- error instanceof Error ? error.message : 'Unknown error',
- timestamp: new Date().toISOString(),
- },
- { status: 500 }
- )
- }
-}
-
-// GET /api/npm/sync - Get sync status/info
-export async function GET() {
- try {
- await initializeDatabase()
-
- // Get latest version info from database
- const latestVersion =
- await databaseService.getLatestVersionFromDatabase()
-
- // Get database statistics
- const [versionsResult, trendsResult, statsResult] =
- await Promise.allSettled([
- databaseService.getVersionHistory(),
- databaseService.getDownloadTrends(30),
- databaseService.getPackageStats(),
- ])
-
- const status = {
- database: {
- versions: {
- count:
- versionsResult.status === 'fulfilled'
- ? versionsResult.value.length
- : 0,
- latest: latestVersion,
- lastUpdate: 'Unknown',
- },
- trends: {
- count:
- trendsResult.status === 'fulfilled'
- ? trendsResult.value.length
- : 0,
- lastUpdate: 'Unknown',
- },
- stats: {
- hasData:
- statsResult.status === 'fulfilled' &&
- statsResult.value !== null,
- version:
- statsResult.status === 'fulfilled' && statsResult.value
- ? statsResult.value.version
- : null,
- lastUpdate: 'Unknown',
- },
- },
- lastSyncAttempt: 'Manual trigger only',
- nextScheduledSync: 'Not scheduled',
- recommendations: [] as string[],
- }
-
- // Add recommendations based on data status
- if (status.database.versions.count === 0) {
- status.recommendations.push('Run sync to populate version history')
- }
- if (status.database.trends.count < 30) {
- status.recommendations.push(
- 'Run sync to get complete download trends'
- )
- }
- if (!status.database.stats.hasData) {
- status.recommendations.push('Run sync to get package statistics')
- }
-
- return NextResponse.json(status)
- } catch (error) {
- console.error('Error getting sync status:', error)
- return NextResponse.json(
- {
- error: 'Failed to get sync status',
- details:
- error instanceof Error ? error.message : 'Unknown error',
- },
- { status: 500 }
- )
- }
-}
diff --git a/apps/blend-monitor/app/api/npm/trends/route.ts b/apps/blend-monitor/app/api/npm/trends/route.ts
deleted file mode 100644
index 282c0c3eb..000000000
--- a/apps/blend-monitor/app/api/npm/trends/route.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { NextResponse } from 'next/server'
-import { databaseService } from '@/backend/lib/database-service'
-import { initializeDatabase } from '@/backend/lib/database'
-import { NPMClient } from '@/backend/external/npm-client'
-
-// GET /api/npm/trends - Get NPM download trends
-export async function GET() {
- let dbError: string | null = null
- let npmError: string | null = null
-
- // Try database first
- try {
- await initializeDatabase()
- const trends = await databaseService.getDownloadTrends(30)
-
- if (trends && trends.length > 0) {
- console.log(`Returning ${trends.length} trends from database`)
- return NextResponse.json(trends)
- }
- console.log('Database connected but no trends found')
- } catch (error) {
- dbError =
- error instanceof Error
- ? error.message
- : 'Database connection failed'
- console.error('Database error:', dbError)
- }
-
- // Try NPM API as fallback
- try {
- console.log('Trying NPM API for download trends...')
- const npmClient = new NPMClient('@juspay/blend-design-system')
- const downloadTrends = await npmClient.getDownloadTrends(30)
-
- if (downloadTrends && downloadTrends.length > 0) {
- // Try to save to database if it's available
- if (!dbError) {
- try {
- for (const trend of downloadTrends) {
- await databaseService.saveDownloadTrend(
- trend.date,
- trend.downloads
- )
- }
- console.log(
- `Fetched and saved ${downloadTrends.length} trends from NPM`
- )
- } catch (saveError) {
- console.warn('Could not save to database:', saveError)
- }
- }
-
- console.log(
- `Returning ${downloadTrends.length} trends from NPM API`
- )
- return NextResponse.json(downloadTrends)
- }
- npmError = 'No trend data available from NPM'
- } catch (error) {
- npmError = error instanceof Error ? error.message : 'NPM API failed'
- console.error('NPM API error:', npmError)
- }
-
- // Both sources failed - return error with details
- return NextResponse.json(
- {
- error: 'Unable to fetch download trends',
- details: {
- database: dbError || 'Connected but no data found',
- npm: npmError || 'Unknown error',
- suggestion:
- 'Check database connection and NPM API availability',
- },
- },
- { status: 503 } // Service Unavailable
- )
-}
diff --git a/apps/blend-monitor/app/api/npm/versions/route.ts b/apps/blend-monitor/app/api/npm/versions/route.ts
deleted file mode 100644
index bbeae2012..000000000
--- a/apps/blend-monitor/app/api/npm/versions/route.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// Re-export from backend API
-export { GET } from '@/backend/api/npm/versions/route'
diff --git a/apps/blend-monitor/app/api/users/[userId]/role/route.ts b/apps/blend-monitor/app/api/users/[userId]/role/route.ts
deleted file mode 100644
index 0e0374525..000000000
--- a/apps/blend-monitor/app/api/users/[userId]/role/route.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server'
-import {
- authenticateRequest,
- requirePermission,
- logAuditEvent,
-} from '@/backend/lib/auth-middleware'
-import { databaseService } from '@/backend/lib/database-service'
-import { initializeDatabase } from '@/backend/lib/database'
-
-export async function PUT(
- request: NextRequest,
- { params }: { params: Promise<{ userId: string }> }
-) {
- // Await the params first
- const { userId } = await params
-
- try {
- await initializeDatabase()
-
- // Authenticate the request
- const user = await authenticateRequest(request)
-
- // Check permissions
- const permissionCheck = await requirePermission('users', 'write')(
- request,
- user
- )
- if (permissionCheck) {
- return permissionCheck
- }
-
- const { newRole } = await request.json()
-
- if (!newRole) {
- return NextResponse.json(
- { error: 'New role is required' },
- { status: 400 }
- )
- }
-
- // Get current user data for audit logging
- const currentUserData =
- await databaseService.getUserByFirebaseUid(userId)
- if (!currentUserData) {
- return NextResponse.json(
- { error: 'User not found' },
- { status: 404 }
- )
- }
-
- const oldRole = currentUserData.role
-
- // Update the user role
- await databaseService.updateUserRole(userId, newRole)
-
- // Log the audit event
- await logAuditEvent(
- user!,
- 'role_change',
- `user:${userId}`,
- {
- targetUser: currentUserData.email,
- oldRole,
- newRole,
- userId: userId,
- },
- 'success'
- )
-
- return NextResponse.json({
- success: true,
- message: 'User role updated successfully',
- oldRole,
- newRole,
- })
- } catch (error) {
- console.error('Error updating user role:', error)
-
- // Log failed attempt
- const user = await authenticateRequest(request)
- if (user) {
- await logAuditEvent(
- user,
- 'role_change',
- `user:${userId}`,
- {
- error:
- error instanceof Error
- ? error.message
- : 'Unknown error',
- userId: userId,
- },
- 'failed'
- )
- }
-
- return NextResponse.json(
- { error: 'Failed to update user role' },
- { status: 500 }
- )
- }
-}
diff --git a/apps/blend-monitor/app/api/users/[userId]/route.ts b/apps/blend-monitor/app/api/users/[userId]/route.ts
deleted file mode 100644
index 861e903ce..000000000
--- a/apps/blend-monitor/app/api/users/[userId]/route.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server'
-import { databaseService } from '@/backend/lib/database-service'
-import { initializeDatabase } from '@/backend/lib/database'
-
-export async function GET(
- request: NextRequest,
- { params }: { params: Promise<{ userId: string }> }
-) {
- const { userId } = await params
-
- try {
- await initializeDatabase()
-
- const user = await databaseService.getUserByFirebaseUid(userId)
-
- if (!user) {
- return NextResponse.json(
- { success: false, error: 'User not found' },
- { status: 404 }
- )
- }
-
- return NextResponse.json({
- success: true,
- user,
- })
- } catch (error) {
- console.error('Error fetching user:', error)
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to fetch user',
- details:
- error instanceof Error ? error.message : 'Unknown error',
- },
- { status: 500 }
- )
- }
-}
-
-export async function PATCH(
- request: NextRequest,
- { params }: { params: Promise<{ userId: string }> }
-) {
- const { userId } = await params
-
- try {
- await initializeDatabase()
-
- const body = await request.json()
- const { role, is_active, display_name } = body
-
- // Update user role if provided
- if (role !== undefined) {
- await databaseService.updateUserRole(userId, role)
- }
-
- // Update user status if provided
- if (is_active !== undefined) {
- await databaseService.updateUserStatus(userId, is_active)
- }
-
- // Update display name if provided
- if (display_name !== undefined) {
- await databaseService.updateUserDisplayName(userId, display_name)
- }
-
- // Get updated user
- const updatedUser = await databaseService.getUserByFirebaseUid(userId)
-
- return NextResponse.json({
- success: true,
- user: updatedUser,
- })
- } catch (error) {
- console.error('Error updating user:', error)
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to update user',
- details:
- error instanceof Error ? error.message : 'Unknown error',
- },
- { status: 500 }
- )
- }
-}
-
-export async function DELETE(
- request: NextRequest,
- { params }: { params: Promise<{ userId: string }> }
-) {
- const { userId } = await params
-
- try {
- await initializeDatabase()
-
- await databaseService.deleteUser(userId)
-
- return NextResponse.json({
- success: true,
- message: 'User deleted successfully',
- })
- } catch (error) {
- console.error('Error deleting user:', error)
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to delete user',
- details:
- error instanceof Error ? error.message : 'Unknown error',
- },
- { status: 500 }
- )
- }
-}
diff --git a/apps/blend-monitor/app/api/users/activity/route.ts b/apps/blend-monitor/app/api/users/activity/route.ts
deleted file mode 100644
index b0a15bc9b..000000000
--- a/apps/blend-monitor/app/api/users/activity/route.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server'
-import { databaseService } from '@/backend/lib/database-service'
-import { initializeDatabase } from '@/backend/lib/database'
-import { authenticateRequest } from '@/backend/lib/auth-middleware'
-
-export async function POST(request: NextRequest) {
- try {
- await initializeDatabase()
-
- // Note: POST is used by the frontend after login/logout
- // We'll allow this without full authentication for now
- const body = await request.json()
- const { user_id, action, details } = body
-
- if (!user_id || !action) {
- return NextResponse.json(
- { success: false, error: 'user_id and action are required' },
- { status: 400 }
- )
- }
-
- // Get user from database using firebase_uid
- const user = await databaseService.getUserByFirebaseUid(user_id)
-
- if (!user) {
- return NextResponse.json(
- { success: false, error: 'User not found' },
- { status: 404 }
- )
- }
-
- // Log the activity
- await databaseService.logUserActivity(user.id, action, details)
-
- return NextResponse.json({
- success: true,
- message: 'Activity logged successfully',
- })
- } catch (error) {
- console.error('Error logging activity:', error)
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to log activity',
- details:
- error instanceof Error ? error.message : 'Unknown error',
- },
- { status: 500 }
- )
- }
-}
-
-export async function GET(request: NextRequest) {
- try {
- await initializeDatabase()
-
- // Authenticate the request
- const authenticatedUser = await authenticateRequest(request)
- if (!authenticatedUser) {
- return NextResponse.json(
- { success: false, error: 'Authentication required' },
- { status: 401 }
- )
- }
-
- const searchParams = request.nextUrl.searchParams
- const userId = searchParams.get('userId')
- const limit = searchParams.get('limit')
- const offset = searchParams.get('offset')
-
- if (!userId) {
- return NextResponse.json(
- { success: false, error: 'userId parameter is required' },
- { status: 400 }
- )
- }
-
- // Get user from database using firebase_uid
- const user = await databaseService.getUserByFirebaseUid(userId)
-
- if (!user) {
- return NextResponse.json(
- { success: false, error: 'User not found' },
- { status: 404 }
- )
- }
-
- // Check if user can view this activity
- // Users can view their own activity, admins can view anyone's
- if (
- authenticatedUser.uid !== userId &&
- authenticatedUser.role !== 'admin'
- ) {
- return NextResponse.json(
- { success: false, error: 'Permission denied' },
- { status: 403 }
- )
- }
-
- const activities = await databaseService.getUserActivity(
- user.id,
- limit ? parseInt(limit) : undefined,
- offset ? parseInt(offset) : undefined
- )
-
- return NextResponse.json({
- success: true,
- activities,
- total: activities.length,
- })
- } catch (error) {
- console.error('Error fetching user activity:', error)
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to fetch user activity',
- details:
- error instanceof Error ? error.message : 'Unknown error',
- },
- { status: 500 }
- )
- }
-}
diff --git a/apps/blend-monitor/app/api/users/route.ts b/apps/blend-monitor/app/api/users/route.ts
deleted file mode 100644
index 090e29578..000000000
--- a/apps/blend-monitor/app/api/users/route.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server'
-import { databaseService } from '@/backend/lib/database-service'
-import { initializeDatabase } from '@/backend/lib/database'
-import {
- authenticateRequest,
- requirePermission,
-} from '@/backend/lib/auth-middleware'
-
-export async function GET(request: NextRequest) {
- let retryCount = 0
- const maxRetries = 3
-
- while (retryCount < maxRetries) {
- try {
- await initializeDatabase()
-
- // Authenticate the request
- const user = await authenticateRequest(request)
-
- // Check permissions - users with 'read' permission can view users
- const permissionCheck = await requirePermission('users', 'read')(
- request,
- user
- )
- if (permissionCheck) {
- return permissionCheck
- }
-
- const searchParams = request.nextUrl.searchParams
- const limit = searchParams.get('limit')
- const offset = searchParams.get('offset')
-
- const users = await databaseService.getAllUsers(
- limit ? parseInt(limit) : undefined,
- offset ? parseInt(offset) : undefined
- )
-
- return NextResponse.json({
- success: true,
- users,
- total: users.length,
- })
- } catch (error) {
- console.error(
- `Error fetching users (attempt ${retryCount + 1}):`,
- error
- )
-
- // Check if it's a database connection error
- const isConnectionError =
- error instanceof Error &&
- (error.message.includes('Connection terminated') ||
- error.message.includes('connection timeout') ||
- error.message.includes('ECONNREFUSED'))
-
- if (isConnectionError && retryCount < maxRetries - 1) {
- retryCount++
- console.log(
- `Retrying database connection (attempt ${retryCount + 1}/${maxRetries})`
- )
- // Wait before retrying
- await new Promise((resolve) =>
- setTimeout(resolve, 1000 * retryCount)
- )
- continue
- }
-
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to fetch users',
- details:
- error instanceof Error
- ? error.message
- : 'Unknown error',
- },
- { status: 500 }
- )
- }
- }
-}
-
-export async function POST(request: NextRequest) {
- try {
- await initializeDatabase()
-
- // Note: POST is used during login to create/update user
- // We'll allow this without full authentication but verify the Firebase UID
- const body = await request.json()
- const {
- firebase_uid,
- email,
- display_name,
- photo_url,
- role = 'viewer',
- } = body
-
- if (!firebase_uid || !email) {
- return NextResponse.json(
- {
- success: false,
- error: 'firebase_uid and email are required',
- },
- { status: 400 }
- )
- }
-
- const user = await databaseService.createOrUpdateUser(firebase_uid, {
- email,
- displayName: display_name,
- photoURL: photo_url,
- role,
- })
-
- return NextResponse.json({
- success: true,
- user,
- })
- } catch (error) {
- console.error('Error creating/updating user:', error)
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to create/update user',
- details:
- error instanceof Error ? error.message : 'Unknown error',
- },
- { status: 500 }
- )
- }
-}
diff --git a/apps/blend-monitor/app/code-connect/health/page.tsx b/apps/blend-monitor/app/code-connect/health/page.tsx
deleted file mode 100644
index 3a3050c6a..000000000
--- a/apps/blend-monitor/app/code-connect/health/page.tsx
+++ /dev/null
@@ -1,370 +0,0 @@
-'use client'
-
-import React, { useState, useMemo } from 'react'
-import { useComponents } from '@/frontend/hooks/usePostgreSQLData'
-import Loader from '@/frontend/components/shared/Loader'
-import {
- Button,
- ButtonSize,
- ButtonType,
- Tag,
- TagVariant,
- TagColor,
- TagSize,
- DataTable,
- ColumnDefinition,
- ColumnType,
-} from '@juspay/blend-design-system'
-import {
- AlertCircle,
- CheckCircle,
- XCircle,
- RefreshCw,
- Eye,
- Clock,
-} from 'lucide-react'
-
-interface HealthStatus {
- component: string
- status: 'active' | 'warning' | 'error'
- lastSync: string
- syncRate: number
- errors: string[]
- avgSyncDuration: number
-}
-
-export default function IntegrationHealthPage() {
- const { components, loading } = useComponents()
- const [refreshing, setRefreshing] = useState(false)
- const [selectedComponent, setSelectedComponent] = useState(
- null
- )
-
- // Mock health data - in production, this would come from Firebase
- const healthData = useMemo(() => {
- return components.map((comp) => ({
- component: comp.name,
- status: comp.hasFigmaConnect
- ? Math.random() > 0.8
- ? ('warning' as const)
- : ('active' as const)
- : ('error' as const),
- lastSync: comp.hasFigmaConnect
- ? new Date(Date.now() - Math.random() * 86400000).toISOString()
- : 'Never',
- syncRate: comp.hasFigmaConnect
- ? Math.floor(Math.random() * 20 + 80)
- : 0,
- errors:
- comp.hasFigmaConnect && Math.random() > 0.8
- ? ['Props mismatch detected', 'Variant not found in Figma']
- : [],
- avgSyncDuration: comp.hasFigmaConnect
- ? Math.floor(Math.random() * 5000 + 1000)
- : 0,
- }))
- }, [components])
-
- // Calculate metrics
- const metrics = useMemo(() => {
- const active = healthData.filter((h) => h.status === 'active').length
- const warning = healthData.filter((h) => h.status === 'warning').length
- const error = healthData.filter((h) => h.status === 'error').length
- const avgSyncRate =
- healthData
- .filter((h) => h.syncRate > 0)
- .reduce((acc, h) => acc + h.syncRate, 0) /
- (active + warning) || 0
-
- return { active, warning, error, avgSyncRate }
- }, [healthData])
-
- const handleRefresh = async () => {
- setRefreshing(true)
- try {
- await fetch('/api/components', { method: 'POST' })
- } catch (error) {
- console.error('Error refreshing:', error)
- }
- setRefreshing(false)
- }
-
- const getStatusIcon = (status: string) => {
- switch (status) {
- case 'active':
- return
- case 'warning':
- return
- case 'error':
- return
- default:
- return null
- }
- }
-
- const columns: ColumnDefinition[] = [
- {
- field: 'status' as keyof HealthStatus,
- header: 'Status',
- type: ColumnType.REACT_ELEMENT,
- isSortable: false,
- renderCell: (value: unknown, row: HealthStatus) => (
-