@loworbitstudio/visor 0.10.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -130,6 +130,7 @@
130
130
  "description": "A button component with multiple variants and sizes using CVA.",
131
131
  "category": "form",
132
132
  "dependencies": [
133
+ "@radix-ui/react-slot",
133
134
  "class-variance-authority",
134
135
  "@loworbitstudio/visor-core"
135
136
  ],
@@ -155,6 +156,7 @@
155
156
  "description": "A text input component with focus and validation states.",
156
157
  "category": "form",
157
158
  "dependencies": [
159
+ "class-variance-authority",
158
160
  "@loworbitstudio/visor-core"
159
161
  ],
160
162
  "registryDependencies": [
@@ -205,6 +207,7 @@
205
207
  "description": "A textarea component with auto-resize and validation states.",
206
208
  "category": "form",
207
209
  "dependencies": [
210
+ "class-variance-authority",
208
211
  "@loworbitstudio/visor-core"
209
212
  ],
210
213
  "registryDependencies": [
@@ -246,7 +249,7 @@
246
249
  {
247
250
  "path": "components/ui/checkbox/checkbox.module.css",
248
251
  "type": "registry:ui",
249
- "content": "/* Checkbox root styles */\n.root {\n position: relative;\n display: flex;\n width: 1rem;\n height: 1rem;\n flex-shrink: 0;\n align-items: center;\n justify-content: center;\n border-radius: var(--radius-sm, 0.25rem);\n border: 1px solid var(--border-default, #e5e7eb);\n background-color: transparent;\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n outline: none;\n cursor: pointer;\n}\n\n@media (hover: hover) {\n .root:hover:not(:focus-visible):not(:disabled):not([aria-invalid=\"true\"]) {\n border-color: var(--border-strong, #d1d5db);\n }\n}\n\n.root:focus-visible {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.root:disabled {\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n.root[aria-invalid=\"true\"] {\n border-color: var(--border-error, #ef4444);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-error, #ef4444) 15%, transparent);\n}\n\n.root[data-state=\"checked\"] {\n border-color: var(--interactive-primary-bg, #111827);\n background-color: var(--interactive-primary-bg, #111827);\n color: var(--interactive-primary-text, #f9fafb);\n}\n\n/* Indicator */\n.indicator {\n display: grid;\n place-content: center;\n color: currentColor;\n transition: none;\n}\n\n.icon {\n width: 0.875rem;\n height: 0.875rem;\n}\n"
252
+ "content": "/* Checkbox root styles */\n.root {\n position: relative;\n display: flex;\n width: 1rem;\n height: 1rem;\n flex-shrink: 0;\n align-items: center;\n justify-content: center;\n border-radius: var(--radius-sm, 0.25rem);\n border: 1px solid var(--checkbox-border, var(--border-default, #e5e7eb));\n background-color: var(--checkbox-bg, transparent);\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n outline: none;\n cursor: pointer;\n}\n\n@media (hover: hover) {\n .root:hover:not(:focus-visible):not(:disabled):not([aria-invalid=\"true\"]) {\n border-color: var(--checkbox-border-hover, var(--border-strong, #d1d5db));\n background-color: var(--checkbox-bg-hover, transparent);\n }\n}\n\n.root:focus-visible {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.root:disabled {\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n.root[aria-invalid=\"true\"] {\n border-color: var(--border-error, #ef4444);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-error, #ef4444) 15%, transparent);\n}\n\n.root[data-state=\"checked\"] {\n border-color: var(--checkbox-border-checked, var(--interactive-primary-bg, #111827));\n background-color: var(--checkbox-bg-checked, var(--interactive-primary-bg, #111827));\n color: var(--interactive-primary-text, #f9fafb);\n}\n\n.root[data-state=\"indeterminate\"] {\n border-color: var(--checkbox-border-checked, var(--interactive-primary-bg, #111827));\n background-color: var(--checkbox-bg-checked, var(--interactive-primary-bg, #111827));\n color: var(--interactive-primary-text, #f9fafb);\n}\n\n/* Indicator */\n.indicator {\n display: grid;\n place-content: center;\n color: currentColor;\n transition: none;\n}\n\n.icon {\n width: 0.875rem;\n height: 0.875rem;\n}\n"
250
253
  }
251
254
  ]
252
255
  },
@@ -398,7 +401,28 @@
398
401
  {
399
402
  "path": "components/ui/badge/badge.module.css",
400
403
  "type": "registry:ui",
401
- "content": "/* Badge base */\n.base {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n height: 1.25rem;\n width: fit-content;\n flex-shrink: 0;\n gap: var(--spacing-1, 0.25rem);\n overflow: hidden;\n border-radius: var(--radius-full, 9999px);\n border: 1px solid transparent;\n padding: calc(var(--spacing-1, 0.25rem) / 2) var(--spacing-2, 0.5rem);\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-medium, 500);\n white-space: nowrap;\n transition: background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n.base:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n/* Variants */\n.variantDefault {\n background-color: var(--interactive-primary-bg, #111827);\n color: var(--interactive-primary-text, #f9fafb);\n border-color: transparent;\n}\n\n.variantSecondary {\n background-color: var(--interactive-secondary-bg, #f3f4f6);\n color: var(--interactive-secondary-text, #111827);\n border-color: transparent;\n}\n\n.variantOutline {\n background-color: transparent;\n color: var(--text-primary, #111827);\n border-color: var(--border-default, #e5e7eb);\n}\n\n.variantDestructive {\n background-color: var(--surface-error-subtle, #fef2f2);\n color: var(--text-error, #b91c1c);\n border-color: transparent;\n}\n\n.variantSuccess {\n background-color: var(--surface-success-subtle, #f0fdf4);\n color: var(--text-success, #15803d);\n border-color: transparent;\n}\n\n.variantWarning {\n background-color: var(--surface-warning-subtle, #fffbeb);\n color: var(--text-warning, #b45309);\n border-color: transparent;\n}\n\n.variantInfo {\n background-color: var(--surface-info-subtle, #f0f9ff);\n color: var(--text-info, #0369a1);\n border-color: transparent;\n}\n\n/* Filled variants — step 9 bg + contrast text */\n.variantFilledDestructive {\n background-color: var(--surface-error-default, #ef4444);\n color: var(--text-inverse, #ffffff);\n border-color: transparent;\n}\n\n.variantFilledSuccess {\n background-color: var(--surface-success-default, #22c55e);\n color: var(--text-inverse, #ffffff);\n border-color: transparent;\n}\n\n.variantFilledWarning {\n background-color: var(--surface-warning-default, #f59e0b);\n color: var(--text-inverse, #ffffff);\n border-color: transparent;\n}\n\n.variantFilledInfo {\n background-color: var(--surface-info-default, #0ea5e9);\n color: var(--text-inverse, #ffffff);\n border-color: transparent;\n}\n"
404
+ "content": "/* Badge base */\n.base {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n height: 1.25rem;\n width: fit-content;\n flex-shrink: 0;\n gap: var(--spacing-1, 0.25rem);\n overflow: hidden;\n border-radius: var(--radius-full, 9999px);\n border: 1px solid transparent;\n padding: calc(var(--spacing-1, 0.25rem) / 2) var(--spacing-2, 0.5rem);\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-medium, 500);\n white-space: nowrap;\n transition: background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n.base:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n/* Variants */\n.variantDefault {\n background-color: var(--interactive-primary-bg, currentColor);\n color: var(--interactive-primary-text, var(--text-inverse, #f9fafb));\n border-color: transparent;\n}\n\n.variantSecondary {\n background-color: var(--interactive-secondary-bg, var(--surface-subtle, transparent));\n color: var(--interactive-secondary-text, var(--text-primary, currentColor));\n border-color: transparent;\n}\n\n.variantOutline {\n background-color: transparent;\n color: var(--text-primary, #111827);\n border-color: var(--border-default, #e5e7eb);\n}\n\n.variantDestructive {\n background-color: var(--surface-error-subtle, transparent);\n color: var(--text-error, currentColor);\n border-color: transparent;\n}\n\n.variantSuccess {\n background-color: var(--surface-success-subtle, transparent);\n color: var(--text-success, currentColor);\n border-color: transparent;\n}\n\n.variantWarning {\n background-color: var(--surface-warning-subtle, transparent);\n color: var(--text-warning, currentColor);\n border-color: transparent;\n}\n\n.variantInfo {\n background-color: var(--surface-info-subtle, transparent);\n color: var(--text-info, currentColor);\n border-color: transparent;\n}\n\n/* Filled variants — step 9 bg + contrast text */\n.variantFilledDestructive {\n background-color: var(--surface-error-default, transparent);\n color: var(--text-inverse, currentColor);\n border-color: transparent;\n}\n\n.variantFilledSuccess {\n background-color: var(--surface-success-default, transparent);\n color: var(--text-inverse, currentColor);\n border-color: transparent;\n}\n\n.variantFilledWarning {\n background-color: var(--surface-warning-default, transparent);\n color: var(--text-inverse, currentColor);\n border-color: transparent;\n}\n\n.variantFilledInfo {\n background-color: var(--surface-info-default, transparent);\n color: var(--text-inverse, currentColor);\n border-color: transparent;\n}\n"
405
+ }
406
+ ]
407
+ },
408
+ {
409
+ "name": "sparkline",
410
+ "type": "registry:ui",
411
+ "description": "A decorative inline SVG mini-trend chart for stat-card trend slots and dense data contexts. Renders a single polyline from a numeric series with zero dependencies; defaults to 96×22, accent-primary stroke.",
412
+ "category": "data-display",
413
+ "dependencies": [
414
+ "@loworbitstudio/visor-core"
415
+ ],
416
+ "files": [
417
+ {
418
+ "path": "components/ui/sparkline/sparkline.tsx",
419
+ "type": "registry:ui",
420
+ "content": "import * as React from \"react\"\nimport styles from \"./sparkline.module.css\"\n\nexport interface SparklineProps\n extends Omit<React.SVGAttributes<SVGSVGElement>, \"viewBox\" | \"values\"> {\n /** Numeric series — minimum 2 values required to render. */\n values: number[]\n /** SVG width in px. Defaults to 96. */\n width?: number\n /** SVG height in px. Defaults to 22. */\n height?: number\n /** Stroke color — accepts CSS var, hex, hsl. Defaults to `var(--accent-primary)`. */\n color?: string\n /** Stroke width in px. Defaults to 1.5. */\n strokeWidth?: number\n /**\n * When true, the rendered `<svg>` omits its `width` attribute so it fills its\n * container (the `viewBox` preserves the aspect ratio). A CSS class forces\n * `width: 100%; height: auto; display: block;`. Defaults to `false`.\n */\n fluid?: boolean\n /** When supplied, the sparkline becomes a labeled image instead of decorative. */\n \"aria-label\"?: string\n}\n\nconst Sparkline = React.forwardRef<SVGSVGElement, SparklineProps>(\n (\n {\n values,\n width = 96,\n height = 22,\n color = \"var(--accent-primary)\",\n strokeWidth = 1.5,\n fluid = false,\n className,\n \"aria-label\": ariaLabel,\n ...props\n },\n ref\n ) => {\n if (!values || values.length < 2) return null\n\n const min = Math.min(...values)\n const max = Math.max(...values)\n const range = max - min || 1\n const stepX = width / (values.length - 1)\n const points = values\n .map((v, i) => {\n const x = i * stepX\n const y = height - ((v - min) / range) * height\n return `${x.toFixed(1)},${y.toFixed(1)}`\n })\n .join(\" \")\n\n const isLabeled = typeof ariaLabel === \"string\" && ariaLabel.length > 0\n\n return (\n <svg\n ref={ref}\n data-slot=\"sparkline\"\n className={[styles.svg, fluid && styles.svgFluid, className]\n .filter(Boolean)\n .join(\" \")}\n {...(fluid ? {} : { width })}\n height={height}\n viewBox={`0 0 ${width} ${height}`}\n role=\"img\"\n {...(isLabeled\n ? { \"aria-label\": ariaLabel }\n : { \"aria-hidden\": true })}\n {...props}\n >\n <polyline\n points={points}\n fill=\"none\"\n stroke={color}\n strokeWidth={strokeWidth}\n strokeLinejoin=\"round\"\n strokeLinecap=\"round\"\n />\n </svg>\n )\n }\n)\nSparkline.displayName = \"Sparkline\"\n\nexport { Sparkline }\n"
421
+ },
422
+ {
423
+ "path": "components/ui/sparkline/sparkline.module.css",
424
+ "type": "registry:ui",
425
+ "content": "/* Sparkline — inline SVG mini-trend chart */\n.svg {\n display: block;\n}\n\n.svgFluid {\n width: 100%;\n height: auto;\n display: block;\n}\n"
402
426
  }
403
427
  ]
404
428
  },
@@ -568,12 +592,12 @@
568
592
  {
569
593
  "path": "components/ui/progress/progress.tsx",
570
594
  "type": "registry:ui",
571
- "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ProgressPrimitive from \"@radix-ui/react-progress\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./progress.module.css\"\n\nconst Progress = React.forwardRef<\n React.ElementRef<typeof ProgressPrimitive.Root>,\n React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>\n>(({ className, value, ...props }, ref) => {\n return (\n <ProgressPrimitive.Root\n ref={ref}\n data-slot=\"progress\"\n className={cn(styles.root, className)}\n {...props}\n >\n <ProgressPrimitive.Indicator\n data-slot=\"progress-indicator\"\n className={styles.indicator}\n style={{ transform: `translateX(-${100 - (value || 0)}%)` }}\n />\n </ProgressPrimitive.Root>\n )\n})\nProgress.displayName = \"Progress\"\n\nexport { Progress }\n"
595
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ProgressPrimitive from \"@radix-ui/react-progress\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./progress.module.css\"\n\nexport interface ProgressProps\n extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {\n /** Track size. `\"thin\"` renders a 4px-tall variant for static admin chrome. */\n size?: \"default\" | \"thin\"\n /** Whether the indicator transitions on value change. Defaults to `true`. */\n animate?: boolean\n}\n\nconst Progress = React.forwardRef<\n React.ElementRef<typeof ProgressPrimitive.Root>,\n ProgressProps\n>(({ className, value, size = \"default\", animate = true, ...props }, ref) => {\n return (\n <ProgressPrimitive.Root\n ref={ref}\n data-slot=\"progress\"\n data-size={size === \"thin\" ? \"thin\" : undefined}\n data-animate={animate === false ? \"false\" : undefined}\n className={cn(styles.root, className)}\n {...props}\n >\n <ProgressPrimitive.Indicator\n data-slot=\"progress-indicator\"\n className={styles.indicator}\n style={{ transform: `translateX(-${100 - (value || 0)}%)` }}\n />\n </ProgressPrimitive.Root>\n )\n})\nProgress.displayName = \"Progress\"\n\nexport { Progress }\n"
572
596
  },
573
597
  {
574
598
  "path": "components/ui/progress/progress.module.css",
575
599
  "type": "registry:ui",
576
- "content": "/* Progress root */\n.root {\n position: relative;\n display: flex;\n width: 100%;\n height: 0.75rem;\n align-items: center;\n overflow: hidden;\n border-radius: var(--radius-full, 9999px);\n background-color: var(--surface-muted, #f3f4f6);\n}\n\n/* Progress indicator */\n.indicator {\n width: 100%;\n height: 100%;\n flex: 1;\n background-color: var(--interactive-primary-bg, #111827);\n transition: transform var(--motion-duration-300, 300ms) var(--motion-easing-default, ease-in-out);\n}\n"
600
+ "content": "/* Progress root */\n.root {\n position: relative;\n display: flex;\n width: 100%;\n height: 0.75rem;\n align-items: center;\n overflow: hidden;\n border-radius: var(--radius-full, 9999px);\n background-color: var(--surface-muted, #f3f4f6);\n}\n\n/* Progress indicator */\n.indicator {\n width: 100%;\n height: 100%;\n flex: 1;\n background-color: var(--interactive-primary-bg, #111827);\n transition: transform var(--motion-duration-300, 300ms) var(--motion-easing-default, ease-in-out);\n}\n\n/* Thin static-chrome variant — 4px capacity bar for admin KPI strips */\n.root[data-size=\"thin\"] {\n height: 4px;\n background-color: var(--surface-interactive-active, #e5e7eb);\n}\n\n/* animate={false} — drop the indicator transition for static chrome */\n.root[data-animate=\"false\"] .indicator {\n transition: none;\n}\n"
577
601
  }
578
602
  ]
579
603
  },
@@ -939,7 +963,7 @@
939
963
  {
940
964
  "path": "components/ui/table/table.module.css",
941
965
  "type": "registry:ui",
942
- "content": "/* Table container */\n.container {\n position: relative;\n width: 100%;\n overflow-x: auto;\n overflow-y: hidden;\n border-radius: var(--radius-lg, 0.5rem);\n box-shadow: var(--shadow-sm);\n}\n\n/* Table */\n.table {\n width: 100%;\n caption-side: bottom;\n border-collapse: collapse;\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-primary, #111827);\n}\n\n/* TableHeader */\n.header tr {\n border-bottom: 1px solid var(--border-default, #e5e7eb);\n}\n\n/* TableBody */\n.body tr:last-child {\n border-bottom: none;\n}\n\n/* TableFooter */\n.footer {\n border-top: 1px solid var(--border-default, #e5e7eb);\n background-color: var(--surface-muted, #f3f4f6);\n font-weight: var(--font-weight-medium, 500);\n}\n\n.footer tr:last-child {\n border-bottom: none;\n}\n\n/* TableRow */\n.row {\n border-bottom: 1px solid var(--border-default, #e5e7eb);\n transition: background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n.row:hover {\n background-color: var(--surface-muted, #f3f4f6);\n}\n\n.row[data-state=\"selected\"] {\n background-color: var(--surface-muted, #f3f4f6);\n}\n\n/* TableHead */\n.head {\n height: 3rem;\n padding-left: var(--spacing-3, 0.75rem);\n padding-right: var(--spacing-3, 0.75rem);\n text-align: left;\n vertical-align: middle;\n font-weight: var(--font-weight-medium, 500);\n white-space: nowrap;\n color: var(--text-primary, #111827);\n}\n\n/* TableCell */\n.cell {\n padding: var(--spacing-3, 0.75rem);\n vertical-align: middle;\n color: var(--text-primary, #111827);\n}\n\n/* TableCaption */\n.caption {\n margin-top: var(--spacing-4, 1rem);\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n}\n"
966
+ "content": "/* Table container */\n.container {\n position: relative;\n width: 100%;\n overflow-x: auto;\n overflow-y: hidden;\n border-radius: var(--radius-lg, 0.5rem);\n box-shadow: var(--shadow-sm);\n}\n\n/* Table */\n.table {\n width: 100%;\n caption-side: bottom;\n border-collapse: collapse;\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-primary, #111827);\n}\n\n/* TableHeader */\n.header tr {\n border-bottom: 1px solid var(--border-default, #e5e7eb);\n}\n\n/* TableBody */\n.body tr:last-child {\n border-bottom: none;\n}\n\n/* TableFooter */\n.footer {\n border-top: 1px solid var(--border-default, #e5e7eb);\n background-color: var(--surface-muted, #f3f4f6);\n font-weight: var(--font-weight-medium, 500);\n}\n\n.footer tr:last-child {\n border-bottom: none;\n}\n\n/* TableRow */\n.row {\n border-bottom: 1px solid var(--border-default, #e5e7eb);\n transition: background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n.row:not([data-slot=\"data-table-group-row\"]):hover {\n background-color: var(--surface-muted, #f3f4f6);\n}\n\n.row[data-state=\"selected\"] {\n background-color: var(--surface-muted, #f3f4f6);\n}\n\n/* TableHead */\n.head {\n height: 3rem;\n padding-left: var(--spacing-3, 0.75rem);\n padding-right: var(--spacing-3, 0.75rem);\n text-align: left;\n vertical-align: middle;\n font-weight: var(--font-weight-medium, 500);\n white-space: nowrap;\n color: var(--text-primary, #111827);\n}\n\n/* TableCell */\n.cell {\n padding: var(--spacing-3, 0.75rem);\n vertical-align: middle;\n color: var(--text-primary, #111827);\n}\n\n/* TableCaption */\n.caption {\n margin-top: var(--spacing-4, 1rem);\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n}\n"
943
967
  }
944
968
  ]
945
969
  },
@@ -2052,6 +2076,32 @@
2052
2076
  }
2053
2077
  ]
2054
2078
  },
2079
+ {
2080
+ "name": "chrome-button",
2081
+ "type": "registry:ui",
2082
+ "description": "A 28px-tall button primitive for topbar and chrome contexts with an optional leading icon and trailing Kbd shortcut hint. Two variants (default, primary). Composes Button-like behavior at chrome density — not a fork of Button.",
2083
+ "category": "admin",
2084
+ "dependencies": [
2085
+ "class-variance-authority",
2086
+ "@loworbitstudio/visor-core"
2087
+ ],
2088
+ "registryDependencies": [
2089
+ "utils",
2090
+ "kbd"
2091
+ ],
2092
+ "files": [
2093
+ {
2094
+ "path": "components/ui/chrome-button/chrome-button.tsx",
2095
+ "type": "registry:ui",
2096
+ "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport { Kbd } from \"../kbd/kbd\"\nimport styles from \"./chrome-button.module.css\"\n\nconst chromeButtonVariants = cva(styles.root, {\n variants: {\n variant: {\n default: styles.variantDefault,\n primary: styles.variantPrimary,\n },\n },\n defaultVariants: {\n variant: \"default\",\n },\n})\n\nexport interface ChromeButtonProps\n extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"children\">,\n VariantProps<typeof chromeButtonVariants> {\n /** Leading icon node — typically a 14px Phosphor icon. */\n icon?: React.ReactNode\n /** Trailing keyboard shortcut hint, e.g. `[\"⌘\", \"K\"]`. Rendered as `<Kbd keys={...} size=\"sm\" />`. */\n keys?: string[]\n children: React.ReactNode\n}\n\n/**\n * ChromeButton — 28px button primitive for topbar and chrome contexts.\n *\n * Composes an optional leading icon, a label, and an optional trailing Kbd\n * shortcut hint. Two variants: `default` (muted interactive surface) and\n * `primary` (accent surface). Inherits all native `<button>` behavior — pass\n * `aria-label` via spread props for icon-only usage.\n *\n * Not a replacement for Button — Button is full-scale body chrome (32/40/48px);\n * ChromeButton is dense topbar chrome (28px) with the inline Kbd slot pattern\n * admin shells repeat.\n */\nconst ChromeButton = React.forwardRef<HTMLButtonElement, ChromeButtonProps>(\n (\n { className, variant, icon, keys, type = \"button\", children, ...props },\n ref\n ) => {\n return (\n <button\n ref={ref}\n type={type}\n data-slot=\"chrome-button\"\n data-variant={variant ?? \"default\"}\n className={cn(chromeButtonVariants({ variant }), className)}\n {...props}\n >\n {icon ? (\n <span\n data-slot=\"chrome-button-icon\"\n className={styles.icon}\n aria-hidden=\"true\"\n >\n {icon}\n </span>\n ) : null}\n <span data-slot=\"chrome-button-label\" className={styles.label}>\n {children}\n </span>\n {keys && keys.length > 0 ? (\n <span data-slot=\"chrome-button-kbd\" className={styles.keys}>\n <Kbd keys={keys} size=\"sm\" />\n </span>\n ) : null}\n </button>\n )\n }\n)\nChromeButton.displayName = \"ChromeButton\"\n\nexport { ChromeButton, chromeButtonVariants }\n"
2097
+ },
2098
+ {
2099
+ "path": "components/ui/chrome-button/chrome-button.module.css",
2100
+ "type": "registry:ui",
2101
+ "content": "/* ChromeButton — 28px topbar/chrome button primitive.\n *\n * Compact dimensions for admin chrome contexts (topbar, toolbar). Composes a\n * leading icon, a label, and a trailing Kbd hint at chrome density. Fully\n * theme-portable — all colors, motion, and radius bind to Visor tokens.\n */\n.root {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n height: 1.75rem; /* 28px */\n padding: 0 var(--spacing-3, 0.75rem);\n border-radius: var(--radius-md, 0.375rem);\n border: 1px solid transparent;\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n line-height: 1;\n cursor: pointer;\n outline: none;\n white-space: nowrap;\n transition:\n background-color var(--motion-duration-150, 150ms)\n var(--motion-easing-default, ease-in-out),\n color var(--motion-duration-150, 150ms)\n var(--motion-easing-default, ease-in-out),\n border-color var(--motion-duration-150, 150ms)\n var(--motion-easing-default, ease-in-out),\n opacity var(--motion-duration-150, 150ms)\n var(--motion-easing-default, ease-in-out);\n}\n\n.root:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.root:active:not(:disabled) {\n transform: translateY(1px);\n transition: none;\n}\n\n.root:disabled {\n pointer-events: none;\n opacity: var(--opacity-50, 0.5);\n}\n\n/* Variants */\n.variantDefault {\n background-color: var(--surface-interactive-default, #f3f4f6);\n color: var(--text-primary, #111827);\n}\n\n.variantPrimary {\n background-color: var(--interactive-primary-bg, var(--accent-primary, #111827));\n color: var(--interactive-primary-text, var(--text-inverse, #f9fafb));\n font-weight: var(--font-weight-semibold, 600);\n}\n\n@media (hover: hover) {\n .variantDefault:hover {\n background-color: var(--surface-interactive-hover, #e5e7eb);\n }\n\n .variantPrimary:hover {\n background-color: var(--interactive-primary-bg-hover, color-mix(in srgb, var(--accent-primary, #111827) 85%, white));\n }\n}\n\n/* Slots */\n.icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n font-size: var(--font-size-sm, 0.875rem);\n line-height: 1;\n}\n\n.icon svg {\n display: block;\n}\n\n.label {\n display: inline-flex;\n align-items: center;\n line-height: 1;\n}\n\n.keys {\n display: inline-flex;\n align-items: center;\n margin-left: calc(var(--spacing-1, 0.25rem) / 2);\n}\n"
2102
+ }
2103
+ ]
2104
+ },
2055
2105
  {
2056
2106
  "name": "confirm-dialog",
2057
2107
  "type": "registry:ui",
@@ -2104,12 +2154,12 @@
2104
2154
  {
2105
2155
  "path": "components/ui/data-table/data-table.tsx",
2106
2156
  "type": "registry:ui",
2107
- "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n flexRender,\n getCoreRowModel,\n getFilteredRowModel,\n getPaginationRowModel,\n getSortedRowModel,\n useReactTable,\n type ColumnDef,\n type OnChangeFn,\n type PaginationState,\n type RowSelectionState,\n type SortingState,\n type Table as TanstackTable,\n} from \"@tanstack/react-table\"\nimport {\n CaretDownIcon,\n CaretLeftIcon,\n CaretRightIcon,\n CaretUpIcon,\n CaretUpDownIcon,\n} from \"@phosphor-icons/react\"\n\nimport { cn } from \"../../../lib/utils\"\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"../table/table\"\nimport { Button } from \"../button/button\"\nimport { Checkbox } from \"../checkbox/checkbox\"\nimport { Skeleton } from \"../skeleton/skeleton\"\nimport { EmptyState } from \"../empty-state/empty-state\"\nimport styles from \"./data-table.module.css\"\n\nexport type {\n ColumnDef,\n SortingState,\n RowSelectionState,\n PaginationState,\n OnChangeFn,\n}\n\nexport interface DataTableGroupRow {\n kind: \"group\"\n id: string\n label: string\n count?: number\n}\n\nexport interface DataTableDataRow<TData> {\n kind: \"data\"\n id: string\n row: TData\n}\n\nexport type DataTableRow<TData> = DataTableGroupRow | DataTableDataRow<TData>\n\nexport interface DataTableProps<TData, TValue = unknown>\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n columns: ColumnDef<TData, TValue>[]\n data?: TData[]\n\n // Mixed render order with group-head separators. When provided, the caller\n // owns sort/grouping/windowing — sort UI and pagination footer are\n // suppressed. Group rows are excluded from selection state.\n rows?: DataTableRow<TData>[]\n groupRowRenderer?: (group: DataTableGroupRow) => React.ReactNode\n\n // Sorting\n sorting?: SortingState\n onSortingChange?: OnChangeFn<SortingState>\n defaultSorting?: SortingState\n\n // Pagination\n pagination?: PaginationState\n onPaginationChange?: OnChangeFn<PaginationState>\n pageSize?: number\n pageSizeOptions?: number[]\n\n // Selection\n enableRowSelection?: boolean\n rowSelection?: RowSelectionState\n onRowSelectionChange?: OnChangeFn<RowSelectionState>\n getRowId?: (row: TData, index: number) => string\n\n // Global filter\n globalFilter?: string\n onGlobalFilterChange?: (value: string) => void\n\n // States\n loading?: boolean\n emptyState?: React.ReactNode\n\n // Layout\n stickyHeader?: boolean\n}\n\nconst DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100]\n\nfunction DataTableInner<TData, TValue = unknown>(\n props: DataTableProps<TData, TValue>,\n ref: React.ForwardedRef<HTMLDivElement>\n) {\n const {\n columns: userColumns,\n data,\n rows,\n groupRowRenderer,\n sorting: controlledSorting,\n onSortingChange,\n defaultSorting,\n pagination: controlledPagination,\n onPaginationChange,\n pageSize = 10,\n pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,\n enableRowSelection = false,\n rowSelection: controlledRowSelection,\n onRowSelectionChange,\n getRowId,\n globalFilter: controlledGlobalFilter,\n onGlobalFilterChange,\n loading = false,\n emptyState,\n stickyHeader = false,\n className,\n ...rest\n } = props\n\n // When rows is provided, the caller owns sort/grouping/windowing. We bypass\n // TanStack pagination and column sort UI, but keep the table instance for\n // selection state and cell rendering on data rows.\n const hasRows = rows != null\n const dataItems = React.useMemo(() => {\n if (hasRows) {\n return rows!.flatMap((item) =>\n item.kind === \"data\" ? [item.row] : []\n )\n }\n return data ?? []\n }, [hasRows, rows, data])\n\n const internalGetRowId = React.useMemo(() => {\n if (getRowId) return getRowId\n if (hasRows) {\n const ids = rows!.flatMap((item) =>\n item.kind === \"data\" ? [item.id] : []\n )\n return (_row: TData, index: number) => ids[index] ?? String(index)\n }\n return undefined\n }, [getRowId, hasRows, rows])\n\n // Uncontrolled sorting state\n const [internalSorting, setInternalSorting] = React.useState<SortingState>(\n defaultSorting ?? []\n )\n const sortingIsControlled = controlledSorting !== undefined\n const sorting = sortingIsControlled ? controlledSorting : internalSorting\n const handleSortingChange: OnChangeFn<SortingState> = (updater) => {\n if (!sortingIsControlled) {\n setInternalSorting((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onSortingChange?.(updater)\n }\n\n // Uncontrolled pagination state\n const [internalPagination, setInternalPagination] =\n React.useState<PaginationState>({ pageIndex: 0, pageSize })\n const paginationIsControlled = controlledPagination !== undefined\n const pagination = paginationIsControlled\n ? controlledPagination\n : internalPagination\n const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {\n if (!paginationIsControlled) {\n setInternalPagination((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onPaginationChange?.(updater)\n }\n\n // Uncontrolled selection state\n const [internalRowSelection, setInternalRowSelection] =\n React.useState<RowSelectionState>({})\n const selectionIsControlled = controlledRowSelection !== undefined\n const rowSelection = selectionIsControlled\n ? controlledRowSelection\n : internalRowSelection\n const handleRowSelectionChange: OnChangeFn<RowSelectionState> = (updater) => {\n if (!selectionIsControlled) {\n setInternalRowSelection((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onRowSelectionChange?.(updater)\n }\n\n // Global filter\n const [internalGlobalFilter, setInternalGlobalFilter] = React.useState(\"\")\n const globalFilterIsControlled = controlledGlobalFilter !== undefined\n const globalFilter = globalFilterIsControlled\n ? controlledGlobalFilter\n : internalGlobalFilter\n const handleGlobalFilterChange = (value: unknown) => {\n const next = typeof value === \"function\" ? (value as (p: string) => string)(globalFilter) : (value as string)\n if (!globalFilterIsControlled) {\n setInternalGlobalFilter(next ?? \"\")\n }\n onGlobalFilterChange?.(next ?? \"\")\n }\n\n // Inject a selection column when enabled\n const columns = React.useMemo<ColumnDef<TData, TValue>[]>(() => {\n if (!enableRowSelection) return userColumns\n const selectionColumn: ColumnDef<TData, TValue> = {\n id: \"__select\",\n enableSorting: false,\n size: 40,\n header: ({ table }) => (\n <Checkbox\n aria-label=\"Select all rows\"\n checked={\n table.getIsAllPageRowsSelected()\n ? true\n : table.getIsSomePageRowsSelected()\n ? \"indeterminate\"\n : false\n }\n onCheckedChange={(value) =>\n table.toggleAllPageRowsSelected(value === true)\n }\n />\n ),\n cell: ({ row }) => (\n <Checkbox\n aria-label=\"Select row\"\n checked={row.getIsSelected()}\n disabled={!row.getCanSelect()}\n onCheckedChange={(value) => row.toggleSelected(value === true)}\n />\n ),\n }\n return [selectionColumn, ...userColumns]\n }, [enableRowSelection, userColumns])\n\n const table: TanstackTable<TData> = useReactTable<TData>({\n data: dataItems,\n columns,\n state: {\n sorting,\n pagination,\n rowSelection,\n globalFilter,\n },\n enableRowSelection,\n getRowId: internalGetRowId,\n onSortingChange: handleSortingChange,\n onPaginationChange: handlePaginationChange,\n onRowSelectionChange: handleRowSelectionChange,\n onGlobalFilterChange: handleGlobalFilterChange,\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n getPaginationRowModel: getPaginationRowModel(),\n getFilteredRowModel: getFilteredRowModel(),\n })\n\n const totalRows = table.getFilteredRowModel().rows.length\n const pageRows = table.getRowModel().rows\n const colCount = columns.length\n const pageIndex = table.getState().pagination.pageIndex\n const currentPageSize = table.getState().pagination.pageSize\n const pageCount = table.getPageCount()\n const firstRow = totalRows === 0 ? 0 : pageIndex * currentPageSize + 1\n const lastRow = Math.min((pageIndex + 1) * currentPageSize, totalRows)\n\n const isEmpty = !loading && dataItems.length === 0 && !hasRows\n const defaultEmpty = <EmptyState heading=\"No results\" tone=\"subtle\" />\n\n const defaultGroupRowContent = (group: DataTableGroupRow) => (\n <span data-slot=\"data-table-group-label\" className={styles.groupLabel}>\n {group.label}\n {group.count != null && (\n <span className={styles.groupCount}>{group.count}</span>\n )}\n </span>\n )\n\n return (\n <div\n ref={ref}\n data-slot=\"data-table\"\n className={cn(styles.root, className)}\n {...rest}\n >\n <Table>\n <TableHeader\n className={cn(stickyHeader && styles.stickyHeader)}\n data-sticky={stickyHeader || undefined}\n >\n {table.getHeaderGroups().map((headerGroup) => (\n <TableRow key={headerGroup.id}>\n {headerGroup.headers.map((header) => {\n const canSort = header.column.getCanSort() && !hasRows\n const sortDir = header.column.getIsSorted()\n const ariaSort: React.AriaAttributes[\"aria-sort\"] = hasRows\n ? undefined\n : sortDir === \"asc\"\n ? \"ascending\"\n : sortDir === \"desc\"\n ? \"descending\"\n : canSort\n ? \"none\"\n : undefined\n const headerContent = header.isPlaceholder\n ? null\n : flexRender(\n header.column.columnDef.header,\n header.getContext()\n )\n const columnLabel =\n typeof header.column.columnDef.header === \"string\"\n ? (header.column.columnDef.header as string)\n : header.column.id\n const nextSortStateLabel =\n sortDir === \"asc\"\n ? \"descending\"\n : sortDir === \"desc\"\n ? \"unsorted\"\n : \"ascending\"\n return (\n <TableHead\n key={header.id}\n aria-sort={ariaSort}\n style={{\n width:\n header.column.id === \"__select\" ? \"40px\" : undefined,\n }}\n >\n {canSort ? (\n <button\n type=\"button\"\n className={styles.sortButton}\n onClick={header.column.getToggleSortingHandler()}\n aria-label={`${columnLabel}, sort ${nextSortStateLabel}`}\n >\n <span className={styles.sortLabel}>\n {headerContent}\n </span>\n <span className={styles.sortIcon} aria-hidden=\"true\">\n {sortDir === \"asc\" ? (\n <CaretUpIcon weight=\"bold\" />\n ) : sortDir === \"desc\" ? (\n <CaretDownIcon weight=\"bold\" />\n ) : (\n <CaretUpDownIcon weight=\"bold\" />\n )}\n </span>\n </button>\n ) : (\n headerContent\n )}\n </TableHead>\n )\n })}\n </TableRow>\n ))}\n </TableHeader>\n <TableBody>\n {loading ? (\n Array.from({ length: currentPageSize }).map((_, rowIdx) => (\n <TableRow key={`skeleton-${rowIdx}`} data-slot=\"data-table-skeleton-row\">\n {columns.map((_col, colIdx) => (\n <TableCell key={`skeleton-${rowIdx}-${colIdx}`}>\n <Skeleton className={styles.skeletonCell} />\n </TableCell>\n ))}\n </TableRow>\n ))\n ) : isEmpty ? (\n <TableRow data-slot=\"data-table-empty-row\">\n <TableCell colSpan={colCount} className={styles.emptyCell}>\n {emptyState ?? defaultEmpty}\n </TableCell>\n </TableRow>\n ) : hasRows ? (\n rows!.map((item) => {\n if (item.kind === \"group\") {\n return (\n <TableRow\n key={`group-${item.id}`}\n data-slot=\"data-table-group-row\"\n className={styles.groupRow}\n >\n <TableCell\n colSpan={colCount}\n className={styles.groupCell}\n >\n {groupRowRenderer\n ? groupRowRenderer(item)\n : defaultGroupRowContent(item)}\n </TableCell>\n </TableRow>\n )\n }\n const tsRow = table.getRow(item.id)\n return (\n <TableRow\n key={item.id}\n data-state={tsRow.getIsSelected() ? \"selected\" : undefined}\n >\n {tsRow.getVisibleCells().map((cell) => (\n <TableCell key={cell.id}>\n {flexRender(\n cell.column.columnDef.cell,\n cell.getContext()\n )}\n </TableCell>\n ))}\n </TableRow>\n )\n })\n ) : pageRows.length === 0 ? (\n <TableRow data-slot=\"data-table-empty-row\">\n <TableCell colSpan={colCount} className={styles.emptyCell}>\n {emptyState ?? defaultEmpty}\n </TableCell>\n </TableRow>\n ) : (\n pageRows.map((row) => (\n <TableRow\n key={row.id}\n data-state={row.getIsSelected() ? \"selected\" : undefined}\n >\n {row.getVisibleCells().map((cell) => (\n <TableCell key={cell.id}>\n {flexRender(cell.column.columnDef.cell, cell.getContext())}\n </TableCell>\n ))}\n </TableRow>\n ))\n )}\n </TableBody>\n </Table>\n\n {!hasRows && (\n <div className={styles.footer} data-slot=\"data-table-footer\">\n <div className={styles.footerInfo} aria-live=\"polite\">\n {totalRows === 0\n ? \"No results\"\n : `Showing ${firstRow} to ${lastRow} of ${totalRows}`}\n </div>\n <div className={styles.footerControls}>\n <label className={styles.pageSizeLabel}>\n <span className={styles.pageSizeLabelText}>Rows per page</span>\n <select\n className={styles.pageSizeSelect}\n value={currentPageSize}\n onChange={(e) => table.setPageSize(Number(e.target.value))}\n aria-label=\"Rows per page\"\n >\n {pageSizeOptions.map((opt) => (\n <option key={opt} value={opt}>\n {opt}\n </option>\n ))}\n </select>\n </label>\n <div className={styles.pageNav}>\n <span className={styles.pageCounter}>\n Page {pageCount === 0 ? 0 : pageIndex + 1} of {pageCount}\n </span>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => table.previousPage()}\n disabled={!table.getCanPreviousPage()}\n aria-label=\"Previous page\"\n >\n <CaretLeftIcon weight=\"bold\" aria-hidden=\"true\" />\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => table.nextPage()}\n disabled={!table.getCanNextPage()}\n aria-label=\"Next page\"\n >\n <CaretRightIcon weight=\"bold\" aria-hidden=\"true\" />\n </Button>\n </div>\n </div>\n </div>\n )}\n </div>\n )\n}\n\n// forwardRef with generics — preserve TData through the cast\nconst DataTable = React.forwardRef(DataTableInner) as <\n TData,\n TValue = unknown,\n>(\n props: DataTableProps<TData, TValue> & {\n ref?: React.ForwardedRef<HTMLDivElement>\n }\n) => ReturnType<typeof DataTableInner>\n\n;(DataTable as unknown as { displayName: string }).displayName = \"DataTable\"\n\nexport { DataTable }\n"
2157
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n flexRender,\n getCoreRowModel,\n getFilteredRowModel,\n getPaginationRowModel,\n getSortedRowModel,\n useReactTable,\n type ColumnDef,\n type OnChangeFn,\n type PaginationState,\n type RowSelectionState,\n type SortingState,\n type Table as TanstackTable,\n} from \"@tanstack/react-table\"\nimport {\n CaretDownIcon,\n CaretLeftIcon,\n CaretRightIcon,\n CaretUpIcon,\n CaretUpDownIcon,\n} from \"@phosphor-icons/react\"\n\nimport { cn } from \"../../../lib/utils\"\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"../table/table\"\nimport { Button } from \"../button/button\"\nimport { Checkbox } from \"../checkbox/checkbox\"\nimport { Skeleton } from \"../skeleton/skeleton\"\nimport { EmptyState } from \"../empty-state/empty-state\"\nimport styles from \"./data-table.module.css\"\n\nexport type {\n ColumnDef,\n SortingState,\n RowSelectionState,\n PaginationState,\n OnChangeFn,\n}\n\nexport interface DataTableGroupRow {\n kind: \"group\"\n id: string\n label: string\n count?: number\n}\n\nexport interface DataTableDataRow<TData> {\n kind: \"data\"\n id: string\n row: TData\n}\n\nexport type DataTableRow<TData> = DataTableGroupRow | DataTableDataRow<TData>\n\n/**\n * Semantic per-row tone keys. Map to subtle background tints via CSS — see\n * `data-table.module.css`. Mirrors the tone vocabulary used by `StatusBadge`\n * / `StatusDot` so a row tagged \"live\" reads as one signal with a \"live\"\n * badge inside the row.\n */\nexport type DataTableRowTone =\n | \"live\"\n | \"warn\"\n | \"scheduled\"\n | \"sold\"\n | \"draft\"\n | \"danger\"\n | \"info\"\n\nexport interface DataTableProps<TData, TValue = unknown>\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n columns: ColumnDef<TData, TValue>[]\n data?: TData[]\n\n // Mixed render order with group-head separators. When provided, the caller\n // owns sort/grouping/windowing — sort UI and pagination footer are\n // suppressed. Group rows are excluded from selection state.\n rows?: DataTableRow<TData>[]\n groupRowRenderer?: (group: DataTableGroupRow) => React.ReactNode\n\n /**\n * Map each data row to a semantic tone for a subtle background tint. When\n * the callback returns `undefined`, the row renders on the default surface.\n * Tones resolve to Visor surface tokens at the CSS layer — see\n * `data-table.module.css`.\n */\n rowTone?: (row: TData) => DataTableRowTone | undefined\n\n /**\n * When supplied, every data row becomes a keyboard-activatable target:\n * `role=\"button\"`, `tabIndex={0}`, click + Enter/Space dispatch the\n * handler, and a `data-clickable=\"true\"` attribute drives the hover/focus\n * affordance. The injected selection checkbox cell stops propagation, so\n * clicking it does not trigger `onRowClick`.\n */\n onRowClick?: (row: TData) => void\n\n // Sorting\n sorting?: SortingState\n onSortingChange?: OnChangeFn<SortingState>\n defaultSorting?: SortingState\n\n // Pagination\n pagination?: PaginationState\n onPaginationChange?: OnChangeFn<PaginationState>\n pageSize?: number\n pageSizeOptions?: number[]\n\n // Selection\n enableRowSelection?: boolean\n rowSelection?: RowSelectionState\n onRowSelectionChange?: OnChangeFn<RowSelectionState>\n getRowId?: (row: TData, index: number) => string\n\n // Global filter\n globalFilter?: string\n onGlobalFilterChange?: (value: string) => void\n\n // States\n loading?: boolean\n emptyState?: React.ReactNode\n\n // Layout\n stickyHeader?: boolean\n\n /**\n * Vertical row padding step. Maps to a `data-density` attribute on the root\n * which drives the `--dt-row-py` custom property the table's `<td>` cells\n * consume. Themes can override per-density values without forking the\n * component — see `data-table.module.css`.\n *\n * - `\"compact\"` — 8px (sub-content density: long lists, narrow viewports)\n * - `\"default\"` — 12px (current behaviour; no visual regression for existing\n * consumers)\n * - `\"editorial\"` — 20px (generous; each row reads as a card; high-design\n * admin patterns)\n */\n density?: \"compact\" | \"default\" | \"editorial\"\n}\n\nconst DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100]\n\nfunction DataTableInner<TData, TValue = unknown>(\n props: DataTableProps<TData, TValue>,\n ref: React.ForwardedRef<HTMLDivElement>\n) {\n const {\n columns: userColumns,\n data,\n rows,\n groupRowRenderer,\n sorting: controlledSorting,\n onSortingChange,\n defaultSorting,\n pagination: controlledPagination,\n onPaginationChange,\n pageSize = 10,\n pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,\n enableRowSelection = false,\n rowSelection: controlledRowSelection,\n onRowSelectionChange,\n getRowId,\n globalFilter: controlledGlobalFilter,\n onGlobalFilterChange,\n loading = false,\n emptyState,\n stickyHeader = false,\n rowTone,\n onRowClick,\n density = \"default\",\n className,\n ...rest\n } = props\n\n // When rows is provided, the caller owns sort/grouping/windowing. We bypass\n // TanStack pagination and column sort UI, but keep the table instance for\n // selection state and cell rendering on data rows.\n const hasRows = rows != null\n const dataItems = React.useMemo(() => {\n if (hasRows) {\n return rows!.flatMap((item) =>\n item.kind === \"data\" ? [item.row] : []\n )\n }\n return data ?? []\n }, [hasRows, rows, data])\n\n const internalGetRowId = React.useMemo(() => {\n if (getRowId) return getRowId\n if (hasRows) {\n const ids = rows!.flatMap((item) =>\n item.kind === \"data\" ? [item.id] : []\n )\n return (_row: TData, index: number) => ids[index] ?? String(index)\n }\n return undefined\n }, [getRowId, hasRows, rows])\n\n // Uncontrolled sorting state\n const [internalSorting, setInternalSorting] = React.useState<SortingState>(\n defaultSorting ?? []\n )\n const sortingIsControlled = controlledSorting !== undefined\n const sorting = sortingIsControlled ? controlledSorting : internalSorting\n const handleSortingChange: OnChangeFn<SortingState> = (updater) => {\n if (!sortingIsControlled) {\n setInternalSorting((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onSortingChange?.(updater)\n }\n\n // Uncontrolled pagination state\n const [internalPagination, setInternalPagination] =\n React.useState<PaginationState>({ pageIndex: 0, pageSize })\n const paginationIsControlled = controlledPagination !== undefined\n const pagination = paginationIsControlled\n ? controlledPagination\n : internalPagination\n const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {\n if (!paginationIsControlled) {\n setInternalPagination((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onPaginationChange?.(updater)\n }\n\n // Uncontrolled selection state\n const [internalRowSelection, setInternalRowSelection] =\n React.useState<RowSelectionState>({})\n const selectionIsControlled = controlledRowSelection !== undefined\n const rowSelection = selectionIsControlled\n ? controlledRowSelection\n : internalRowSelection\n const handleRowSelectionChange: OnChangeFn<RowSelectionState> = (updater) => {\n if (!selectionIsControlled) {\n setInternalRowSelection((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onRowSelectionChange?.(updater)\n }\n\n // Global filter\n const [internalGlobalFilter, setInternalGlobalFilter] = React.useState(\"\")\n const globalFilterIsControlled = controlledGlobalFilter !== undefined\n const globalFilter = globalFilterIsControlled\n ? controlledGlobalFilter\n : internalGlobalFilter\n const handleGlobalFilterChange = (value: unknown) => {\n const next = typeof value === \"function\" ? (value as (p: string) => string)(globalFilter) : (value as string)\n if (!globalFilterIsControlled) {\n setInternalGlobalFilter(next ?? \"\")\n }\n onGlobalFilterChange?.(next ?? \"\")\n }\n\n // Inject a selection column when enabled\n const columns = React.useMemo<ColumnDef<TData, TValue>[]>(() => {\n if (!enableRowSelection) return userColumns\n const selectionColumn: ColumnDef<TData, TValue> = {\n id: \"__select\",\n enableSorting: false,\n size: 40,\n header: ({ table }) => (\n <Checkbox\n aria-label=\"Select all rows\"\n checked={\n table.getIsAllPageRowsSelected()\n ? true\n : table.getIsSomePageRowsSelected()\n ? \"indeterminate\"\n : false\n }\n onCheckedChange={(value) =>\n table.toggleAllPageRowsSelected(value === true)\n }\n />\n ),\n cell: ({ row }) => (\n // Stop click/keydown from bubbling to the parent <tr>, otherwise\n // toggling the checkbox would also fire `onRowClick`. The wrapper\n // is presentational — focus and ARIA continue to live on the\n // underlying Checkbox.\n <div\n data-slot=\"data-table-selection-cell\"\n onClick={(e) => e.stopPropagation()}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" || e.key === \" \") e.stopPropagation()\n }}\n >\n <Checkbox\n aria-label=\"Select row\"\n checked={row.getIsSelected()}\n disabled={!row.getCanSelect()}\n onCheckedChange={(value) => row.toggleSelected(value === true)}\n />\n </div>\n ),\n }\n return [selectionColumn, ...userColumns]\n }, [enableRowSelection, userColumns])\n\n const table: TanstackTable<TData> = useReactTable<TData>({\n data: dataItems,\n columns,\n state: {\n sorting,\n pagination,\n rowSelection,\n globalFilter,\n },\n enableRowSelection,\n getRowId: internalGetRowId,\n onSortingChange: handleSortingChange,\n onPaginationChange: handlePaginationChange,\n onRowSelectionChange: handleRowSelectionChange,\n onGlobalFilterChange: handleGlobalFilterChange,\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n getPaginationRowModel: getPaginationRowModel(),\n getFilteredRowModel: getFilteredRowModel(),\n })\n\n const totalRows = table.getFilteredRowModel().rows.length\n const pageRows = table.getRowModel().rows\n const colCount = columns.length\n const pageIndex = table.getState().pagination.pageIndex\n const currentPageSize = table.getState().pagination.pageSize\n const pageCount = table.getPageCount()\n const firstRow = totalRows === 0 ? 0 : pageIndex * currentPageSize + 1\n const lastRow = Math.min((pageIndex + 1) * currentPageSize, totalRows)\n\n const isEmpty = !loading && dataItems.length === 0 && !hasRows\n const defaultEmpty = <EmptyState heading=\"No results\" tone=\"subtle\" />\n\n const defaultGroupRowContent = (group: DataTableGroupRow) => (\n <span data-slot=\"data-table-group-label\" className={styles.groupLabel}>\n {group.label}\n {group.count != null && (\n <span className={styles.groupCount}>{group.count}</span>\n )}\n </span>\n )\n\n // Build the per-data-row props (tone, clickable affordance, keyboard\n // activation). Shared between the `rows`-driven path and the standard\n // pageRows path so the two stay in lockstep.\n //\n // Note on role=\"button\": axe flags nested-interactive when a `<tr>` carries\n // `role=\"button\"` and also contains an interactive control (the selection\n // checkbox cell). When selection is enabled, the row stays semantically a\n // table row — click + keyboard activation still work via the explicit\n // handlers, but the role override is dropped to keep `<tr>` semantics and\n // satisfy WCAG nested-interactive. When selection is off, the row is a\n // pure click target and `role=\"button\"` is safe.\n const getDataRowProps = (rowData: TData) => {\n const tone = rowTone?.(rowData)\n const clickable = onRowClick != null\n const handleClick = clickable\n ? () => onRowClick!(rowData)\n : undefined\n const handleKeyDown = clickable\n ? (e: React.KeyboardEvent<HTMLTableRowElement>) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault()\n onRowClick!(rowData)\n }\n }\n : undefined\n const useButtonRole = clickable && !enableRowSelection\n return {\n className: styles.dataRow,\n \"data-tone\": tone,\n \"data-clickable\": clickable ? \"true\" : undefined,\n role: useButtonRole ? (\"button\" as const) : undefined,\n tabIndex: clickable ? 0 : undefined,\n onClick: handleClick,\n onKeyDown: handleKeyDown,\n }\n }\n\n return (\n <div\n ref={ref}\n data-slot=\"data-table\"\n data-density={density}\n className={cn(styles.root, className)}\n {...rest}\n >\n <Table>\n <TableHeader\n className={cn(stickyHeader && styles.stickyHeader)}\n data-sticky={stickyHeader || undefined}\n >\n {table.getHeaderGroups().map((headerGroup) => (\n <TableRow key={headerGroup.id}>\n {headerGroup.headers.map((header) => {\n const canSort = header.column.getCanSort() && !hasRows\n const sortDir = header.column.getIsSorted()\n const ariaSort: React.AriaAttributes[\"aria-sort\"] = hasRows\n ? undefined\n : sortDir === \"asc\"\n ? \"ascending\"\n : sortDir === \"desc\"\n ? \"descending\"\n : canSort\n ? \"none\"\n : undefined\n const headerContent = header.isPlaceholder\n ? null\n : flexRender(\n header.column.columnDef.header,\n header.getContext()\n )\n const columnLabel =\n typeof header.column.columnDef.header === \"string\"\n ? (header.column.columnDef.header as string)\n : header.column.id\n const nextSortStateLabel =\n sortDir === \"asc\"\n ? \"descending\"\n : sortDir === \"desc\"\n ? \"unsorted\"\n : \"ascending\"\n return (\n <TableHead\n key={header.id}\n aria-sort={ariaSort}\n style={{\n width:\n header.column.id === \"__select\" ? \"40px\" : undefined,\n }}\n >\n {canSort ? (\n <button\n type=\"button\"\n className={styles.sortButton}\n onClick={header.column.getToggleSortingHandler()}\n aria-label={`${columnLabel}, sort ${nextSortStateLabel}`}\n >\n <span className={styles.sortLabel}>\n {headerContent}\n </span>\n <span className={styles.sortIcon} aria-hidden=\"true\">\n {sortDir === \"asc\" ? (\n <CaretUpIcon weight=\"bold\" />\n ) : sortDir === \"desc\" ? (\n <CaretDownIcon weight=\"bold\" />\n ) : (\n <CaretUpDownIcon weight=\"bold\" />\n )}\n </span>\n </button>\n ) : (\n headerContent\n )}\n </TableHead>\n )\n })}\n </TableRow>\n ))}\n </TableHeader>\n <TableBody>\n {loading ? (\n Array.from({ length: currentPageSize }).map((_, rowIdx) => (\n <TableRow key={`skeleton-${rowIdx}`} data-slot=\"data-table-skeleton-row\">\n {columns.map((_col, colIdx) => (\n <TableCell key={`skeleton-${rowIdx}-${colIdx}`}>\n <Skeleton className={styles.skeletonCell} />\n </TableCell>\n ))}\n </TableRow>\n ))\n ) : isEmpty ? (\n <TableRow data-slot=\"data-table-empty-row\">\n <TableCell colSpan={colCount} className={styles.emptyCell}>\n {emptyState ?? defaultEmpty}\n </TableCell>\n </TableRow>\n ) : hasRows ? (\n rows!.map((item) => {\n if (item.kind === \"group\") {\n return (\n <TableRow\n key={`group-${item.id}`}\n data-slot=\"data-table-group-row\"\n className={styles.groupRow}\n >\n <TableCell\n colSpan={colCount}\n className={styles.groupCell}\n >\n {groupRowRenderer\n ? groupRowRenderer(item)\n : defaultGroupRowContent(item)}\n </TableCell>\n </TableRow>\n )\n }\n const tsRow = table.getRow(item.id)\n const rowProps = getDataRowProps(item.row)\n return (\n <TableRow\n key={item.id}\n data-state={tsRow.getIsSelected() ? \"selected\" : undefined}\n {...rowProps}\n >\n {tsRow.getVisibleCells().map((cell) => (\n <TableCell key={cell.id}>\n {flexRender(\n cell.column.columnDef.cell,\n cell.getContext()\n )}\n </TableCell>\n ))}\n </TableRow>\n )\n })\n ) : pageRows.length === 0 ? (\n <TableRow data-slot=\"data-table-empty-row\">\n <TableCell colSpan={colCount} className={styles.emptyCell}>\n {emptyState ?? defaultEmpty}\n </TableCell>\n </TableRow>\n ) : (\n pageRows.map((row) => {\n const rowProps = getDataRowProps(row.original)\n return (\n <TableRow\n key={row.id}\n data-state={row.getIsSelected() ? \"selected\" : undefined}\n {...rowProps}\n >\n {row.getVisibleCells().map((cell) => (\n <TableCell key={cell.id}>\n {flexRender(\n cell.column.columnDef.cell,\n cell.getContext()\n )}\n </TableCell>\n ))}\n </TableRow>\n )\n })\n )}\n </TableBody>\n </Table>\n\n {!hasRows && (\n <div className={styles.footer} data-slot=\"data-table-footer\">\n <div className={styles.footerInfo} aria-live=\"polite\">\n {totalRows === 0\n ? \"No results\"\n : `Showing ${firstRow} to ${lastRow} of ${totalRows}`}\n </div>\n <div className={styles.footerControls}>\n <label className={styles.pageSizeLabel}>\n <span className={styles.pageSizeLabelText}>Rows per page</span>\n <select\n className={styles.pageSizeSelect}\n value={currentPageSize}\n onChange={(e) => table.setPageSize(Number(e.target.value))}\n aria-label=\"Rows per page\"\n >\n {pageSizeOptions.map((opt) => (\n <option key={opt} value={opt}>\n {opt}\n </option>\n ))}\n </select>\n </label>\n <div className={styles.pageNav}>\n <span className={styles.pageCounter}>\n Page {pageCount === 0 ? 0 : pageIndex + 1} of {pageCount}\n </span>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => table.previousPage()}\n disabled={!table.getCanPreviousPage()}\n aria-label=\"Previous page\"\n >\n <CaretLeftIcon weight=\"bold\" aria-hidden=\"true\" />\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => table.nextPage()}\n disabled={!table.getCanNextPage()}\n aria-label=\"Next page\"\n >\n <CaretRightIcon weight=\"bold\" aria-hidden=\"true\" />\n </Button>\n </div>\n </div>\n </div>\n )}\n </div>\n )\n}\n\n// forwardRef with generics — preserve TData through the cast\nconst DataTable = React.forwardRef(DataTableInner) as <\n TData,\n TValue = unknown,\n>(\n props: DataTableProps<TData, TValue> & {\n ref?: React.ForwardedRef<HTMLDivElement>\n }\n) => ReturnType<typeof DataTableInner>\n\n;(DataTable as unknown as { displayName: string }).displayName = \"DataTable\"\n\nexport { DataTable }\n"
2108
2158
  },
2109
2159
  {
2110
2160
  "path": "components/ui/data-table/data-table.module.css",
2111
2161
  "type": "registry:ui",
2112
- "content": "/* DataTable root — inline-size container for responsive collapse */\n.root {\n display: flex;\n flex-direction: column;\n width: 100%;\n min-width: 0;\n gap: var(--spacing-3, 0.75rem);\n container-type: inline-size;\n container-name: data-table;\n color: var(--text-primary, #111827);\n}\n\n/* Sticky header wrapper — toggled when stickyHeader prop is true */\n.stickyHeader {\n position: sticky;\n top: 0;\n z-index: 1;\n background: var(--surface-card, #ffffff);\n}\n\n/* Sort button — renders inside the <th> */\n.sortButton {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n padding: 0;\n margin: 0;\n background: transparent;\n border: 0;\n color: inherit;\n font: inherit;\n font-weight: var(--font-weight-semibold, 600);\n cursor: pointer;\n line-height: var(--line-height-tight, 1.25);\n border-radius: var(--radius-sm, 0.25rem);\n transition:\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.sortButton:hover {\n color: var(--text-primary, #111827);\n}\n\n.sortButton:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.sortLabel {\n display: inline-block;\n}\n\n.sortIcon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: var(--spacing-4, 1rem);\n height: var(--spacing-4, 1rem);\n color: var(--text-tertiary, #6b7280);\n flex-shrink: 0;\n}\n\n.sortIcon svg {\n width: 100%;\n height: 100%;\n}\n\n/* Skeleton cell fills the cell width */\n.skeletonCell {\n width: 100%;\n height: var(--spacing-4, 1rem);\n}\n\n/* Empty-row cell centers the EmptyState slot */\n.emptyCell {\n padding: var(--spacing-6, 1.5rem) var(--spacing-4, 1rem);\n text-align: center;\n}\n\n/* Footer — info on the left, controls on the right */\n.footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--spacing-4, 1rem);\n padding: var(--spacing-2, 0.5rem) var(--spacing-1, 0.25rem);\n min-width: 0;\n flex-wrap: wrap;\n}\n\n.footerInfo {\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n min-width: 0;\n}\n\n.footerControls {\n display: flex;\n align-items: center;\n gap: var(--spacing-4, 1rem);\n flex-wrap: wrap;\n}\n\n.pageSizeLabel {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n}\n\n.pageSizeLabelText {\n white-space: nowrap;\n}\n\n/* Native <select> styled to match Visor input controls */\n.pageSizeSelect {\n appearance: none;\n -webkit-appearance: none;\n -moz-appearance: none;\n height: var(--spacing-8, 2rem);\n padding: 0 var(--spacing-6, 1.5rem) 0 var(--spacing-2, 0.5rem);\n border-radius: var(--radius-md, 0.375rem);\n border: 1px solid var(--border-default, #e5e7eb);\n background-color: var(--surface-card, #ffffff);\n background-image: linear-gradient(\n 45deg,\n transparent 50%,\n var(--text-tertiary, #6b7280) 50%\n ),\n linear-gradient(\n 135deg,\n var(--text-tertiary, #6b7280) 50%,\n transparent 50%\n );\n background-position:\n right var(--spacing-3, 0.75rem) center,\n right var(--spacing-2, 0.5rem) center;\n background-size:\n 5px 5px,\n 5px 5px;\n background-repeat: no-repeat;\n color: var(--text-primary, #111827);\n font-size: var(--font-size-sm, 0.875rem);\n line-height: var(--line-height-tight, 1.25);\n cursor: pointer;\n transition:\n border-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n box-shadow var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.pageSizeSelect:hover {\n border-color: var(--border-strong, #d1d5db);\n}\n\n.pageSizeSelect:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.pageNav {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n}\n\n.pageCounter {\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n white-space: nowrap;\n}\n\n/* Group-head row — full-width separator inside the table body. The colSpan\n cell carries the visual; the inner span owns the sticky positioning so it\n pins to the top of the scroll container as data rows scroll beneath. */\n.groupRow {\n /* No hover highlight — group rows are structural, not interactive */\n}\n\n.groupCell {\n padding: 0;\n}\n\n.groupLabel {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n height: 28px;\n padding: 0 var(--spacing-4, 1rem);\n background: var(--surface-subtle, #f3f4f6);\n font-size: var(--font-size-xs, 0.6875rem);\n font-weight: var(--font-weight-medium, 500);\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n\n.groupCount {\n font-size: var(--font-size-xs, 0.6875rem);\n color: var(--text-quaternary, #9ca3af);\n font-variant-numeric: tabular-nums;\n letter-spacing: 0;\n}\n\n/* Narrow containers — stack footer sections */\n@container data-table (max-width: 560px) {\n .footer {\n flex-direction: column;\n align-items: stretch;\n }\n\n .footerControls {\n justify-content: space-between;\n }\n}\n"
2162
+ "content": "/* DataTable root — inline-size container for responsive collapse */\n.root {\n display: flex;\n flex-direction: column;\n width: 100%;\n min-width: 0;\n gap: var(--spacing-3, 0.75rem);\n container-type: inline-size;\n container-name: data-table;\n color: var(--text-primary, #111827);\n}\n\n/* Density — vertical row padding step driven by `data-density` on the root.\n Cells consume `--dt-row-py` via the `.root td` rule below. Horizontal cell\n padding stays at TableCell's `padding: var(--spacing-3)` shorthand cascade\n — we only override the top/bottom longhand properties here, so `default`\n density renders identically to pre-VI-425 markup.\n\n Themes can override per-density values via their own selectors. Example:\n [data-theme=\"entr\"] [data-density=\"editorial\"] { --dt-row-py: var(--spacing-6); } */\n.root[data-density=\"compact\"] { --dt-row-py: var(--spacing-2, 0.5rem); }\n.root[data-density=\"default\"] { --dt-row-py: var(--spacing-3, 0.75rem); }\n.root[data-density=\"editorial\"] { --dt-row-py: var(--spacing-5, 1.25rem); }\n\n.root td {\n padding-top: var(--dt-row-py, var(--spacing-3, 0.75rem));\n padding-bottom: var(--dt-row-py, var(--spacing-3, 0.75rem));\n}\n\n/* Sticky header wrapper — toggled when stickyHeader prop is true */\n.stickyHeader {\n position: sticky;\n top: 0;\n z-index: 1;\n background: var(--surface-card, #ffffff);\n}\n\n/* Sort button — renders inside the <th> */\n.sortButton {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n padding: 0;\n margin: 0;\n background: transparent;\n border: 0;\n color: inherit;\n font: inherit;\n font-weight: var(--font-weight-semibold, 600);\n cursor: pointer;\n line-height: var(--line-height-tight, 1.25);\n border-radius: var(--radius-sm, 0.25rem);\n transition:\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.sortButton:hover {\n color: var(--text-primary, #111827);\n}\n\n.sortButton:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.sortLabel {\n display: inline-block;\n}\n\n.sortIcon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: var(--spacing-4, 1rem);\n height: var(--spacing-4, 1rem);\n color: var(--text-tertiary, #6b7280);\n flex-shrink: 0;\n}\n\n.sortIcon svg {\n width: 100%;\n height: 100%;\n}\n\n/* Skeleton cell fills the cell width */\n.skeletonCell {\n width: 100%;\n height: var(--spacing-4, 1rem);\n}\n\n/* Empty-row cell centers the EmptyState slot */\n.emptyCell {\n padding: var(--spacing-6, 1.5rem) var(--spacing-4, 1rem);\n text-align: center;\n}\n\n/* Footer — info on the left, controls on the right */\n.footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--spacing-4, 1rem);\n padding: var(--spacing-2, 0.5rem) var(--spacing-1, 0.25rem);\n min-width: 0;\n flex-wrap: wrap;\n}\n\n.footerInfo {\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n min-width: 0;\n}\n\n.footerControls {\n display: flex;\n align-items: center;\n gap: var(--spacing-4, 1rem);\n flex-wrap: wrap;\n}\n\n.pageSizeLabel {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n}\n\n.pageSizeLabelText {\n white-space: nowrap;\n}\n\n/* Native <select> styled to match Visor input controls */\n.pageSizeSelect {\n appearance: none;\n -webkit-appearance: none;\n -moz-appearance: none;\n height: var(--spacing-8, 2rem);\n padding: 0 var(--spacing-6, 1.5rem) 0 var(--spacing-2, 0.5rem);\n border-radius: var(--radius-md, 0.375rem);\n border: 1px solid var(--border-default, #e5e7eb);\n background-color: var(--surface-card, #ffffff);\n background-image: linear-gradient(\n 45deg,\n transparent 50%,\n var(--text-tertiary, #6b7280) 50%\n ),\n linear-gradient(\n 135deg,\n var(--text-tertiary, #6b7280) 50%,\n transparent 50%\n );\n background-position:\n right var(--spacing-3, 0.75rem) center,\n right var(--spacing-2, 0.5rem) center;\n background-size:\n 5px 5px,\n 5px 5px;\n background-repeat: no-repeat;\n color: var(--text-primary, #111827);\n font-size: var(--font-size-sm, 0.875rem);\n line-height: var(--line-height-tight, 1.25);\n cursor: pointer;\n transition:\n border-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n box-shadow var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.pageSizeSelect:hover {\n border-color: var(--border-strong, #d1d5db);\n}\n\n.pageSizeSelect:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.pageNav {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n}\n\n.pageCounter {\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n white-space: nowrap;\n}\n\n/* Data row — base layer for the per-row data-state / data-tone / data-clickable\n variants below. The selectors are scoped to `.dataRow` so the tone/selected\n tints never bleed onto group-head rows or skeleton/empty rows. */\n.dataRow {\n transition: background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n/* Selected row — wires the latent `data-state=\"selected\"` attribute (already\n emitted by TanStack on selected rows) to a subtle accent fill. Fixes a\n latent bug where selection had no visual feedback. */\n.dataRow[data-state=\"selected\"] {\n background-color: var(\n --surface-selected,\n var(--surface-interactive-active, #e5e7eb)\n );\n}\n\n/* Per-row tone tints — semantic background hints for editorial status.\n Mirrors the tone token mapping used by StatusBadge / StatusDot so a\n row tinted \"live\" reads consistently with a \"live\" badge inline.\n `scheduled` and `draft` intentionally have no tint — they render on\n the default surface to keep visual signal focused on actionable rows. */\n.dataRow[data-tone=\"live\"],\n.dataRow[data-tone=\"sold\"] {\n background-color: var(--surface-success-subtle, #ecfdf5);\n}\n\n.dataRow[data-tone=\"warn\"] {\n background-color: var(--surface-warning-subtle, #fffbeb);\n}\n\n.dataRow[data-tone=\"danger\"] {\n background-color: var(--surface-error-subtle, #fef2f2);\n}\n\n.dataRow[data-tone=\"info\"] {\n background-color: var(--surface-info-subtle, #eff6ff);\n}\n\n/* Selected state should win over tone tint — selection is a stronger\n signal than editorial status. */\n.dataRow[data-state=\"selected\"][data-tone] {\n background-color: var(\n --surface-selected,\n var(--surface-interactive-active, #e5e7eb)\n );\n}\n\n/* Clickable rows — opt-in affordance when `onRowClick` is supplied. */\n.dataRow[data-clickable=\"true\"] {\n cursor: pointer;\n}\n\n.dataRow[data-clickable=\"true\"]:hover {\n background-color: var(--surface-interactive-default, #f3f4f6);\n}\n\n.dataRow[data-clickable=\"true\"]:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: calc(var(--focus-ring-offset, 2px) * -1);\n}\n\n/* Group-head row — full-width separator inside the table body. The colSpan\n cell carries the visual; the inner span owns the sticky positioning so it\n pins to the top of the scroll container as data rows scroll beneath. */\n.groupRow {\n background-color: transparent;\n cursor: default;\n}\n\n.groupRow:hover {\n background-color: transparent;\n}\n\n.groupCell {\n padding: 0;\n}\n\n.groupLabel {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n height: 28px;\n padding: 0 var(--spacing-4, 1rem);\n background: var(--surface-subtle, #f3f4f6);\n font-size: var(--font-size-xs, 0.6875rem);\n font-weight: var(--font-weight-medium, 500);\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n\n.groupCount {\n font-size: var(--font-size-xs, 0.6875rem);\n color: var(--text-quaternary, #9ca3af);\n font-variant-numeric: tabular-nums;\n letter-spacing: 0;\n}\n\n/* Narrow containers — stack footer sections */\n@container data-table (max-width: 560px) {\n .footer {\n flex-direction: column;\n align-items: stretch;\n }\n\n .footerControls {\n justify-content: space-between;\n }\n}\n"
2113
2163
  }
2114
2164
  ]
2115
2165
  },
@@ -2207,12 +2257,87 @@
2207
2257
  {
2208
2258
  "path": "components/ui/page-header/page-header.tsx",
2209
2259
  "type": "registry:ui",
2210
- "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./page-header.module.css\"\n\nconst pageHeaderVariants = cva(styles.base, {\n variants: {\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n lg: styles.sizeLg,\n },\n },\n defaultVariants: {\n size: \"md\",\n },\n})\n\ntype PageHeaderElement = \"header\" | \"section\" | \"div\"\ntype TitleElement = \"h1\" | \"h2\" | \"h3\"\n\nexport interface PageHeaderProps\n extends Omit<React.HTMLAttributes<HTMLElement>, \"title\">,\n VariantProps<typeof pageHeaderVariants> {\n /** Optional small uppercase label rendered above the title. */\n eyebrow?: React.ReactNode\n /** Page heading content. Rendered in the element given by `titleAs`. */\n title: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /** Optional ReactNode rendered above the title row (typically a Breadcrumb). */\n breadcrumb?: React.ReactNode\n /** Optional ReactNode rendered on the right side of the title row. */\n actions?: React.ReactNode\n /** Root element tag. Defaults to `header`. */\n as?: PageHeaderElement\n /** Heading level for the title. Defaults to `h1`. */\n titleAs?: TitleElement\n}\n\nconst PageHeader = React.forwardRef<HTMLElement, PageHeaderProps>(\n (\n {\n className,\n size,\n eyebrow,\n title,\n description,\n breadcrumb,\n actions,\n as = \"header\",\n titleAs = \"h1\",\n ...props\n },\n ref\n ) => {\n const Root = as as React.ElementType\n const Title = titleAs as React.ElementType\n\n return (\n <Root\n ref={ref}\n data-slot=\"page-header\"\n className={cn(pageHeaderVariants({ size }), className)}\n {...props}\n >\n {breadcrumb ? (\n <div data-slot=\"page-header-breadcrumb\" className={styles.breadcrumb}>\n {breadcrumb}\n </div>\n ) : null}\n <div data-slot=\"page-header-row\" className={styles.row}>\n <div data-slot=\"page-header-text\" className={styles.text}>\n {eyebrow ? (\n <div data-slot=\"page-header-eyebrow\" className={styles.eyebrow}>\n {eyebrow}\n </div>\n ) : null}\n <Title data-slot=\"page-header-title\" className={styles.title}>\n {title}\n </Title>\n {description ? (\n <p\n data-slot=\"page-header-description\"\n className={styles.description}\n >\n {description}\n </p>\n ) : null}\n </div>\n {actions ? (\n <div data-slot=\"page-header-actions\" className={styles.actions}>\n {actions}\n </div>\n ) : null}\n </div>\n </Root>\n )\n }\n)\nPageHeader.displayName = \"PageHeader\"\n\nexport { PageHeader, pageHeaderVariants }\n"
2260
+ "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./page-header.module.css\"\n\nconst pageHeaderVariants = cva(styles.base, {\n variants: {\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n lg: styles.sizeLg,\n },\n },\n defaultVariants: {\n size: \"md\",\n },\n})\n\ntype PageHeaderElement = \"header\" | \"section\" | \"div\"\ntype TitleElement = \"h1\" | \"h2\" | \"h3\"\n\n/** Token preset values for the `titleSize` prop. */\nconst TITLE_SIZE_TOKENS = [\"default\", \"marquee\"] as const\ntype TitleSizeToken = (typeof TITLE_SIZE_TOKENS)[number]\n\n/** Token preset values for the `titleFamily` prop. */\nconst TITLE_FAMILY_TOKENS = [\"heading\", \"display\"] as const\ntype TitleFamilyToken = (typeof TITLE_FAMILY_TOKENS)[number]\n\nfunction isTitleSizeToken(value: string): value is TitleSizeToken {\n return (TITLE_SIZE_TOKENS as readonly string[]).includes(value)\n}\n\nfunction isTitleFamilyToken(value: string): value is TitleFamilyToken {\n return (TITLE_FAMILY_TOKENS as readonly string[]).includes(value)\n}\n\nexport interface PageHeaderProps\n extends Omit<React.HTMLAttributes<HTMLElement>, \"title\">,\n VariantProps<typeof pageHeaderVariants> {\n /** Optional small uppercase label rendered above the title. */\n eyebrow?: React.ReactNode\n /** Page heading content. Rendered in the element given by `titleAs`. */\n title: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /** Optional ReactNode rendered above the title row (typically a Breadcrumb). */\n breadcrumb?: React.ReactNode\n /** Optional ReactNode rendered on the right side of the title row. */\n actions?: React.ReactNode\n /** Root element tag. Defaults to `header`. */\n as?: PageHeaderElement\n /** Heading level for the title. Defaults to `h1`. */\n titleAs?: TitleElement\n /**\n * Title font-size override. Token presets (`\"default\" | \"marquee\"`) map to\n * `data-title-size` attributes; any other string is forwarded as a raw CSS\n * length on the `--page-header-title-size` custom property.\n */\n titleSize?: \"default\" | \"marquee\" | (string & {})\n /**\n * Title font-family override. Token presets (`\"heading\" | \"display\"`) map\n * to `data-title-family` attributes; any other string is forwarded as a\n * raw CSS family on the `--page-header-title-family` custom property.\n */\n titleFamily?: \"heading\" | \"display\" | (string & {})\n}\n\nconst PageHeader = React.forwardRef<HTMLElement, PageHeaderProps>(\n (\n {\n className,\n size,\n eyebrow,\n title,\n description,\n breadcrumb,\n actions,\n as = \"header\",\n titleAs = \"h1\",\n titleSize,\n titleFamily,\n ...props\n },\n ref\n ) => {\n const Root = as as React.ElementType\n const Title = titleAs as React.ElementType\n\n const titleSizeToken =\n typeof titleSize === \"string\" && isTitleSizeToken(titleSize)\n ? titleSize\n : undefined\n const titleFamilyToken =\n typeof titleFamily === \"string\" && isTitleFamilyToken(titleFamily)\n ? titleFamily\n : undefined\n\n const rawTitleSize =\n typeof titleSize === \"string\" && !titleSizeToken ? titleSize : undefined\n const rawTitleFamily =\n typeof titleFamily === \"string\" && !titleFamilyToken\n ? titleFamily\n : undefined\n\n const titleStyle: React.CSSProperties | undefined =\n rawTitleSize || rawTitleFamily\n ? {\n ...(rawTitleSize\n ? ({\n \"--page-header-title-size\": rawTitleSize,\n } as React.CSSProperties)\n : null),\n ...(rawTitleFamily\n ? ({\n \"--page-header-title-family\": rawTitleFamily,\n } as React.CSSProperties)\n : null),\n }\n : undefined\n\n // When a raw string is supplied, switch the title into the \"marquee\" /\n // \"display\" rules that consume the custom property so the override\n // actually takes effect. Token values map directly to data-attributes.\n const resolvedTitleSizeAttr =\n titleSizeToken ?? (rawTitleSize ? \"marquee\" : undefined)\n const resolvedTitleFamilyAttr =\n titleFamilyToken ?? (rawTitleFamily ? \"display\" : undefined)\n\n return (\n <Root\n ref={ref}\n data-slot=\"page-header\"\n className={cn(pageHeaderVariants({ size }), className)}\n {...props}\n >\n {breadcrumb ? (\n <div data-slot=\"page-header-breadcrumb\" className={styles.breadcrumb}>\n {breadcrumb}\n </div>\n ) : null}\n <div data-slot=\"page-header-row\" className={styles.row}>\n <div data-slot=\"page-header-text\" className={styles.text}>\n {eyebrow ? (\n <div data-slot=\"page-header-eyebrow\" className={styles.eyebrow}>\n {eyebrow}\n </div>\n ) : null}\n <Title\n data-slot=\"page-header-title\"\n data-title-size={resolvedTitleSizeAttr}\n data-title-family={resolvedTitleFamilyAttr}\n className={styles.title}\n style={titleStyle}\n >\n {title}\n </Title>\n {description ? (\n <p\n data-slot=\"page-header-description\"\n className={styles.description}\n >\n {description}\n </p>\n ) : null}\n </div>\n {actions ? (\n <div data-slot=\"page-header-actions\" className={styles.actions}>\n {actions}\n </div>\n ) : null}\n </div>\n </Root>\n )\n }\n)\nPageHeader.displayName = \"PageHeader\"\n\nexport { PageHeader, pageHeaderVariants }\n"
2211
2261
  },
2212
2262
  {
2213
2263
  "path": "components/ui/page-header/page-header.module.css",
2214
2264
  "type": "registry:ui",
2215
- "content": "/* Page Header base: container query wrapper */\n.base {\n display: flex;\n flex-direction: column;\n width: 100%;\n min-width: 0;\n gap: var(--spacing-4, 1rem);\n container-type: inline-size;\n container-name: page-header;\n color: var(--text-primary, #111827);\n}\n\n/* Size variants control vertical rhythm */\n.sizeSm {\n gap: var(--spacing-3, 0.75rem);\n}\n\n.sizeMd {\n gap: var(--spacing-4, 1rem);\n}\n\n.sizeLg {\n gap: var(--spacing-5, 1.25rem);\n}\n\n/* Breadcrumb slot sits above the title row */\n.breadcrumb {\n display: flex;\n align-items: center;\n min-width: 0;\n}\n\n/* Main row: text block on the left, actions on the right */\n.row {\n display: flex;\n align-items: flex-end;\n justify-content: space-between;\n gap: var(--spacing-6, 1.5rem);\n min-width: 0;\n}\n\n/* Text block: eyebrow, title, description stacked */\n.text {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-2, 0.5rem);\n min-width: 0;\n flex: 1 1 auto;\n}\n\n.sizeSm .text {\n gap: var(--spacing-1, 0.25rem);\n}\n\n.sizeLg .text {\n gap: var(--spacing-3, 0.75rem);\n}\n\n/* Eyebrow: small uppercase label above the title */\n.eyebrow {\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-semibold, 600);\n letter-spacing: var(--letter-spacing-wide, 0.05em);\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n line-height: var(--line-height-tight, 1.25);\n}\n\n/* Title */\n.title {\n margin: 0;\n font-family: var(--font-family-heading, inherit);\n font-size: var(--font-size-2xl, 1.5rem);\n font-weight: var(--font-weight-semibold, 600);\n line-height: var(--line-height-tight, 1.2);\n color: var(--text-primary, #111827);\n letter-spacing: var(--letter-spacing-tight, -0.01em);\n}\n\n.sizeSm .title {\n font-size: var(--font-size-xl, 1.25rem);\n}\n\n.sizeLg .title {\n font-size: var(--font-size-3xl, 1.875rem);\n}\n\n/* Description */\n.description {\n margin: 0;\n font-size: var(--font-size-sm, 0.875rem);\n line-height: var(--line-height-relaxed, 1.6);\n color: var(--text-secondary, #6b7280);\n max-width: 65ch;\n}\n\n.sizeLg .description {\n font-size: var(--font-size-base, 1rem);\n}\n\n/* Actions slot: cluster on the right */\n.actions {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n flex: 0 0 auto;\n flex-wrap: wrap;\n justify-content: flex-end;\n}\n\n/* Container query: stack to column below a small container width */\n@container page-header (max-width: 480px) {\n .row {\n flex-direction: column;\n align-items: flex-start;\n gap: var(--spacing-4, 1rem);\n }\n\n .actions {\n width: 100%;\n justify-content: flex-start;\n }\n}\n"
2265
+ "content": "/* Page Header base: container query wrapper */\n.base {\n /* Override hooks for title typography. Themes (or consumers via a wrapping\n className) can set these to retune the marquee title without forking the\n component. Props are sugar over the same custom properties. */\n --page-header-title-size: 3.5rem;\n --page-header-title-family: var(--font-display, var(--font-family-heading, inherit));\n\n display: flex;\n flex-direction: column;\n width: 100%;\n min-width: 0;\n gap: var(--spacing-4, 1rem);\n container-type: inline-size;\n container-name: page-header;\n color: var(--text-primary, #111827);\n}\n\n/* Size variants control vertical rhythm */\n.sizeSm {\n gap: var(--spacing-3, 0.75rem);\n}\n\n.sizeMd {\n gap: var(--spacing-4, 1rem);\n}\n\n.sizeLg {\n gap: var(--spacing-5, 1.25rem);\n}\n\n/* Breadcrumb slot sits above the title row */\n.breadcrumb {\n display: flex;\n align-items: center;\n min-width: 0;\n}\n\n/* Main row: text block on the left, actions on the right */\n.row {\n display: flex;\n align-items: flex-end;\n justify-content: space-between;\n gap: var(--spacing-6, 1.5rem);\n min-width: 0;\n}\n\n/* Text block: eyebrow, title, description stacked */\n.text {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-2, 0.5rem);\n min-width: 0;\n flex: 1 1 auto;\n}\n\n.sizeSm .text {\n gap: var(--spacing-1, 0.25rem);\n}\n\n.sizeLg .text {\n gap: var(--spacing-3, 0.75rem);\n}\n\n/* Eyebrow: small uppercase label above the title */\n.eyebrow {\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-semibold, 600);\n letter-spacing: var(--letter-spacing-wide, 0.05em);\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n line-height: var(--line-height-tight, 1.25);\n}\n\n/* Title */\n.title {\n margin: 0;\n font-family: var(--font-family-heading, inherit);\n font-size: var(--font-size-2xl, 1.5rem);\n font-weight: var(--font-weight-semibold, 600);\n line-height: var(--line-height-tight, 1.2);\n color: var(--text-primary, #111827);\n letter-spacing: var(--letter-spacing-tight, -0.01em);\n}\n\n.sizeSm .title {\n font-size: var(--font-size-xl, 1.25rem);\n}\n\n.sizeLg .title {\n font-size: var(--font-size-3xl, 1.875rem);\n}\n\n/* Title typography overrides (orthogonal to size variants).\n `data-title-size=\"marquee\"` pulls the title up to a hero scale via the\n `--page-header-title-size` custom property. Raw-string consumers pass an\n inline `--page-header-title-size` and rely on the same rule. */\n.title[data-title-size=\"marquee\"] {\n font-size: var(--page-header-title-size, 3.5rem);\n line-height: 1;\n letter-spacing: var(--letter-spacing-tight, -0.01em);\n}\n\n/* `data-title-family=\"display\"` switches to the display family with a\n graceful fallback to the heading family when no display font is themed. */\n.title[data-title-family=\"display\"] {\n font-family: var(--page-header-title-family, var(--font-display, var(--font-family-heading, inherit)));\n}\n\n/* Description */\n.description {\n margin: 0;\n font-size: var(--font-size-sm, 0.875rem);\n line-height: var(--line-height-relaxed, 1.6);\n color: var(--text-secondary, #6b7280);\n max-width: 65ch;\n}\n\n.sizeLg .description {\n font-size: var(--font-size-base, 1rem);\n}\n\n/* Actions slot: cluster on the right */\n.actions {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n flex: 0 0 auto;\n flex-wrap: wrap;\n justify-content: flex-end;\n}\n\n/* Container query: stack to column below a small container width */\n@container page-header (max-width: 480px) {\n .row {\n flex-direction: column;\n align-items: flex-start;\n gap: var(--spacing-4, 1rem);\n }\n\n .actions {\n width: 100%;\n justify-content: flex-start;\n }\n}\n"
2266
+ }
2267
+ ]
2268
+ },
2269
+ {
2270
+ "name": "quick-actions",
2271
+ "type": "registry:ui",
2272
+ "description": "A vertical list of action rows pairing a left-aligned label with a right-aligned Kbd shortcut. Display-only by default; opt-in interactive mode via onActivate makes rows activatable buttons with click, Enter, and Space activation.",
2273
+ "category": "navigation",
2274
+ "dependencies": [
2275
+ "@loworbitstudio/visor-core"
2276
+ ],
2277
+ "registryDependencies": [
2278
+ "utils",
2279
+ "kbd"
2280
+ ],
2281
+ "files": [
2282
+ {
2283
+ "path": "components/ui/quick-actions/quick-actions.tsx",
2284
+ "type": "registry:ui",
2285
+ "content": "import * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport { Kbd } from \"../kbd/kbd\"\nimport styles from \"./quick-actions.module.css\"\n\nexport interface QuickAction {\n id: string\n label: React.ReactNode\n /** Shortcut keys, forwarded to `<Kbd keys={...} />`. */\n keys: string[]\n}\n\nexport interface QuickActionsProps\n extends Omit<React.HTMLAttributes<HTMLUListElement>, \"onClick\"> {\n actions: QuickAction[]\n /**\n * When supplied, rows render as activatable buttons that fire this handler\n * on click, Enter, or Space. When omitted, rows render as plain list items\n * with no interactive affordance — the user reads the label and presses the\n * actual key on the keyboard.\n */\n onActivate?: (id: string) => void\n}\n\n/**\n * QuickActions — a vertical list of action rows pairing a left-aligned label\n * with a right-aligned `Kbd` shortcut. Sized for dashboard side-rail \"quick\"\n * panels and command-palette previews.\n *\n * Display-only by default: rows render as `<li>` and the keys are presented as\n * semantic `<kbd>` chrome. Supply `onActivate` to opt rows into interactive\n * mode — they become `role=\"button\"` with `tabIndex={0}` and respond to click,\n * Enter, and Space.\n */\nconst QuickActions = React.forwardRef<HTMLUListElement, QuickActionsProps>(\n ({ className, actions, onActivate, ...props }, ref) => {\n const interactive = typeof onActivate === \"function\"\n\n return (\n <ul\n ref={ref}\n data-slot=\"quick-actions\"\n role={interactive ? \"group\" : \"list\"}\n className={cn(styles.root, className)}\n {...props}\n >\n {actions.map((action) => {\n if (interactive) {\n const handleClick = () => onActivate(action.id)\n const handleKeyDown = (\n event: React.KeyboardEvent<HTMLLIElement>\n ) => {\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault()\n onActivate(action.id)\n }\n }\n\n return (\n <li\n key={action.id}\n data-slot=\"quick-actions-row\"\n data-interactive=\"true\"\n role=\"button\"\n tabIndex={0}\n className={styles.row}\n onClick={handleClick}\n onKeyDown={handleKeyDown}\n >\n <span\n data-slot=\"quick-actions-label\"\n className={styles.label}\n >\n {action.label}\n </span>\n <span data-slot=\"quick-actions-keys\" className={styles.keys}>\n <Kbd keys={action.keys} size=\"sm\" />\n </span>\n </li>\n )\n }\n\n return (\n <li\n key={action.id}\n data-slot=\"quick-actions-row\"\n className={styles.row}\n >\n <span data-slot=\"quick-actions-label\" className={styles.label}>\n {action.label}\n </span>\n <span data-slot=\"quick-actions-keys\" className={styles.keys}>\n <Kbd keys={action.keys} size=\"sm\" />\n </span>\n </li>\n )\n })}\n </ul>\n )\n }\n)\nQuickActions.displayName = \"QuickActions\"\n\nexport { QuickActions }\n"
2286
+ },
2287
+ {
2288
+ "path": "components/ui/quick-actions/quick-actions.module.css",
2289
+ "type": "registry:ui",
2290
+ "content": "/* QuickActions — vertical list of label + Kbd rows.\n * Ported from admin-v7-r3 reference: 6px gap, 12-16px padding,\n * 13-14px label sized off --font-size-sm, --text-secondary color.\n *\n * No --spacing-1.5 token exists, so the 6px gap uses\n * calc(--spacing-1 + --spacing-1/2) — mirrors the pattern used in kbd. */\n.root {\n display: flex;\n flex-direction: column;\n gap: calc(var(--spacing-1, 0.25rem) + var(--spacing-1, 0.25rem) / 2);\n padding: var(--spacing-3, 0.75rem) var(--spacing-4, 1rem);\n background-color: var(--surface-card, #ffffff);\n margin: 0;\n list-style: none;\n}\n\n.row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--spacing-3, 0.75rem);\n font-size: var(--font-size-sm, 0.875rem);\n line-height: 1.25;\n border-radius: var(--radius-sm, 0.25rem);\n}\n\n.label {\n color: var(--text-secondary, #6b7280);\n min-width: 0;\n}\n\n.keys {\n display: inline-flex;\n align-items: center;\n flex-shrink: 0;\n}\n\n/* Interactive mode — rows opt in via the onActivate prop, which adds\n * data-interactive=\"true\" and role=\"button\". Adds hover/focus affordances\n * and a tap target boost so a click feels like a button. */\n.row[data-interactive=\"true\"] {\n cursor: pointer;\n padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);\n margin: calc(-1 * var(--spacing-1, 0.25rem))\n calc(-1 * var(--spacing-2, 0.5rem));\n transition:\n background-color var(--motion-duration-150, 150ms)\n var(--motion-easing-default, ease-in-out),\n color var(--motion-duration-150, 150ms)\n var(--motion-easing-default, ease-in-out);\n}\n\n@media (hover: hover) {\n .row[data-interactive=\"true\"]:hover {\n background-color: var(--surface-muted, #f3f4f6);\n }\n\n .row[data-interactive=\"true\"]:hover .label {\n color: var(--text-primary, #111827);\n }\n}\n\n.row[data-interactive=\"true\"]:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n"
2291
+ }
2292
+ ]
2293
+ },
2294
+ {
2295
+ "name": "section-header",
2296
+ "type": "registry:ui",
2297
+ "description": "A compact section-divider primitive with an uppercase title and optional right-aligned meta label. Sized for in-page content sectioning, distinct from the page-level PageHeader.",
2298
+ "category": "navigation",
2299
+ "dependencies": [
2300
+ "@loworbitstudio/visor-core"
2301
+ ],
2302
+ "registryDependencies": [
2303
+ "utils"
2304
+ ],
2305
+ "files": [
2306
+ {
2307
+ "path": "components/ui/section-header/section-header.tsx",
2308
+ "type": "registry:ui",
2309
+ "content": "import * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./section-header.module.css\"\n\ntype SectionHeaderElement = \"header\" | \"div\" | \"section\"\n\nexport interface SectionHeaderProps\n extends Omit<React.HTMLAttributes<HTMLElement>, \"title\"> {\n /** Uppercase tracking label rendered on the left. */\n title: React.ReactNode\n /** Optional right-aligned meta — count, timestamp, status. */\n meta?: React.ReactNode\n /** Root element tag. Defaults to `header`. */\n as?: SectionHeaderElement\n}\n\nconst SectionHeader = React.forwardRef<HTMLElement, SectionHeaderProps>(\n ({ className, title, meta, as = \"header\", ...props }, ref) => {\n const Root = as as React.ElementType\n\n return (\n <Root\n ref={ref}\n data-slot=\"section-header\"\n className={cn(styles.root, className)}\n {...props}\n >\n <span data-slot=\"section-header-title\" className={styles.title}>\n {title}\n </span>\n {meta ? (\n <span data-slot=\"section-header-meta\" className={styles.meta}>\n {meta}\n </span>\n ) : null}\n </Root>\n )\n }\n)\nSectionHeader.displayName = \"SectionHeader\"\n\nexport { SectionHeader }\n"
2310
+ },
2311
+ {
2312
+ "path": "components/ui/section-header/section-header.module.css",
2313
+ "type": "registry:ui",
2314
+ "content": "/* Section divider header — 36px row, surface-subtle background.\n Sits between PageHeader (page-level hero) and Heading (in-content h2/h3). */\n.root {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--spacing-3, 0.75rem);\n height: 36px;\n padding: 0 var(--spacing-4, 1rem);\n background: var(--surface-subtle, #f9fafb);\n}\n\n/* Left: uppercase eyebrow-style label. */\n.title {\n font-size: var(--font-size-xs, 0.6875rem);\n font-weight: var(--font-weight-medium, 500);\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n min-width: 0;\n}\n\n/* Right: optional meta — count, timestamp, status. */\n.meta {\n font-size: var(--font-size-sm, 0.8125rem);\n color: var(--text-tertiary, #6b7280);\n font-variant-numeric: tabular-nums;\n flex: 0 0 auto;\n}\n"
2315
+ }
2316
+ ]
2317
+ },
2318
+ {
2319
+ "name": "score-indicator",
2320
+ "type": "registry:ui",
2321
+ "description": "Compact circular ring visualization for percentage / ratio metrics — health score, uptime, engagement. Auto-toned color mapping from the value/max ratio, three sizes, optional trailing or below denominator label, and an icon overlay for destructive / warning tones.",
2322
+ "category": "admin",
2323
+ "dependencies": [
2324
+ "class-variance-authority",
2325
+ "@phosphor-icons/react",
2326
+ "@loworbitstudio/visor-core"
2327
+ ],
2328
+ "registryDependencies": [
2329
+ "utils"
2330
+ ],
2331
+ "files": [
2332
+ {
2333
+ "path": "components/ui/score-indicator/score-indicator.tsx",
2334
+ "type": "registry:ui",
2335
+ "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { WarningCircle, Warning } from \"@phosphor-icons/react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./score-indicator.module.css\"\n\nconst scoreIndicatorVariants = cva(styles.base, {\n variants: {\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n lg: styles.sizeLg,\n },\n denominator: {\n none: styles.denominatorNone,\n trailing: styles.denominatorTrailing,\n below: styles.denominatorBelow,\n },\n },\n defaultVariants: {\n size: \"md\",\n denominator: \"trailing\",\n },\n})\n\nconst TONE_CLASS: Record<ResolvedTone, string> = {\n success: styles.toneSuccess,\n warning: styles.toneWarning,\n destructive: styles.toneDestructive,\n info: styles.toneInfo,\n neutral: styles.toneNeutral,\n}\n\nexport type ScoreIndicatorTone =\n | \"auto\"\n | \"success\"\n | \"warning\"\n | \"destructive\"\n | \"info\"\n | \"neutral\"\n\nexport type ResolvedTone = Exclude<ScoreIndicatorTone, \"auto\">\n\nexport interface ScoreIndicatorProps\n extends Omit<React.HTMLAttributes<HTMLSpanElement>, \"children\">,\n VariantProps<typeof scoreIndicatorVariants> {\n /** Current value. */\n value: number\n /** Maximum value the score can reach. @default 100 */\n max?: number\n /** Visual size. @default \"md\" */\n size?: \"sm\" | \"md\" | \"lg\"\n /** Color treatment. @default \"auto\" — derives from value/max ratio */\n tone?: ScoreIndicatorTone\n /** Optional label for accessibility. Defaults to \"{value} out of {max}\". */\n ariaLabel?: string\n /** Where to show the denominator. @default \"trailing\" */\n denominator?: \"none\" | \"trailing\" | \"below\"\n /** Custom format for the displayed value. Defaults to rounded integer. */\n format?: (value: number, max: number) => string\n}\n\nconst SIZE_RING_PX: Record<NonNullable<ScoreIndicatorProps[\"size\"]>, number> = {\n sm: 24,\n md: 36,\n lg: 56,\n}\n\nconst SIZE_STROKE_PX: Record<NonNullable<ScoreIndicatorProps[\"size\"]>, number> = {\n sm: 2.5,\n md: 3.5,\n lg: 5,\n}\n\nconst defaultFormat = (value: number, _max: number): string =>\n String(Math.round(value))\n\nexport function deriveAutoTone(ratio: number): ResolvedTone {\n if (ratio >= 0.85) return \"success\"\n if (ratio >= 0.6) return \"info\"\n if (ratio >= 0.4) return \"warning\"\n return \"destructive\"\n}\n\nconst ScoreIndicator = React.forwardRef<HTMLSpanElement, ScoreIndicatorProps>(\n (\n {\n className,\n value,\n max = 100,\n size = \"md\",\n tone = \"auto\",\n ariaLabel,\n denominator = \"trailing\",\n format,\n ...props\n },\n ref\n ) => {\n const safeMax = max > 0 ? max : 100\n const clamped = Math.min(Math.max(value, 0), safeMax)\n const ratio = clamped / safeMax\n const resolvedTone: ResolvedTone =\n tone === \"auto\" ? deriveAutoTone(ratio) : tone\n\n const ringPx = SIZE_RING_PX[size]\n const strokePx = SIZE_STROKE_PX[size]\n const viewBox = 100\n const center = viewBox / 2\n const radius = center - (strokePx / 2) * (viewBox / ringPx)\n const circumference = 2 * Math.PI * radius\n const dashOffset = circumference * (1 - ratio)\n\n const formatted = (format ?? defaultFormat)(value, safeMax)\n const denominatorText = `/ ${defaultFormat(safeMax, safeMax)}`\n const computedAriaLabel = ariaLabel ?? `${value} out of ${safeMax}`\n\n return (\n <span\n ref={ref}\n data-slot=\"score-indicator\"\n data-size={size}\n data-tone={resolvedTone}\n data-denominator={denominator}\n className={cn(\n scoreIndicatorVariants({ size, denominator }),\n TONE_CLASS[resolvedTone],\n className\n )}\n {...props}\n >\n <span\n data-slot=\"score-indicator-ring\"\n role=\"img\"\n aria-label={computedAriaLabel}\n className={styles.ring}\n style={{ width: ringPx, height: ringPx }}\n >\n <svg\n className={styles.svg}\n viewBox={`0 0 ${viewBox} ${viewBox}`}\n aria-hidden=\"true\"\n focusable=\"false\"\n >\n <circle\n className={styles.track}\n cx={center}\n cy={center}\n r={radius}\n fill=\"none\"\n strokeWidth={strokePx * (viewBox / ringPx)}\n />\n <circle\n className={styles.indicator}\n cx={center}\n cy={center}\n r={radius}\n fill=\"none\"\n strokeWidth={strokePx * (viewBox / ringPx)}\n strokeDasharray={circumference}\n strokeDashoffset={dashOffset}\n strokeLinecap=\"round\"\n transform={`rotate(-90 ${center} ${center})`}\n />\n </svg>\n <span data-slot=\"score-indicator-value\" className={styles.value}>\n {formatted}\n </span>\n {resolvedTone === \"destructive\" || resolvedTone === \"warning\" ? (\n <span\n data-slot=\"score-indicator-icon\"\n className={styles.iconOverlay}\n aria-hidden=\"true\"\n >\n {resolvedTone === \"destructive\" ? (\n <WarningCircle weight=\"fill\" />\n ) : (\n <Warning weight=\"fill\" />\n )}\n </span>\n ) : null}\n </span>\n {denominator !== \"none\" ? (\n <span\n data-slot=\"score-indicator-denominator\"\n className={styles.denominator}\n aria-hidden=\"true\"\n >\n {denominatorText}\n </span>\n ) : null}\n </span>\n )\n }\n)\nScoreIndicator.displayName = \"ScoreIndicator\"\n\nexport { ScoreIndicator, scoreIndicatorVariants }\n"
2336
+ },
2337
+ {
2338
+ "path": "components/ui/score-indicator/score-indicator.module.css",
2339
+ "type": "registry:ui",
2340
+ "content": "/* Score Indicator\n *\n * Compact circular score visualization for percentage / ratio metrics\n * (health, uptime, engagement, etc.). Renders an SVG track + indicator\n * ring with the value centered inside and an optional denominator label.\n *\n * All colors flow through CSS custom properties so themes (or consumers)\n * can tune per-tone ring + value colors without forking the component.\n */\n\n.base {\n --score-indicator-track-color: color-mix(\n in srgb,\n var(--border-default, #e5e7eb) 100%,\n transparent\n );\n --score-indicator-stroke-color: var(--text-primary, #111827);\n --score-indicator-value-color: var(--text-primary, #111827);\n --score-indicator-center-bg: transparent;\n --score-indicator-icon-color: var(--text-primary, #111827);\n\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n color: var(--text-primary, #111827);\n font-variant-numeric: tabular-nums;\n line-height: var(--line-height-tight, 1.1);\n}\n\n/* Below denominator: stack ring on top of the \"/ N\" label */\n.denominatorBelow {\n flex-direction: column;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n}\n\n.denominatorNone,\n.denominatorTrailing {\n /* default flex-row layout */\n}\n\n/* Ring container — holds the SVG + centered value text */\n.ring {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border-radius: var(--radius-full, 9999px);\n background-color: var(--score-indicator-center-bg);\n flex-shrink: 0;\n}\n\n.svg {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n display: block;\n overflow: visible;\n}\n\n/* Track — full ring at low opacity */\n.track {\n stroke: var(--score-indicator-track-color);\n transition: stroke var(--motion-duration-150, 150ms)\n var(--motion-easing-default, ease-in-out);\n}\n\n/* Indicator — arc proportional to value/max */\n.indicator {\n stroke: var(--score-indicator-stroke-color);\n transition:\n stroke-dashoffset var(--motion-duration-500, 500ms)\n var(--motion-easing-default, ease-in-out),\n stroke var(--motion-duration-150, 150ms)\n var(--motion-easing-default, ease-in-out);\n}\n\n/* Centered value text — sits above the SVG */\n.value {\n position: relative;\n z-index: 1;\n font-weight: var(--font-weight-semibold, 600);\n color: var(--score-indicator-value-color);\n line-height: 1;\n}\n\n/* Trailing or below denominator label */\n.denominator {\n color: var(--text-tertiary, #6b7280);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-regular, 400);\n white-space: nowrap;\n}\n\n/* Icon overlay for destructive / warning tones — sits at top-right of ring */\n.iconOverlay {\n position: absolute;\n top: -2px;\n right: -2px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n color: var(--score-indicator-icon-color);\n background-color: var(--surface-card, #ffffff);\n border-radius: var(--radius-full, 9999px);\n line-height: 0;\n z-index: 2;\n}\n\n/* Size sm — 24px ring, 11px value */\n.sizeSm .value {\n font-size: 11px;\n}\n\n.sizeSm .denominator {\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n.sizeSm .iconOverlay {\n font-size: 10px;\n}\n\n/* Size md — 36px ring, 14px value (default) */\n.sizeMd .value {\n font-size: 14px;\n}\n\n.sizeMd .denominator {\n font-size: var(--font-size-sm, 0.875rem);\n}\n\n.sizeMd .iconOverlay {\n font-size: 12px;\n}\n\n/* Size lg — 56px ring, 20px value */\n.sizeLg .value {\n font-size: 20px;\n}\n\n.sizeLg .denominator {\n font-size: var(--font-size-base, 1rem);\n}\n\n.sizeLg .iconOverlay {\n font-size: 14px;\n}\n\n/* Tone bindings: each maps the local custom properties to semantic theme\n * tokens. Consumers override by setting any of the --score-indicator-* hooks\n * on the wrapper. Each tone tints the center subtly via color-mix over the\n * matching semantic surface so the value sits on a hint of color.\n */\n\n.toneSuccess {\n --score-indicator-stroke-color: var(--text-success, #16a34a);\n --score-indicator-value-color: var(--text-success, #16a34a);\n --score-indicator-icon-color: var(--text-success, #16a34a);\n --score-indicator-center-bg: color-mix(\n in srgb,\n var(--surface-success-subtle, var(--text-success, #16a34a)) 35%,\n transparent\n );\n}\n\n.toneInfo {\n --score-indicator-stroke-color: var(--text-info, #2563eb);\n --score-indicator-value-color: var(--text-info, #2563eb);\n --score-indicator-icon-color: var(--text-info, #2563eb);\n --score-indicator-center-bg: color-mix(\n in srgb,\n var(--surface-info-subtle, var(--text-info, #2563eb)) 35%,\n transparent\n );\n}\n\n.toneWarning {\n --score-indicator-stroke-color: var(--text-warning, #d97706);\n --score-indicator-value-color: var(--text-warning, #d97706);\n --score-indicator-icon-color: var(--text-warning, #d97706);\n --score-indicator-center-bg: color-mix(\n in srgb,\n var(--surface-warning-subtle, var(--text-warning, #d97706)) 35%,\n transparent\n );\n}\n\n.toneDestructive {\n --score-indicator-stroke-color: var(--text-error, #dc2626);\n --score-indicator-value-color: var(--text-error, #dc2626);\n --score-indicator-icon-color: var(--text-error, #dc2626);\n --score-indicator-center-bg: color-mix(\n in srgb,\n var(--surface-error-subtle, var(--text-error, #dc2626)) 35%,\n transparent\n );\n}\n\n.toneNeutral {\n --score-indicator-stroke-color: var(--text-tertiary, #6b7280);\n --score-indicator-value-color: var(--text-primary, #111827);\n --score-indicator-icon-color: var(--text-tertiary, #6b7280);\n --score-indicator-center-bg: transparent;\n}\n\n/* Respect reduced motion — disable transitions */\n@media (prefers-reduced-motion: reduce) {\n .indicator,\n .track {\n transition: none;\n }\n}\n"
2216
2341
  }
2217
2342
  ]
2218
2343
  },
@@ -2232,12 +2357,12 @@
2232
2357
  {
2233
2358
  "path": "components/ui/stat-card/stat-card.tsx",
2234
2359
  "type": "registry:ui",
2235
- "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./stat-card.module.css\"\n\nconst statCardVariants = cva(styles.base, {\n variants: {\n variant: {\n default: styles.variantDefault,\n highlight: styles.variantHighlight,\n },\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"md\",\n },\n})\n\nexport type StatCardDeltaDirection = \"up\" | \"down\" | \"flat\"\n\nexport interface StatCardDelta {\n /** Display value, e.g. \"+12.4%\" or \"-$2.1K\". */\n value: React.ReactNode\n /** Semantic direction of the change. Drives color and glyph. */\n direction: StatCardDeltaDirection\n /** Optional context label, e.g. \"vs last month\". Visible and announced. */\n label?: string\n}\n\ntype StatCardElement = \"article\" | \"section\" | \"div\"\n\nexport interface StatCardProps\n extends React.HTMLAttributes<HTMLElement>,\n VariantProps<typeof statCardVariants> {\n /** Small uppercase label describing the metric, e.g. \"Total Revenue\". */\n label: React.ReactNode\n /** Prominent metric value, e.g. \"$48,120\". */\n value: React.ReactNode\n /** Optional change indicator. */\n delta?: StatCardDelta\n /** Optional slot for a sparkline, chart, or icon. */\n trend?: React.ReactNode\n /** Optional sublabel or link rendered beneath the value. */\n footer?: React.ReactNode\n /** Root element tag. Defaults to `article` for landmark semantics. */\n as?: StatCardElement\n /** Typography scale for the value. \"hero\" = marquee display treatment. Defaults to \"default\". */\n valueAs?: \"default\" | \"hero\" | \"compact\"\n /** Additional class names forwarded to the value element. */\n valueClassName?: string\n}\n\nconst DELTA_GLYPH: Record<StatCardDeltaDirection, string> = {\n up: \"\\u2191\",\n down: \"\\u2193\",\n flat: \"\\u2192\",\n}\n\nconst DELTA_WORD: Record<StatCardDeltaDirection, string> = {\n up: \"up\",\n down: \"down\",\n flat: \"flat\",\n}\n\nconst StatCard = React.forwardRef<HTMLElement, StatCardProps>(\n (\n {\n className,\n variant,\n size,\n label,\n value,\n delta,\n trend,\n footer,\n as = \"article\",\n valueAs,\n valueClassName,\n ...props\n },\n ref\n ) => {\n const Root = as as React.ElementType\n\n return (\n <Root\n ref={ref}\n data-slot=\"stat-card\"\n data-variant={variant ?? \"default\"}\n data-size={size ?? \"md\"}\n className={cn(statCardVariants({ variant, size }), className)}\n {...props}\n >\n <div data-slot=\"stat-card-header\" className={styles.header}>\n <p data-slot=\"stat-card-label\" className={styles.label}>\n {label}\n </p>\n {trend ? (\n <div\n data-slot=\"stat-card-trend\"\n className={styles.trend}\n aria-hidden=\"true\"\n >\n {trend}\n </div>\n ) : null}\n </div>\n\n <p\n data-slot=\"stat-card-value\"\n data-value-as={valueAs}\n className={cn(styles.value, valueClassName)}\n >\n {value}\n </p>\n\n {delta ? (\n <div\n data-slot=\"stat-card-delta\"\n data-direction={delta.direction}\n className={styles.delta}\n >\n <span className={styles.deltaGlyph} aria-hidden=\"true\">\n {DELTA_GLYPH[delta.direction]}\n </span>\n <span className={styles.deltaValue}>{delta.value}</span>\n {delta.label ? (\n <span className={styles.deltaLabel}>{delta.label}</span>\n ) : null}\n <span className={styles.srOnly}>\n {DELTA_WORD[delta.direction]}\n {delta.label ? ` ${delta.label}` : \"\"}\n </span>\n </div>\n ) : null}\n\n {footer ? (\n <div data-slot=\"stat-card-footer\" className={styles.footer}>\n {footer}\n </div>\n ) : null}\n </Root>\n )\n }\n)\nStatCard.displayName = \"StatCard\"\n\nexport { StatCard, statCardVariants }\n"
2360
+ "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./stat-card.module.css\"\n\nconst statCardVariants = cva(styles.base, {\n variants: {\n variant: {\n default: styles.variantDefault,\n highlight: styles.variantHighlight,\n },\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"md\",\n },\n})\n\nexport type StatCardDeltaDirection = \"up\" | \"down\" | \"flat\"\n\nexport interface StatCardDelta {\n /** Display value, e.g. \"+12.4%\" or \"-$2.1K\". */\n value: React.ReactNode\n /** Semantic direction of the change. Drives color and glyph. */\n direction: StatCardDeltaDirection\n /** Optional context label, e.g. \"vs last month\". Visible and announced. */\n label?: string\n}\n\ntype StatCardElement = \"article\" | \"section\" | \"div\"\n\nexport interface StatCardProps\n extends React.HTMLAttributes<HTMLElement>,\n VariantProps<typeof statCardVariants> {\n /** Small uppercase label describing the metric, e.g. \"Total Revenue\". */\n label: React.ReactNode\n /** Prominent metric value, e.g. \"$48,120\". */\n value: React.ReactNode\n /** Optional change indicator. */\n delta?: StatCardDelta\n /** Optional slot for a sparkline, chart, or icon. */\n trend?: React.ReactNode\n /**\n * Where to render the `trend` slot.\n * - `\"footer\"` (default) — direct child of the card root, after value/delta and\n * before the `footer` slot. Full card width, padded above. Best for\n * Progress bars, full-width sparklines, and anything that competes with the\n * label for header space.\n * - `\"header\"` — inside the header row, right-aligned next to the label.\n * Legacy layout, useful for compact icons or thumbnail-sized sparklines.\n * Defaults to `\"footer\"`.\n */\n trendPosition?: \"header\" | \"footer\"\n /** Optional sublabel or link rendered beneath the value. */\n footer?: React.ReactNode\n /** Root element tag. Defaults to `article` for landmark semantics. */\n as?: StatCardElement\n /** Typography scale for the value. \"hero\" = marquee display treatment. Defaults to \"default\". */\n valueAs?: \"default\" | \"hero\" | \"compact\"\n /** Additional class names forwarded to the value element. */\n valueClassName?: string\n}\n\nconst DELTA_GLYPH: Record<StatCardDeltaDirection, string> = {\n up: \"\\u2191\",\n down: \"\\u2193\",\n flat: \"\\u2192\",\n}\n\nconst DELTA_WORD: Record<StatCardDeltaDirection, string> = {\n up: \"up\",\n down: \"down\",\n flat: \"flat\",\n}\n\nconst StatCard = React.forwardRef<HTMLElement, StatCardProps>(\n (\n {\n className,\n variant,\n size,\n label,\n value,\n delta,\n trend,\n trendPosition = \"footer\",\n footer,\n as = \"article\",\n valueAs,\n valueClassName,\n ...props\n },\n ref\n ) => {\n const Root = as as React.ElementType\n\n return (\n <Root\n ref={ref}\n data-slot=\"stat-card\"\n data-variant={variant ?? \"default\"}\n data-size={size ?? \"md\"}\n className={cn(statCardVariants({ variant, size }), className)}\n {...props}\n >\n <div data-slot=\"stat-card-header\" className={styles.header}>\n <p data-slot=\"stat-card-label\" className={styles.label}>\n {label}\n </p>\n {trend && trendPosition === \"header\" ? (\n <div\n data-slot=\"stat-card-trend\"\n data-trend-position=\"header\"\n className={styles.trend}\n aria-hidden=\"true\"\n >\n {trend}\n </div>\n ) : null}\n </div>\n\n <p\n data-slot=\"stat-card-value\"\n data-value-as={valueAs}\n className={cn(styles.value, valueClassName)}\n >\n {value}\n </p>\n\n {delta ? (\n <div\n data-slot=\"stat-card-delta\"\n data-direction={delta.direction}\n className={styles.delta}\n >\n <span className={styles.deltaGlyph} aria-hidden=\"true\">\n {DELTA_GLYPH[delta.direction]}\n </span>\n <span className={styles.deltaValue}>{delta.value}</span>\n {delta.label ? (\n <span className={styles.deltaLabel}>{delta.label}</span>\n ) : null}\n <span className={styles.srOnly}>\n {DELTA_WORD[delta.direction]}\n {delta.label ? ` ${delta.label}` : \"\"}\n </span>\n </div>\n ) : null}\n\n {trend && trendPosition === \"footer\" ? (\n <div\n data-slot=\"stat-card-trend\"\n data-trend-position=\"footer\"\n className={styles.trendFooter}\n aria-hidden=\"true\"\n >\n {trend}\n </div>\n ) : null}\n\n {footer ? (\n <div data-slot=\"stat-card-footer\" className={styles.footer}>\n {footer}\n </div>\n ) : null}\n </Root>\n )\n }\n)\nStatCard.displayName = \"StatCard\"\n\nexport { StatCard, statCardVariants }\n"
2236
2361
  },
2237
2362
  {
2238
2363
  "path": "components/ui/stat-card/stat-card.module.css",
2239
2364
  "type": "registry:ui",
2240
- "content": "/* Stat Card base: self-contained dashboard metric card\n *\n * Sizing strategy: hug content by default. Place stat cards in a grid\n * (`grid-template-columns: repeat(N, 1fr)`) or a flex row with `flex: 1`\n * per child when uniform widths are needed across a dashboard. A single\n * stat card in a generic flex-row parent renders at its natural content\n * width — the big value token drives the minimum width.\n *\n * No inline-size containment here: inline-size containment prevents the\n * element's intrinsic width from depending on its children, which means\n * it collapses to 0 inside a flex-row parent with `flex: 0 1 auto`. Since\n * the card's responsive variants are already driven by explicit props,\n * the container query buys nothing and costs the natural sizing behavior.\n */\n.base {\n --stat-card-value-font: var(--font-display, var(--font-family-heading, inherit));\n --stat-card-value-size: var(--font-size-3xl, 1.875rem);\n\n display: flex;\n flex-direction: column;\n gap: var(--spacing-2, 0.5rem);\n padding: var(--spacing-5, 1.25rem);\n background-color: var(--surface-card, #ffffff);\n border: 1px solid var(--border-default, #e5e7eb);\n border-radius: var(--radius-lg, 0.75rem);\n color: var(--text-primary, #111827);\n box-shadow: var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05));\n transition:\n box-shadow var(--motion-duration-150, 150ms)\n var(--motion-easing-standard, ease),\n border-color var(--motion-duration-150, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n/* Default variant — just the base styles */\n.variantDefault {\n /* inherits base */\n}\n\n/* Highlight variant — accent-tinted surface with elevation\n *\n * Uses color-mix over the theme's brand accent (--interactive-primary-bg)\n * plus the theme's card surface, so the variant reads as \"featured / primary\"\n * regardless of whether the active theme is light or dark and regardless of\n * which brand color the theme ships. No hardcoded gradient — gradients\n * conflict with dark modes where lighter colors at the top look like flares.\n */\n.variantHighlight {\n background-color: color-mix(\n in srgb,\n var(--interactive-primary-bg, #2563eb) 6%,\n var(--surface-card, #ffffff)\n );\n border-color: color-mix(\n in srgb,\n var(--interactive-primary-bg, #2563eb) 40%,\n var(--border-default, #e5e7eb)\n );\n box-shadow:\n var(--shadow-md, 0 4px 6px -1px rgb(0 0 0 / 0.1)),\n 0 0 24px -8px\n color-mix(\n in srgb,\n var(--interactive-primary-bg, #2563eb) 25%,\n transparent\n );\n}\n\n/* Size md — default — no overrides; uses the base padding/gap/font-size */\n.sizeMd {\n /* inherits base */\n}\n\n/* Size sm — denser padding, smaller value */\n.sizeSm {\n padding: var(--spacing-4, 1rem);\n gap: var(--spacing-1, 0.25rem);\n}\n\n/* Header row: label on the left, trend slot on the right */\n.header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: var(--spacing-3, 0.75rem);\n min-width: 0;\n}\n\n/* Label — small uppercase muted text */\n.label {\n margin: 0;\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-semibold, 600);\n letter-spacing: var(--letter-spacing-wide, 0.05em);\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n line-height: var(--line-height-tight, 1.25);\n min-width: 0;\n}\n\n/* Trend slot — sparkline / icon container */\n.trend {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n color: var(--text-tertiary, #6b7280);\n flex: 0 0 auto;\n}\n\n/* Value — big numeric display */\n.value {\n margin: 0;\n font-family: var(--font-family-heading, inherit);\n font-size: var(--font-size-3xl, 1.875rem);\n font-weight: var(--font-weight-semibold, 600);\n line-height: var(--line-height-tight, 1.1);\n letter-spacing: var(--letter-spacing-tight, -0.02em);\n color: var(--text-primary, #111827);\n font-variant-numeric: tabular-nums;\n}\n\n/* valueAs=\"hero\" — marquee display treatment */\n.value[data-value-as=\"hero\"] {\n font-family: var(--stat-card-value-font);\n font-size: var(--stat-card-value-size, 3.5rem);\n font-weight: var(--font-weight-normal, 400);\n letter-spacing: 0;\n line-height: 1;\n}\n\n/* valueAs=\"compact\" — tighter than default (2xl) */\n.value[data-value-as=\"compact\"] {\n font-size: var(--font-size-2xl, 1.5rem);\n}\n\n.sizeSm .value {\n font-size: var(--font-size-2xl, 1.5rem);\n}\n\n.sizeSm .label {\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n.variantHighlight .value {\n font-size: var(--font-size-4xl, 2.25rem);\n}\n\n/* Delta row — change indicator */\n.delta {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n line-height: var(--line-height-tight, 1.25);\n color: var(--text-secondary, #6b7280);\n flex-wrap: wrap;\n}\n\n.delta[data-direction=\"up\"] {\n color: var(--text-success, #16a34a);\n}\n\n.delta[data-direction=\"down\"] {\n color: var(--text-danger, #dc2626);\n}\n\n.delta[data-direction=\"flat\"] {\n color: var(--text-tertiary, #6b7280);\n}\n\n.deltaGlyph {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n font-size: 1em;\n line-height: 1;\n}\n\n.deltaValue {\n font-variant-numeric: tabular-nums;\n}\n\n.deltaLabel {\n color: var(--text-tertiary, #6b7280);\n font-weight: var(--font-weight-regular, 400);\n margin-left: var(--spacing-1, 0.25rem);\n}\n\n/* Visually hidden but announced to screen readers */\n.srOnly {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n}\n\n/* Footer slot */\n.footer {\n margin-top: var(--spacing-1, 0.25rem);\n padding-top: var(--spacing-3, 0.75rem);\n border-top: 1px solid var(--border-default, #e5e7eb);\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n line-height: var(--line-height-normal, 1.5);\n}\n\n.sizeSm .footer {\n padding-top: var(--spacing-2, 0.5rem);\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n/* Respect reduced motion */\n@media (prefers-reduced-motion: reduce) {\n .base {\n transition: none;\n }\n}\n"
2365
+ "content": "/* Stat Card base: self-contained dashboard metric card\n *\n * Sizing strategy: hug content by default. Place stat cards in a grid\n * (`grid-template-columns: repeat(N, 1fr)`) or a flex row with `flex: 1`\n * per child when uniform widths are needed across a dashboard. A single\n * stat card in a generic flex-row parent renders at its natural content\n * width — the big value token drives the minimum width.\n *\n * No inline-size containment here: inline-size containment prevents the\n * element's intrinsic width from depending on its children, which means\n * it collapses to 0 inside a flex-row parent with `flex: 0 1 auto`. Since\n * the card's responsive variants are already driven by explicit props,\n * the container query buys nothing and costs the natural sizing behavior.\n */\n.base {\n --stat-card-value-font: var(--font-display, var(--font-family-heading, inherit));\n --stat-card-value-size: var(--font-size-3xl, 1.875rem);\n --stat-card-value-size-hero: var(--font-size-6xl, 3.5rem);\n\n display: flex;\n flex-direction: column;\n gap: var(--spacing-2, 0.5rem);\n padding: var(--spacing-5, 1.25rem);\n background-color: var(--surface-card, #ffffff);\n border: 1px solid var(--border-default, #e5e7eb);\n border-radius: var(--radius-lg, 0.75rem);\n color: var(--text-primary, #111827);\n box-shadow: var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05));\n transition:\n box-shadow var(--motion-duration-150, 150ms)\n var(--motion-easing-standard, ease),\n border-color var(--motion-duration-150, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n/* Default variant — just the base styles */\n.variantDefault {\n /* inherits base */\n}\n\n/* Highlight variant — accent-tinted surface with elevation\n *\n * Uses color-mix over the theme's brand accent (--interactive-primary-bg)\n * plus the theme's card surface, so the variant reads as \"featured / primary\"\n * regardless of whether the active theme is light or dark and regardless of\n * which brand color the theme ships. No hardcoded gradient — gradients\n * conflict with dark modes where lighter colors at the top look like flares.\n */\n.variantHighlight {\n background-color: color-mix(\n in srgb,\n var(--interactive-primary-bg, #2563eb) 6%,\n var(--surface-card, #ffffff)\n );\n border-color: color-mix(\n in srgb,\n var(--interactive-primary-bg, #2563eb) 40%,\n var(--border-default, #e5e7eb)\n );\n box-shadow:\n var(--shadow-md, 0 4px 6px -1px rgb(0 0 0 / 0.1)),\n 0 0 24px -8px\n color-mix(\n in srgb,\n var(--interactive-primary-bg, #2563eb) 25%,\n transparent\n );\n}\n\n/* Size md — default — no overrides; uses the base padding/gap/font-size */\n.sizeMd {\n /* inherits base */\n}\n\n/* Size sm — denser padding, smaller value */\n.sizeSm {\n padding: var(--spacing-4, 1rem);\n gap: var(--spacing-1, 0.25rem);\n}\n\n/* Header row: label on the left, trend slot on the right */\n.header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: var(--spacing-3, 0.75rem);\n min-width: 0;\n}\n\n/* Label — small uppercase muted text */\n.label {\n margin: 0;\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-semibold, 600);\n letter-spacing: var(--letter-spacing-wide, 0.05em);\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n line-height: var(--line-height-tight, 1.25);\n min-width: 0;\n}\n\n/* Trend slot — header position (legacy): right-aligned sparkline / icon container */\n.trend {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n color: var(--text-tertiary, #6b7280);\n flex: 0 0 auto;\n}\n\n/* Trend slot — footer position (default): full-width, padded above the value row.\n *\n * Rendered as a direct child of the card root after the value/delta and before the\n * footer slot. Full card width, no fixed flex sizing — so Progress bars, full-width\n * sparklines, and anything that competes with the label for header space have room\n * to breathe. The `--stat-card-trend-padding-top` hook lets consumers tune the gap\n * to the value row without touching internal CSS.\n */\n.trendFooter {\n display: flex;\n align-items: center;\n width: 100%;\n padding-top: var(--stat-card-trend-padding-top, var(--spacing-3, 0.75rem));\n color: var(--text-tertiary, #6b7280);\n}\n\n/* Value — big numeric display */\n.value {\n margin: 0;\n font-family: var(--font-family-heading, inherit);\n font-size: var(--font-size-3xl, 1.875rem);\n font-weight: var(--font-weight-semibold, 600);\n line-height: var(--line-height-tight, 1.1);\n letter-spacing: var(--letter-spacing-tight, -0.02em);\n color: var(--text-primary, #111827);\n font-variant-numeric: tabular-nums;\n}\n\n/* valueAs=\"hero\" — marquee display treatment */\n.value[data-value-as=\"hero\"] {\n font-family: var(--stat-card-value-font);\n font-size: var(--stat-card-value-size-hero, var(--stat-card-value-size, 3.5rem));\n font-weight: var(--font-weight-normal, 400);\n letter-spacing: 0;\n line-height: 1;\n}\n\n/* valueAs=\"compact\" — tighter than default (2xl) */\n.value[data-value-as=\"compact\"] {\n font-size: var(--font-size-2xl, 1.5rem);\n}\n\n.sizeSm .value {\n font-size: var(--font-size-2xl, 1.5rem);\n}\n\n.sizeSm .label {\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n.variantHighlight .value {\n font-size: var(--font-size-4xl, 2.25rem);\n}\n\n/* Delta row — change indicator */\n.delta {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n line-height: var(--line-height-tight, 1.25);\n color: var(--text-secondary, #6b7280);\n flex-wrap: wrap;\n}\n\n.delta[data-direction=\"up\"] {\n color: var(--text-success, #16a34a);\n}\n\n.delta[data-direction=\"down\"] {\n color: var(--text-danger, #dc2626);\n}\n\n.delta[data-direction=\"flat\"] {\n color: var(--text-tertiary, #6b7280);\n}\n\n.deltaGlyph {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n font-size: 1em;\n line-height: 1;\n}\n\n.deltaValue {\n font-variant-numeric: tabular-nums;\n}\n\n.deltaLabel {\n color: var(--text-tertiary, #6b7280);\n font-weight: var(--font-weight-regular, 400);\n margin-left: var(--spacing-1, 0.25rem);\n}\n\n/* Visually hidden but announced to screen readers */\n.srOnly {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n}\n\n/* Footer slot */\n.footer {\n margin-top: var(--spacing-1, 0.25rem);\n padding-top: var(--spacing-3, 0.75rem);\n border-top: 1px solid var(--border-default, #e5e7eb);\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n line-height: var(--line-height-normal, 1.5);\n}\n\n.sizeSm .footer {\n padding-top: var(--spacing-2, 0.5rem);\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n/* Respect reduced motion */\n@media (prefers-reduced-motion: reduce) {\n .base {\n transition: none;\n }\n}\n"
2241
2366
  }
2242
2367
  ]
2243
2368
  },
@@ -2282,7 +2407,7 @@
2282
2407
  {
2283
2408
  "path": "components/ui/status-badge/status-badge.tsx",
2284
2409
  "type": "registry:ui",
2285
- "content": "import * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport { Badge } from \"../badge/badge\"\nimport type { BadgeProps } from \"../badge/badge\"\nimport styles from \"./status-badge.module.css\"\n\nexport type StatusBadgeStatus =\n | \"healthy\"\n | \"degraded\"\n | \"down\"\n | \"failed\"\n | \"running\"\n | \"pending\"\n | \"queued\"\n | \"idle\"\n | \"complete\"\n\nexport type StatusBadgeTone = \"subtle\" | \"filled\"\n\nexport interface StatusBadgeProps\n extends Omit<React.HTMLAttributes<HTMLSpanElement>, \"children\"> {\n /** Semantic admin status. Drives both color and default label. */\n status: StatusBadgeStatus\n /** Visible text. Defaults to the capitalized status key. */\n label?: React.ReactNode\n /** Which Badge variant family to use. Defaults to \"subtle\". */\n tone?: StatusBadgeTone\n /** Render the leading indicator dot. Defaults to true. */\n indicator?: boolean\n /** Animate the indicator dot with a soft pulse. Defaults to false. */\n pulse?: boolean\n}\n\n/**\n * Default human-readable labels for each status. Exported so consumers can\n * localize or override without reimplementing the map.\n */\nexport const statusBadgeLabels: Record<StatusBadgeStatus, string> = {\n healthy: \"Healthy\",\n degraded: \"Degraded\",\n down: \"Down\",\n failed: \"Failed\",\n running: \"Running\",\n pending: \"Pending\",\n queued: \"Queued\",\n idle: \"Idle\",\n complete: \"Complete\",\n}\n\ntype BadgeVariant = NonNullable<BadgeProps[\"variant\"]>\n\n/**\n * Color group keyed on the semantic status. Used to pick the indicator dot\n * class and the underlying Badge variant.\n */\ntype StatusColorGroup =\n | \"success\"\n | \"warning\"\n | \"destructive\"\n | \"info\"\n | \"neutral\"\n\nconst STATUS_COLOR_GROUP: Record<StatusBadgeStatus, StatusColorGroup> = {\n healthy: \"success\",\n complete: \"success\",\n degraded: \"warning\",\n pending: \"warning\",\n down: \"destructive\",\n failed: \"destructive\",\n running: \"info\",\n queued: \"neutral\",\n idle: \"neutral\",\n}\n\nconst SUBTLE_VARIANT: Record<StatusColorGroup, BadgeVariant> = {\n success: \"success\",\n warning: \"warning\",\n destructive: \"destructive\",\n info: \"info\",\n neutral: \"secondary\",\n}\n\nconst FILLED_VARIANT: Record<StatusColorGroup, BadgeVariant> = {\n success: \"filled-success\",\n warning: \"filled-warning\",\n destructive: \"filled-destructive\",\n info: \"filled-info\",\n // No `filled-secondary` exists on Badge — fall back to secondary.\n neutral: \"secondary\",\n}\n\nconst INDICATOR_CLASS: Record<StatusColorGroup, string> = {\n success: styles.indicatorSuccess,\n warning: styles.indicatorWarning,\n destructive: styles.indicatorDestructive,\n info: styles.indicatorInfo,\n neutral: styles.indicatorNeutral,\n}\n\nconst StatusBadge = React.forwardRef<HTMLSpanElement, StatusBadgeProps>(\n (\n {\n className,\n status,\n label,\n tone = \"subtle\",\n indicator = true,\n pulse = false,\n ...props\n },\n ref\n ) => {\n const group = STATUS_COLOR_GROUP[status]\n const variant: BadgeVariant =\n tone === \"filled\" ? FILLED_VARIANT[group] : SUBTLE_VARIANT[group]\n const visibleLabel = label ?? statusBadgeLabels[status]\n\n return (\n <Badge\n ref={ref}\n variant={variant}\n data-slot=\"status-badge\"\n data-status={status}\n data-tone={tone}\n className={cn(className)}\n {...props}\n >\n {indicator ? (\n <span\n data-slot=\"status-badge-indicator\"\n aria-hidden=\"true\"\n className={cn(\n styles.indicator,\n INDICATOR_CLASS[group],\n pulse && styles.pulse\n )}\n />\n ) : null}\n <span className={styles.srOnly}>Status: </span>\n <span data-slot=\"status-badge-label\">{visibleLabel}</span>\n </Badge>\n )\n }\n)\nStatusBadge.displayName = \"StatusBadge\"\n\nexport { StatusBadge }\n"
2410
+ "content": "import * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport { Badge } from \"../badge/badge\"\nimport type { BadgeProps } from \"../badge/badge\"\nimport styles from \"./status-badge.module.css\"\n\nexport type StatusBadgeStatus =\n | \"healthy\"\n | \"degraded\"\n | \"down\"\n | \"failed\"\n | \"running\"\n | \"pending\"\n | \"queued\"\n | \"idle\"\n | \"complete\"\n | \"live\"\n | \"warn\"\n | \"scheduled\"\n | \"sold\"\n | \"draft\"\n\nexport type StatusBadgeTone = \"subtle\" | \"filled\"\n\nexport interface StatusBadgeProps\n extends Omit<React.HTMLAttributes<HTMLSpanElement>, \"children\"> {\n /** Semantic admin status. Drives both color and default label. */\n status: StatusBadgeStatus\n /** Visible text. Defaults to the capitalized status key. */\n label?: React.ReactNode\n /** Which Badge variant family to use. Defaults to \"subtle\". */\n tone?: StatusBadgeTone\n /** Render the leading indicator dot. Defaults to true. */\n indicator?: boolean\n /** Animate the indicator dot with a soft pulse. Defaults to false. */\n pulse?: boolean\n}\n\n/**\n * Default human-readable labels for each status. Exported so consumers can\n * localize or override without reimplementing the map.\n */\nexport const statusBadgeLabels: Record<StatusBadgeStatus, string> = {\n healthy: \"Healthy\",\n degraded: \"Degraded\",\n down: \"Down\",\n failed: \"Failed\",\n running: \"Running\",\n pending: \"Pending\",\n queued: \"Queued\",\n idle: \"Idle\",\n complete: \"Complete\",\n live: \"Live\",\n warn: \"Warn\",\n scheduled: \"Scheduled\",\n sold: \"Sold\",\n draft: \"Draft\",\n}\n\ntype BadgeVariant = NonNullable<BadgeProps[\"variant\"]>\n\n/**\n * Color group keyed on the semantic status. Used to pick the indicator dot\n * class and the underlying Badge variant.\n */\ntype StatusColorGroup =\n | \"success\"\n | \"warning\"\n | \"destructive\"\n | \"info\"\n | \"neutral\"\n\nconst STATUS_COLOR_GROUP: Record<StatusBadgeStatus, StatusColorGroup> = {\n healthy: \"success\",\n complete: \"success\",\n degraded: \"warning\",\n pending: \"warning\",\n down: \"destructive\",\n failed: \"destructive\",\n running: \"info\",\n queued: \"neutral\",\n idle: \"neutral\",\n // Admin-ui event tones — map to existing semantic groups.\n // live: active/in-progress positive event → success accent\n // warn: needs attention but not failing → warning\n // scheduled: upcoming/planned, visually grouped with draft → neutral\n // sold: positive completed outcome → success\n // draft: unpublished/muted → neutral\n live: \"success\",\n warn: \"warning\",\n scheduled: \"neutral\",\n sold: \"success\",\n draft: \"neutral\",\n}\n\nconst SUBTLE_VARIANT: Record<StatusColorGroup, BadgeVariant> = {\n success: \"success\",\n warning: \"warning\",\n destructive: \"destructive\",\n info: \"info\",\n neutral: \"secondary\",\n}\n\nconst FILLED_VARIANT: Record<StatusColorGroup, BadgeVariant> = {\n success: \"filled-success\",\n warning: \"filled-warning\",\n destructive: \"filled-destructive\",\n info: \"filled-info\",\n // No `filled-secondary` exists on Badge — fall back to secondary.\n neutral: \"secondary\",\n}\n\nconst INDICATOR_CLASS: Record<StatusColorGroup, string> = {\n success: styles.indicatorSuccess,\n warning: styles.indicatorWarning,\n destructive: styles.indicatorDestructive,\n info: styles.indicatorInfo,\n neutral: styles.indicatorNeutral,\n}\n\nconst StatusBadge = React.forwardRef<HTMLSpanElement, StatusBadgeProps>(\n (\n {\n className,\n status,\n label,\n tone = \"subtle\",\n indicator = true,\n pulse = false,\n ...props\n },\n ref\n ) => {\n const group = STATUS_COLOR_GROUP[status]\n const variant: BadgeVariant =\n tone === \"filled\" ? FILLED_VARIANT[group] : SUBTLE_VARIANT[group]\n const visibleLabel = label ?? statusBadgeLabels[status]\n\n return (\n <Badge\n ref={ref}\n variant={variant}\n data-slot=\"status-badge\"\n data-status={status}\n data-tone={tone}\n className={cn(className)}\n {...props}\n >\n {indicator ? (\n <span\n data-slot=\"status-badge-indicator\"\n aria-hidden=\"true\"\n className={cn(\n styles.indicator,\n INDICATOR_CLASS[group],\n pulse && styles.pulse\n )}\n />\n ) : null}\n <span className={styles.srOnly}>Status: </span>\n <span data-slot=\"status-badge-label\">{visibleLabel}</span>\n </Badge>\n )\n }\n)\nStatusBadge.displayName = \"StatusBadge\"\n\nexport { StatusBadge }\n"
2286
2411
  },
2287
2412
  {
2288
2413
  "path": "components/ui/status-badge/status-badge.module.css",
@@ -2291,6 +2416,30 @@
2291
2416
  }
2292
2417
  ]
2293
2418
  },
2419
+ {
2420
+ "name": "status-dot",
2421
+ "type": "registry:ui",
2422
+ "description": "A 6×6px tone-tinted indicator dot. Five tones (mint, warn, muted, danger, info) resolve from Visor semantic tokens. Composes inside badges, table rows, activity feed items, and inline status text. Decorative by default; supplying aria-label flips it into a labeled image.",
2423
+ "category": "data-display",
2424
+ "dependencies": [
2425
+ "@loworbitstudio/visor-core"
2426
+ ],
2427
+ "registryDependencies": [
2428
+ "utils"
2429
+ ],
2430
+ "files": [
2431
+ {
2432
+ "path": "components/ui/status-dot/status-dot.tsx",
2433
+ "type": "registry:ui",
2434
+ "content": "import * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./status-dot.module.css\"\n\nexport type StatusDotTone = \"mint\" | \"warn\" | \"muted\" | \"danger\" | \"info\"\n\nexport interface StatusDotProps\n extends React.HTMLAttributes<HTMLSpanElement> {\n /** Tone. Defaults to `\"muted\"`. */\n tone?: StatusDotTone\n /**\n * When supplied, the dot is announced as a labeled image instead of being\n * marked decorative. Use only when the dot stands alone — when it's\n * paired with adjacent status text, leave `aria-label` unset and let the\n * surrounding label carry the meaning.\n */\n \"aria-label\"?: string\n}\n\n/**\n * StatusDot — a 6×6px tone-tinted indicator dot.\n *\n * Composes inside `Badge`, `ActivityFeed` leading slots, table status cells,\n * and inline status text. Decorative by default (`aria-hidden=\"true\"`);\n * supplying `aria-label` flips it into a labeled `role=\"img\"` so screen\n * readers announce the standalone state.\n */\nconst StatusDot = React.forwardRef<HTMLSpanElement, StatusDotProps>(\n ({ className, tone = \"muted\", ...props }, ref) => {\n const hasLabel = props[\"aria-label\"] !== undefined\n return (\n <span\n ref={ref}\n data-slot=\"status-dot\"\n data-tone={tone}\n role={hasLabel ? \"img\" : undefined}\n aria-hidden={hasLabel ? undefined : true}\n className={cn(styles.dot, className)}\n {...props}\n />\n )\n }\n)\nStatusDot.displayName = \"StatusDot\"\n\nexport { StatusDot }\n"
2435
+ },
2436
+ {
2437
+ "path": "components/ui/status-dot/status-dot.module.css",
2438
+ "type": "registry:ui",
2439
+ "content": "/*\n * StatusDot — a 6×6px tone-tinted indicator dot.\n *\n * The dot mirrors StatusBadge's indicator CSS pattern (size, radius, fill\n * sourced from saturated surface tokens) but stands on its own as a\n * reusable primitive that composes inside Badge, ActivityFeed items, and\n * table status cells. Tone colors resolve from the same semantic tokens\n * StatusBadge uses for its leading indicator so the two read as one\n * coherent system across surfaces.\n */\n.dot {\n display: inline-block;\n width: 6px;\n height: 6px;\n flex-shrink: 0;\n border-radius: var(--radius-full, 9999px);\n}\n\n.dot[data-tone=\"mint\"] {\n background-color: var(--surface-success-default, #22c55e);\n}\n\n.dot[data-tone=\"warn\"] {\n background-color: var(--surface-warning-default, #f59e0b);\n}\n\n.dot[data-tone=\"muted\"] {\n background-color: var(--text-tertiary, #6b7280);\n}\n\n.dot[data-tone=\"danger\"] {\n background-color: var(--surface-error-default, #ef4444);\n}\n\n.dot[data-tone=\"info\"] {\n background-color: var(--surface-info-default, #0ea5e9);\n}\n"
2440
+ }
2441
+ ]
2442
+ },
2294
2443
  {
2295
2444
  "name": "station-spectrum",
2296
2445
  "type": "registry:ui",
@@ -2336,6 +2485,156 @@
2336
2485
  }
2337
2486
  ]
2338
2487
  },
2488
+ {
2489
+ "name": "box",
2490
+ "type": "registry:ui",
2491
+ "description": "Universal layout wrapper for padding, margin, background, border, and border-radius via Visor design tokens. Token-typed props enforce design-system values; off-system literals are TypeScript errors.",
2492
+ "category": "layout",
2493
+ "dependencies": [
2494
+ "@loworbitstudio/visor-core"
2495
+ ],
2496
+ "registryDependencies": [
2497
+ "utils"
2498
+ ],
2499
+ "files": [
2500
+ {
2501
+ "path": "components/ui/box/box.tsx",
2502
+ "type": "registry:ui",
2503
+ "content": "import * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./box.module.css\"\n\n// ---------------------------------------------------------------------------\n// Token vocabularies — typed so off-system values are type errors.\n// ---------------------------------------------------------------------------\n\n/**\n * Spacing token suffixes. These map 1:1 to the `--spacing-*` CSS variables\n * shipped by `@loworbitstudio/visor-core`. We expose the friendly aliases\n * (`xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`) in addition to the raw\n * numeric suffixes so consumers can write `padding=\"md\"`.\n */\nexport type SpacingToken =\n | \"none\"\n | \"xs\"\n | \"sm\"\n | \"md\"\n | \"lg\"\n | \"xl\"\n | \"2xl\"\n | \"3xl\"\n\n/** Border radius tokens (`--radius-*`). */\nexport type RadiusToken =\n | \"none\"\n | \"sm\"\n | \"md\"\n | \"lg\"\n | \"xl\"\n | \"2xl\"\n | \"3xl\"\n | \"full\"\n\n/**\n * Visor surface tokens — the named `--surface-*` properties. We accept a\n * shorthand alias (\"card\", \"subtle\", \"muted\", \"page\") that maps to the\n * underlying variable. Off-system values are intentionally rejected.\n */\nexport type SurfaceToken =\n | \"page\"\n | \"card\"\n | \"subtle\"\n | \"muted\"\n | \"popover\"\n | \"accent-subtle\"\n | \"accent-default\"\n | \"success-subtle\"\n | \"warning-subtle\"\n | \"error-subtle\"\n | \"info-subtle\"\n\n/** Border color tokens (`--border-*`). */\nexport type BorderToken =\n | \"default\"\n | \"muted\"\n | \"strong\"\n | \"focus\"\n | \"success\"\n | \"warning\"\n | \"error\"\n | \"info\"\n\n/**\n * Responsive prop syntax. Plain token (e.g. `\"md\"`) or a breakpoint map\n * keyed by Visor breakpoints. The `base` key is required for the map shape\n * to keep responsive intent explicit at the smallest viewport.\n */\nexport type ResponsiveProp<T> = T | { base: T; sm?: T; md?: T; lg?: T; xl?: T }\n\n// ---------------------------------------------------------------------------\n// Token → CSS-variable resolution helpers.\n// ---------------------------------------------------------------------------\n\nconst SPACING_MAP: Record<SpacingToken, string> = {\n none: \"0\",\n xs: \"var(--spacing-1, 0.25rem)\",\n sm: \"var(--spacing-2, 0.5rem)\",\n md: \"var(--spacing-4, 1rem)\",\n lg: \"var(--spacing-6, 1.5rem)\",\n xl: \"var(--spacing-8, 2rem)\",\n \"2xl\": \"var(--spacing-12, 3rem)\",\n \"3xl\": \"var(--spacing-16, 4rem)\",\n}\n\nconst RADIUS_MAP: Record<RadiusToken, string> = {\n none: \"0\",\n sm: \"var(--radius-sm, 2px)\",\n md: \"var(--radius-md, 4px)\",\n lg: \"var(--radius-lg, 8px)\",\n xl: \"var(--radius-xl, 12px)\",\n \"2xl\": \"var(--radius-2xl, 16px)\",\n \"3xl\": \"var(--radius-3xl, 24px)\",\n full: \"var(--radius-full, 9999px)\",\n}\n\nfunction resolveResponsive<T>(value: ResponsiveProp<T> | undefined): {\n base: T | undefined\n sm: T | undefined\n md: T | undefined\n lg: T | undefined\n xl: T | undefined\n} {\n if (value === undefined) {\n return { base: undefined, sm: undefined, md: undefined, lg: undefined, xl: undefined }\n }\n if (typeof value === \"object\" && value !== null && \"base\" in value) {\n const v = value as { base: T; sm?: T; md?: T; lg?: T; xl?: T }\n return { base: v.base, sm: v.sm, md: v.md, lg: v.lg, xl: v.xl }\n }\n return { base: value as T, sm: undefined, md: undefined, lg: undefined, xl: undefined }\n}\n\n/**\n * Resolve a spacing token (or responsive map) into the inline-style CSS\n * variables that the module CSS picks up. Variables are namespaced by the\n * prefix the caller chooses (e.g. `box-p`, `box-px`, `box-pl`).\n */\nfunction spacingVars(\n prefix: string,\n value: ResponsiveProp<SpacingToken> | undefined,\n componentPrefix: string\n): Record<string, string> {\n const out: Record<string, string> = {}\n if (value === undefined) return out\n const { base, sm, md, lg, xl } = resolveResponsive(value)\n if (base !== undefined) out[`--${componentPrefix}-${prefix}`] = SPACING_MAP[base]\n if (sm !== undefined) out[`--${componentPrefix}-${prefix}-sm`] = SPACING_MAP[sm]\n if (md !== undefined) out[`--${componentPrefix}-${prefix}-md`] = SPACING_MAP[md]\n if (lg !== undefined) out[`--${componentPrefix}-${prefix}-lg`] = SPACING_MAP[lg]\n if (xl !== undefined) out[`--${componentPrefix}-${prefix}-xl`] = SPACING_MAP[xl]\n return out\n}\n\n// ---------------------------------------------------------------------------\n// BoxProps\n// ---------------------------------------------------------------------------\n\n/**\n * Box is the universal layout wrapper. Use it for padding, margin, background,\n * border, and border-radius. For arrangement of children, reach for Stack,\n * Inline, or Grid instead.\n */\nexport interface BoxOwnProps {\n /** Render as a different HTML element. Defaults to `<div>`. */\n as?: keyof React.JSX.IntrinsicElements\n /** Shorthand padding on all sides. */\n padding?: ResponsiveProp<SpacingToken>\n /** Shorthand padding on the X axis. Overrides `padding` for left/right. */\n paddingX?: ResponsiveProp<SpacingToken>\n /** Shorthand padding on the Y axis. Overrides `padding` for top/bottom. */\n paddingY?: ResponsiveProp<SpacingToken>\n /** Padding on a single edge. Overrides `padding`/`paddingX`/`paddingY`. */\n paddingTop?: ResponsiveProp<SpacingToken>\n paddingRight?: ResponsiveProp<SpacingToken>\n paddingBottom?: ResponsiveProp<SpacingToken>\n paddingLeft?: ResponsiveProp<SpacingToken>\n /** Shorthand margin on all sides. */\n margin?: ResponsiveProp<SpacingToken>\n marginX?: ResponsiveProp<SpacingToken>\n marginY?: ResponsiveProp<SpacingToken>\n marginTop?: ResponsiveProp<SpacingToken>\n marginRight?: ResponsiveProp<SpacingToken>\n marginBottom?: ResponsiveProp<SpacingToken>\n marginLeft?: ResponsiveProp<SpacingToken>\n /** Token-named background surface. */\n bg?: SurfaceToken\n /**\n * When true, applies a 1.5px (regular stroke) `--border-default` border.\n * When a token name (e.g. \"strong\" or \"error\") is passed, uses that\n * border color token instead.\n */\n border?: boolean | BorderToken\n /** Token-named border radius. */\n borderRadius?: RadiusToken\n}\n\nexport type BoxProps = BoxOwnProps & Omit<React.HTMLAttributes<HTMLElement>, keyof BoxOwnProps>\n\n/** Build the inline `style` object for a Box given its token-named props. */\nexport function buildBoxStyle(\n props: BoxOwnProps,\n componentPrefix = \"box\"\n): React.CSSProperties {\n const out: Record<string, string> = {}\n\n // Padding\n Object.assign(out, spacingVars(\"p\", props.padding, componentPrefix))\n Object.assign(out, spacingVars(\"px\", props.paddingX, componentPrefix))\n Object.assign(out, spacingVars(\"py\", props.paddingY, componentPrefix))\n Object.assign(out, spacingVars(\"pt\", props.paddingTop, componentPrefix))\n Object.assign(out, spacingVars(\"pr\", props.paddingRight, componentPrefix))\n Object.assign(out, spacingVars(\"pb\", props.paddingBottom, componentPrefix))\n Object.assign(out, spacingVars(\"pl\", props.paddingLeft, componentPrefix))\n\n // Margin\n Object.assign(out, spacingVars(\"m\", props.margin, componentPrefix))\n Object.assign(out, spacingVars(\"mx\", props.marginX, componentPrefix))\n Object.assign(out, spacingVars(\"my\", props.marginY, componentPrefix))\n Object.assign(out, spacingVars(\"mt\", props.marginTop, componentPrefix))\n Object.assign(out, spacingVars(\"mr\", props.marginRight, componentPrefix))\n Object.assign(out, spacingVars(\"mb\", props.marginBottom, componentPrefix))\n Object.assign(out, spacingVars(\"ml\", props.marginLeft, componentPrefix))\n\n if (props.bg !== undefined) {\n out[`--${componentPrefix}-bg`] = `var(--surface-${props.bg})`\n }\n if (props.borderRadius !== undefined) {\n out[`--${componentPrefix}-radius`] = RADIUS_MAP[props.borderRadius]\n }\n if (props.border === true) {\n out[`--${componentPrefix}-border-color`] = \"var(--border-default, #e5e7eb)\"\n } else if (typeof props.border === \"string\") {\n out[`--${componentPrefix}-border-color`] = `var(--border-${props.border})`\n }\n\n return out as React.CSSProperties\n}\n\n// ---------------------------------------------------------------------------\n// Box component\n// ---------------------------------------------------------------------------\n\nconst Box = React.forwardRef<HTMLElement, BoxProps>(\n (\n {\n as: Tag = \"div\",\n className,\n style,\n padding,\n paddingX,\n paddingY,\n paddingTop,\n paddingRight,\n paddingBottom,\n paddingLeft,\n margin,\n marginX,\n marginY,\n marginTop,\n marginRight,\n marginBottom,\n marginLeft,\n bg,\n border,\n borderRadius,\n ...rest\n },\n ref\n ) => {\n const boxStyle = buildBoxStyle({\n padding,\n paddingX,\n paddingY,\n paddingTop,\n paddingRight,\n paddingBottom,\n paddingLeft,\n margin,\n marginX,\n marginY,\n marginTop,\n marginRight,\n marginBottom,\n marginLeft,\n bg,\n border,\n borderRadius,\n })\n\n const Component = Tag as React.ElementType\n\n return (\n <Component\n ref={ref}\n data-slot=\"box\"\n data-border={border ? \"true\" : undefined}\n className={cn(styles.box, className)}\n style={{ ...boxStyle, ...style }}\n {...rest}\n />\n )\n }\n)\nBox.displayName = \"Box\"\n\nexport { Box }\n"
2504
+ },
2505
+ {
2506
+ "path": "components/ui/box/box.module.css",
2507
+ "type": "registry:ui",
2508
+ "content": "/* ============================================================\n Box — universal token-driven layout wrapper.\n\n Every modifier prop maps to a single CSS custom property which\n the consumer-facing prop API sets via inline style. The class\n list below only declares the property → CSS-variable wiring.\n ============================================================ */\n\n.box {\n /* Padding — picked up from --box-p / per-axis fallbacks. */\n padding-top: var(--box-pt, var(--box-py, var(--box-p, 0)));\n padding-right: var(--box-pr, var(--box-px, var(--box-p, 0)));\n padding-bottom: var(--box-pb, var(--box-py, var(--box-p, 0)));\n padding-left: var(--box-pl, var(--box-px, var(--box-p, 0)));\n\n /* Margin — picked up from --box-m / per-axis fallbacks. */\n margin-top: var(--box-mt, var(--box-my, var(--box-m, 0)));\n margin-right: var(--box-mr, var(--box-mx, var(--box-m, 0)));\n margin-bottom: var(--box-mb, var(--box-my, var(--box-m, 0)));\n margin-left: var(--box-ml, var(--box-mx, var(--box-m, 0)));\n\n /* Surface + border tokens fall back to \"no styling\" so Box can be\n used purely for spacing without inheriting visual chrome. */\n background-color: var(--box-bg, transparent);\n border-radius: var(--box-radius, 0);\n color: var(--box-color, inherit);\n}\n\n/* Border presence is opt-in — when --box-border-color is set, we use\n the tokenized stroke-width-regular as the default width. */\n.box[data-border=\"true\"] {\n border-width: var(--box-border-width, var(--stroke-width-regular, 1.5px));\n border-style: solid;\n border-color: var(--box-border-color, var(--border-default, #e5e7eb));\n}\n"
2509
+ }
2510
+ ]
2511
+ },
2512
+ {
2513
+ "name": "stack",
2514
+ "type": "registry:ui",
2515
+ "description": "Vertical flex container with token-driven gap, alignment, and `as` polymorphism. Supports responsive gap via the `{ base, sm, md, lg, xl }` map.",
2516
+ "category": "layout",
2517
+ "dependencies": [
2518
+ "@loworbitstudio/visor-core"
2519
+ ],
2520
+ "registryDependencies": [
2521
+ "utils"
2522
+ ],
2523
+ "files": [
2524
+ {
2525
+ "path": "components/ui/stack/stack.tsx",
2526
+ "type": "registry:ui",
2527
+ "content": "import * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./stack.module.css\"\n\n// ---------------------------------------------------------------------------\n// Token vocabularies\n// ---------------------------------------------------------------------------\n\nexport type StackSpacing =\n | \"none\"\n | \"xs\"\n | \"sm\"\n | \"md\"\n | \"lg\"\n | \"xl\"\n | \"2xl\"\n | \"3xl\"\n\nexport type ResponsiveProp<T> = T | { base: T; sm?: T; md?: T; lg?: T; xl?: T }\n\nexport type StackAlign = \"start\" | \"center\" | \"end\" | \"stretch\"\nexport type StackJustify =\n | \"start\"\n | \"center\"\n | \"end\"\n | \"between\"\n | \"around\"\n | \"evenly\"\n\nconst SPACING_MAP: Record<StackSpacing, string> = {\n none: \"0\",\n xs: \"var(--spacing-1, 0.25rem)\",\n sm: \"var(--spacing-2, 0.5rem)\",\n md: \"var(--spacing-4, 1rem)\",\n lg: \"var(--spacing-6, 1.5rem)\",\n xl: \"var(--spacing-8, 2rem)\",\n \"2xl\": \"var(--spacing-12, 3rem)\",\n \"3xl\": \"var(--spacing-16, 4rem)\",\n}\n\nfunction resolveResponsiveSpacing(\n value: ResponsiveProp<StackSpacing> | undefined,\n prefix: string\n): Record<string, string> {\n if (value === undefined) return {}\n if (typeof value === \"object\" && value !== null && \"base\" in value) {\n const v = value as { base: StackSpacing; sm?: StackSpacing; md?: StackSpacing; lg?: StackSpacing; xl?: StackSpacing }\n const out: Record<string, string> = { [`--${prefix}`]: SPACING_MAP[v.base] }\n if (v.sm !== undefined) out[`--${prefix}-sm`] = SPACING_MAP[v.sm]\n if (v.md !== undefined) out[`--${prefix}-md`] = SPACING_MAP[v.md]\n if (v.lg !== undefined) out[`--${prefix}-lg`] = SPACING_MAP[v.lg]\n if (v.xl !== undefined) out[`--${prefix}-xl`] = SPACING_MAP[v.xl]\n return out\n }\n return { [`--${prefix}`]: SPACING_MAP[value as StackSpacing] }\n}\n\n// ---------------------------------------------------------------------------\n// StackProps\n// ---------------------------------------------------------------------------\n\nexport interface StackOwnProps {\n /** Render as a different HTML element. Defaults to `<div>`. */\n as?: keyof React.JSX.IntrinsicElements\n /** Space between children. Token-named. Defaults to `\"md\"`. */\n gap?: ResponsiveProp<StackSpacing>\n /** Cross-axis alignment (horizontal in a column flex). */\n align?: StackAlign\n /** Main-axis alignment (vertical in a column flex). */\n justify?: StackJustify\n}\n\nexport type StackProps = StackOwnProps & Omit<React.HTMLAttributes<HTMLElement>, keyof StackOwnProps>\n\nconst Stack = React.forwardRef<HTMLElement, StackProps>(\n (\n {\n as: Tag = \"div\",\n className,\n style,\n gap = \"md\",\n align,\n justify,\n ...rest\n },\n ref\n ) => {\n const gapVars = resolveResponsiveSpacing(gap, \"stack-gap\")\n\n const Component = Tag as React.ElementType\n\n return (\n <Component\n ref={ref}\n data-slot=\"stack\"\n className={cn(\n styles.stack,\n align === \"start\" && styles.alignStart,\n align === \"center\" && styles.alignCenter,\n align === \"end\" && styles.alignEnd,\n align === \"stretch\" && styles.alignStretch,\n justify === \"start\" && styles.justifyStart,\n justify === \"center\" && styles.justifyCenter,\n justify === \"end\" && styles.justifyEnd,\n justify === \"between\" && styles.justifyBetween,\n justify === \"around\" && styles.justifyAround,\n justify === \"evenly\" && styles.justifyEvenly,\n className\n )}\n style={{ ...gapVars, ...style }}\n {...rest}\n />\n )\n }\n)\nStack.displayName = \"Stack\"\n\nexport { Stack }\n"
2528
+ },
2529
+ {
2530
+ "path": "components/ui/stack/stack.module.css",
2531
+ "type": "registry:ui",
2532
+ "content": "/* ============================================================\n Stack — vertical flex container with token-driven gap.\n\n Responsive gap is supported via `--stack-gap-sm/md/lg/xl` CSS\n variables that the component sets on the inline style when the\n consumer passes a breakpoint map.\n ============================================================ */\n\n.stack {\n display: flex;\n flex-direction: column;\n gap: var(--stack-gap, var(--spacing-4, 1rem));\n}\n\n/* Alignment — cross-axis (horizontal in a column flex) */\n.alignStart { align-items: flex-start; }\n.alignCenter { align-items: center; }\n.alignEnd { align-items: flex-end; }\n.alignStretch { align-items: stretch; }\n\n/* Justify — main-axis (vertical in a column flex) */\n.justifyStart { justify-content: flex-start; }\n.justifyCenter { justify-content: center; }\n.justifyEnd { justify-content: flex-end; }\n.justifyBetween { justify-content: space-between; }\n.justifyAround { justify-content: space-around; }\n.justifyEvenly { justify-content: space-evenly; }\n\n/* Responsive gap — picked up from CSS variables set by the consumer. */\n@media (min-width: 640px) {\n .stack {\n gap: var(--stack-gap-sm, var(--stack-gap, var(--spacing-4, 1rem)));\n }\n}\n@media (min-width: 768px) {\n .stack {\n gap: var(--stack-gap-md, var(--stack-gap-sm, var(--stack-gap, var(--spacing-4, 1rem))));\n }\n}\n@media (min-width: 1024px) {\n .stack {\n gap: var(--stack-gap-lg, var(--stack-gap-md, var(--stack-gap-sm, var(--stack-gap, var(--spacing-4, 1rem)))));\n }\n}\n@media (min-width: 1280px) {\n .stack {\n gap: var(--stack-gap-xl, var(--stack-gap-lg, var(--stack-gap-md, var(--stack-gap-sm, var(--stack-gap, var(--spacing-4, 1rem))))));\n }\n}\n"
2533
+ }
2534
+ ]
2535
+ },
2536
+ {
2537
+ "name": "inline",
2538
+ "type": "registry:ui",
2539
+ "description": "Horizontal flex container with token-driven gap, alignment, optional wrap, and `as` polymorphism. Horizontal counterpart to Stack.",
2540
+ "category": "layout",
2541
+ "dependencies": [
2542
+ "@loworbitstudio/visor-core"
2543
+ ],
2544
+ "registryDependencies": [
2545
+ "utils"
2546
+ ],
2547
+ "files": [
2548
+ {
2549
+ "path": "components/ui/inline/inline.tsx",
2550
+ "type": "registry:ui",
2551
+ "content": "import * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./inline.module.css\"\n\n// ---------------------------------------------------------------------------\n// Token vocabularies\n// ---------------------------------------------------------------------------\n\nexport type InlineSpacing =\n | \"none\"\n | \"xs\"\n | \"sm\"\n | \"md\"\n | \"lg\"\n | \"xl\"\n | \"2xl\"\n | \"3xl\"\n\nexport type ResponsiveProp<T> = T | { base: T; sm?: T; md?: T; lg?: T; xl?: T }\n\nexport type InlineAlign = \"start\" | \"center\" | \"end\" | \"stretch\" | \"baseline\"\nexport type InlineJustify =\n | \"start\"\n | \"center\"\n | \"end\"\n | \"between\"\n | \"around\"\n | \"evenly\"\n\nconst SPACING_MAP: Record<InlineSpacing, string> = {\n none: \"0\",\n xs: \"var(--spacing-1, 0.25rem)\",\n sm: \"var(--spacing-2, 0.5rem)\",\n md: \"var(--spacing-4, 1rem)\",\n lg: \"var(--spacing-6, 1.5rem)\",\n xl: \"var(--spacing-8, 2rem)\",\n \"2xl\": \"var(--spacing-12, 3rem)\",\n \"3xl\": \"var(--spacing-16, 4rem)\",\n}\n\nfunction resolveResponsiveSpacing(\n value: ResponsiveProp<InlineSpacing> | undefined,\n prefix: string\n): Record<string, string> {\n if (value === undefined) return {}\n if (typeof value === \"object\" && value !== null && \"base\" in value) {\n const v = value as { base: InlineSpacing; sm?: InlineSpacing; md?: InlineSpacing; lg?: InlineSpacing; xl?: InlineSpacing }\n const out: Record<string, string> = { [`--${prefix}`]: SPACING_MAP[v.base] }\n if (v.sm !== undefined) out[`--${prefix}-sm`] = SPACING_MAP[v.sm]\n if (v.md !== undefined) out[`--${prefix}-md`] = SPACING_MAP[v.md]\n if (v.lg !== undefined) out[`--${prefix}-lg`] = SPACING_MAP[v.lg]\n if (v.xl !== undefined) out[`--${prefix}-xl`] = SPACING_MAP[v.xl]\n return out\n }\n return { [`--${prefix}`]: SPACING_MAP[value as InlineSpacing] }\n}\n\n// ---------------------------------------------------------------------------\n// InlineProps\n// ---------------------------------------------------------------------------\n\nexport interface InlineOwnProps {\n /** Render as a different HTML element. Defaults to `<div>`. */\n as?: keyof React.JSX.IntrinsicElements\n /** Space between children. Token-named. Defaults to `\"md\"`. */\n gap?: ResponsiveProp<InlineSpacing>\n /** Cross-axis alignment (vertical in a row flex). Defaults to `\"center\"`. */\n align?: InlineAlign\n /** Main-axis alignment (horizontal in a row flex). */\n justify?: InlineJustify\n /** Allow children to wrap onto multiple lines. Defaults to `false`. */\n wrap?: boolean\n}\n\nexport type InlineProps = InlineOwnProps & Omit<React.HTMLAttributes<HTMLElement>, keyof InlineOwnProps>\n\nconst Inline = React.forwardRef<HTMLElement, InlineProps>(\n (\n {\n as: Tag = \"div\",\n className,\n style,\n gap = \"md\",\n align = \"center\",\n justify,\n wrap = false,\n ...rest\n },\n ref\n ) => {\n const gapVars = resolveResponsiveSpacing(gap, \"inline-gap\")\n\n const Component = Tag as React.ElementType\n\n return (\n <Component\n ref={ref}\n data-slot=\"inline\"\n data-wrap={wrap ? \"true\" : undefined}\n className={cn(\n styles.inline,\n wrap && styles.wrap,\n align === \"start\" && styles.alignStart,\n align === \"center\" && styles.alignCenter,\n align === \"end\" && styles.alignEnd,\n align === \"stretch\" && styles.alignStretch,\n align === \"baseline\" && styles.alignBaseline,\n justify === \"start\" && styles.justifyStart,\n justify === \"center\" && styles.justifyCenter,\n justify === \"end\" && styles.justifyEnd,\n justify === \"between\" && styles.justifyBetween,\n justify === \"around\" && styles.justifyAround,\n justify === \"evenly\" && styles.justifyEvenly,\n className\n )}\n style={{ ...gapVars, ...style }}\n {...rest}\n />\n )\n }\n)\nInline.displayName = \"Inline\"\n\nexport { Inline }\n"
2552
+ },
2553
+ {
2554
+ "path": "components/ui/inline/inline.module.css",
2555
+ "type": "registry:ui",
2556
+ "content": "/* ============================================================\n Inline — horizontal flex container with token-driven gap.\n\n Default direction is `row`. When `wrap` is enabled, items wrap\n onto multiple lines; gap applies to both row and column gaps.\n ============================================================ */\n\n.inline {\n display: flex;\n flex-direction: row;\n gap: var(--inline-gap, var(--spacing-4, 1rem));\n}\n\n.wrap {\n flex-wrap: wrap;\n}\n\n/* Alignment — cross-axis (vertical in a row flex) */\n.alignStart { align-items: flex-start; }\n.alignCenter { align-items: center; }\n.alignEnd { align-items: flex-end; }\n.alignStretch { align-items: stretch; }\n.alignBaseline { align-items: baseline; }\n\n/* Justify — main-axis (horizontal in a row flex) */\n.justifyStart { justify-content: flex-start; }\n.justifyCenter { justify-content: center; }\n.justifyEnd { justify-content: flex-end; }\n.justifyBetween { justify-content: space-between; }\n.justifyAround { justify-content: space-around; }\n.justifyEvenly { justify-content: space-evenly; }\n\n/* Responsive gap — picked up from CSS variables set by the consumer. */\n@media (min-width: 640px) {\n .inline {\n gap: var(--inline-gap-sm, var(--inline-gap, var(--spacing-4, 1rem)));\n }\n}\n@media (min-width: 768px) {\n .inline {\n gap: var(--inline-gap-md, var(--inline-gap-sm, var(--inline-gap, var(--spacing-4, 1rem))));\n }\n}\n@media (min-width: 1024px) {\n .inline {\n gap: var(--inline-gap-lg, var(--inline-gap-md, var(--inline-gap-sm, var(--inline-gap, var(--spacing-4, 1rem)))));\n }\n}\n@media (min-width: 1280px) {\n .inline {\n gap: var(--inline-gap-xl, var(--inline-gap-lg, var(--inline-gap-md, var(--inline-gap-sm, var(--inline-gap, var(--spacing-4, 1rem))))));\n }\n}\n"
2557
+ }
2558
+ ]
2559
+ },
2560
+ {
2561
+ "name": "grid",
2562
+ "type": "registry:ui",
2563
+ "description": "CSS Grid wrapper with token-driven gap, responsive column count, and explicit `grid-template-columns` escape hatch.",
2564
+ "category": "layout",
2565
+ "dependencies": [
2566
+ "@loworbitstudio/visor-core"
2567
+ ],
2568
+ "registryDependencies": [
2569
+ "utils"
2570
+ ],
2571
+ "files": [
2572
+ {
2573
+ "path": "components/ui/grid/grid.tsx",
2574
+ "type": "registry:ui",
2575
+ "content": "import * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./grid.module.css\"\n\n// ---------------------------------------------------------------------------\n// Token vocabularies\n// ---------------------------------------------------------------------------\n\nexport type GridSpacing =\n | \"none\"\n | \"xs\"\n | \"sm\"\n | \"md\"\n | \"lg\"\n | \"xl\"\n | \"2xl\"\n | \"3xl\"\n\nexport type ResponsiveProp<T> = T | { base: T; sm?: T; md?: T; lg?: T; xl?: T }\n\nexport type GridAlign = \"start\" | \"center\" | \"end\" | \"stretch\"\nexport type GridJustify = \"start\" | \"center\" | \"end\" | \"stretch\"\n\nconst SPACING_MAP: Record<GridSpacing, string> = {\n none: \"0\",\n xs: \"var(--spacing-1, 0.25rem)\",\n sm: \"var(--spacing-2, 0.5rem)\",\n md: \"var(--spacing-4, 1rem)\",\n lg: \"var(--spacing-6, 1.5rem)\",\n xl: \"var(--spacing-8, 2rem)\",\n \"2xl\": \"var(--spacing-12, 3rem)\",\n \"3xl\": \"var(--spacing-16, 4rem)\",\n}\n\nfunction resolveResponsiveSpacing(\n value: ResponsiveProp<GridSpacing> | undefined,\n prefix: string\n): Record<string, string> {\n if (value === undefined) return {}\n if (typeof value === \"object\" && value !== null && \"base\" in value) {\n const v = value as { base: GridSpacing; sm?: GridSpacing; md?: GridSpacing; lg?: GridSpacing; xl?: GridSpacing }\n const out: Record<string, string> = { [`--${prefix}`]: SPACING_MAP[v.base] }\n if (v.sm !== undefined) out[`--${prefix}-sm`] = SPACING_MAP[v.sm]\n if (v.md !== undefined) out[`--${prefix}-md`] = SPACING_MAP[v.md]\n if (v.lg !== undefined) out[`--${prefix}-lg`] = SPACING_MAP[v.lg]\n if (v.xl !== undefined) out[`--${prefix}-xl`] = SPACING_MAP[v.xl]\n return out\n }\n return { [`--${prefix}`]: SPACING_MAP[value as GridSpacing] }\n}\n\nfunction resolveResponsiveNumber(\n value: ResponsiveProp<number> | undefined,\n prefix: string\n): Record<string, string | number> {\n if (value === undefined) return {}\n if (typeof value === \"object\" && value !== null && \"base\" in value) {\n const v = value as { base: number; sm?: number; md?: number; lg?: number; xl?: number }\n const out: Record<string, string | number> = { [`--${prefix}`]: v.base }\n if (v.sm !== undefined) out[`--${prefix}-sm`] = v.sm\n if (v.md !== undefined) out[`--${prefix}-md`] = v.md\n if (v.lg !== undefined) out[`--${prefix}-lg`] = v.lg\n if (v.xl !== undefined) out[`--${prefix}-xl`] = v.xl\n return out\n }\n return { [`--${prefix}`]: value as number }\n}\n\n// ---------------------------------------------------------------------------\n// GridProps\n// ---------------------------------------------------------------------------\n\nexport interface GridOwnProps {\n /** Render as a different HTML element. Defaults to `<div>`. */\n as?: keyof React.JSX.IntrinsicElements\n /**\n * Either a column count (number, or responsive map of numbers) or an\n * explicit `grid-template-columns` string for advanced layouts\n * (e.g. `\"1fr 2fr\"`). Defaults to `1`.\n */\n columns?: ResponsiveProp<number> | string\n /** Space between cells. Token-named. Defaults to `\"md\"`. */\n gap?: ResponsiveProp<GridSpacing>\n /** Block-axis (vertical) alignment of items within their cells. */\n align?: GridAlign\n /** Inline-axis (horizontal) alignment of items within their cells. */\n justify?: GridJustify\n}\n\nexport type GridProps = GridOwnProps & Omit<React.HTMLAttributes<HTMLElement>, keyof GridOwnProps>\n\nconst Grid = React.forwardRef<HTMLElement, GridProps>(\n (\n {\n as: Tag = \"div\",\n className,\n style,\n columns = 1,\n gap = \"md\",\n align,\n justify,\n ...rest\n },\n ref\n ) => {\n const isTemplateString = typeof columns === \"string\"\n const colVars = isTemplateString\n ? ({ \"--grid-template-columns\": columns } as Record<string, string>)\n : resolveResponsiveNumber(columns as ResponsiveProp<number>, \"grid-cols\")\n const gapVars = resolveResponsiveSpacing(gap, \"grid-gap\")\n\n const Component = Tag as React.ElementType\n\n return (\n <Component\n ref={ref}\n data-slot=\"grid\"\n data-template={isTemplateString ? \"true\" : undefined}\n className={cn(\n styles.grid,\n align === \"start\" && styles.alignStart,\n align === \"center\" && styles.alignCenter,\n align === \"end\" && styles.alignEnd,\n align === \"stretch\" && styles.alignStretch,\n justify === \"start\" && styles.justifyStart,\n justify === \"center\" && styles.justifyCenter,\n justify === \"end\" && styles.justifyEnd,\n justify === \"stretch\" && styles.justifyStretch,\n className\n )}\n style={{ ...colVars, ...gapVars, ...style }}\n {...rest}\n />\n )\n }\n)\nGrid.displayName = \"Grid\"\n\nexport { Grid }\n"
2576
+ },
2577
+ {
2578
+ "path": "components/ui/grid/grid.module.css",
2579
+ "type": "registry:ui",
2580
+ "content": "/* ============================================================\n Grid — CSS Grid wrapper with token-driven gap and responsive\n column count.\n\n Columns and gap are both responsive — the component sets the\n `--grid-cols-*` and `--grid-gap-*` CSS variables on the inline\n style and the media queries below pick them up at each\n breakpoint.\n ============================================================ */\n\n.grid {\n display: grid;\n grid-template-columns: repeat(var(--grid-cols, 1), minmax(0, 1fr));\n gap: var(--grid-gap, var(--spacing-4, 1rem));\n}\n\n/* Alignment */\n.alignStart { align-items: start; }\n.alignCenter { align-items: center; }\n.alignEnd { align-items: end; }\n.alignStretch { align-items: stretch; }\n\n.justifyStart { justify-items: start; }\n.justifyCenter { justify-items: center; }\n.justifyEnd { justify-items: end; }\n.justifyStretch { justify-items: stretch; }\n\n/* Responsive columns + gap */\n@media (min-width: 640px) {\n .grid {\n grid-template-columns: repeat(var(--grid-cols-sm, var(--grid-cols, 1)), minmax(0, 1fr));\n gap: var(--grid-gap-sm, var(--grid-gap, var(--spacing-4, 1rem)));\n }\n}\n@media (min-width: 768px) {\n .grid {\n grid-template-columns: repeat(var(--grid-cols-md, var(--grid-cols-sm, var(--grid-cols, 1))), minmax(0, 1fr));\n gap: var(--grid-gap-md, var(--grid-gap-sm, var(--grid-gap, var(--spacing-4, 1rem))));\n }\n}\n@media (min-width: 1024px) {\n .grid {\n grid-template-columns: repeat(var(--grid-cols-lg, var(--grid-cols-md, var(--grid-cols-sm, var(--grid-cols, 1)))), minmax(0, 1fr));\n gap: var(--grid-gap-lg, var(--grid-gap-md, var(--grid-gap-sm, var(--grid-gap, var(--spacing-4, 1rem)))));\n }\n}\n@media (min-width: 1280px) {\n .grid {\n grid-template-columns: repeat(var(--grid-cols-xl, var(--grid-cols-lg, var(--grid-cols-md, var(--grid-cols-sm, var(--grid-cols, 1))))), minmax(0, 1fr));\n gap: var(--grid-gap-xl, var(--grid-gap-lg, var(--grid-gap-md, var(--grid-gap-sm, var(--grid-gap, var(--spacing-4, 1rem))))));\n }\n}\n\n/* Custom template string mode — when consumer passes a string template,\n the inline style sets `--grid-template-columns` directly and the\n override below wins over the column-count rules above. */\n.grid[data-template=\"true\"] {\n grid-template-columns: var(--grid-template-columns);\n}\n"
2581
+ }
2582
+ ]
2583
+ },
2584
+ {
2585
+ "name": "container",
2586
+ "type": "registry:ui",
2587
+ "description": "Max-width centered wrapper for page content with token-driven horizontal padding. Sizes map to Visor responsive breakpoints (sm 640, md 768, lg 1024, xl 1280, full no limit).",
2588
+ "category": "layout",
2589
+ "dependencies": [
2590
+ "@loworbitstudio/visor-core"
2591
+ ],
2592
+ "registryDependencies": [
2593
+ "utils"
2594
+ ],
2595
+ "files": [
2596
+ {
2597
+ "path": "components/ui/container/container.tsx",
2598
+ "type": "registry:ui",
2599
+ "content": "import * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./container.module.css\"\n\n// ---------------------------------------------------------------------------\n// Token vocabularies\n// ---------------------------------------------------------------------------\n\nexport type ContainerSpacing =\n | \"none\"\n | \"xs\"\n | \"sm\"\n | \"md\"\n | \"lg\"\n | \"xl\"\n | \"2xl\"\n | \"3xl\"\n\nexport type ContainerSize = \"sm\" | \"md\" | \"lg\" | \"xl\" | \"full\"\n\nconst SPACING_MAP: Record<ContainerSpacing, string> = {\n none: \"0\",\n xs: \"var(--spacing-1, 0.25rem)\",\n sm: \"var(--spacing-2, 0.5rem)\",\n md: \"var(--spacing-4, 1rem)\",\n lg: \"var(--spacing-6, 1.5rem)\",\n xl: \"var(--spacing-8, 2rem)\",\n \"2xl\": \"var(--spacing-12, 3rem)\",\n \"3xl\": \"var(--spacing-16, 4rem)\",\n}\n\n// ---------------------------------------------------------------------------\n// ContainerProps\n// ---------------------------------------------------------------------------\n\nexport interface ContainerOwnProps {\n /** Render as a different HTML element. Defaults to `<div>`. */\n as?: keyof React.JSX.IntrinsicElements\n /** Max-width preset. Defaults to `\"lg\"` (1024px). */\n size?: ContainerSize\n /**\n * Horizontal padding inside the container. Token-named.\n * Defaults to `\"md\"` (1rem).\n */\n padding?: ContainerSpacing\n}\n\nexport type ContainerProps = ContainerOwnProps &\n Omit<React.HTMLAttributes<HTMLElement>, keyof ContainerOwnProps>\n\nconst Container = React.forwardRef<HTMLElement, ContainerProps>(\n (\n {\n as: Tag = \"div\",\n className,\n style,\n size = \"lg\",\n padding = \"md\",\n ...rest\n },\n ref\n ) => {\n const paddingVar = {\n \"--container-padding\": SPACING_MAP[padding],\n } as React.CSSProperties\n\n const Component = Tag as React.ElementType\n\n return (\n <Component\n ref={ref}\n data-slot=\"container\"\n data-size={size}\n className={cn(\n styles.container,\n size === \"sm\" && styles.sizeSm,\n size === \"md\" && styles.sizeMd,\n size === \"lg\" && styles.sizeLg,\n size === \"xl\" && styles.sizeXl,\n size === \"full\" && styles.sizeFull,\n className\n )}\n style={{ ...paddingVar, ...style }}\n {...rest}\n />\n )\n }\n)\nContainer.displayName = \"Container\"\n\nexport { Container }\n"
2600
+ },
2601
+ {
2602
+ "path": "components/ui/container/container.module.css",
2603
+ "type": "registry:ui",
2604
+ "content": "/* ============================================================\n Container — max-width centered wrapper for page content.\n\n Sizes map to common page-content widths. `full` is no\n max-width — useful when you want only the padding affordance.\n Padding uses the standard `--spacing-*` tokens.\n ============================================================ */\n\n.container {\n width: 100%;\n margin-left: auto;\n margin-right: auto;\n padding-left: var(--container-padding, var(--spacing-4, 1rem));\n padding-right: var(--container-padding, var(--spacing-4, 1rem));\n max-width: var(--container-max-width, 1024px);\n}\n\n/* Size variants — chosen to match the Visor responsive breakpoints so\n consumers can pair them with media-query layouts naturally. */\n.sizeSm { --container-max-width: 640px; }\n.sizeMd { --container-max-width: 768px; }\n.sizeLg { --container-max-width: 1024px; }\n.sizeXl { --container-max-width: 1280px; }\n.sizeFull { max-width: none; }\n"
2605
+ }
2606
+ ]
2607
+ },
2608
+ {
2609
+ "name": "color-picker",
2610
+ "type": "registry:ui",
2611
+ "description": "An OKLCH-based color picker with a 2D lightness/chroma plane, hue slider, hex input, and optional preset chips. Renders inline or in a Radix Popover. Reuses the validated OKLCH engine from `@loworbitstudio/visor-theme-engine`.",
2612
+ "category": "form",
2613
+ "dependencies": [
2614
+ "@radix-ui/react-popover",
2615
+ "@loworbitstudio/visor-theme-engine"
2616
+ ],
2617
+ "registryDependencies": [
2618
+ "utils"
2619
+ ],
2620
+ "files": [
2621
+ {
2622
+ "path": "components/ui/color-picker/color-picker.tsx",
2623
+ "type": "registry:ui",
2624
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\"\nimport { cn } from \"../../../lib/utils\"\nimport {\n MAX_CHROMA,\n HUE_PREVIEW_L,\n HUE_PREVIEW_C,\n safeHexToOklch,\n oklchToHex,\n clampToSrgb,\n rgbToHex,\n isValidHex,\n normalizeHex,\n} from \"./oklch\"\nimport styles from \"./color-picker.module.css\"\n\n// ─── Types ──────────────────────────────────────────────────────────────────\n\nexport type ColorPickerSize = \"sm\" | \"md\" | \"lg\"\nexport type ColorPickerMode = \"popover\" | \"inline\"\n\nexport interface ColorPickerProps {\n /** Controlled hex value (e.g. \"#3b82f6\"). */\n value?: string\n /** Uncontrolled initial value. Defaults to `#3b82f6`. */\n defaultValue?: string\n /** Fired on every continuous interaction. */\n onChange?: (hex: string) => void\n /** Fired on pointer-up / popover-close — for debounced consumers. */\n onCommit?: (hex: string) => void\n /** Render in a Radix Popover (default) or inline. */\n mode?: ColorPickerMode\n /** Affects trigger swatch size + plane height. */\n size?: ColorPickerSize\n /** Hide the hex input row. */\n showHex?: boolean\n /** Hide the L/C/H readout row. */\n showReadout?: boolean\n /** Optional hex preset chips. */\n presets?: string[]\n /** Disables interaction. */\n disabled?: boolean\n /** Forwarded to a hidden input for form submission. */\n name?: string\n /** Required for screen readers. Falls back to \"Color picker\". */\n \"aria-label\"?: string\n className?: string\n}\n\n// ─── Hooks ──────────────────────────────────────────────────────────────────\n\nfunction useControlledValue(\n controlled: string | undefined,\n defaultValue: string,\n onChange: ((hex: string) => void) | undefined\n): [string, (next: string) => void] {\n const isControlled = controlled !== undefined\n const [internal, setInternal] = React.useState(defaultValue)\n const value = isControlled ? controlled : internal\n const setValue = React.useCallback(\n (next: string) => {\n if (!isControlled) setInternal(next)\n onChange?.(next)\n },\n [isControlled, onChange]\n )\n return [value, setValue]\n}\n\n// ─── Surface (the actual picker UI) ─────────────────────────────────────────\n\ninterface ColorPickerSurfaceProps {\n value: string\n onChange: (hex: string) => void\n onCommit?: (hex: string) => void\n showHex: boolean\n showReadout: boolean\n presets?: string[]\n disabled?: boolean\n ariaLabel: string\n}\n\nfunction ColorPickerSurface({\n value,\n onChange,\n onCommit,\n showHex,\n showReadout,\n presets,\n disabled,\n ariaLabel,\n}: ColorPickerSurfaceProps) {\n const planeCanvasRef = React.useRef<HTMLCanvasElement>(null)\n const hueCanvasRef = React.useRef<HTMLCanvasElement>(null)\n const planeWrapperRef = React.useRef<HTMLDivElement>(null)\n const hueWrapperRef = React.useRef<HTMLDivElement>(null)\n const isDraggingPlaneRef = React.useRef(false)\n const isDraggingHueRef = React.useRef(false)\n const lastRenderedHueRef = React.useRef<number>(-1)\n const animFrameRef = React.useRef<number>(0)\n\n const [oklch, setOklch] = React.useState<[number, number, number]>(() =>\n safeHexToOklch(value)\n )\n const [hexDraft, setHexDraft] = React.useState(value)\n const [hexValid, setHexValid] = React.useState(true)\n\n // Sync external value\n React.useEffect(() => {\n const [L, C, H] = safeHexToOklch(value)\n setOklch((prev) => {\n const prevHex = oklchToHex(prev[0], prev[1], prev[2])\n if (prevHex.toLowerCase() === value.toLowerCase()) return prev\n return [L, C, H]\n })\n setHexDraft(value)\n setHexValid(true)\n }, [value])\n\n const [L, C, H] = oklch\n\n // ─── Render Plane ──────────────────────────────────────────────────────\n const renderPlane = React.useCallback((hue: number) => {\n const canvas = planeCanvasRef.current\n if (!canvas) return\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n\n const { width, height } = canvas\n if (width === 0 || height === 0) return\n\n const imageData = ctx.createImageData(width, height)\n const data = imageData.data\n\n for (let y = 0; y < height; y++) {\n const lightness = 1.0 - y / (height - 1)\n for (let x = 0; x < width; x++) {\n const chroma = (x / (width - 1)) * MAX_CHROMA\n const idx = (y * width + x) * 4\n\n // Out-of-gamut detection — same approach as the reference picker\n // in `packages/docs/app/create/components/oklch-picker.tsx`. We\n // compare the clamped sRGB roundtrip to the direct OKLCH→hex\n // conversion; mismatches mean the triple wasn't representable in\n // sRGB and we dim toward neutral grey.\n const clamped = clampToSrgb(lightness, chroma, hue)\n const clampedHex = rgbToHex(clamped)\n const directHex = oklchToHex(lightness, chroma, hue)\n if (clampedHex !== directHex) {\n // Out of gamut — dim toward neutral grey\n data[idx] = Math.round(clamped[0] * 0.4 + 128 * 0.6)\n data[idx + 1] = Math.round(clamped[1] * 0.4 + 128 * 0.6)\n data[idx + 2] = Math.round(clamped[2] * 0.4 + 128 * 0.6)\n data[idx + 3] = 255\n } else {\n data[idx] = clamped[0]\n data[idx + 1] = clamped[1]\n data[idx + 2] = clamped[2]\n data[idx + 3] = 255\n }\n }\n }\n\n ctx.putImageData(imageData, 0, 0)\n lastRenderedHueRef.current = hue\n }, [])\n\n // ─── Render Hue ───────────────────────────────────────────────────────\n const renderHue = React.useCallback(() => {\n const canvas = hueCanvasRef.current\n if (!canvas) return\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n\n const { width, height } = canvas\n if (width === 0 || height === 0) return\n\n const imageData = ctx.createImageData(width, height)\n const data = imageData.data\n\n for (let x = 0; x < width; x++) {\n const hue = (x / (width - 1)) * 360\n const rgb = clampToSrgb(HUE_PREVIEW_L, HUE_PREVIEW_C, hue)\n\n for (let y = 0; y < height; y++) {\n const idx = (y * width + x) * 4\n data[idx] = rgb[0]\n data[idx + 1] = rgb[1]\n data[idx + 2] = rgb[2]\n data[idx + 3] = 255\n }\n }\n\n ctx.putImageData(imageData, 0, 0)\n }, [])\n\n // ─── Canvas Sizing & Initial Render ───────────────────────────────────\n React.useEffect(() => {\n const planeCanvas = planeCanvasRef.current\n const hueCanvas = hueCanvasRef.current\n if (!planeCanvas || !hueCanvas) return\n\n const planeRect = planeCanvas.getBoundingClientRect()\n const hueRect = hueCanvas.getBoundingClientRect()\n\n const maxDim = 200\n planeCanvas.width = Math.max(1, Math.min(maxDim, Math.round(planeRect.width)))\n planeCanvas.height = Math.max(\n 1,\n Math.min(maxDim, Math.round(planeRect.height))\n )\n hueCanvas.width = Math.max(1, Math.min(maxDim, Math.round(hueRect.width)))\n hueCanvas.height = Math.max(1, Math.min(24, Math.round(hueRect.height)))\n\n renderHue()\n renderPlane(H)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [renderHue, renderPlane])\n\n // Re-render plane when hue changes\n React.useEffect(() => {\n if (lastRenderedHueRef.current === H) return\n if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current)\n animFrameRef.current = requestAnimationFrame(() => {\n renderPlane(H)\n })\n return () => {\n if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current)\n }\n }, [H, renderPlane])\n\n // ─── Plane interaction ────────────────────────────────────────────────\n const updatePlane = React.useCallback(\n (clientX: number, clientY: number) => {\n const wrapper = planeWrapperRef.current\n if (!wrapper) return\n const rect = wrapper.getBoundingClientRect()\n const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))\n const y = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height))\n const newL = 1.0 - y\n const newC = x * MAX_CHROMA\n setOklch([newL, newC, H])\n const hex = oklchToHex(newL, newC, H)\n onChange(hex)\n },\n [H, onChange]\n )\n\n const handlePlanePointerDown = (e: React.PointerEvent) => {\n if (disabled) return\n isDraggingPlaneRef.current = true\n if ((e.target as HTMLElement).setPointerCapture) {\n (e.target as HTMLElement).setPointerCapture(e.pointerId)\n }\n updatePlane(e.clientX, e.clientY)\n }\n const handlePlanePointerMove = (e: React.PointerEvent) => {\n if (!isDraggingPlaneRef.current || disabled) return\n updatePlane(e.clientX, e.clientY)\n }\n const handlePlanePointerUp = () => {\n if (isDraggingPlaneRef.current) {\n isDraggingPlaneRef.current = false\n onCommit?.(oklchToHex(L, C, H))\n }\n }\n\n const handlePlaneKeyDown = (e: React.KeyboardEvent) => {\n if (disabled) return\n const stepL = e.shiftKey ? 0.05 : 0.01\n const stepC = e.shiftKey ? 0.05 : 0.01\n let newL = L\n let newC = C\n let handled = true\n switch (e.key) {\n case \"ArrowUp\":\n newL = Math.min(1, L + stepL)\n break\n case \"ArrowDown\":\n newL = Math.max(0, L - stepL)\n break\n case \"ArrowRight\":\n newC = Math.min(MAX_CHROMA, C + stepC)\n break\n case \"ArrowLeft\":\n newC = Math.max(0, C - stepC)\n break\n case \"PageUp\":\n newL = Math.min(1, L + 0.1)\n break\n case \"PageDown\":\n newL = Math.max(0, L - 0.1)\n break\n default:\n handled = false\n }\n if (handled) {\n e.preventDefault()\n setOklch([newL, newC, H])\n const hex = oklchToHex(newL, newC, H)\n onChange(hex)\n onCommit?.(hex)\n }\n }\n\n // ─── Hue interaction ──────────────────────────────────────────────────\n const updateHue = React.useCallback(\n (clientX: number) => {\n const wrapper = hueWrapperRef.current\n if (!wrapper) return\n const rect = wrapper.getBoundingClientRect()\n const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))\n const newH = x * 360\n setOklch([L, C, newH])\n const hex = oklchToHex(L, C, newH)\n onChange(hex)\n },\n [L, C, onChange]\n )\n\n const handleHuePointerDown = (e: React.PointerEvent) => {\n if (disabled) return\n isDraggingHueRef.current = true\n if ((e.target as HTMLElement).setPointerCapture) {\n (e.target as HTMLElement).setPointerCapture(e.pointerId)\n }\n updateHue(e.clientX)\n }\n const handleHuePointerMove = (e: React.PointerEvent) => {\n if (!isDraggingHueRef.current || disabled) return\n updateHue(e.clientX)\n }\n const handleHuePointerUp = () => {\n if (isDraggingHueRef.current) {\n isDraggingHueRef.current = false\n onCommit?.(oklchToHex(L, C, H))\n }\n }\n\n const handleHueKeyDown = (e: React.KeyboardEvent) => {\n if (disabled) return\n const step = e.shiftKey ? 15 : 1\n let newH = H\n let handled = true\n switch (e.key) {\n case \"ArrowRight\":\n case \"ArrowUp\":\n newH = Math.min(360, H + step)\n break\n case \"ArrowLeft\":\n case \"ArrowDown\":\n newH = Math.max(0, H - step)\n break\n case \"Home\":\n newH = 0\n break\n case \"End\":\n newH = 360\n break\n default:\n handled = false\n }\n if (handled) {\n e.preventDefault()\n setOklch([L, C, newH])\n const hex = oklchToHex(L, C, newH)\n onChange(hex)\n onCommit?.(hex)\n }\n }\n\n // ─── Hex input ────────────────────────────────────────────────────────\n const handleHexChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n let raw = e.target.value\n setHexDraft(raw)\n if (raw && !raw.startsWith(\"#\")) raw = `#${raw}`\n const normalized = normalizeHex(raw)\n if (normalized) {\n setHexValid(true)\n onChange(normalized)\n } else {\n setHexValid(raw.length === 0 || raw === \"#\")\n }\n }\n\n const handleHexBlur = () => {\n if (!hexValid) {\n setHexDraft(value)\n setHexValid(true)\n } else if (isValidHex(hexDraft)) {\n onCommit?.(hexDraft.toLowerCase())\n }\n }\n\n // ─── Preset chip ──────────────────────────────────────────────────────\n const handlePresetClick = (hex: string) => {\n if (disabled) return\n const normalized = normalizeHex(hex)\n if (!normalized) return\n onChange(normalized)\n onCommit?.(normalized)\n }\n\n // ─── Crosshair positions ──────────────────────────────────────────────\n const crosshairX = `${(C / MAX_CHROMA) * 100}%`\n const crosshairY = `${(1.0 - L) * 100}%`\n const hueIndicatorX = `${(H / 360) * 100}%`\n const currentHex = oklchToHex(L, C, H)\n\n return (\n <div\n className={styles.picker}\n role=\"group\"\n aria-label={ariaLabel}\n data-disabled={disabled ? \"\" : undefined}\n >\n <div\n ref={planeWrapperRef}\n className={styles.planeWrapper}\n onPointerDown={handlePlanePointerDown}\n onPointerMove={handlePlanePointerMove}\n onPointerUp={handlePlanePointerUp}\n onKeyDown={handlePlaneKeyDown}\n role=\"slider\"\n aria-label=\"Lightness and chroma\"\n aria-valuetext={`Lightness ${L.toFixed(2)}, Chroma ${C.toFixed(3)}`}\n aria-disabled={disabled || undefined}\n tabIndex={disabled ? -1 : 0}\n data-slot=\"color-picker-plane\"\n >\n <canvas ref={planeCanvasRef} className={styles.planeCanvas} />\n <div\n className={styles.crosshair}\n style={{ left: crosshairX, top: crosshairY }}\n />\n </div>\n\n <div\n ref={hueWrapperRef}\n className={styles.hueWrapper}\n onPointerDown={handleHuePointerDown}\n onPointerMove={handleHuePointerMove}\n onPointerUp={handleHuePointerUp}\n onKeyDown={handleHueKeyDown}\n role=\"slider\"\n aria-label=\"Hue\"\n aria-valuemin={0}\n aria-valuemax={360}\n aria-valuenow={Math.round(H)}\n aria-disabled={disabled || undefined}\n tabIndex={disabled ? -1 : 0}\n data-slot=\"color-picker-hue\"\n >\n <canvas ref={hueCanvasRef} className={styles.hueCanvas} />\n <div\n className={styles.hueIndicator}\n style={{ left: hueIndicatorX }}\n />\n </div>\n\n {showReadout && (\n <div className={styles.readout} aria-hidden=\"true\">\n <span>Hue: {Math.round(H)}&deg;</span>\n <span>L: {L.toFixed(2)}</span>\n <span>C: {C.toFixed(3)}</span>\n </div>\n )}\n\n {showHex && (\n <div className={styles.hexRow}>\n <span\n className={styles.hexChip}\n style={{ backgroundColor: currentHex }}\n aria-hidden=\"true\"\n />\n <input\n type=\"text\"\n className={styles.hexInput}\n value={hexDraft}\n onChange={handleHexChange}\n onBlur={handleHexBlur}\n placeholder=\"#000000\"\n spellCheck={false}\n autoComplete=\"off\"\n aria-label=\"Hex value\"\n aria-invalid={hexValid ? undefined : true}\n disabled={disabled}\n />\n </div>\n )}\n\n {presets && presets.length > 0 && (\n <div\n className={styles.presets}\n role=\"group\"\n aria-label=\"Color presets\"\n data-slot=\"color-picker-presets\"\n >\n {presets.map((preset) => (\n <button\n key={preset}\n type=\"button\"\n className={styles.presetChip}\n style={{ backgroundColor: preset }}\n onClick={() => handlePresetClick(preset)}\n aria-label={`Use color ${preset}`}\n disabled={disabled}\n />\n ))}\n </div>\n )}\n </div>\n )\n}\n\n// ─── ColorPicker (root) ─────────────────────────────────────────────────────\n\nconst ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(\n (\n {\n value: controlledValue,\n defaultValue = \"#3b82f6\",\n onChange: onChangeProp,\n onCommit,\n mode = \"popover\",\n size = \"md\",\n showHex = true,\n showReadout = true,\n presets,\n disabled = false,\n name,\n className,\n \"aria-label\": ariaLabel = \"Color picker\",\n ...rest\n },\n ref\n ) => {\n const [value, setValue] = useControlledValue(\n controlledValue,\n defaultValue,\n onChangeProp\n )\n const [open, setOpen] = React.useState(false)\n\n const triggerSizeClass =\n size === \"sm\"\n ? styles.triggerSm\n : size === \"lg\"\n ? styles.triggerLg\n : styles.triggerMd\n\n if (mode === \"inline\") {\n return (\n <div\n ref={ref}\n className={cn(styles.root, className)}\n data-slot=\"color-picker\"\n data-mode=\"inline\"\n {...rest}\n >\n <ColorPickerSurface\n value={value}\n onChange={setValue}\n onCommit={onCommit}\n showHex={showHex}\n showReadout={showReadout}\n presets={presets}\n disabled={disabled}\n ariaLabel={ariaLabel}\n />\n {name && <input type=\"hidden\" name={name} value={value} />}\n </div>\n )\n }\n\n return (\n <div\n ref={ref}\n className={cn(styles.root, className)}\n data-slot=\"color-picker\"\n data-mode=\"popover\"\n {...rest}\n >\n <PopoverPrimitive.Root\n open={open}\n onOpenChange={(next) => {\n setOpen(next)\n if (!next) onCommit?.(value)\n }}\n >\n <PopoverPrimitive.Trigger asChild>\n <button\n type=\"button\"\n className={cn(styles.trigger, triggerSizeClass)}\n aria-haspopup=\"dialog\"\n aria-expanded={open}\n aria-label={`${ariaLabel}: ${value}`}\n disabled={disabled}\n data-slot=\"color-picker-trigger\"\n >\n <span\n className={styles.triggerSwatch}\n style={{ backgroundColor: value }}\n aria-hidden=\"true\"\n />\n </button>\n </PopoverPrimitive.Trigger>\n <PopoverPrimitive.Portal>\n <PopoverPrimitive.Content\n className={styles.popoverContent}\n align=\"start\"\n sideOffset={4}\n data-slot=\"color-picker-content\"\n >\n <ColorPickerSurface\n value={value}\n onChange={setValue}\n onCommit={onCommit}\n showHex={showHex}\n showReadout={showReadout}\n presets={presets}\n disabled={disabled}\n ariaLabel={ariaLabel}\n />\n </PopoverPrimitive.Content>\n </PopoverPrimitive.Portal>\n </PopoverPrimitive.Root>\n {name && <input type=\"hidden\" name={name} value={value} />}\n </div>\n )\n }\n)\nColorPicker.displayName = \"ColorPicker\"\n\nexport { ColorPicker }\n"
2625
+ },
2626
+ {
2627
+ "path": "components/ui/color-picker/color-picker.module.css",
2628
+ "type": "registry:ui",
2629
+ "content": "/* ColorPicker — theme-agnostic OKLCH picker.\n *\n * All surface, border, focus, shadow, and motion declarations use semantic\n * tokens via var() with Tailwind Gray fallbacks. Canvas pixels are computed\n * colors — outputs of the picker, not theme surfaces — so they reference no\n * tokens. The indicator dimensions (crosshair 12px, hue indicator 4px) are\n * documented as intentional pixel values for pointing precision per\n * the token-rules doc, rule 12.\n */\n\n/* ─── Root container ──────────────────────────────────────────────────── */\n\n.root {\n display: inline-block;\n}\n\n.root[data-mode=\"inline\"] {\n display: block;\n width: 100%;\n}\n\n/* ─── Trigger (popover mode) ──────────────────────────────────────────── */\n\n.trigger {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0;\n background-color: transparent;\n border: var(--stroke-width-thin, 1px) solid var(--border-default, #e5e7eb);\n border-radius: var(--radius-sm, 0.5rem);\n cursor: pointer;\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n outline: none;\n overflow: hidden;\n}\n\n@media (hover: hover) {\n .trigger:hover:not(:focus-visible):not(:disabled) {\n border-color: var(--border-strong, #d1d5db);\n }\n}\n\n.trigger:focus-visible {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.trigger:disabled {\n cursor: not-allowed;\n opacity: var(--opacity-40, 0.4);\n}\n\n.triggerSm {\n width: 1.5rem;\n height: 1.5rem;\n}\n\n.triggerMd {\n width: 2rem;\n height: 2rem;\n}\n\n.triggerLg {\n width: 2.5rem;\n height: 2.5rem;\n}\n\n.triggerSwatch {\n display: block;\n width: 100%;\n height: 100%;\n}\n\n/* ─── Popover content ─────────────────────────────────────────────────── */\n\n.popoverContent {\n z-index: 50;\n min-width: 16rem;\n padding: var(--spacing-4, 1rem);\n background-color: var(--surface-popover, var(--surface-card, #ffffff));\n color: var(--text-primary, #111827);\n border: var(--stroke-width-thin, 1px) solid var(--border-default, #e5e7eb);\n border-radius: var(--radius-sm, 0.5rem);\n box-shadow: var(--shadow-md);\n}\n\n.popoverContent[data-state=\"open\"] {\n animation: contentShow var(--motion-duration-fast, 100ms) var(--motion-easing-enter, ease-out);\n}\n\n.popoverContent[data-state=\"closed\"] {\n animation: contentHide var(--motion-duration-fast, 100ms) var(--motion-easing-exit, ease-in);\n}\n\n@keyframes contentShow {\n from {\n opacity: 0;\n transform: scale(0.96);\n }\n to {\n opacity: 1;\n transform: scale(1);\n }\n}\n\n@keyframes contentHide {\n from {\n opacity: 1;\n transform: scale(1);\n }\n to {\n opacity: 0;\n transform: scale(0.96);\n }\n}\n\n/* ─── Picker surface (the actual picker UI) ───────────────────────────── */\n\n.picker {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-3, 0.75rem);\n min-width: 16rem;\n}\n\n.picker[data-disabled] {\n opacity: var(--opacity-40, 0.4);\n pointer-events: none;\n}\n\n/* ─── 2D Lightness × Chroma plane ─────────────────────────────────────── */\n\n.planeWrapper {\n position: relative;\n width: 100%;\n aspect-ratio: 1.4;\n border-radius: var(--radius-sm, 0.25rem);\n overflow: hidden;\n cursor: crosshair;\n border: var(--stroke-width-thin, 1px) solid var(--border-default, #e5e7eb);\n outline: none;\n}\n\n.planeWrapper:focus-visible {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.planeCanvas {\n display: block;\n width: 100%;\n height: 100%;\n}\n\n.crosshair {\n /* Crosshair is a floating overlay on top of the computed-color canvas, not\n * a theme surface. The 12px diameter is an intentional pixel value chosen\n * for pointing precision (documented under token-rules rule 12). The white\n * stroke + dark micro-shadow give the indicator legibility against any\n * underlying canvas color regardless of theme — that's the whole point of\n * the marker. Themes can opt to override these via the color-picker tokens\n * declared in the planeWrapper rule above. */\n position: absolute;\n width: 12px;\n height: 12px;\n border: var(--stroke-width-medium, 2px) solid var(--color-picker-indicator, #ffffff);\n border-radius: 50%;\n box-shadow: var(\n --color-picker-indicator-shadow,\n 0 0 0 var(--stroke-width-thin, 1px) rgba(0, 0, 0, 0.3)\n ),\n inset 0 0 0 var(--stroke-width-thin, 1px) var(--color-picker-indicator-shadow-color, rgba(0, 0, 0, 0.3));\n pointer-events: none;\n transform: translate(-50%, -50%);\n transition: left var(--motion-duration-fast, 100ms) var(--motion-easing-default, ease-in-out),\n top var(--motion-duration-fast, 100ms) var(--motion-easing-default, ease-in-out);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .crosshair {\n transition: none;\n }\n}\n\n/* ─── Hue slider ──────────────────────────────────────────────────────── */\n\n.hueWrapper {\n position: relative;\n width: 100%;\n height: var(--spacing-4, 1rem);\n border-radius: var(--radius-sm, 0.25rem);\n overflow: hidden;\n cursor: crosshair;\n border: var(--stroke-width-thin, 1px) solid var(--border-default, #e5e7eb);\n outline: none;\n}\n\n.hueWrapper:focus-visible {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.hueCanvas {\n display: block;\n width: 100%;\n height: 100%;\n}\n\n.hueIndicator {\n /* Hue indicator is a floating overlay on top of the computed-color hue\n * canvas, not a theme surface. The 4px width is an intentional pixel value\n * chosen for pointing precision (documented under token-rules rule 12).\n * The white stroke + dark micro-shadow give the indicator legibility\n * against any underlying hue regardless of theme. Themes can opt to\n * override via the color-picker tokens declared in the planeWrapper rule\n * above. */\n position: absolute;\n top: calc(-1 * var(--stroke-width-thin, 1px));\n bottom: calc(-1 * var(--stroke-width-thin, 1px));\n width: 4px;\n border: var(--stroke-width-medium, 2px) solid var(--color-picker-indicator, #ffffff);\n border-radius: 1px;\n box-shadow: var(\n --color-picker-indicator-shadow,\n 0 0 0 var(--stroke-width-thin, 1px) rgba(0, 0, 0, 0.3)\n );\n pointer-events: none;\n transform: translateX(-50%);\n transition: left var(--motion-duration-fast, 100ms) var(--motion-easing-default, ease-in-out);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .hueIndicator {\n transition: none;\n }\n}\n\n/* ─── Readout (L / C / H) ─────────────────────────────────────────────── */\n\n.readout {\n display: flex;\n justify-content: space-between;\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--text-tertiary, #9ca3af);\n font-variant-numeric: tabular-nums;\n}\n\n/* ─── Hex row (chip + input) ──────────────────────────────────────────── */\n\n.hexRow {\n display: flex;\n gap: var(--spacing-2, 0.5rem);\n align-items: center;\n}\n\n.hexChip {\n width: 2rem;\n height: 2rem;\n border: var(--stroke-width-thin, 1px) solid var(--border-default, #e5e7eb);\n border-radius: var(--radius-sm, 0.5rem);\n flex-shrink: 0;\n}\n\n.hexInput {\n flex: 1;\n min-width: 0;\n height: 2.25rem;\n padding: var(--spacing-1, 0.25rem) var(--spacing-3, 0.75rem);\n border: var(--stroke-width-thin, 1px) solid var(--border-default, #e5e7eb);\n border-radius: var(--radius-sm, 0.5rem);\n background-color: var(--surface-interactive-default, #f9fafb);\n color: var(--text-primary, #111827);\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: var(--font-size-sm, 0.875rem);\n font-variant-numeric: tabular-nums;\n outline: none;\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n.hexInput:focus-visible {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.hexInput[aria-invalid=\"true\"] {\n border-color: var(--border-error, #ef4444);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-error, #ef4444) 15%, transparent);\n}\n\n.hexInput:disabled {\n cursor: not-allowed;\n opacity: var(--opacity-40, 0.4);\n}\n\n/* ─── Preset chips ────────────────────────────────────────────────────── */\n\n.presets {\n display: flex;\n gap: var(--spacing-2, 0.5rem);\n flex-wrap: wrap;\n}\n\n.presetChip {\n width: 1.5rem;\n height: 1.5rem;\n padding: 0;\n border: var(--stroke-width-thin, 1px) solid var(--border-default, #e5e7eb);\n border-radius: var(--radius-sm, 0.25rem);\n cursor: pointer;\n outline: none;\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n@media (hover: hover) {\n .presetChip:hover:not(:focus-visible):not(:disabled) {\n border-color: var(--border-strong, #d1d5db);\n }\n}\n\n.presetChip:focus-visible {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.presetChip:disabled {\n cursor: not-allowed;\n opacity: var(--opacity-40, 0.4);\n}\n"
2630
+ },
2631
+ {
2632
+ "path": "components/ui/color-picker/oklch.ts",
2633
+ "type": "registry:ui",
2634
+ "content": "/**\n * OKLCH color-space helpers for ColorPicker.\n *\n * Re-exports the validated OKLCH math from `@loworbitstudio/visor-theme-engine`\n * — the same engine that powers the docs theme creator — and adds a couple of\n * gamut-aware helpers used by the picker's plane / hue canvases. The math\n * itself is *not* re-implemented locally: the theme-engine package is already\n * the single source of truth for color conversions across Visor.\n *\n * Keeping this as a small pure-math module (no React imports) lets the picker\n * stay easy to unit-test and lets consumers swap in their own math if they\n * fork the component.\n */\n\nimport {\n hexToOklch,\n oklchToHex,\n clampToSrgb,\n rgbToHex,\n isValidHex,\n normalizeHex,\n} from \"@loworbitstudio/visor-theme-engine\"\n\n/**\n * Maximum chroma rendered on the lightness/chroma plane. Above this, almost\n * every OKLCH triple is out of sRGB gamut — extending past it just shows the\n * dim out-of-gamut blend with no useful color. Documented as intentional;\n * matches the reference engine in `packages/docs/app/create/components/oklch-picker.tsx`.\n */\nexport const MAX_CHROMA = 0.37\n\n/** Preview lightness for the hue track strip. */\nexport const HUE_PREVIEW_L = 0.7\n\n/** Preview chroma for the hue track strip. */\nexport const HUE_PREVIEW_C = 0.15\n\nexport type OKLCH = [number, number, number]\n\n/** Parse a hex string, returning a fallback OKLCH if the hex is invalid. */\nexport function safeHexToOklch(hex: string, fallback: OKLCH = [0.55, 0.15, 260]): OKLCH {\n try {\n return hexToOklch(hex)\n } catch {\n return fallback\n }\n}\n\n/**\n * Returns true if the given OKLCH triple falls outside sRGB gamut.\n *\n * Note: this mirrors the reference picker's `clampedHex !== directHex` check.\n * In the current `@loworbitstudio/visor-theme-engine` build, `oklchToHex`\n * routes through `rgbToHex(clampToSrgb(...))` — so this comparison is always\n * false in practice. The helper stays here as a stable seam: a future engine\n * release can distinguish gamut-mapping from clamping (e.g. by exposing the\n * unclamped linear RGB) and the picker's dim-out-of-gamut branch will light\n * up automatically.\n */\nexport function isOutOfGamut(L: number, C: number, H: number): boolean {\n const clamped = clampToSrgb(L, C, H)\n const clampedHex = rgbToHex(clamped)\n const directHex = oklchToHex(L, C, H)\n return clampedHex !== directHex\n}\n\nexport {\n hexToOklch,\n oklchToHex,\n clampToSrgb,\n rgbToHex,\n isValidHex,\n normalizeHex,\n}\n"
2635
+ }
2636
+ ]
2637
+ },
2339
2638
  {
2340
2639
  "name": "use-media-query",
2341
2640
  "type": "registry:hook",
@@ -3018,12 +3317,12 @@
3018
3317
  {
3019
3318
  "path": "blocks/admin-dashboard/admin-dashboard.tsx",
3020
3319
  "type": "registry:block",
3021
- "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"../../lib/utils\"\nimport {\n PageHeader,\n type PageHeaderProps,\n} from \"../../components/ui/page-header/page-header\"\nimport {\n StatCard,\n type StatCardDelta,\n type StatCardProps,\n} from \"../../components/ui/stat-card/stat-card\"\nimport {\n ActivityFeed,\n ActivityFeedItem,\n} from \"../../components/ui/activity-feed/activity-feed\"\nimport { EmptyState } from \"../../components/ui/empty-state/empty-state\"\nimport styles from \"./admin-dashboard.module.css\"\n\nexport interface AdminDashboardStat {\n id: string\n label: React.ReactNode\n value: React.ReactNode\n delta?: StatCardDelta\n trend?: React.ReactNode\n variant?: \"default\" | \"highlight\"\n size?: \"sm\" | \"md\"\n valueAs?: StatCardProps[\"valueAs\"]\n}\n\nexport interface AdminDashboardActivity {\n id: string\n leading?: React.ReactNode\n title: React.ReactNode\n description?: React.ReactNode\n actor?: React.ReactNode\n timestamp: React.ReactNode\n trailing?: React.ReactNode\n}\n\nexport interface AdminDashboardProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n /** Page title rendered inside the PageHeader. */\n title: React.ReactNode\n /** Optional small uppercase label above the title. */\n eyebrow?: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /** Optional actions slot rendered on the right side of the header. */\n actions?: React.ReactNode\n\n /** Stat cards rendered in the responsive grid. */\n stats: AdminDashboardStat[]\n\n /** Heading rendered above the activity feed. Defaults to \"Recent activity\". */\n activityTitle?: React.ReactNode\n /** Activity events rendered in the feed. */\n activities: AdminDashboardActivity[]\n /** If provided, renders a \"View all\" link in the activity section header. */\n activityViewAllHref?: string\n /** Replaces the default EmptyState when `activities` is empty. */\n activityEmptyState?: React.ReactNode\n /** Variant forwarded to the underlying ActivityFeed. */\n activityVariant?: \"default\" | \"compact\" | \"timeline\"\n\n /** Optional region rendered below the stat grid and above the activity feed. */\n secondaryRegion?: React.ReactNode\n\n /** Heading level used for the PageHeader title. Defaults to `h1`. */\n titleAs?: PageHeaderProps[\"titleAs\"]\n}\n\nexport function AdminDashboard({\n title,\n eyebrow,\n description,\n actions,\n stats,\n activityTitle = \"Recent activity\",\n activities,\n activityViewAllHref,\n activityEmptyState,\n activityVariant = \"default\",\n secondaryRegion,\n titleAs = \"h1\",\n className,\n ...props\n}: AdminDashboardProps) {\n const activityHeadingId = React.useId()\n const hasActivities = activities.length > 0\n\n return (\n <div\n className={cn(styles.root, className)}\n data-slot=\"admin-dashboard\"\n {...props}\n >\n <PageHeader\n eyebrow={eyebrow}\n title={title}\n description={description}\n actions={actions}\n titleAs={titleAs}\n />\n\n {stats.length > 0 ? (\n <div\n className={styles.statGrid}\n data-slot=\"admin-dashboard-stats\"\n data-count={stats.length}\n >\n {stats.map((stat) => (\n <StatCard\n key={stat.id}\n label={stat.label}\n value={stat.value}\n delta={stat.delta}\n trend={stat.trend}\n variant={stat.variant}\n size={stat.size}\n valueAs={stat.valueAs}\n />\n ))}\n </div>\n ) : null}\n\n {secondaryRegion ? (\n <div\n className={styles.secondaryRegion}\n data-slot=\"admin-dashboard-secondary\"\n >\n {secondaryRegion}\n </div>\n ) : null}\n\n <section\n className={styles.activitySection}\n data-slot=\"admin-dashboard-activity\"\n aria-labelledby={activityHeadingId}\n >\n <header className={styles.activityHeader}>\n <h2 id={activityHeadingId} className={styles.activityTitle}>\n {activityTitle}\n </h2>\n {activityViewAllHref ? (\n <a\n href={activityViewAllHref}\n className={styles.activityViewAll}\n data-slot=\"admin-dashboard-activity-view-all\"\n >\n View all\n </a>\n ) : null}\n </header>\n\n {hasActivities ? (\n <ActivityFeed variant={activityVariant}>\n {activities.map((activity) => (\n <ActivityFeedItem\n key={activity.id}\n leading={activity.leading}\n title={activity.title}\n description={activity.description}\n actor={activity.actor}\n timestamp={activity.timestamp}\n trailing={activity.trailing}\n />\n ))}\n </ActivityFeed>\n ) : activityEmptyState !== undefined ? (\n activityEmptyState\n ) : (\n <EmptyState\n heading=\"No recent activity\"\n description=\"New events will show up here as they happen.\"\n tone=\"subtle\"\n />\n )}\n </section>\n </div>\n )\n}\n"
3320
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"../../lib/utils\"\nimport {\n PageHeader,\n type PageHeaderProps,\n} from \"../../components/ui/page-header/page-header\"\nimport {\n StatCard,\n type StatCardDelta,\n type StatCardProps,\n} from \"../../components/ui/stat-card/stat-card\"\nimport {\n ActivityFeed,\n ActivityFeedItem,\n} from \"../../components/ui/activity-feed/activity-feed\"\nimport { EmptyState } from \"../../components/ui/empty-state/empty-state\"\nimport styles from \"./admin-dashboard.module.css\"\n\nexport interface AdminDashboardStat {\n id: string\n label: React.ReactNode\n value: React.ReactNode\n delta?: StatCardDelta\n trend?: React.ReactNode\n variant?: \"default\" | \"highlight\"\n size?: \"sm\" | \"md\"\n valueAs?: StatCardProps[\"valueAs\"]\n}\n\nexport interface AdminDashboardActivity {\n id: string\n leading?: React.ReactNode\n title: React.ReactNode\n description?: React.ReactNode\n actor?: React.ReactNode\n timestamp: React.ReactNode\n trailing?: React.ReactNode\n}\n\nexport interface AdminDashboardProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n /** Page title rendered inside the PageHeader. */\n title: React.ReactNode\n /** Optional small uppercase label above the title. */\n eyebrow?: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /** Optional actions slot rendered on the right side of the header. */\n actions?: React.ReactNode\n\n /** Stat cards rendered in the responsive grid. */\n stats: AdminDashboardStat[]\n\n /**\n * Body layout mode.\n * - `\"single\"` (default) renders the existing flow: stat grid → optional\n * `secondaryRegion` → activity section. Backwards-compatible — every\n * existing consumer renders unchanged when `layout` is omitted.\n * - `\"split\"` renders a 2-column body grid (`mainCol` left, `sideCol` right)\n * below the stat strip. In split mode you compose the body yourself —\n * the default activity feed and `secondaryRegion` are not rendered.\n */\n layout?: \"single\" | \"split\"\n\n /** Left column content. Only rendered when `layout=\"split\"`. */\n mainCol?: React.ReactNode\n /** Right column content. Only rendered when `layout=\"split\"`. */\n sideCol?: React.ReactNode\n\n /** Heading rendered above the activity feed. Defaults to \"Recent activity\". */\n activityTitle?: React.ReactNode\n /** Activity events rendered in the feed. Ignored when `layout=\"split\"`. */\n activities: AdminDashboardActivity[]\n /** If provided, renders a \"View all\" link in the activity section header. */\n activityViewAllHref?: string\n /** Replaces the default EmptyState when `activities` is empty. */\n activityEmptyState?: React.ReactNode\n /** Variant forwarded to the underlying ActivityFeed. */\n activityVariant?: \"default\" | \"compact\" | \"timeline\"\n\n /**\n * Optional region rendered below the stat grid and above the activity feed.\n * Ignored when `layout=\"split\"` — compose body content via `mainCol`/`sideCol`.\n */\n secondaryRegion?: React.ReactNode\n\n /** Heading level used for the PageHeader title. Defaults to `h1`. */\n titleAs?: PageHeaderProps[\"titleAs\"]\n}\n\nexport function AdminDashboard({\n title,\n eyebrow,\n description,\n actions,\n stats,\n layout = \"single\",\n mainCol,\n sideCol,\n activityTitle = \"Recent activity\",\n activities,\n activityViewAllHref,\n activityEmptyState,\n activityVariant = \"default\",\n secondaryRegion,\n titleAs = \"h1\",\n className,\n ...props\n}: AdminDashboardProps) {\n const activityHeadingId = React.useId()\n const hasActivities = activities.length > 0\n const isSplit = layout === \"split\"\n\n // Dev-mode warning: in split mode the caller composes the body via mainCol /\n // sideCol, so any `activities` / `secondaryRegion` props are silently dropped.\n // Flag the mismatch loudly during development so consumers notice before\n // shipping. Suppressed in production to avoid noisy logs.\n if (\n isSplit &&\n process.env.NODE_ENV !== \"production\" &&\n (activities.length > 0 || secondaryRegion !== undefined)\n ) {\n // eslint-disable-next-line no-console\n console.warn(\n \"[AdminDashboard] layout=\\\"split\\\" ignores `activities` and `secondaryRegion`. \" +\n \"Compose body content inside `mainCol` / `sideCol` instead.\"\n )\n }\n\n return (\n <div\n className={cn(styles.root, className)}\n data-slot=\"admin-dashboard\"\n data-layout={layout}\n {...props}\n >\n <PageHeader\n eyebrow={eyebrow}\n title={title}\n description={description}\n actions={actions}\n titleAs={titleAs}\n />\n\n {stats.length > 0 ? (\n <div\n className={styles.statGrid}\n data-slot=\"admin-dashboard-stats\"\n data-count={stats.length}\n >\n {stats.map((stat) => (\n <StatCard\n key={stat.id}\n label={stat.label}\n value={stat.value}\n delta={stat.delta}\n trend={stat.trend}\n variant={stat.variant}\n size={stat.size}\n valueAs={stat.valueAs}\n />\n ))}\n </div>\n ) : null}\n\n {isSplit ? (\n <div\n className={styles.body}\n data-slot=\"admin-dashboard-body\"\n data-layout=\"split\"\n >\n <div\n className={styles.mainCol}\n data-slot=\"admin-dashboard-main-col\"\n >\n {mainCol}\n </div>\n <aside\n className={styles.sideCol}\n data-slot=\"admin-dashboard-side-col\"\n >\n {sideCol}\n </aside>\n </div>\n ) : (\n <>\n {secondaryRegion ? (\n <div\n className={styles.secondaryRegion}\n data-slot=\"admin-dashboard-secondary\"\n >\n {secondaryRegion}\n </div>\n ) : null}\n\n <section\n className={styles.activitySection}\n data-slot=\"admin-dashboard-activity\"\n aria-labelledby={activityHeadingId}\n >\n <header className={styles.activityHeader}>\n <h2 id={activityHeadingId} className={styles.activityTitle}>\n {activityTitle}\n </h2>\n {activityViewAllHref ? (\n <a\n href={activityViewAllHref}\n className={styles.activityViewAll}\n data-slot=\"admin-dashboard-activity-view-all\"\n >\n View all\n </a>\n ) : null}\n </header>\n\n {hasActivities ? (\n <ActivityFeed variant={activityVariant}>\n {activities.map((activity) => (\n <ActivityFeedItem\n key={activity.id}\n leading={activity.leading}\n title={activity.title}\n description={activity.description}\n actor={activity.actor}\n timestamp={activity.timestamp}\n trailing={activity.trailing}\n />\n ))}\n </ActivityFeed>\n ) : activityEmptyState !== undefined ? (\n activityEmptyState\n ) : (\n <EmptyState\n heading=\"No recent activity\"\n description=\"New events will show up here as they happen.\"\n tone=\"subtle\"\n />\n )}\n </section>\n </>\n )}\n </div>\n )\n}\n"
3022
3321
  },
3023
3322
  {
3024
3323
  "path": "blocks/admin-dashboard/admin-dashboard.module.css",
3025
3324
  "type": "registry:block",
3026
- "content": ".root {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-8, 2rem);\n width: 100%;\n container-type: inline-size;\n container-name: admin-dashboard;\n color: var(--text-primary, #111827);\n}\n\n/* Responsive stat grid driven entirely by container queries. */\n.statGrid {\n display: grid;\n gap: var(--spacing-4, 1rem);\n grid-template-columns: 1fr;\n}\n\n@container admin-dashboard (min-width: 36rem) {\n .statGrid {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n}\n\n@container admin-dashboard (min-width: 56rem) {\n .statGrid {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: var(--spacing-5, 1.25rem);\n }\n}\n\n@container admin-dashboard (min-width: 72rem) {\n .statGrid {\n grid-template-columns: repeat(4, minmax(0, 1fr));\n gap: var(--spacing-6, 1.5rem);\n }\n}\n\n.secondaryRegion {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n min-width: 0;\n}\n\n.activitySection {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n padding: var(--spacing-6, 1.5rem);\n background: var(--surface-card, #ffffff);\n border: 1px solid var(--border-default, #e5e7eb);\n border-radius: var(--radius-lg, 0.75rem);\n box-shadow: var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05));\n min-width: 0;\n}\n\n.activityHeader {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--spacing-3, 0.75rem);\n min-width: 0;\n}\n\n.activityTitle {\n margin: 0;\n font-family: var(--font-family-heading, inherit);\n font-size: var(--font-size-lg, 1.125rem);\n font-weight: var(--font-weight-semibold, 600);\n line-height: var(--line-height-tight, 1.25);\n letter-spacing: var(--letter-spacing-tight, -0.01em);\n color: var(--text-primary, #111827);\n min-width: 0;\n}\n\n.activityViewAll {\n flex-shrink: 0;\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n color: var(--text-link, var(--accent-primary, #3b82f6));\n text-decoration: none;\n border-radius: var(--radius-sm, 0.25rem);\n transition:\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.activityViewAll:hover {\n text-decoration: underline;\n}\n\n.activityViewAll:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, var(--accent-primary, #3b82f6));\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n@container admin-dashboard (max-width: 36rem) {\n .activitySection {\n padding: var(--spacing-4, 1rem);\n }\n}\n"
3325
+ "content": ".root {\n /* Tunable knobs — themes (and one-off pages) can override these without\n reaching into block internals. Defaults match the r3 reference build.\n\n Note: --admin-dashboard-stack-bp is exposed as a custom property for\n theme/documentation discoverability, but CSS @container queries cannot\n dereference custom properties today, so the breakpoint below is the\n hard-coded fallback. Override the @container rule in a theme layer if\n you need a different stack point. */\n --admin-dashboard-side-col-width: 320px;\n --admin-dashboard-stack-bp: 960px;\n\n display: flex;\n flex-direction: column;\n gap: var(--spacing-8, 2rem);\n width: 100%;\n container-type: inline-size;\n container-name: admin-dashboard;\n color: var(--text-primary, #111827);\n}\n\n/* Responsive stat grid driven entirely by container queries. */\n.statGrid {\n display: grid;\n gap: var(--spacing-4, 1rem);\n grid-template-columns: 1fr;\n}\n\n@container admin-dashboard (min-width: 36rem) {\n .statGrid {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n}\n\n@container admin-dashboard (min-width: 56rem) {\n .statGrid {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: var(--spacing-5, 1.25rem);\n }\n}\n\n@container admin-dashboard (min-width: 72rem) {\n .statGrid {\n grid-template-columns: repeat(4, minmax(0, 1fr));\n gap: var(--spacing-6, 1.5rem);\n }\n}\n\n.secondaryRegion {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n min-width: 0;\n}\n\n/* ─── Split body layout ─────────────────────────────────────────────────────\n When layout=\"split\", the activity / secondaryRegion flow is replaced by a\n 2-column grid below the KPI strip. Defaults below mirror the r3 dashboard\n reference (1fr | 320px, 20px gutter). Sub-960px stacks side-col below. */\n\n.body {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-5, 1.25rem);\n min-width: 0;\n}\n\n.body[data-layout=\"split\"] {\n display: grid;\n grid-template-columns: minmax(0, 1fr) var(--admin-dashboard-side-col-width);\n gap: var(--spacing-5, 1.25rem);\n}\n\n.mainCol,\n.sideCol {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-5, 1.25rem);\n min-width: 0;\n}\n\n@container admin-dashboard (max-width: 60rem) {\n /* Default stack breakpoint = 960px (60rem). Override the rule in a theme\n layer if --admin-dashboard-stack-bp is retuned. */\n .body[data-layout=\"split\"] {\n grid-template-columns: minmax(0, 1fr);\n }\n}\n\n.activitySection {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n padding: var(--spacing-6, 1.5rem);\n background: var(--surface-card, #ffffff);\n border: 1px solid var(--border-default, #e5e7eb);\n border-radius: var(--radius-lg, 0.75rem);\n box-shadow: var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05));\n min-width: 0;\n}\n\n.activityHeader {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--spacing-3, 0.75rem);\n min-width: 0;\n}\n\n.activityTitle {\n margin: 0;\n font-family: var(--font-family-heading, inherit);\n font-size: var(--font-size-lg, 1.125rem);\n font-weight: var(--font-weight-semibold, 600);\n line-height: var(--line-height-tight, 1.25);\n letter-spacing: var(--letter-spacing-tight, -0.01em);\n color: var(--text-primary, #111827);\n min-width: 0;\n}\n\n.activityViewAll {\n flex-shrink: 0;\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n color: var(--text-link, var(--accent-primary, #3b82f6));\n text-decoration: none;\n border-radius: var(--radius-sm, 0.25rem);\n transition:\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.activityViewAll:hover {\n text-decoration: underline;\n}\n\n.activityViewAll:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, var(--accent-primary, #3b82f6));\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n@container admin-dashboard (max-width: 36rem) {\n .activitySection {\n padding: var(--spacing-4, 1rem);\n }\n}\n"
3027
3326
  }
3028
3327
  ]
3029
3328
  },
@@ -3048,12 +3347,12 @@
3048
3347
  {
3049
3348
  "path": "blocks/admin-list-page/admin-list-page.tsx",
3050
3349
  "type": "registry:block",
3051
- "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n type ColumnDef,\n type OnChangeFn,\n type PaginationState,\n type RowSelectionState,\n type SortingState,\n} from \"@tanstack/react-table\"\n\nimport { cn } from \"../../lib/utils\"\nimport {\n PageHeader,\n type PageHeaderProps,\n} from \"../../components/ui/page-header/page-header\"\nimport { FilterBar } from \"../../components/ui/filter-bar/filter-bar\"\nimport { DataTable } from \"../../components/ui/data-table/data-table\"\nimport { BulkActionBar } from \"../../components/ui/bulk-action-bar/bulk-action-bar\"\nimport styles from \"./admin-list-page.module.css\"\n\nexport interface AdminListPageProps<TData>\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n // ── Header ───────────────────────────────────────────────────────────────\n /** Page title rendered inside the PageHeader. */\n title: React.ReactNode\n /** Optional small uppercase label rendered above the title. */\n eyebrow?: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /** Optional actions slot rendered on the right side of the header. */\n actions?: React.ReactNode\n /** Optional breadcrumb rendered above the title row. */\n breadcrumb?: React.ReactNode\n /** Heading level used for the PageHeader title. Defaults to `h1`. */\n titleAs?: PageHeaderProps[\"titleAs\"]\n\n // ── Filter bar ───────────────────────────────────────────────────────────\n /**\n * Controlled search value. Pass with `onSearchChange` to enable the search\n * input. You own the state and filtering logic:\n * @example\n * const [search, setSearch] = React.useState('');\n * const filtered = data.filter(row => row.name.includes(search));\n * <AdminListPage searchValue={search} onSearchChange={setSearch} data={filtered} />\n */\n searchValue?: string\n /** Handler invoked when the search input changes. Pair with `searchValue`. */\n onSearchChange?: (value: string) => void\n /** Placeholder for the search input. Defaults to \"Search...\". */\n searchPlaceholder?: string\n /** Filter controls rendered inside the FilterBar. */\n filters?: React.ReactNode\n /** Active filter chips rendered below the FilterBar. */\n activeFilters?: Array<{\n id: string\n label: React.ReactNode\n onRemove: () => void\n }>\n /** Handler invoked when the clear-all filters button is clicked. */\n onClearFilters?: () => void\n /** Meta text rendered on the far right of the FilterBar, e.g. \"42 results\". */\n resultsCount?: React.ReactNode\n /** Hide the FilterBar entirely. Defaults to `false`. */\n hideFilterBar?: boolean\n\n // ── Data table ───────────────────────────────────────────────────────────\n columns: ColumnDef<TData, unknown>[]\n data: TData[]\n getRowId?: (row: TData, index: number) => string\n\n sorting?: SortingState\n onSortingChange?: OnChangeFn<SortingState>\n\n pagination?: PaginationState\n onPaginationChange?: OnChangeFn<PaginationState>\n pageSize?: number\n pageSizeOptions?: number[]\n\n rowSelection?: RowSelectionState\n onRowSelectionChange?: OnChangeFn<RowSelectionState>\n /** Enable row selection checkboxes. Defaults to `false`. */\n enableRowSelection?: boolean\n\n loading?: boolean\n /** Custom empty state forwarded to the DataTable. */\n emptyState?: React.ReactNode\n\n // ── Bulk actions ─────────────────────────────────────────────────────────\n /** Action buttons cluster — rendered only when selection count > 0. */\n bulkActions?: React.ReactNode\n /** Render the BulkActionBar inline (non-sticky). Defaults to `false`. */\n bulkActionBarInline?: boolean\n /** Selection label renderer. Defaults to `(n) => `${n} selected``. */\n bulkActionLabel?: (count: number) => React.ReactNode\n}\n\nfunction countSelectedRows(state: RowSelectionState): number {\n let count = 0\n for (const key in state) {\n if (state[key]) count += 1\n }\n return count\n}\n\nfunction AdminListPageInner<TData>(\n props: AdminListPageProps<TData>,\n ref: React.Ref<HTMLDivElement>\n) {\n const {\n // Header\n title,\n eyebrow,\n description,\n actions,\n breadcrumb,\n titleAs = \"h1\",\n // Filter bar\n searchValue,\n onSearchChange,\n searchPlaceholder,\n filters,\n activeFilters,\n onClearFilters,\n resultsCount,\n hideFilterBar = false,\n // Data table\n columns,\n data,\n getRowId,\n sorting,\n onSortingChange,\n pagination,\n onPaginationChange,\n pageSize,\n pageSizeOptions,\n rowSelection: controlledRowSelection,\n onRowSelectionChange,\n enableRowSelection = false,\n loading,\n emptyState,\n // Bulk actions\n bulkActions,\n bulkActionBarInline = false,\n bulkActionLabel,\n // Root\n className,\n ...rest\n } = props\n\n // Uncontrolled row selection state, mirroring DataTable's pattern so the\n // block can drive the BulkActionBar without requiring consumers to lift state.\n const [internalRowSelection, setInternalRowSelection] =\n React.useState<RowSelectionState>({})\n const selectionIsControlled = controlledRowSelection !== undefined\n const rowSelection = selectionIsControlled\n ? controlledRowSelection\n : internalRowSelection\n\n const handleRowSelectionChange: OnChangeFn<RowSelectionState> = (updater) => {\n if (!selectionIsControlled) {\n setInternalRowSelection((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onRowSelectionChange?.(updater)\n }\n\n const handleClearSelection = React.useCallback(() => {\n if (!selectionIsControlled) {\n setInternalRowSelection({})\n }\n onRowSelectionChange?.({})\n }, [selectionIsControlled, onRowSelectionChange])\n\n const selectedCount = countSelectedRows(rowSelection)\n const showBulkActionBar = Boolean(bulkActions) && selectedCount > 0\n\n return (\n <div\n ref={ref}\n data-slot=\"admin-list-page\"\n className={cn(styles.root, className)}\n {...rest}\n >\n <header className={styles.header} data-slot=\"admin-list-page-header\">\n <PageHeader\n eyebrow={eyebrow}\n title={title}\n description={description}\n actions={actions}\n breadcrumb={breadcrumb}\n titleAs={titleAs}\n />\n\n {hideFilterBar ? null : (\n <FilterBar\n searchValue={searchValue}\n onSearchChange={onSearchChange}\n searchPlaceholder={searchPlaceholder}\n activeFilters={activeFilters}\n onClearAll={onClearFilters}\n resultsCount={resultsCount}\n >\n {filters}\n </FilterBar>\n )}\n </header>\n\n <section\n className={styles.tableSection}\n data-slot=\"admin-list-page-table\"\n aria-label={typeof title === \"string\" ? title : undefined}\n >\n <DataTable<TData>\n columns={columns}\n data={data}\n getRowId={getRowId}\n sorting={sorting}\n onSortingChange={onSortingChange}\n pagination={pagination}\n onPaginationChange={onPaginationChange}\n pageSize={pageSize}\n pageSizeOptions={pageSizeOptions}\n enableRowSelection={enableRowSelection}\n rowSelection={rowSelection}\n onRowSelectionChange={handleRowSelectionChange}\n loading={loading}\n emptyState={emptyState}\n />\n\n {showBulkActionBar ? (\n <BulkActionBar\n count={selectedCount}\n inline={bulkActionBarInline}\n label={bulkActionLabel}\n onClear={handleClearSelection}\n >\n {bulkActions}\n </BulkActionBar>\n ) : null}\n </section>\n </div>\n )\n}\n\n// Generic forwardRef — cast preserves TData through the forwardRef boundary.\nconst AdminListPage = React.forwardRef(AdminListPageInner) as <TData>(\n props: AdminListPageProps<TData> & { ref?: React.Ref<HTMLDivElement> }\n) => React.ReactElement\n\n;(AdminListPage as unknown as { displayName: string }).displayName =\n \"AdminListPage\"\n\nexport { AdminListPage }\n"
3350
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n type ColumnDef,\n type OnChangeFn,\n type PaginationState,\n type RowSelectionState,\n type SortingState,\n} from \"@tanstack/react-table\"\n\nimport { cn } from \"../../lib/utils\"\nimport {\n PageHeader,\n type PageHeaderProps,\n} from \"../../components/ui/page-header/page-header\"\nimport { FilterBar } from \"../../components/ui/filter-bar/filter-bar\"\nimport {\n DataTable,\n type DataTableGroupRow,\n type DataTableRow,\n type DataTableRowTone,\n} from \"../../components/ui/data-table/data-table\"\nimport { BulkActionBar } from \"../../components/ui/bulk-action-bar/bulk-action-bar\"\nimport styles from \"./admin-list-page.module.css\"\n\nexport interface AdminListPageProps<TData>\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n // ── Header ───────────────────────────────────────────────────────────────\n /** Page title rendered inside the PageHeader. */\n title: React.ReactNode\n /** Optional small uppercase label rendered above the title. */\n eyebrow?: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /** Optional actions slot rendered on the right side of the header. */\n actions?: React.ReactNode\n /** Optional breadcrumb rendered above the title row. */\n breadcrumb?: React.ReactNode\n /** Heading level used for the PageHeader title. Defaults to `h1`. */\n titleAs?: PageHeaderProps[\"titleAs\"]\n\n // ── Filter bar ───────────────────────────────────────────────────────────\n /**\n * Controlled search value. Pass with `onSearchChange` to enable the search\n * input. You own the state and filtering logic:\n * @example\n * const [search, setSearch] = React.useState('');\n * const filtered = data.filter(row => row.name.includes(search));\n * <AdminListPage searchValue={search} onSearchChange={setSearch} data={filtered} />\n */\n searchValue?: string\n /** Handler invoked when the search input changes. Pair with `searchValue`. */\n onSearchChange?: (value: string) => void\n /** Placeholder for the search input. Defaults to \"Search...\". */\n searchPlaceholder?: string\n /** Filter controls rendered inside the FilterBar. */\n filters?: React.ReactNode\n /** Active filter chips rendered below the FilterBar. */\n activeFilters?: Array<{\n id: string\n label: React.ReactNode\n onRemove: () => void\n }>\n /** Handler invoked when the clear-all filters button is clicked. */\n onClearFilters?: () => void\n /** Meta text rendered on the far right of the FilterBar, e.g. \"42 results\". */\n resultsCount?: React.ReactNode\n /** Hide the FilterBar entirely. Defaults to `false`. */\n hideFilterBar?: boolean\n /**\n * Replaces the default `<FilterBar>` entirely. When provided, all FilterBar-\n * specific props (`searchValue`, `onSearchChange`, `searchPlaceholder`,\n * `filters`, `activeFilters`, `onClearFilters`, `resultsCount`) are ignored.\n * Renders inside the header region with a\n * `data-slot=\"admin-list-page-custom-filter-bar\"` wrapper. `hideFilterBar`\n * still wins over both default and custom bars.\n *\n * Dev-mode `console.warn` fires if `customFilterBar` is supplied alongside\n * any FilterBar-specific prop.\n */\n customFilterBar?: React.ReactNode\n\n // ── Data table ───────────────────────────────────────────────────────────\n columns: ColumnDef<TData, unknown>[]\n /**\n * Flat row data forwarded to DataTable. Optional — omit when supplying\n * `rows` (the discriminated-union grouped API) or when rendering a fully\n * custom table via `customFilterBar` + a non-table body. Defaults to `[]`\n * when omitted, which yields DataTable's empty state.\n */\n data?: TData[]\n /**\n * Mixed-order discriminated-union row list forwarded to DataTable. Use\n * this to interleave group headers and data rows (e.g., \"Tonight\",\n * \"This week\" sections). When `rows` is supplied, `data` is ignored —\n * dev-mode `console.warn` fires if both are passed.\n *\n * @example\n * <AdminListPage\n * rows={[\n * { kind: \"group\", id: \"tonight\", label: \"Tonight\" },\n * { kind: \"data\", id: \"evt-1\", row: event1 },\n * { kind: \"group\", id: \"week\", label: \"This week\", count: 3 },\n * { kind: \"data\", id: \"evt-2\", row: event2 },\n * ]}\n * />\n */\n rows?: DataTableRow<TData>[]\n /**\n * Optional renderer for group rows. Receives the group descriptor and\n * returns the cell contents. Forwarded as-is to DataTable.\n */\n groupRowRenderer?: (group: DataTableGroupRow) => React.ReactNode\n /**\n * Map each data row to a semantic tone for a subtle background tint.\n * Forwarded as-is to DataTable — tones resolve to Visor surface tokens at\n * the CSS layer (see `data-table.module.css`).\n */\n rowTone?: (row: TData) => DataTableRowTone | undefined\n /**\n * When supplied, every data row becomes a keyboard-activatable target\n * (click + Enter/Space dispatch the handler). Forwarded as-is to\n * DataTable. Typical use: open a detail drawer for the clicked row.\n */\n onRowClick?: (row: TData) => void\n getRowId?: (row: TData, index: number) => string\n\n sorting?: SortingState\n onSortingChange?: OnChangeFn<SortingState>\n\n pagination?: PaginationState\n onPaginationChange?: OnChangeFn<PaginationState>\n pageSize?: number\n pageSizeOptions?: number[]\n\n rowSelection?: RowSelectionState\n onRowSelectionChange?: OnChangeFn<RowSelectionState>\n /** Enable row selection checkboxes. Defaults to `false`. */\n enableRowSelection?: boolean\n\n loading?: boolean\n /** Custom empty state forwarded to the DataTable. */\n emptyState?: React.ReactNode\n\n // ── Bulk actions ─────────────────────────────────────────────────────────\n /** Action buttons cluster — rendered only when selection count > 0. */\n bulkActions?: React.ReactNode\n /** Render the BulkActionBar inline (non-sticky). Defaults to `false`. */\n bulkActionBarInline?: boolean\n /** Selection label renderer. Defaults to `(n) => `${n} selected``. */\n bulkActionLabel?: (count: number) => React.ReactNode\n\n // ── Footer status ────────────────────────────────────────────────────────\n /**\n * Always-on info row rendered below the table, as a sibling of the table\n * section (direct child of the block root). Independent of `BulkActionBar`\n * — the two can coexist. Typical content is a selection count, total, and\n * Kbd hint cluster.\n *\n * Wrapped in a `data-slot=\"admin-list-page-footer-status\"` element for CSS\n * hooks. Consumer composes the row layout (flat slot, not structured).\n *\n * BREAKING (VI-404): previously rendered inside the table section; now\n * rendered as a sibling. Consumers using descendant selectors of the form\n * `[data-slot=\"admin-list-page-table\"] [data-slot=\"admin-list-page-footer-status\"]`\n * will silently stop matching — drop the `admin-list-page-table` ancestor.\n */\n footerStatus?: React.ReactNode\n}\n\nfunction countSelectedRows(state: RowSelectionState): number {\n let count = 0\n for (const key in state) {\n if (state[key]) count += 1\n }\n return count\n}\n\nfunction AdminListPageInner<TData>(\n props: AdminListPageProps<TData>,\n ref: React.Ref<HTMLDivElement>\n) {\n const {\n // Header\n title,\n eyebrow,\n description,\n actions,\n breadcrumb,\n titleAs = \"h1\",\n // Filter bar\n searchValue,\n onSearchChange,\n searchPlaceholder,\n filters,\n activeFilters,\n onClearFilters,\n resultsCount,\n hideFilterBar = false,\n customFilterBar,\n // Data table\n columns,\n data,\n rows,\n groupRowRenderer,\n rowTone,\n onRowClick,\n getRowId,\n sorting,\n onSortingChange,\n pagination,\n onPaginationChange,\n pageSize,\n pageSizeOptions,\n rowSelection: controlledRowSelection,\n onRowSelectionChange,\n enableRowSelection = false,\n loading,\n emptyState,\n // Bulk actions\n bulkActions,\n bulkActionBarInline = false,\n bulkActionLabel,\n // Footer status\n footerStatus,\n // Root\n className,\n ...rest\n } = props\n\n // Dev-mode warning when both `rows` and `data` are supplied. `rows` wins\n // silently inside DataTable; the warning surfaces the mistake without\n // breaking render output.\n if (\n process.env.NODE_ENV !== \"production\" &&\n rows !== undefined &&\n data !== undefined\n ) {\n // eslint-disable-next-line no-console\n console.warn(\n \"AdminListPage: both `rows` and `data` were supplied. The `rows` prop replaces `data` and wins; the `data` prop is ignored. Pass only one.\"\n )\n }\n\n // Dev-mode warning when customFilterBar is mixed with FilterBar-specific props.\n // The custom bar wins silently; the warning surfaces the mistake without\n // breaking render output.\n if (process.env.NODE_ENV !== \"production\" && customFilterBar !== undefined) {\n const conflicting: string[] = []\n if (searchValue !== undefined) conflicting.push(\"searchValue\")\n if (onSearchChange !== undefined) conflicting.push(\"onSearchChange\")\n if (searchPlaceholder !== undefined) conflicting.push(\"searchPlaceholder\")\n if (filters !== undefined) conflicting.push(\"filters\")\n if (activeFilters !== undefined) conflicting.push(\"activeFilters\")\n if (onClearFilters !== undefined) conflicting.push(\"onClearFilters\")\n if (resultsCount !== undefined) conflicting.push(\"resultsCount\")\n if (conflicting.length > 0) {\n // eslint-disable-next-line no-console\n console.warn(\n `AdminListPage: \\`customFilterBar\\` was supplied alongside FilterBar-specific prop(s): ${conflicting.join(\", \")}. The custom filter bar replaces the default FilterBar entirely; the FilterBar-specific props are ignored.`\n )\n }\n }\n\n // Uncontrolled row selection state, mirroring DataTable's pattern so the\n // block can drive the BulkActionBar without requiring consumers to lift state.\n const [internalRowSelection, setInternalRowSelection] =\n React.useState<RowSelectionState>({})\n const selectionIsControlled = controlledRowSelection !== undefined\n const rowSelection = selectionIsControlled\n ? controlledRowSelection\n : internalRowSelection\n\n const handleRowSelectionChange: OnChangeFn<RowSelectionState> = (updater) => {\n if (!selectionIsControlled) {\n setInternalRowSelection((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onRowSelectionChange?.(updater)\n }\n\n const handleClearSelection = React.useCallback(() => {\n if (!selectionIsControlled) {\n setInternalRowSelection({})\n }\n onRowSelectionChange?.({})\n }, [selectionIsControlled, onRowSelectionChange])\n\n const selectedCount = countSelectedRows(rowSelection)\n const showBulkActionBar = Boolean(bulkActions) && selectedCount > 0\n\n return (\n <div\n ref={ref}\n data-slot=\"admin-list-page\"\n className={cn(styles.root, className)}\n {...rest}\n >\n <header className={styles.header} data-slot=\"admin-list-page-header\">\n <PageHeader\n eyebrow={eyebrow}\n title={title}\n description={description}\n actions={actions}\n breadcrumb={breadcrumb}\n titleAs={titleAs}\n />\n\n {hideFilterBar ? null : customFilterBar !== undefined ? (\n <div data-slot=\"admin-list-page-custom-filter-bar\">\n {customFilterBar}\n </div>\n ) : (\n <FilterBar\n searchValue={searchValue}\n onSearchChange={onSearchChange}\n searchPlaceholder={searchPlaceholder}\n activeFilters={activeFilters}\n onClearAll={onClearFilters}\n resultsCount={resultsCount}\n >\n {filters}\n </FilterBar>\n )}\n </header>\n\n <section\n className={styles.tableSection}\n data-slot=\"admin-list-page-table\"\n aria-label={typeof title === \"string\" ? title : undefined}\n >\n <DataTable<TData>\n columns={columns}\n data={data ?? []}\n rows={rows}\n groupRowRenderer={groupRowRenderer}\n rowTone={rowTone}\n onRowClick={onRowClick}\n getRowId={getRowId}\n sorting={sorting}\n onSortingChange={onSortingChange}\n pagination={pagination}\n onPaginationChange={onPaginationChange}\n pageSize={pageSize}\n pageSizeOptions={pageSizeOptions}\n enableRowSelection={enableRowSelection}\n rowSelection={rowSelection}\n onRowSelectionChange={handleRowSelectionChange}\n loading={loading}\n emptyState={emptyState}\n />\n\n {showBulkActionBar ? (\n <BulkActionBar\n count={selectedCount}\n inline={bulkActionBarInline}\n label={bulkActionLabel}\n onClear={handleClearSelection}\n >\n {bulkActions}\n </BulkActionBar>\n ) : null}\n </section>\n\n {footerStatus !== undefined ? (\n <div\n data-slot=\"admin-list-page-footer-status\"\n className={styles.footerStatus}\n >\n {footerStatus}\n </div>\n ) : null}\n </div>\n )\n}\n\n// Generic forwardRef — cast preserves TData through the forwardRef boundary.\nconst AdminListPage = React.forwardRef(AdminListPageInner) as <TData>(\n props: AdminListPageProps<TData> & { ref?: React.Ref<HTMLDivElement> }\n) => React.ReactElement\n\n;(AdminListPage as unknown as { displayName: string }).displayName =\n \"AdminListPage\"\n\nexport { AdminListPage }\n"
3052
3351
  },
3053
3352
  {
3054
3353
  "path": "blocks/admin-list-page/admin-list-page.module.css",
3055
3354
  "type": "registry:block",
3056
- "content": ".root {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-6, 1.5rem);\n width: 100%;\n container-type: inline-size;\n container-name: admin-list-page;\n color: var(--text-primary, #111827);\n}\n\n.header {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-5, 1.25rem);\n min-width: 0;\n}\n\n.tableSection {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n min-width: 0;\n}\n\n@container admin-list-page (min-width: 56rem) {\n .root {\n gap: var(--spacing-8, 2rem);\n }\n\n .header {\n gap: var(--spacing-6, 1.5rem);\n }\n}\n"
3355
+ "content": ".root {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-6, 1.5rem);\n width: 100%;\n container-type: inline-size;\n container-name: admin-list-page;\n color: var(--text-primary, #111827);\n}\n\n.header {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-5, 1.25rem);\n min-width: 0;\n}\n\n.tableSection {\n background-color: var(--admin-list-page-table-bg, transparent);\n border-radius: var(--admin-list-page-table-radius, 0);\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n min-width: 0;\n}\n\n.footerStatus {\n background-color: var(--admin-list-page-footer-bg, transparent);\n border-radius: var(--admin-list-page-footer-radius, 0);\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--spacing-3, 0.75rem);\n padding: var(--admin-list-page-footer-padding, var(--spacing-3, 0.75rem) 0);\n border-top: var(\n --admin-list-page-footer-border-top,\n var(--stroke-width-thin, 1px) solid\n var(--border-subtle, var(--border-default, #e5e7eb))\n );\n color: var(--text-tertiary, var(--text-muted, #6b7280));\n font-size: var(--text-xs, 0.6875rem);\n line-height: var(--leading-snug, 1.4);\n min-width: 0;\n}\n\n@container admin-list-page (min-width: 56rem) {\n .root {\n gap: var(--spacing-8, 2rem);\n }\n\n .header {\n gap: var(--spacing-6, 1.5rem);\n }\n}\n"
3057
3356
  }
3058
3357
  ]
3059
3358
  },
@@ -3085,6 +3384,36 @@
3085
3384
  }
3086
3385
  ]
3087
3386
  },
3387
+ {
3388
+ "name": "command-dialog",
3389
+ "type": "registry:block",
3390
+ "description": "⌘K command palette composing Command + Dialog primitives. Exposes named slots for scope chip, grouped results with hit-highlighting, per-item meta + Kbd shortcut, and a footer hint row with derived result count. Binds ⌘K / Ctrl+K to toggle open by default.",
3391
+ "category": "navigation",
3392
+ "dependencies": [
3393
+ "cmdk",
3394
+ "@radix-ui/react-dialog",
3395
+ "@phosphor-icons/react",
3396
+ "@loworbitstudio/visor-core"
3397
+ ],
3398
+ "registryDependencies": [
3399
+ "utils",
3400
+ "command",
3401
+ "dialog",
3402
+ "kbd"
3403
+ ],
3404
+ "files": [
3405
+ {
3406
+ "path": "blocks/command-dialog/command-dialog.tsx",
3407
+ "type": "registry:block",
3408
+ "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"../../lib/utils\"\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"../../components/ui/command/command\"\nimport {\n Dialog,\n DialogContent,\n DialogTitle,\n} from \"../../components/ui/dialog/dialog\"\nimport { Kbd } from \"../../components/ui/kbd/kbd\"\nimport styles from \"./command-dialog.module.css\"\n\n// ── Types ─────────────────────────────────────────────────────────────────\n\nexport interface CommandDialogItem {\n /** Stable identifier — used as React key. */\n id: string\n /** Plain-text value that cmdk uses for its built-in filter. */\n value: string\n /**\n * Label content. Pass a ReactNode with `<span data-hit>` spans inside to\n * render hit-highlighting. The block does not auto-highlight — that's the\n * consumer's job (server-side, local fuzzy match, etc.).\n */\n label: React.ReactNode\n /** Optional leading icon node. */\n icon?: React.ReactNode\n /** Optional meta line shown after the label (e.g. \"Tonight · 22:00 · House of Yes\"). */\n meta?: React.ReactNode\n /** Optional trailing keyboard shortcut. Renders as `<Kbd size=\"sm\">`. */\n shortcut?: string\n /** Optional select handler — fired when the item is activated (Enter or click). */\n onSelect?: (value: string) => void\n}\n\nexport interface CommandDialogGroup {\n /** Stable identifier — used as React key. */\n id: string\n /** Group heading content. */\n heading: React.ReactNode\n /** Optional count rendered next to the heading. */\n count?: number\n items: CommandDialogItem[]\n}\n\nexport interface CommandDialogFooterHint {\n /** Single key string or array of keys (multi-key chords). */\n keys: string | string[]\n /** Hint label, e.g. \"navigate\". */\n label: React.ReactNode\n}\n\nexport interface CommandDialogProps {\n /** Controlled open state. */\n open: boolean\n /** Open-state change handler. */\n onOpenChange: (open: boolean) => void\n\n /** Input placeholder. Defaults to \"Type a command, search…\". */\n placeholder?: string\n\n /**\n * Optional scope chip rendered top-right of the input. Pass a string (\"Events\")\n * for a default \"in Events\" rendering, or a ReactNode for full control.\n */\n scope?: React.ReactNode\n\n /** Grouped results. Each group renders a heading and a list of items. */\n groups: CommandDialogGroup[]\n\n /**\n * Empty-state content rendered when no items match the active filter.\n * Defaults to \"No results.\"\n */\n emptyMessage?: React.ReactNode\n\n /** Footer hint rows (e.g. ↑↓ navigate, ↵ open). When omitted, no hints render. */\n footerHints?: CommandDialogFooterHint[]\n\n /**\n * Total result count rendered in the bottom-right of the footer. When omitted,\n * the count is derived from `groups`. Pass an explicit number to override\n * (e.g. server-paginated results where the visible groups are a window).\n */\n resultCount?: number\n\n /** Hide the derived/explicit result count in the footer. Defaults to `false`. */\n hideResultCount?: boolean\n\n /** Bind ⌘K / Ctrl+K to toggle `open`. Defaults to `true`. */\n enableShortcut?: boolean\n\n /** Additional className merged onto DialogContent. */\n className?: string\n}\n\n// ── Helpers ──────────────────────────────────────────────────────────────\n\nfunction normalizeKeys(keys: string | string[]): string[] {\n return Array.isArray(keys) ? keys : [keys]\n}\n\nfunction deriveResultCount(groups: CommandDialogGroup[]): number {\n return groups.reduce((total, group) => total + group.items.length, 0)\n}\n\nfunction renderScope(scope: React.ReactNode): React.ReactNode {\n if (typeof scope === \"string\") {\n return (\n <>\n <span\n data-slot=\"command-dialog-scope-label\"\n className={styles.scopeLabel}\n >\n in\n </span>{\" \"}\n {scope}\n </>\n )\n }\n return scope\n}\n\n// ── Component ────────────────────────────────────────────────────────────\n\nconst CommandDialog = React.forwardRef<HTMLDivElement, CommandDialogProps>(\n function CommandDialog(\n {\n open,\n onOpenChange,\n placeholder = \"Type a command, search…\",\n scope,\n groups,\n emptyMessage = \"No results.\",\n footerHints,\n resultCount,\n hideResultCount = false,\n enableShortcut = true,\n className,\n },\n ref\n ) {\n // ⌘K / Ctrl+K — toggle open. Cleans up on unmount.\n React.useEffect(() => {\n if (!enableShortcut) return\n const handler = (event: KeyboardEvent) => {\n if (event.key === \"k\" && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n onOpenChange(!open)\n }\n }\n document.addEventListener(\"keydown\", handler)\n return () => document.removeEventListener(\"keydown\", handler)\n }, [enableShortcut, onOpenChange, open])\n\n const totalResults = resultCount ?? deriveResultCount(groups)\n const showResultCount = !hideResultCount\n const hasFooterHints = Boolean(footerHints && footerHints.length > 0)\n const showFooter = hasFooterHints || showResultCount\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent\n ref={ref}\n className={cn(styles.dialog, className)}\n data-slot=\"command-dialog\"\n aria-describedby={undefined}\n >\n <DialogTitle className={styles.srOnly}>Command Palette</DialogTitle>\n\n <Command className={styles.command}>\n <div\n className={styles.inputRow}\n data-slot=\"command-dialog-input-row\"\n >\n <CommandInput\n placeholder={placeholder}\n className={styles.input}\n />\n {scope ? (\n <span\n data-slot=\"command-dialog-scope-chip\"\n className={styles.scope}\n >\n {renderScope(scope)}\n </span>\n ) : null}\n <Kbd size=\"sm\" className={styles.escKbd}>\n Esc\n </Kbd>\n </div>\n\n <CommandList className={styles.list}>\n <CommandEmpty className={styles.empty}>\n {emptyMessage}\n </CommandEmpty>\n\n {groups.map((group) => (\n <CommandGroup\n key={group.id}\n className={styles.group}\n heading={\n <span\n data-slot=\"command-dialog-group-heading\"\n className={styles.groupHead}\n >\n {group.heading}\n {typeof group.count === \"number\" ? (\n <span className={styles.groupCount}>{group.count}</span>\n ) : null}\n </span>\n }\n >\n {group.items.map((item) => (\n <CommandItem\n key={item.id}\n value={item.value}\n onSelect={item.onSelect}\n className={styles.item}\n >\n {item.icon ? (\n <span\n className={styles.itemIcon}\n data-slot=\"command-dialog-item-icon\"\n aria-hidden=\"true\"\n >\n {item.icon}\n </span>\n ) : null}\n <span\n className={styles.itemLabel}\n data-slot=\"command-dialog-item-label\"\n >\n {item.label}\n </span>\n {item.meta ? (\n <span\n className={styles.itemMeta}\n data-slot=\"command-dialog-item-meta\"\n >\n {item.meta}\n </span>\n ) : null}\n {item.shortcut ? (\n <Kbd\n size=\"sm\"\n data-slot=\"command-dialog-item-kbd\"\n >\n {item.shortcut}\n </Kbd>\n ) : null}\n </CommandItem>\n ))}\n </CommandGroup>\n ))}\n </CommandList>\n\n {showFooter ? (\n <footer\n className={styles.footer}\n data-slot=\"command-dialog-footer\"\n >\n {hasFooterHints ? (\n <span\n className={styles.footerHints}\n data-slot=\"command-dialog-footer-hints\"\n >\n {footerHints!.map((hint, idx) => {\n const keys = normalizeKeys(hint.keys)\n return (\n <span\n key={idx}\n className={styles.hint}\n data-slot=\"command-dialog-footer-hint\"\n >\n {keys.length > 1 ? (\n <Kbd size=\"sm\" keys={keys} />\n ) : (\n <Kbd size=\"sm\">{keys[0]}</Kbd>\n )}{\" \"}\n {hint.label}\n </span>\n )\n })}\n </span>\n ) : null}\n <span className={styles.spacer} />\n {showResultCount ? (\n <span\n className={styles.resultCount}\n data-slot=\"command-dialog-result-count\"\n >\n {totalResults} {totalResults === 1 ? \"result\" : \"results\"}\n </span>\n ) : null}\n </footer>\n ) : null}\n </Command>\n </DialogContent>\n </Dialog>\n )\n }\n)\n\nCommandDialog.displayName = \"CommandDialog\"\n\nexport { CommandDialog }\n"
3409
+ },
3410
+ {
3411
+ "path": "blocks/command-dialog/command-dialog.module.css",
3412
+ "type": "registry:block",
3413
+ "content": "/* CommandDialog block — ⌘K palette composing Command + Dialog primitives.\n * Ports the r3 admin-ui palette visual contract onto Visor tokens; no new\n * tokens are introduced (everything resolves through the active theme).\n */\n\n/* ── Dialog content override ─────────────────────────────────────────── */\n\n/* DialogContent reset — transparent, no padding, no border. The Command\n * primitive paints its own surface. Tight max width matches r3 (640px). */\n.dialog {\n background: transparent;\n padding: 0;\n border: 0;\n width: 40rem;\n max-width: 90vw;\n box-shadow: none;\n}\n\n/* Hide DialogContent's built-in close X — palette closes via Esc only. */\n.dialog > button:last-child {\n display: none;\n}\n\n/* Visually-hidden DialogTitle for a11y (Radix requires one). */\n.srOnly {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border-width: 0;\n}\n\n/* ── Command root ────────────────────────────────────────────────────── */\n\n.command {\n background: var(--surface-card, #ffffff);\n box-shadow: var(--shadow-xl, 0 30px 80px rgba(0, 0, 0, 0.6));\n border-radius: var(--radius-lg, 0.5rem);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n}\n\n/* ── Input row ──────────────────────────────────────────────────────── */\n\n.inputRow {\n position: relative;\n display: flex;\n align-items: center;\n background: var(--surface-interactive-default, transparent);\n}\n\n.inputRow :global([data-slot=\"command-input-wrapper\"]) {\n flex: 1;\n border-bottom: 0;\n padding: 0.875rem 1.25rem;\n gap: 0.625rem;\n background: transparent;\n}\n\n.input {\n font-size: var(--text-16, 1rem);\n color: var(--text-primary, #111827);\n background: transparent;\n}\n\n.input::placeholder {\n color: var(--text-tertiary, #9ca3af);\n}\n\n.scope {\n display: inline-flex;\n align-items: center;\n gap: 0.25rem;\n height: 1.375rem;\n padding: 0 0.5rem;\n border-radius: var(--radius-sm, 0.25rem);\n background: var(--surface-interactive-active, rgba(0, 0, 0, 0.04));\n font-size: var(--text-11, 0.6875rem);\n color: var(--text-primary, #111827);\n margin-right: 0.625rem;\n}\n\n.scopeLabel {\n color: var(--text-tertiary, #9ca3af);\n}\n\n.escKbd {\n margin-right: 1rem;\n}\n\n/* ── Results list ───────────────────────────────────────────────────── */\n\n.list {\n max-height: 27rem;\n padding: 0.375rem 0;\n background: var(--surface-card, #ffffff);\n}\n\n.empty {\n padding: 1.5rem;\n font-size: var(--text-13, 0.8125rem);\n color: var(--text-tertiary, #9ca3af);\n text-align: center;\n}\n\n/* ── Group ──────────────────────────────────────────────────────────── */\n\n.group {\n padding: 0;\n}\n\n.group :global([cmdk-group-heading]) {\n padding: 0.375rem 1.25rem;\n font-size: var(--text-11, 0.6875rem);\n font-weight: 500;\n text-transform: uppercase;\n letter-spacing: 0.18em;\n color: var(--text-tertiary, #9ca3af);\n}\n\n.groupHead {\n display: inline-flex;\n align-items: baseline;\n gap: 0.375rem;\n}\n\n.groupCount {\n color: var(--text-tertiary, #9ca3af);\n font-size: var(--text-11, 0.6875rem);\n}\n\n/* ── Item ───────────────────────────────────────────────────────────── */\n\n.item {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n height: 2.25rem;\n padding: 0 1.25rem;\n border-radius: 0;\n font-size: var(--text-14, 0.875rem);\n color: var(--text-primary, #111827);\n}\n\n.item[data-selected=\"true\"] {\n background: var(--surface-selected, rgba(0, 0, 0, 0.04));\n color: var(--text-primary, #111827);\n}\n\n.item[data-selected=\"true\"] .itemIcon {\n color: var(--text-success, #10b981);\n}\n\n.itemIcon {\n flex: 0 0 auto;\n width: 1rem;\n height: 1rem;\n color: var(--text-tertiary, #9ca3af);\n display: inline-flex;\n align-items: center;\n justify-content: center;\n}\n\n.itemLabel {\n flex: 0 0 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n min-width: 0;\n}\n\n.itemMeta {\n font-size: var(--text-13, 0.8125rem);\n color: var(--text-tertiary, #9ca3af);\n margin-left: 0.5rem;\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n min-width: 0;\n}\n\n/* Hit-highlight inside labels — callers wrap matched substrings in\n * `<span data-hit>` and the rule paints them with the accent token. */\n.itemLabel :global([data-hit]) {\n color: var(--text-success, #10b981);\n}\n\n/* ── Footer ─────────────────────────────────────────────────────────── */\n\n.footer {\n display: flex;\n align-items: center;\n gap: 1rem;\n min-height: 2.25rem;\n padding: 0 1rem;\n background: var(--surface-interactive-default, transparent);\n font-size: var(--text-13, 0.8125rem);\n color: var(--text-tertiary, #9ca3af);\n}\n\n.footerHints {\n display: inline-flex;\n align-items: center;\n gap: 1rem;\n}\n\n.hint {\n display: inline-flex;\n align-items: center;\n gap: 0.375rem;\n}\n\n.spacer {\n flex: 1;\n}\n\n.resultCount {\n color: var(--text-tertiary, #9ca3af);\n}\n"
3414
+ }
3415
+ ]
3416
+ },
3088
3417
  {
3089
3418
  "name": "admin-tabbed-editor",
3090
3419
  "type": "registry:block",
@@ -3135,12 +3464,12 @@
3135
3464
  {
3136
3465
  "path": "blocks/admin-settings-page/admin-settings-page.tsx",
3137
3466
  "type": "registry:block",
3138
- "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"../../lib/utils\"\nimport { PageHeader } from \"../../components/ui/page-header/page-header\"\nimport { Heading } from \"../../components/ui/heading/heading\"\nimport { Text } from \"../../components/ui/text/text\"\nimport { Separator } from \"../../components/ui/separator/separator\"\nimport { Button } from \"../../components/ui/button/button\"\nimport { ConfirmDialog } from \"../../components/ui/confirm-dialog/confirm-dialog\"\nimport styles from \"./admin-settings-page.module.css\"\n\nexport interface AdminSettingsSection {\n /** Stable identifier — used as DOM id anchor and nav highlight key. */\n id: string\n /** Label rendered inside the left nav / top tab bar. */\n label: React.ReactNode\n /** Section heading rendered above the content. */\n title: React.ReactNode\n /** Optional supporting copy rendered below the heading. */\n description?: React.ReactNode\n /** Optional leading icon rendered before the label in the nav. */\n icon?: React.ReactNode\n /** Section body — form fields or arbitrary content. */\n content: React.ReactNode\n\n // ── Per-section save mode ──────────────────────────────────────────────\n /** Dirty flag — only meaningful when the page runs in `perSectionSave` mode. */\n dirty?: boolean\n /** Busy flag — only meaningful when the page runs in `perSectionSave` mode. */\n busy?: boolean\n /** Save handler — only called in `perSectionSave` mode. Async-aware. */\n onSave?: () => void | Promise<void>\n /** Revert handler — only called in `perSectionSave` mode. */\n onRevert?: () => void\n /** Override the per-section save button label. Defaults to \"Save\". */\n saveLabel?: React.ReactNode\n /** Override the per-section revert button label. Defaults to \"Revert\". */\n revertLabel?: React.ReactNode\n\n // ── Grouped nav extras ─────────────────────────────────────────────────\n /** Trailing badge in the nav item — e.g. member count \"8\" or status \"default\".\n * Rendered as a dim trailing element in the side rail; suppressed in the top chip bar. */\n meta?: React.ReactNode\n /** Mutes the nav item's text color via `--text-tertiary` — used for \"add\" / utility actions. */\n muted?: boolean\n}\n\nexport interface AdminSettingsSectionGroup {\n /** Categorical eyebrow label rendered above this group's nav items.\n * Omit to render an ungrouped cluster (no label). */\n label?: React.ReactNode\n /** Ordered list of settings sections that belong to this group. */\n sections: AdminSettingsSection[]\n}\n\nexport interface AdminSettingsPageProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n // ── Header ──────────────────────────────────────────────────────────────\n /** Page title rendered inside the PageHeader. */\n title: React.ReactNode\n /** Optional eyebrow rendered above the title. */\n eyebrow?: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /** Optional breadcrumb node rendered above the title row. */\n breadcrumb?: React.ReactNode\n /** Optional header actions slot rendered on the right side of the header. */\n headerActions?: React.ReactNode\n\n // ── Sections ────────────────────────────────────────────────────────────\n /** Flat ordered list of settings sections. Use when grouping is not needed. */\n sections?: AdminSettingsSection[]\n /** Grouped sections with categorical eyebrow labels.\n * Mutually exclusive with `sections` — if both are passed, `sectionGroups` wins\n * and a dev-mode console.warn fires. */\n sectionGroups?: AdminSettingsSectionGroup[]\n\n // ── Navigation ──────────────────────────────────────────────────────────\n /** Show the section nav. Defaults to true when there is more than one section. */\n showNav?: boolean\n /** Nav position — \"left\" (sticky side rail) or \"top\" (horizontal chip bar). Defaults to \"left\". */\n navPosition?: \"left\" | \"top\"\n\n // ── Mode ────────────────────────────────────────────────────────────────\n /**\n * If true, each section renders its own save/revert footer row and the global\n * sticky footer is not rendered. Defaults to false (global single-save mode).\n */\n perSectionSave?: boolean\n\n // ── Global save footer (single-save mode only) ──────────────────────────\n /** Save handler. Async-aware — a returned Promise drives the save button's pending state. */\n onSave?: () => void | Promise<void>\n /** Cancel handler. Protected by the unsaved-changes guard when `dirty` is true. */\n onCancel?: () => void\n /** Save button label. Defaults to \"Save changes\". */\n saveLabel?: React.ReactNode\n /** Cancel button label. Defaults to \"Cancel\". */\n cancelLabel?: React.ReactNode\n /** Dirty flag — drives the save button disabled state and the cancel guard. */\n dirty?: boolean\n /** Externally-controlled busy state. Overrides internal async pending detection. */\n busy?: boolean\n /** Middle slot inside the sticky footer — e.g. \"Last saved 2 minutes ago\". */\n footerStatus?: React.ReactNode\n /** Hide the global footer. */\n hideFooter?: boolean\n\n // ── Unsaved guard (global cancel only) ──────────────────────────────────\n /** Title of the unsaved-changes confirm dialog. */\n unsavedGuardTitle?: React.ReactNode\n /** Description of the unsaved-changes confirm dialog. */\n unsavedGuardDescription?: React.ReactNode\n /** Confirm (discard) label. Defaults to \"Discard\". */\n unsavedGuardConfirmLabel?: React.ReactNode\n /** Cancel (keep editing) label. Defaults to \"Keep editing\". */\n unsavedGuardCancelLabel?: React.ReactNode\n}\n\nconst DEFAULT_UNSAVED_DESCRIPTION =\n \"You have unsaved changes that will be lost if you leave this page.\"\n\n/** Internal helper: returns the flat section list used by the observer, refs,\n * and main-column renderer. When `sectionGroups` is provided it wins; `sections`\n * is the flat fallback. */\nfunction flattenSections(\n sections: AdminSettingsSection[] | undefined,\n sectionGroups: AdminSettingsSectionGroup[] | undefined\n): AdminSettingsSection[] {\n if (sectionGroups) return sectionGroups.flatMap((g) => g.sections)\n return sections ?? []\n}\n\nconst AdminSettingsPage = React.forwardRef<\n HTMLDivElement,\n AdminSettingsPageProps\n>(function AdminSettingsPage(\n {\n title,\n eyebrow,\n description,\n breadcrumb,\n headerActions,\n sections,\n sectionGroups,\n showNav,\n navPosition = \"left\",\n perSectionSave = false,\n onSave,\n onCancel,\n saveLabel = \"Save changes\",\n cancelLabel = \"Cancel\",\n dirty = false,\n busy,\n footerStatus,\n hideFooter = false,\n unsavedGuardTitle = \"Discard unsaved changes?\",\n unsavedGuardDescription = DEFAULT_UNSAVED_DESCRIPTION,\n unsavedGuardConfirmLabel = \"Discard\",\n unsavedGuardCancelLabel = \"Keep editing\",\n className,\n ...rest\n },\n ref\n) {\n // Dev-mode warning when both props are provided.\n if (\n process.env.NODE_ENV !== \"production\" &&\n sections !== undefined &&\n sectionGroups !== undefined\n ) {\n console.warn(\n \"[AdminSettingsPage] Both `sections` and `sectionGroups` were provided. \" +\n \"`sectionGroups` takes precedence and `sections` is ignored. \" +\n \"Pass only one of these props.\"\n )\n }\n\n const flatSections = flattenSections(sections, sectionGroups)\n const shouldShowNav = showNav ?? flatSections.length > 1\n const firstSectionId = flatSections[0]?.id\n const [activeId, setActiveId] = React.useState<string | undefined>(\n firstSectionId\n )\n const [isGlobalPending, setIsGlobalPending] = React.useState(false)\n const [pendingCancel, setPendingCancel] = React.useState(false)\n const sectionRefs = React.useRef<Map<string, HTMLElement>>(new Map())\n\n const effectiveBusy = busy ?? isGlobalPending\n\n // Intersection observer to highlight the nav item whose section is in view.\n React.useEffect(() => {\n if (!shouldShowNav) return\n if (typeof window === \"undefined\") return\n if (typeof IntersectionObserver === \"undefined\") return\n\n const elements = Array.from(sectionRefs.current.values())\n if (elements.length === 0) return\n\n const observer = new IntersectionObserver(\n (entries) => {\n // Find the topmost visible entry.\n const visible = entries\n .filter((e) => e.isIntersecting)\n .sort(\n (a, b) =>\n a.boundingClientRect.top - b.boundingClientRect.top\n )\n if (visible[0]) {\n const id = (visible[0].target as HTMLElement).id\n if (id) setActiveId(id)\n }\n },\n {\n // Trigger when a section enters the top portion of the scroll container.\n rootMargin: \"0px 0px -60% 0px\",\n threshold: [0, 0.1, 0.5, 1],\n }\n )\n\n elements.forEach((el) => observer.observe(el))\n return () => observer.disconnect()\n }, [shouldShowNav, flatSections])\n\n const registerSectionRef = React.useCallback(\n (id: string) => (node: HTMLElement | null) => {\n const map = sectionRefs.current\n if (node) {\n map.set(id, node)\n } else {\n map.delete(id)\n }\n },\n []\n )\n\n const handleNavClick = React.useCallback(\n (event: React.MouseEvent<HTMLAnchorElement>, id: string) => {\n event.preventDefault()\n const target = sectionRefs.current.get(id)\n if (target) {\n target.scrollIntoView({ block: \"start\", behavior: \"smooth\" })\n // Update hash without jumping.\n if (typeof window !== \"undefined\") {\n window.history.replaceState(null, \"\", `#${id}`)\n }\n setActiveId(id)\n }\n },\n []\n )\n\n const handleGlobalCancelClick = React.useCallback(() => {\n if (effectiveBusy) return\n if (dirty) {\n setPendingCancel(true)\n return\n }\n onCancel?.()\n }, [dirty, effectiveBusy, onCancel])\n\n const handleGlobalSaveClick = React.useCallback(async () => {\n if (!onSave) return\n const result = onSave()\n if (result && typeof (result as Promise<void>).then === \"function\") {\n setIsGlobalPending(true)\n try {\n await result\n setIsGlobalPending(false)\n } catch (err) {\n setIsGlobalPending(false)\n throw err\n }\n }\n }, [onSave])\n\n const handleGuardConfirm = React.useCallback(() => {\n if (pendingCancel) {\n setPendingCancel(false)\n onCancel?.()\n }\n }, [pendingCancel, onCancel])\n\n const handleGuardCancel = React.useCallback(() => {\n setPendingCancel(false)\n }, [])\n\n const globalSaveDisabled = effectiveBusy || !dirty\n\n return (\n <>\n <div\n ref={ref}\n className={cn(\n styles.root,\n shouldShowNav && navPosition === \"left\"\n ? styles.withLeftNav\n : styles.noLeftNav,\n className\n )}\n data-slot=\"admin-settings-page\"\n data-nav-position={shouldShowNav ? navPosition : undefined}\n {...rest}\n >\n {firstSectionId ? (\n <a className={styles.skipLink} href={`#${firstSectionId}`}>\n Skip to content\n </a>\n ) : null}\n\n <PageHeader\n className={styles.header}\n eyebrow={eyebrow}\n title={title}\n description={description}\n breadcrumb={breadcrumb}\n actions={headerActions}\n />\n\n {shouldShowNav && navPosition === \"top\" ? (\n <nav\n aria-label=\"Settings sections\"\n className={styles.topNav}\n data-slot=\"admin-settings-page-nav\"\n >\n <ul className={styles.topNavList}>\n {(sectionGroups ?? (flatSections.length > 0 ? [{ sections: flatSections }] : [])).map((group, gi) => (\n <React.Fragment key={gi}>\n {gi > 0 ? (\n <li\n role=\"separator\"\n aria-orientation=\"vertical\"\n aria-label={typeof group.label === \"string\" ? group.label : undefined}\n className={styles.topNavSeparator}\n />\n ) : null}\n {group.sections.map((section) => {\n const isActive = section.id === activeId\n return (\n <li key={section.id} className={styles.topNavItem}>\n <a\n href={`#${section.id}`}\n className={cn(\n styles.topNavLink,\n isActive && styles.navLinkActive\n )}\n aria-current={isActive ? \"true\" : undefined}\n onClick={(e) => handleNavClick(e, section.id)}\n >\n {section.icon ? (\n <span\n className={styles.navIcon}\n aria-hidden=\"true\"\n >\n {section.icon}\n </span>\n ) : null}\n <span className={styles.navLabel}>\n {section.label}\n </span>\n </a>\n </li>\n )\n })}\n </React.Fragment>\n ))}\n </ul>\n </nav>\n ) : null}\n\n <div className={styles.body}>\n {shouldShowNav && navPosition === \"left\" ? (\n <nav\n aria-label=\"Settings sections\"\n className={styles.sideNav}\n data-slot=\"admin-settings-page-nav\"\n >\n <ul className={styles.sideNavList}>\n {(sectionGroups ?? (flatSections.length > 0 ? [{ sections: flatSections }] : [])).map((group, gi) => (\n <li key={gi} className={styles.sideNavGroup}>\n {group.label ? (\n <div\n className={styles.navGroupLabel}\n aria-hidden=\"true\"\n >\n {group.label}\n </div>\n ) : null}\n <ul\n className={styles.sideNavGroupList}\n role=\"list\"\n aria-label={typeof group.label === \"string\" ? group.label : undefined}\n >\n {group.sections.map((section) => {\n const isActive = section.id === activeId\n return (\n <li key={section.id} className={styles.sideNavItem}>\n <a\n href={`#${section.id}`}\n className={cn(\n styles.sideNavLink,\n isActive && styles.navLinkActive,\n section.muted && styles.navItemMuted\n )}\n aria-current={isActive ? \"true\" : undefined}\n onClick={(e) => handleNavClick(e, section.id)}\n >\n {section.icon ? (\n <span\n className={styles.navIcon}\n aria-hidden=\"true\"\n >\n {section.icon}\n </span>\n ) : null}\n <span className={styles.navLabel}>\n {section.label}\n </span>\n {section.meta !== undefined ? (\n <span className={styles.navItemMeta}>\n {section.meta}\n </span>\n ) : null}\n </a>\n </li>\n )\n })}\n </ul>\n </li>\n ))}\n </ul>\n </nav>\n ) : null}\n\n <div\n className={styles.main}\n data-slot=\"admin-settings-page-main\"\n >\n {flatSections.map((section, index) => {\n const titleId = `${section.id}-title`\n const showSectionFooter =\n perSectionSave && (section.onSave || section.onRevert)\n const sectionSaveDisabled =\n (section.busy ?? false) || !(section.dirty ?? false)\n\n return (\n <React.Fragment key={section.id}>\n {index > 0 ? (\n <Separator className={styles.sectionSeparator} />\n ) : null}\n <section\n id={section.id}\n ref={registerSectionRef(section.id)}\n aria-labelledby={titleId}\n className={styles.section}\n data-slot=\"admin-settings-page-section\"\n >\n <header className={styles.sectionHeader}>\n <Heading\n level={2}\n size=\"lg\"\n id={titleId}\n className={styles.sectionTitle}\n >\n {section.title}\n </Heading>\n {section.description ? (\n <Text\n size=\"sm\"\n color=\"secondary\"\n className={styles.sectionDescription}\n >\n {section.description}\n </Text>\n ) : null}\n </header>\n\n <div className={styles.sectionContent}>\n {section.content}\n </div>\n\n {showSectionFooter ? (\n <div\n className={styles.sectionFooter}\n role=\"group\"\n aria-label=\"Section actions\"\n data-slot=\"admin-settings-page-section-footer\"\n >\n {section.onRevert ? (\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={section.onRevert}\n disabled={\n (section.busy ?? false) ||\n !(section.dirty ?? false)\n }\n data-slot=\"admin-settings-page-section-revert\"\n >\n {section.revertLabel ?? \"Revert\"}\n </Button>\n ) : null}\n {section.onSave ? (\n <Button\n type=\"button\"\n onClick={() => {\n const result = section.onSave?.()\n if (\n result &&\n typeof (result as Promise<void>).then ===\n \"function\"\n ) {\n // Consumer owns busy state in per-section mode.\n void result\n }\n }}\n disabled={sectionSaveDisabled}\n aria-busy={\n (section.busy ?? false) || undefined\n }\n data-slot=\"admin-settings-page-section-save\"\n >\n {section.saveLabel ?? \"Save\"}\n </Button>\n ) : null}\n </div>\n ) : null}\n </section>\n </React.Fragment>\n )\n })}\n </div>\n </div>\n\n {!perSectionSave && !hideFooter ? (\n <div\n className={styles.footer}\n data-slot=\"admin-settings-page-footer\"\n role=\"group\"\n aria-label=\"Settings actions\"\n >\n <div className={styles.footerCancel}>\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={handleGlobalCancelClick}\n disabled={effectiveBusy}\n data-slot=\"admin-settings-page-cancel\"\n >\n {cancelLabel}\n </Button>\n </div>\n {footerStatus ? (\n <div\n className={styles.footerStatus}\n data-slot=\"admin-settings-page-status\"\n >\n {footerStatus}\n </div>\n ) : null}\n <div className={styles.footerSave}>\n <Button\n type=\"button\"\n onClick={handleGlobalSaveClick}\n disabled={globalSaveDisabled}\n aria-busy={effectiveBusy || undefined}\n data-slot=\"admin-settings-page-save\"\n >\n {saveLabel}\n </Button>\n </div>\n </div>\n ) : null}\n </div>\n\n {!perSectionSave ? (\n <ConfirmDialog\n open={pendingCancel}\n onOpenChange={(next) => {\n if (!next) handleGuardCancel()\n }}\n severity=\"warning\"\n title={unsavedGuardTitle}\n description={unsavedGuardDescription}\n confirmLabel={unsavedGuardConfirmLabel}\n cancelLabel={unsavedGuardCancelLabel}\n onConfirm={handleGuardConfirm}\n onCancel={handleGuardCancel}\n />\n ) : null}\n </>\n )\n})\n\nAdminSettingsPage.displayName = \"AdminSettingsPage\"\n\nexport { AdminSettingsPage }\n"
3467
+ "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"../../lib/utils\"\nimport { PageHeader } from \"../../components/ui/page-header/page-header\"\nimport { Heading } from \"../../components/ui/heading/heading\"\nimport { Text } from \"../../components/ui/text/text\"\nimport { Separator } from \"../../components/ui/separator/separator\"\nimport { Button } from \"../../components/ui/button/button\"\nimport { ConfirmDialog } from \"../../components/ui/confirm-dialog/confirm-dialog\"\nimport styles from \"./admin-settings-page.module.css\"\n\nexport interface AdminSettingsSection {\n /** Stable identifier — used as DOM id anchor and nav highlight key. */\n id: string\n /** Label rendered inside the left nav / top tab bar. */\n label: React.ReactNode\n /** Section heading rendered above the content. */\n title: React.ReactNode\n /** Optional small uppercase label rendered above the section title.\n * Mirrors PageHeader's eyebrow (VI-303) — same token contract. */\n eyebrow?: React.ReactNode\n /** Section title font-size override. `\"default\"` preserves the current h2\n * scale; `\"lg\" | \"xl\" | \"marquee\"` step up to display sizes. Mirrors\n * PageHeader's `titleSize` token presets (VI-303). */\n titleSize?: \"default\" | \"lg\" | \"xl\" | \"marquee\"\n /** Section title font-family override. `\"body\"` (default) inherits the body\n * family; `\"marquee\"` binds to the display family with a graceful fallback\n * to the heading family. Mirrors PageHeader's `titleFamily` (VI-303). */\n titleFamily?: \"body\" | \"marquee\"\n /** Optional supporting copy rendered below the heading. */\n description?: React.ReactNode\n /** Optional leading icon rendered before the label in the nav. */\n icon?: React.ReactNode\n /** Section body — form fields or arbitrary content. */\n content: React.ReactNode\n\n // ── Per-section save mode ──────────────────────────────────────────────\n /** Dirty flag — only meaningful when the page runs in `perSectionSave` mode. */\n dirty?: boolean\n /** Busy flag — only meaningful when the page runs in `perSectionSave` mode. */\n busy?: boolean\n /** Save handler — only called in `perSectionSave` mode. Async-aware. */\n onSave?: () => void | Promise<void>\n /** Revert handler — only called in `perSectionSave` mode. */\n onRevert?: () => void\n /** Override the per-section save button label. Defaults to \"Save\". */\n saveLabel?: React.ReactNode\n /** Override the per-section revert button label. Defaults to \"Revert\". */\n revertLabel?: React.ReactNode\n\n // ── Grouped nav extras ─────────────────────────────────────────────────\n /** Trailing badge in the nav item — e.g. member count \"8\" or status \"default\".\n * Rendered as a dim trailing element in the side rail; suppressed in the top chip bar. */\n meta?: React.ReactNode\n /** Mutes the nav item's text color via `--text-tertiary` — used for \"add\" / utility actions. */\n muted?: boolean\n}\n\nexport interface AdminSettingsSectionGroup {\n /** Categorical eyebrow label rendered above this group's nav items.\n * Omit to render an ungrouped cluster (no label). */\n label?: React.ReactNode\n /** Ordered list of settings sections that belong to this group. */\n sections: AdminSettingsSection[]\n}\n\nexport interface AdminSettingsPageProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n // ── Header ──────────────────────────────────────────────────────────────\n /** Page title rendered inside the PageHeader. */\n title: React.ReactNode\n /** Optional eyebrow rendered above the title. */\n eyebrow?: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /** Optional breadcrumb node rendered above the title row. */\n breadcrumb?: React.ReactNode\n /** Optional header actions slot rendered on the right side of the header. */\n headerActions?: React.ReactNode\n\n // ── Sections ────────────────────────────────────────────────────────────\n /** Flat ordered list of settings sections. Use when grouping is not needed. */\n sections?: AdminSettingsSection[]\n /** Grouped sections with categorical eyebrow labels.\n * Mutually exclusive with `sections` — if both are passed, `sectionGroups` wins\n * and a dev-mode console.warn fires. */\n sectionGroups?: AdminSettingsSectionGroup[]\n\n // ── Navigation ──────────────────────────────────────────────────────────\n /** Show the section nav. Defaults to true when there is more than one section. */\n showNav?: boolean\n /** Nav position — \"left\" (sticky side rail) or \"top\" (horizontal chip bar). Defaults to \"left\". */\n navPosition?: \"left\" | \"top\"\n\n // ── Mode ────────────────────────────────────────────────────────────────\n /**\n * If true, each section renders its own save/revert footer row and the global\n * sticky footer is not rendered. Defaults to false (global single-save mode).\n */\n perSectionSave?: boolean\n\n // ── Global save footer (single-save mode only) ──────────────────────────\n /** Save handler. Async-aware — a returned Promise drives the save button's pending state. */\n onSave?: () => void | Promise<void>\n /** Cancel handler. Protected by the unsaved-changes guard when `dirty` is true. */\n onCancel?: () => void\n /** Save button label. Defaults to \"Save changes\". */\n saveLabel?: React.ReactNode\n /** Cancel button label. Defaults to \"Cancel\". */\n cancelLabel?: React.ReactNode\n /** Dirty flag — drives the save button disabled state and the cancel guard. */\n dirty?: boolean\n /** Externally-controlled busy state. Overrides internal async pending detection. */\n busy?: boolean\n /** Middle slot inside the sticky footer — e.g. \"Last saved 2 minutes ago\". */\n footerStatus?: React.ReactNode\n /** Hide the global footer. */\n hideFooter?: boolean\n /**\n * Replace the default footer entirely. When set (and `hideFooter` is false and\n * `perSectionSave` is false), the block renders this node inside a\n * `data-slot=\"admin-settings-page-custom-footer\"` wrapper instead of the default\n * Cancel + status + Save row. The caller owns layout and wiring — `onSave` /\n * `onCancel` / `saveLabel` / `cancelLabel` / `dirty` / `busy` / `footerStatus`\n * are ignored when `customFooter` is supplied.\n *\n * Order of precedence: `hideFooter` > `customFooter` > default footer.\n *\n * The unsaved-changes ConfirmDialog guard remains active — wire your custom\n * Cancel handler through the block's `onCancel` prop to preserve it.\n *\n * Mirrors `admin-detail-drawer`'s `customHeader` precedent.\n */\n customFooter?: React.ReactNode\n\n // ── Unsaved guard (global cancel only) ──────────────────────────────────\n /** Title of the unsaved-changes confirm dialog. */\n unsavedGuardTitle?: React.ReactNode\n /** Description of the unsaved-changes confirm dialog. */\n unsavedGuardDescription?: React.ReactNode\n /** Confirm (discard) label. Defaults to \"Discard\". */\n unsavedGuardConfirmLabel?: React.ReactNode\n /** Cancel (keep editing) label. Defaults to \"Keep editing\". */\n unsavedGuardCancelLabel?: React.ReactNode\n}\n\nconst DEFAULT_UNSAVED_DESCRIPTION =\n \"You have unsaved changes that will be lost if you leave this page.\"\n\n/** Internal helper: returns the flat section list used by the observer, refs,\n * and main-column renderer. When `sectionGroups` is provided it wins; `sections`\n * is the flat fallback. */\nfunction flattenSections(\n sections: AdminSettingsSection[] | undefined,\n sectionGroups: AdminSettingsSectionGroup[] | undefined\n): AdminSettingsSection[] {\n if (sectionGroups) return sectionGroups.flatMap((g) => g.sections)\n return sections ?? []\n}\n\nconst AdminSettingsPage = React.forwardRef<\n HTMLDivElement,\n AdminSettingsPageProps\n>(function AdminSettingsPage(\n {\n title,\n eyebrow,\n description,\n breadcrumb,\n headerActions,\n sections,\n sectionGroups,\n showNav,\n navPosition = \"left\",\n perSectionSave = false,\n onSave,\n onCancel,\n saveLabel = \"Save changes\",\n cancelLabel = \"Cancel\",\n dirty = false,\n busy,\n footerStatus,\n hideFooter = false,\n customFooter,\n unsavedGuardTitle = \"Discard unsaved changes?\",\n unsavedGuardDescription = DEFAULT_UNSAVED_DESCRIPTION,\n unsavedGuardConfirmLabel = \"Discard\",\n unsavedGuardCancelLabel = \"Keep editing\",\n className,\n ...rest\n },\n ref\n) {\n // Dev-mode warning when both props are provided.\n if (\n process.env.NODE_ENV !== \"production\" &&\n sections !== undefined &&\n sectionGroups !== undefined\n ) {\n console.warn(\n \"[AdminSettingsPage] Both `sections` and `sectionGroups` were provided. \" +\n \"`sectionGroups` takes precedence and `sections` is ignored. \" +\n \"Pass only one of these props.\"\n )\n }\n\n // Dev-mode warning when `customFooter` is combined with the default-footer\n // sugar props it overrides. `customFooter` wins; the sugar props are ignored.\n if (\n process.env.NODE_ENV !== \"production\" &&\n customFooter !== undefined &&\n footerStatus !== undefined\n ) {\n console.warn(\n \"[AdminSettingsPage] Both `customFooter` and `footerStatus` were provided. \" +\n \"`customFooter` replaces the default footer entirely, so `footerStatus` \" +\n \"is ignored. Compose the status into your custom footer node instead.\"\n )\n }\n\n // Dev-mode warning when `customFooter` is combined with `perSectionSave`. The\n // block does not render a global footer in per-section mode, so the custom\n // footer would never render.\n if (\n process.env.NODE_ENV !== \"production\" &&\n customFooter !== undefined &&\n perSectionSave\n ) {\n console.warn(\n \"[AdminSettingsPage] `customFooter` is ignored when `perSectionSave` is \" +\n \"true — per-section footers render inline and no block-level footer is \" +\n \"shown. Drop `customFooter` or disable `perSectionSave`.\"\n )\n }\n\n const flatSections = flattenSections(sections, sectionGroups)\n const shouldShowNav = showNav ?? flatSections.length > 1\n const firstSectionId = flatSections[0]?.id\n const [activeId, setActiveId] = React.useState<string | undefined>(\n firstSectionId\n )\n const [isGlobalPending, setIsGlobalPending] = React.useState(false)\n const [pendingCancel, setPendingCancel] = React.useState(false)\n const sectionRefs = React.useRef<Map<string, HTMLElement>>(new Map())\n\n const effectiveBusy = busy ?? isGlobalPending\n\n // Intersection observer to highlight the nav item whose section is in view.\n React.useEffect(() => {\n if (!shouldShowNav) return\n if (typeof window === \"undefined\") return\n if (typeof IntersectionObserver === \"undefined\") return\n\n const elements = Array.from(sectionRefs.current.values())\n if (elements.length === 0) return\n\n const observer = new IntersectionObserver(\n (entries) => {\n // Find the topmost visible entry.\n const visible = entries\n .filter((e) => e.isIntersecting)\n .sort(\n (a, b) =>\n a.boundingClientRect.top - b.boundingClientRect.top\n )\n if (visible[0]) {\n const id = (visible[0].target as HTMLElement).id\n if (id) setActiveId(id)\n }\n },\n {\n // Trigger when a section enters the top portion of the scroll container.\n rootMargin: \"0px 0px -60% 0px\",\n threshold: [0, 0.1, 0.5, 1],\n }\n )\n\n elements.forEach((el) => observer.observe(el))\n return () => observer.disconnect()\n }, [shouldShowNav, flatSections])\n\n const registerSectionRef = React.useCallback(\n (id: string) => (node: HTMLElement | null) => {\n const map = sectionRefs.current\n if (node) {\n map.set(id, node)\n } else {\n map.delete(id)\n }\n },\n []\n )\n\n const handleNavClick = React.useCallback(\n (event: React.MouseEvent<HTMLAnchorElement>, id: string) => {\n event.preventDefault()\n const target = sectionRefs.current.get(id)\n if (target) {\n target.scrollIntoView({ block: \"start\", behavior: \"smooth\" })\n // Update hash without jumping.\n if (typeof window !== \"undefined\") {\n window.history.replaceState(null, \"\", `#${id}`)\n }\n setActiveId(id)\n }\n },\n []\n )\n\n const handleGlobalCancelClick = React.useCallback(() => {\n if (effectiveBusy) return\n if (dirty) {\n setPendingCancel(true)\n return\n }\n onCancel?.()\n }, [dirty, effectiveBusy, onCancel])\n\n const handleGlobalSaveClick = React.useCallback(async () => {\n if (!onSave) return\n const result = onSave()\n if (result && typeof (result as Promise<void>).then === \"function\") {\n setIsGlobalPending(true)\n try {\n await result\n setIsGlobalPending(false)\n } catch (err) {\n setIsGlobalPending(false)\n throw err\n }\n }\n }, [onSave])\n\n const handleGuardConfirm = React.useCallback(() => {\n if (pendingCancel) {\n setPendingCancel(false)\n onCancel?.()\n }\n }, [pendingCancel, onCancel])\n\n const handleGuardCancel = React.useCallback(() => {\n setPendingCancel(false)\n }, [])\n\n const globalSaveDisabled = effectiveBusy || !dirty\n\n return (\n <>\n <div\n ref={ref}\n className={cn(\n styles.root,\n shouldShowNav && navPosition === \"left\"\n ? styles.withLeftNav\n : styles.noLeftNav,\n className\n )}\n data-slot=\"admin-settings-page\"\n data-nav-position={shouldShowNav ? navPosition : undefined}\n {...rest}\n >\n {firstSectionId ? (\n <a className={styles.skipLink} href={`#${firstSectionId}`}>\n Skip to content\n </a>\n ) : null}\n\n <PageHeader\n className={styles.header}\n eyebrow={eyebrow}\n title={title}\n description={description}\n breadcrumb={breadcrumb}\n actions={headerActions}\n />\n\n {shouldShowNav && navPosition === \"top\" ? (\n <nav\n aria-label=\"Settings sections\"\n className={styles.topNav}\n data-slot=\"admin-settings-page-nav\"\n >\n <ul className={styles.topNavList}>\n {(sectionGroups ?? (flatSections.length > 0 ? [{ sections: flatSections }] : [])).map((group, gi) => (\n <React.Fragment key={gi}>\n {gi > 0 ? (\n <li\n role=\"separator\"\n aria-orientation=\"vertical\"\n aria-label={typeof group.label === \"string\" ? group.label : undefined}\n className={styles.topNavSeparator}\n />\n ) : null}\n {group.sections.map((section) => {\n const isActive = section.id === activeId\n return (\n <li key={section.id} className={styles.topNavItem}>\n <a\n href={`#${section.id}`}\n className={cn(\n styles.topNavLink,\n isActive && styles.navLinkActive\n )}\n aria-current={isActive ? \"true\" : undefined}\n onClick={(e) => handleNavClick(e, section.id)}\n >\n {section.icon ? (\n <span\n className={styles.navIcon}\n aria-hidden=\"true\"\n >\n {section.icon}\n </span>\n ) : null}\n <span className={styles.navLabel}>\n {section.label}\n </span>\n </a>\n </li>\n )\n })}\n </React.Fragment>\n ))}\n </ul>\n </nav>\n ) : null}\n\n <div className={styles.body}>\n {shouldShowNav && navPosition === \"left\" ? (\n <nav\n aria-label=\"Settings sections\"\n className={styles.sideNav}\n data-slot=\"admin-settings-page-nav\"\n >\n <div\n className={styles.sideNavSticky}\n data-slot=\"admin-settings-page-side-nav-sticky\"\n >\n <ul className={styles.sideNavList}>\n {(sectionGroups ?? (flatSections.length > 0 ? [{ sections: flatSections }] : [])).map((group, gi) => (\n <li key={gi} className={styles.sideNavGroup}>\n {group.label ? (\n <div\n className={styles.navGroupLabel}\n aria-hidden=\"true\"\n >\n {group.label}\n </div>\n ) : null}\n <ul\n className={styles.sideNavGroupList}\n role=\"list\"\n aria-label={typeof group.label === \"string\" ? group.label : undefined}\n >\n {group.sections.map((section) => {\n const isActive = section.id === activeId\n return (\n <li key={section.id} className={styles.sideNavItem}>\n <a\n href={`#${section.id}`}\n className={cn(\n styles.sideNavLink,\n isActive && styles.navLinkActive,\n section.muted && styles.navItemMuted\n )}\n aria-current={isActive ? \"true\" : undefined}\n onClick={(e) => handleNavClick(e, section.id)}\n >\n {section.icon ? (\n <span\n className={styles.navIcon}\n aria-hidden=\"true\"\n >\n {section.icon}\n </span>\n ) : null}\n <span className={styles.navLabel}>\n {section.label}\n </span>\n {section.meta !== undefined ? (\n <span className={styles.navItemMeta}>\n {section.meta}\n </span>\n ) : null}\n </a>\n </li>\n )\n })}\n </ul>\n </li>\n ))}\n </ul>\n </div>\n </nav>\n ) : null}\n\n <div\n className={styles.main}\n data-slot=\"admin-settings-page-main\"\n >\n {flatSections.map((section, index) => {\n const titleId = `${section.id}-title`\n const showSectionFooter =\n perSectionSave && (section.onSave || section.onRevert)\n const sectionSaveDisabled =\n (section.busy ?? false) || !(section.dirty ?? false)\n\n return (\n <React.Fragment key={section.id}>\n {index > 0 ? (\n <Separator className={styles.sectionSeparator} />\n ) : null}\n <section\n id={section.id}\n ref={registerSectionRef(section.id)}\n aria-labelledby={titleId}\n className={styles.section}\n data-slot=\"admin-settings-page-section\"\n >\n <header\n className={styles.sectionHeader}\n data-slot=\"admin-settings-section-header\"\n >\n {section.eyebrow ? (\n <p\n data-slot=\"admin-settings-section-eyebrow\"\n className={styles.sectionEyebrow}\n >\n {section.eyebrow}\n </p>\n ) : null}\n <Heading\n level={2}\n size=\"lg\"\n id={titleId}\n data-slot=\"admin-settings-section-title\"\n data-title-size={section.titleSize}\n data-title-family={section.titleFamily}\n className={styles.sectionTitle}\n >\n {section.title}\n </Heading>\n {section.description ? (\n <Text\n size=\"sm\"\n color=\"secondary\"\n className={styles.sectionDescription}\n >\n {section.description}\n </Text>\n ) : null}\n </header>\n\n <div className={styles.sectionContent}>\n {section.content}\n </div>\n\n {showSectionFooter ? (\n <div\n className={styles.sectionFooter}\n role=\"group\"\n aria-label=\"Section actions\"\n data-slot=\"admin-settings-page-section-footer\"\n >\n {section.onRevert ? (\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={section.onRevert}\n disabled={\n (section.busy ?? false) ||\n !(section.dirty ?? false)\n }\n data-slot=\"admin-settings-page-section-revert\"\n >\n {section.revertLabel ?? \"Revert\"}\n </Button>\n ) : null}\n {section.onSave ? (\n <Button\n type=\"button\"\n onClick={() => {\n const result = section.onSave?.()\n if (\n result &&\n typeof (result as Promise<void>).then ===\n \"function\"\n ) {\n // Consumer owns busy state in per-section mode.\n void result\n }\n }}\n disabled={sectionSaveDisabled}\n aria-busy={\n (section.busy ?? false) || undefined\n }\n data-slot=\"admin-settings-page-section-save\"\n >\n {section.saveLabel ?? \"Save\"}\n </Button>\n ) : null}\n </div>\n ) : null}\n </section>\n </React.Fragment>\n )\n })}\n </div>\n </div>\n\n {!perSectionSave && !hideFooter ? (\n customFooter !== undefined ? (\n <div\n className={styles.customFooter}\n data-slot=\"admin-settings-page-custom-footer\"\n >\n {customFooter}\n </div>\n ) : (\n <div\n className={styles.footer}\n data-slot=\"admin-settings-page-footer\"\n role=\"group\"\n aria-label=\"Settings actions\"\n >\n <div className={styles.footerCancel}>\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={handleGlobalCancelClick}\n disabled={effectiveBusy}\n data-slot=\"admin-settings-page-cancel\"\n >\n {cancelLabel}\n </Button>\n </div>\n {footerStatus ? (\n <div\n className={styles.footerStatus}\n data-slot=\"admin-settings-page-status\"\n >\n {footerStatus}\n </div>\n ) : null}\n <div className={styles.footerSave}>\n <Button\n type=\"button\"\n onClick={handleGlobalSaveClick}\n disabled={globalSaveDisabled}\n aria-busy={effectiveBusy || undefined}\n data-slot=\"admin-settings-page-save\"\n >\n {saveLabel}\n </Button>\n </div>\n </div>\n )\n ) : null}\n </div>\n\n {!perSectionSave ? (\n <ConfirmDialog\n open={pendingCancel}\n onOpenChange={(next) => {\n if (!next) handleGuardCancel()\n }}\n severity=\"warning\"\n title={unsavedGuardTitle}\n description={unsavedGuardDescription}\n confirmLabel={unsavedGuardConfirmLabel}\n cancelLabel={unsavedGuardCancelLabel}\n onConfirm={handleGuardConfirm}\n onCancel={handleGuardCancel}\n />\n ) : null}\n </>\n )\n})\n\nAdminSettingsPage.displayName = \"AdminSettingsPage\"\n\nexport { AdminSettingsPage }\n"
3139
3468
  },
3140
3469
  {
3141
3470
  "path": "blocks/admin-settings-page/admin-settings-page.module.css",
3142
3471
  "type": "registry:block",
3143
- "content": "/* Admin Settings Page\n * Long scrollable settings archetype: PageHeader → optional nav + main\n * column of stacked sections → optional sticky global save footer. Nav can\n * live to the left (sticky rail) or above (horizontal chip bar). At narrow\n * container widths the side nav collapses above the main column.\n */\n\n.root {\n container-type: inline-size;\n display: flex;\n flex-direction: column;\n min-height: 0;\n width: 100%;\n gap: 0;\n background-color: var(--surface-page, #ffffff);\n color: var(--text-primary, #111827);\n scroll-behavior: smooth;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .root {\n scroll-behavior: auto;\n }\n}\n\n/* Visually hidden skip link — becomes visible on focus so keyboard users\n * can jump past the header and nav into the first section. */\n.skipLink {\n position: absolute;\n left: var(--spacing-2, 0.5rem);\n top: var(--spacing-2, 0.5rem);\n z-index: 2;\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n background-color: var(--surface-page, #ffffff);\n color: var(--text-primary, #111827);\n border: 1px solid var(--border-default, #d1d5db);\n border-radius: var(--radius-md, 0.5rem);\n box-shadow: var(--shadow-sm);\n font-size: var(--font-size-sm, 0.875rem);\n text-decoration: none;\n transform: translateY(-200%);\n transition:\n transform var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.skipLink:focus-visible {\n transform: translateY(0);\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.header {\n padding: var(--spacing-6, 1.5rem);\n border-bottom: 1px solid var(--border-muted, #e5e7eb);\n}\n\n/* Top nav variant — horizontal chip bar rendered below the header. */\n.topNav {\n flex: 0 0 auto;\n padding: var(--spacing-3, 0.75rem) var(--spacing-6, 1.5rem);\n border-bottom: 1px solid var(--border-muted, #e5e7eb);\n overflow-x: auto;\n overflow-y: hidden;\n}\n\n.topNavList {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.topNavItem {\n flex: 0 0 auto;\n}\n\n/* Group separator in the top chip bar — 1px vertical hairline between clusters. */\n.topNavSeparator {\n flex: 0 0 auto;\n width: var(--stroke-width-thin, 1px);\n align-self: stretch;\n background-color: var(--border-muted, #e5e7eb);\n margin: 0 var(--spacing-2, 0.5rem);\n list-style: none;\n}\n\n.topNavLink {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n border-radius: var(--radius-md, 0.5rem);\n color: var(--text-secondary, #6b7280);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: 500;\n text-decoration: none;\n white-space: nowrap;\n transition:\n background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.topNavLink:hover {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n color: var(--text-primary, #111827);\n}\n\n.topNavLink:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n/* Body = optional left nav + main column. */\n.body {\n flex: 1 1 auto;\n min-height: 0;\n display: grid;\n grid-template-columns: 1fr;\n width: 100%;\n}\n\n.withLeftNav .body {\n grid-template-columns: minmax(12rem, 16rem) 1fr;\n}\n\n/* Side nav — sticky rail pinned to the top of the scroll container. */\n.sideNav {\n position: sticky;\n top: 0;\n align-self: start;\n padding: var(--spacing-6, 1.5rem) var(--spacing-4, 1rem)\n var(--spacing-6, 1.5rem) var(--spacing-6, 1.5rem);\n border-right: 1px solid var(--border-muted, #e5e7eb);\n max-height: 100vh;\n overflow-y: auto;\n}\n\n.sideNavList {\n display: flex;\n flex-direction: column;\n gap: 0;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n/* Group container — wraps the optional eyebrow label + a list of nav items. */\n.sideNavGroup {\n display: flex;\n flex-direction: column;\n}\n\n/* Categorical eyebrow label above a group's nav items.\n * Reuses the same token set as PageHeader.eyebrow for visual consistency. */\n.navGroupLabel {\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-semibold, 600);\n letter-spacing: var(--letter-spacing-wide, 0.05em);\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n padding: var(--spacing-5, 1.25rem) var(--spacing-3, 0.75rem)\n var(--spacing-2, 0.5rem);\n line-height: var(--line-height-tight, 1.25);\n}\n\n/* Nested list of sections within a group — no bullet, no extra padding. */\n.sideNavGroupList {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-1, 0.25rem);\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.sideNavItem {\n display: block;\n}\n\n/* Muted nav item — dim text for \"add\" / utility actions. */\n.navItemMuted.sideNavLink {\n color: var(--text-tertiary, #6b7280);\n}\n\n/* Trailing badge inside a side-nav link — e.g. member count or status. */\n.navItemMeta {\n margin-left: auto;\n flex: 0 0 auto;\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--text-tertiary, #6b7280);\n line-height: 1;\n}\n\n.sideNavLink {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n width: 100%;\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n border-radius: var(--radius-md, 0.5rem);\n color: var(--text-secondary, #6b7280);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: 500;\n text-decoration: none;\n transition:\n background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.sideNavLink:hover {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n color: var(--text-primary, #111827);\n}\n\n.sideNavLink:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.navLinkActive {\n background-color: var(--surface-interactive-active, #e5e7eb);\n color: var(--text-primary, #111827);\n font-weight: 600;\n}\n\n.navIcon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n width: 1em;\n height: 1em;\n font-size: var(--font-size-base, 1rem);\n line-height: 1;\n}\n\n.navLabel {\n display: inline-flex;\n align-items: center;\n min-width: 0;\n}\n\n/* Main column — stacked sections. */\n.main {\n min-width: 0;\n display: flex;\n flex-direction: column;\n padding: var(--spacing-6, 1.5rem);\n gap: var(--spacing-6, 1.5rem);\n}\n\n.section {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n scroll-margin-top: var(--spacing-6, 1.5rem);\n}\n\n.sectionHeader {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-1, 0.25rem);\n}\n\n.sectionTitle {\n margin: 0;\n}\n\n.sectionDescription {\n margin: 0;\n}\n\n.sectionContent {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n}\n\n.sectionSeparator {\n margin: 0;\n}\n\n.sectionFooter {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-end;\n gap: var(--spacing-2, 0.5rem);\n padding-top: var(--spacing-2, 0.5rem);\n}\n\n/* Sticky global footer — pinned to the bottom of the scroll container. */\n.footer {\n position: sticky;\n bottom: 0;\n z-index: 1;\n flex: 0 0 auto;\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: var(--spacing-3, 0.75rem);\n padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);\n border-top: 1px solid var(--border-muted, #e5e7eb);\n background-color: var(--surface-page, #ffffff);\n box-shadow: var(--shadow-sm);\n}\n\n.footerCancel {\n flex: 0 0 auto;\n}\n\n.footerStatus {\n flex: 1 1 auto;\n min-width: 0;\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n text-align: center;\n}\n\n.footerSave {\n flex: 0 0 auto;\n margin-left: auto;\n}\n\n.footerStatus + .footerSave {\n margin-left: 0;\n}\n\n/* Narrow container — collapse left nav above the main column and let the\n * footer wrap the status below the action buttons. */\n@container (max-width: 48rem) {\n .withLeftNav .body {\n grid-template-columns: 1fr;\n }\n\n .sideNav {\n position: static;\n max-height: none;\n padding: var(--spacing-3, 0.75rem) var(--spacing-6, 1.5rem);\n border-right: none;\n border-bottom: 1px solid var(--border-muted, #e5e7eb);\n overflow-x: auto;\n overflow-y: hidden;\n }\n\n .sideNavList {\n flex-direction: row;\n gap: var(--spacing-2, 0.5rem);\n align-items: center;\n }\n\n /* Groups collapse into an inline row — no visible grouping at narrow width. */\n .sideNavGroup {\n flex-direction: row;\n gap: var(--spacing-2, 0.5rem);\n align-items: center;\n }\n\n /* Hide eyebrow labels in collapsed horizontal strip — no room for them. */\n .navGroupLabel {\n display: none;\n }\n\n .sideNavGroupList {\n flex-direction: row;\n gap: var(--spacing-2, 0.5rem);\n }\n\n .sideNavItem {\n flex: 0 0 auto;\n }\n\n /* Hide meta badges in collapsed strip — space is too constrained. */\n .navItemMeta {\n display: none;\n }\n\n .footer {\n flex-wrap: wrap;\n }\n\n .footerStatus {\n order: 3;\n flex-basis: 100%;\n text-align: left;\n }\n}\n"
3472
+ "content": "/* Admin Settings Page\n * Long scrollable settings archetype: PageHeader → optional nav + main\n * column of stacked sections → optional sticky global save footer. Nav can\n * live to the left (sticky rail) or above (horizontal chip bar). At narrow\n * container widths the side nav collapses above the main column.\n */\n\n.root {\n container-type: inline-size;\n display: flex;\n flex-direction: column;\n min-height: 0;\n width: 100%;\n gap: 0;\n background-color: var(--surface-page, #ffffff);\n color: var(--text-primary, #111827);\n scroll-behavior: smooth;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .root {\n scroll-behavior: auto;\n }\n}\n\n/* Visually hidden skip link — becomes visible on focus so keyboard users\n * can jump past the header and nav into the first section. */\n.skipLink {\n position: absolute;\n left: var(--spacing-2, 0.5rem);\n top: var(--spacing-2, 0.5rem);\n z-index: 2;\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n background-color: var(--surface-page, #ffffff);\n color: var(--text-primary, #111827);\n border: 1px solid var(--border-default, #d1d5db);\n border-radius: var(--radius-md, 0.5rem);\n box-shadow: var(--shadow-sm);\n font-size: var(--font-size-sm, 0.875rem);\n text-decoration: none;\n transform: translateY(-200%);\n transition:\n transform var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.skipLink:focus-visible {\n transform: translateY(0);\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.header {\n padding: var(--spacing-6, 1.5rem);\n border-bottom: 1px solid var(--border-muted, #e5e7eb);\n}\n\n/* Top nav variant — horizontal chip bar rendered below the header. */\n.topNav {\n flex: 0 0 auto;\n padding: var(--spacing-3, 0.75rem) var(--spacing-6, 1.5rem);\n border-bottom: 1px solid var(--border-muted, #e5e7eb);\n overflow-x: auto;\n overflow-y: hidden;\n}\n\n.topNavList {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.topNavItem {\n flex: 0 0 auto;\n}\n\n/* Group separator in the top chip bar — 1px vertical hairline between clusters. */\n.topNavSeparator {\n flex: 0 0 auto;\n width: var(--stroke-width-thin, 1px);\n align-self: stretch;\n background-color: var(--border-muted, #e5e7eb);\n margin: 0 var(--spacing-2, 0.5rem);\n list-style: none;\n}\n\n.topNavLink {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n border-radius: var(--radius-md, 0.5rem);\n color: var(--text-secondary, #6b7280);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: 500;\n text-decoration: none;\n white-space: nowrap;\n transition:\n background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.topNavLink:hover {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n color: var(--text-primary, #111827);\n}\n\n.topNavLink:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n/* Body = optional left nav + main column. */\n.body {\n flex: 1 1 auto;\n min-height: 0;\n display: grid;\n grid-template-columns: 1fr;\n width: 100%;\n}\n\n.withLeftNav .body {\n grid-template-columns: var(--admin-settings-page-nav-width, minmax(12rem, 16rem)) 1fr;\n}\n\n/* Side nav — stretching grid track. The outer rail fills the grid track so\n * its surface (border-right, background) extends to the bottom of the body.\n * The inner `.sideNavSticky` div carries the sticky scroll-anchor behavior. */\n.sideNav {\n align-self: stretch;\n min-height: 100%;\n border-right: 1px solid var(--border-muted, #e5e7eb);\n display: flex;\n flex-direction: column;\n}\n\n/* Inner sticky pane — pinned to the top of the scroll container, scrolls\n * internally when the nav contents exceed the viewport height. */\n.sideNavSticky {\n position: sticky;\n top: 0;\n padding: var(--spacing-6, 1.5rem) var(--spacing-4, 1rem)\n var(--spacing-6, 1.5rem) var(--spacing-6, 1.5rem);\n max-height: 100vh;\n overflow-y: auto;\n}\n\n.sideNavList {\n display: flex;\n flex-direction: column;\n gap: 0;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n/* Group container — wraps the optional eyebrow label + a list of nav items. */\n.sideNavGroup {\n display: flex;\n flex-direction: column;\n}\n\n/* Categorical eyebrow label above a group's nav items.\n * Reuses the same token set as PageHeader.eyebrow for visual consistency. */\n.navGroupLabel {\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-semibold, 600);\n letter-spacing: var(--letter-spacing-wide, 0.05em);\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n padding: var(--spacing-5, 1.25rem) var(--spacing-3, 0.75rem)\n var(--spacing-2, 0.5rem);\n line-height: var(--line-height-tight, 1.25);\n}\n\n/* Nested list of sections within a group — no bullet, no extra padding. */\n.sideNavGroupList {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-1, 0.25rem);\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.sideNavItem {\n display: block;\n}\n\n/* Muted nav item — dim text for \"add\" / utility actions. */\n.navItemMuted.sideNavLink {\n color: var(--text-tertiary, #6b7280);\n}\n\n/* Trailing badge inside a side-nav link — e.g. member count or status. */\n.navItemMeta {\n margin-left: auto;\n flex: 0 0 auto;\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--text-tertiary, #6b7280);\n line-height: 1;\n}\n\n.sideNavLink {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n width: 100%;\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n border-radius: var(--radius-md, 0.5rem);\n color: var(--text-secondary, #6b7280);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: 500;\n text-decoration: none;\n transition:\n background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.sideNavLink:hover {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n color: var(--text-primary, #111827);\n}\n\n.sideNavLink:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.navLinkActive {\n background-color: var(--surface-interactive-active, #e5e7eb);\n color: var(--text-primary, #111827);\n font-weight: 600;\n}\n\n.navIcon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n width: 1em;\n height: 1em;\n font-size: var(--font-size-base, 1rem);\n line-height: 1;\n}\n\n.navLabel {\n display: inline-flex;\n align-items: center;\n min-width: 0;\n}\n\n/* Main column — stacked sections. */\n.main {\n min-width: 0;\n display: flex;\n flex-direction: column;\n padding: var(--spacing-6, 1.5rem);\n gap: var(--spacing-6, 1.5rem);\n}\n\n.section {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n scroll-margin-top: var(--spacing-6, 1.5rem);\n}\n\n.sectionHeader {\n /* Override hooks for section-title typography. Themes (or consumers via a\n wrapping className) can set these to retune the marquee title without\n forking the block. Props are sugar over the same custom properties. */\n --admin-settings-section-title-size: 3rem;\n --admin-settings-section-title-family: var(\n --font-family-display,\n var(--font-family-heading, inherit)\n );\n\n display: flex;\n flex-direction: column;\n gap: var(--spacing-1, 0.25rem);\n}\n\n/* Eyebrow: small uppercase label above the section title.\n * Reuses the same token set as PageHeader.eyebrow (VI-303) for visual\n * consistency — no new theme tokens. */\n.sectionEyebrow {\n margin: 0;\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-semibold, 600);\n letter-spacing: var(--letter-spacing-wide, 0.05em);\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n line-height: var(--line-height-tight, 1.25);\n}\n\n.sectionTitle {\n margin: 0;\n}\n\n/* Section title typography overrides (orthogonal to the default h2 scale).\n `data-title-size=\"lg\" | \"xl\" | \"marquee\"` step the title up through the\n display scale; `data-title-size=\"default\"` is a no-op preserving the\n current h2 lg size. Mirrors PageHeader's pattern (VI-303). */\n.sectionTitle[data-title-size=\"lg\"] {\n font-size: var(--font-size-2xl, 1.5rem);\n line-height: var(--line-height-tight, 1.2);\n}\n\n.sectionTitle[data-title-size=\"xl\"] {\n font-size: var(--font-size-3xl, 1.875rem);\n line-height: var(--line-height-tight, 1.2);\n}\n\n.sectionTitle[data-title-size=\"marquee\"] {\n font-size: var(--admin-settings-section-title-size, 3rem);\n line-height: 1;\n letter-spacing: var(--letter-spacing-tight, -0.01em);\n}\n\n/* `data-title-family=\"marquee\"` switches to the display family with a\n graceful fallback to the heading family when no display font is themed. */\n.sectionTitle[data-title-family=\"marquee\"] {\n font-family: var(\n --admin-settings-section-title-family,\n var(--font-family-display, var(--font-family-heading, inherit))\n );\n}\n\n.sectionDescription {\n margin: 0;\n}\n\n.sectionContent {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n}\n\n.sectionSeparator {\n margin: 0;\n}\n\n.sectionFooter {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-end;\n gap: var(--spacing-2, 0.5rem);\n padding-top: var(--spacing-2, 0.5rem);\n}\n\n/* Sticky global footer — pinned to the bottom of the scroll container. */\n.footer {\n position: sticky;\n bottom: 0;\n z-index: 1;\n flex: 0 0 auto;\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: var(--spacing-3, 0.75rem);\n padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);\n border-top: 1px solid var(--border-muted, #e5e7eb);\n background-color: var(--surface-page, #ffffff);\n box-shadow: var(--shadow-sm);\n}\n\n.footerCancel {\n flex: 0 0 auto;\n}\n\n.footerStatus {\n flex: 1 1 auto;\n min-width: 0;\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n text-align: center;\n}\n\n.footerSave {\n flex: 0 0 auto;\n margin-left: auto;\n}\n\n.footerStatus + .footerSave {\n margin-left: 0;\n}\n\n/* Custom footer slot — replaces the default footer entirely. Pinned to the\n * bottom of the scroll container with the same surface treatment as the default\n * footer; layout inside is fully owned by the consumer. */\n.customFooter {\n position: sticky;\n bottom: 0;\n z-index: 1;\n flex: 0 0 auto;\n width: 100%;\n border-top: 1px solid var(--border-muted, #e5e7eb);\n background-color: var(--surface-page, #ffffff);\n box-shadow: var(--shadow-sm);\n}\n\n/* Narrow container — collapse left nav above the main column and let the\n * footer wrap the status below the action buttons. */\n@container (max-width: 48rem) {\n .withLeftNav .body {\n grid-template-columns: 1fr;\n }\n\n .sideNav {\n align-self: auto;\n min-height: 0;\n border-right: none;\n border-bottom: 1px solid var(--border-muted, #e5e7eb);\n display: block;\n }\n\n /* Drop the inner sticky pane back to static flow at narrow widths so the\n * collapsed horizontal strip scrolls with the page. Be explicit — don't\n * rely on inheritance from `.sideNav`. */\n .sideNavSticky {\n position: static;\n max-height: none;\n padding: var(--spacing-3, 0.75rem) var(--spacing-6, 1.5rem);\n overflow-x: auto;\n overflow-y: visible;\n }\n\n .sideNavList {\n flex-direction: row;\n gap: var(--spacing-2, 0.5rem);\n align-items: center;\n }\n\n /* Groups collapse into an inline row — no visible grouping at narrow width. */\n .sideNavGroup {\n flex-direction: row;\n gap: var(--spacing-2, 0.5rem);\n align-items: center;\n }\n\n /* Hide eyebrow labels in collapsed horizontal strip — no room for them. */\n .navGroupLabel {\n display: none;\n }\n\n .sideNavGroupList {\n flex-direction: row;\n gap: var(--spacing-2, 0.5rem);\n }\n\n .sideNavItem {\n flex: 0 0 auto;\n }\n\n /* Hide meta badges in collapsed strip — space is too constrained. */\n .navItemMeta {\n display: none;\n }\n\n .footer {\n flex-wrap: wrap;\n }\n\n .footerStatus {\n order: 3;\n flex-basis: 100%;\n text-align: left;\n }\n}\n"
3144
3473
  }
3145
3474
  ]
3146
3475
  },
@@ -3172,6 +3501,30 @@
3172
3501
  }
3173
3502
  ]
3174
3503
  },
3504
+ {
3505
+ "name": "right-rail-list",
3506
+ "type": "registry:block",
3507
+ "description": "Compact vertical list block tuned for admin dashboard side rails. Each row pairs an optional leading slot (label / avatar / badge / status dot), a primary label (typically a link), and an optional trailing meta value (count, value, tone-tinted status word). Trailing accepts default / mint / muted / warn / danger / info tones bound to shared semantic text tokens. Supports compact density and ul / ol / div root element variants.",
3508
+ "category": "data-display",
3509
+ "dependencies": [
3510
+ "@loworbitstudio/visor-core"
3511
+ ],
3512
+ "registryDependencies": [
3513
+ "utils"
3514
+ ],
3515
+ "files": [
3516
+ {
3517
+ "path": "blocks/right-rail-list/right-rail-list.tsx",
3518
+ "type": "registry:block",
3519
+ "content": "import * as React from \"react\"\n\nimport { cn } from \"../../lib/utils\"\nimport styles from \"./right-rail-list.module.css\"\n\nexport type RightRailListTone =\n | \"default\"\n | \"mint\"\n | \"muted\"\n | \"warn\"\n\nexport interface RightRailRow {\n /** Stable key for the row. */\n id: string\n /**\n * Optional leading slot — short label (e.g. \"Sun\"), avatar, badge,\n * status dot, or any other compact stamp anchoring the row's left edge.\n */\n leading?: React.ReactNode\n /**\n * Primary content — typically a link or plain text. Truncates with\n * ellipsis when overflowing the row.\n */\n primary: React.ReactNode\n /**\n * Optional trailing slot — count, value, or status word rendered at the\n * row's right edge.\n */\n trailing?: React.ReactNode\n /**\n * Tone applied to the trailing element via `data-tone`. Defaults to\n * `\"default\"`. Tones bind to the shared semantic text tokens so they\n * resolve correctly under every theme.\n */\n trailingTone?: RightRailListTone\n}\n\ntype RootElement = \"ul\" | \"ol\" | \"div\"\n\nexport interface RightRailListProps\n extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n /** Row data — each entry renders as a single list row. */\n rows: RightRailRow[]\n /**\n * Tighter vertical padding for high-density rails. Defaults to `false`.\n */\n compact?: boolean\n /**\n * Root element. Defaults to `\"ul\"`. Use `\"ol\"` for ordered rankings\n * (e.g. top promoters) or `\"div\"` when the surrounding context already\n * provides list semantics.\n */\n as?: RootElement\n}\n\nconst RightRailList = React.forwardRef<HTMLElement, RightRailListProps>(\n function RightRailList(\n { rows, compact = false, as = \"ul\", className, ...rest },\n ref\n ) {\n const Root = as as React.ElementType\n const RowTag: React.ElementType = as === \"div\" ? \"div\" : \"li\"\n\n return (\n <Root\n ref={ref as React.Ref<HTMLElement>}\n className={cn(styles.root, compact && styles.rootCompact, className)}\n data-slot=\"right-rail-list\"\n data-compact={compact ? \"true\" : undefined}\n {...rest}\n >\n {rows.map((row) => (\n <RowTag\n key={row.id}\n className={styles.row}\n data-slot=\"right-rail-list-row\"\n >\n {row.leading !== undefined && row.leading !== null ? (\n <span\n className={styles.leading}\n data-slot=\"right-rail-list-leading\"\n >\n {row.leading}\n </span>\n ) : null}\n <span\n className={styles.primary}\n data-slot=\"right-rail-list-primary\"\n >\n {row.primary}\n </span>\n {row.trailing !== undefined && row.trailing !== null ? (\n <span\n className={styles.trailing}\n data-slot=\"right-rail-list-trailing\"\n data-tone={row.trailingTone ?? \"default\"}\n >\n {row.trailing}\n </span>\n ) : null}\n </RowTag>\n ))}\n </Root>\n )\n }\n)\n\nRightRailList.displayName = \"RightRailList\"\n\nexport { RightRailList }\n"
3520
+ },
3521
+ {
3522
+ "path": "blocks/right-rail-list/right-rail-list.module.css",
3523
+ "type": "registry:block",
3524
+ "content": "/* Right Rail List\n * Compact vertical list block tuned for dashboard side rails.\n * Each row pairs an optional leading stamp (label / avatar / badge),\n * a primary label (typically a link), and an optional trailing meta\n * value (count / value / tone-tinted status word).\n *\n * Ports the r3 admin-v7-r3 RightRailList component into a theme-portable\n * block — every color, size, and spacing value binds to a Visor token so\n * the block adopts the active theme without modification.\n */\n\n.root {\n display: flex;\n flex-direction: column;\n background: var(--surface-card);\n /* Reset list semantics chrome when root is a <ul> / <ol>. */\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.row {\n display: flex;\n align-items: center;\n gap: var(--spacing-3, 0.75rem);\n padding: var(--spacing-2-5, 0.625rem) var(--spacing-4, 1rem);\n font-size: var(--font-size-sm, 0.8125rem);\n /* Hairline divider between rows — matches the subtle separator pattern\n * used in the r3 reference. The first row has no top border so the\n * block sits flush against any panel chrome above it. */\n border-top: var(--stroke-width-thin, 1px) solid var(--border-subtle, transparent);\n}\n\n.row:first-child {\n border-top: 0;\n}\n\n/* Subtle hover affordance — rows are not always interactive, but a\n * gentle background shift signals where focus would land if a primary\n * link is present. */\n.row:hover {\n background-color: var(--surface-hover, transparent);\n}\n\n/* Compact density — shaves vertical padding for tighter rails. */\n.rootCompact .row {\n padding: var(--spacing-2, 0.5rem) var(--spacing-4, 1rem);\n}\n\n.leading {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n font-size: var(--font-size-xs, 0.6875rem);\n color: var(--text-tertiary);\n font-variant-numeric: tabular-nums;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n min-width: var(--spacing-9, 2.25rem);\n}\n\n.primary {\n flex: 1;\n min-width: 0;\n color: var(--text-primary);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.trailing {\n flex-shrink: 0;\n font-variant-numeric: tabular-nums;\n color: var(--text-tertiary);\n}\n\n/* Tone variants — each tone binds to a Visor semantic text token so the\n * block adopts the active theme's color palette without modification.\n * Pattern mirrors status-badge.module.css. */\n.trailing[data-tone=\"mint\"] {\n color: var(--text-success);\n font-weight: 500;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n font-size: var(--font-size-xs, 0.6875rem);\n}\n\n.trailing[data-tone=\"muted\"] {\n color: var(--text-tertiary);\n}\n\n.trailing[data-tone=\"warn\"] {\n color: var(--text-warning);\n font-weight: 500;\n}\n\n.trailing[data-tone=\"danger\"] {\n color: var(--text-danger, var(--text-error));\n font-weight: 500;\n}\n\n.trailing[data-tone=\"info\"] {\n color: var(--text-info);\n font-weight: 500;\n}\n\n/* Respect users who prefer reduced motion — the hover transition is\n * cosmetic, but we omit it under the preference. */\n@media (prefers-reduced-motion: no-preference) {\n .row {\n transition: background-color var(--motion-duration-100, 120ms)\n var(--motion-easing-standard, ease-out);\n }\n}\n"
3525
+ }
3526
+ ]
3527
+ },
3175
3528
  {
3176
3529
  "name": "workspace-switcher",
3177
3530
  "type": "registry:block",
@@ -3199,6 +3552,64 @@
3199
3552
  }
3200
3553
  ]
3201
3554
  },
3555
+ {
3556
+ "name": "profile-menu",
3557
+ "type": "registry:block",
3558
+ "description": "Sidebar-footer profile menu composing an avatar + identity row trigger and an upward-opening DropdownMenu of account, notifications, appearance, keyboard shortcut, help, and sign-out items. Supports status dots, badges, and per-item shortcuts. Drop-in for the AdminShell sidebarFooter slot.",
3559
+ "category": "admin",
3560
+ "dependencies": [
3561
+ "@loworbitstudio/visor-core",
3562
+ "@phosphor-icons/react"
3563
+ ],
3564
+ "registryDependencies": [
3565
+ "utils",
3566
+ "avatar",
3567
+ "dropdown-menu"
3568
+ ],
3569
+ "files": [
3570
+ {
3571
+ "path": "blocks/profile-menu/profile-menu.tsx",
3572
+ "type": "registry:block",
3573
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n BellIcon,\n CaretUpDownIcon,\n CommandIcon,\n MoonIcon,\n QuestionIcon,\n SignOutIcon,\n UserCircleIcon,\n} from \"@phosphor-icons/react\"\nimport { cn } from \"../../lib/utils\"\nimport {\n Avatar,\n AvatarFallback,\n AvatarImage,\n} from \"../../components/ui/avatar/avatar\"\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuShortcut,\n DropdownMenuTrigger,\n} from \"../../components/ui/dropdown-menu/dropdown-menu\"\nimport styles from \"./profile-menu.module.css\"\n\nexport type ProfileMenuStatus = \"online\" | \"away\" | \"busy\" | \"offline\"\n\nconst STATUS_LABEL: Record<ProfileMenuStatus, string> = {\n online: \"Online\",\n away: \"Away\",\n busy: \"Busy\",\n offline: \"Offline\",\n}\n\nexport interface ProfileMenuUser {\n name: string\n email?: string\n avatarUrl?: string\n initials?: string\n status?: ProfileMenuStatus\n}\n\nexport interface ProfileMenuContext {\n label: string\n icon?: React.ReactNode\n}\n\nexport type ProfileMenuItem =\n | {\n type: \"item\"\n icon?: React.ReactNode\n label: string\n shortcut?: string\n badge?: React.ReactNode\n variant?: \"default\" | \"destructive\"\n onSelect?: () => void\n }\n | { type: \"separator\" }\n | { type: \"label\"; text: string }\n\nexport interface ProfileMenuProps {\n user: ProfileMenuUser\n context?: ProfileMenuContext\n items: ProfileMenuItem[]\n onSignOut?: () => void\n /** Register a window-level ⌘⇧Q / Ctrl+⇧+Q handler that calls onSignOut. Default false. */\n enableGlobalShortcuts?: boolean\n /** Open direction. Default \"top\" — footer is bottom-anchored. */\n side?: \"top\" | \"bottom\" | \"auto\"\n className?: string\n}\n\nfunction deriveInitials(name: string): string {\n const parts = name.trim().split(/\\s+/).filter(Boolean)\n if (parts.length === 0) return \"?\"\n if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase()\n return ((parts[0]![0] ?? \"\") + (parts[parts.length - 1]![0] ?? \"\")).toUpperCase()\n}\n\nexport function ProfileMenu({\n user,\n context,\n items,\n onSignOut,\n enableGlobalShortcuts = false,\n side = \"top\",\n className,\n}: ProfileMenuProps) {\n const initials = user.initials ?? deriveInitials(user.name)\n const triggerLabel = context?.label\n ? `Account menu · ${user.name} · ${context.label}`\n : `Account menu · ${user.name}`\n\n React.useEffect(() => {\n if (!enableGlobalShortcuts || !onSignOut) return\n function handler(event: KeyboardEvent) {\n const mod = event.metaKey || event.ctrlKey\n if (mod && event.shiftKey && (event.key === \"Q\" || event.key === \"q\")) {\n event.preventDefault()\n onSignOut?.()\n }\n }\n window.addEventListener(\"keydown\", handler)\n return () => window.removeEventListener(\"keydown\", handler)\n }, [enableGlobalShortcuts, onSignOut])\n\n const contentSide = side === \"auto\" ? undefined : side\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <button\n type=\"button\"\n aria-label={triggerLabel}\n data-slot=\"profile-menu-trigger\"\n className={cn(styles.trigger, className)}\n >\n <span className={styles.triggerAvatarWrap}>\n <Avatar size=\"default\" className={styles.triggerAvatar} aria-hidden=\"true\">\n {user.avatarUrl ? (\n <AvatarImage src={user.avatarUrl} alt=\"\" />\n ) : null}\n <AvatarFallback aria-hidden=\"true\">{initials}</AvatarFallback>\n </Avatar>\n {user.status ? (\n <span\n className={styles.statusDot}\n data-status={user.status}\n role=\"img\"\n aria-label={STATUS_LABEL[user.status]}\n />\n ) : null}\n </span>\n\n <span className={styles.triggerIdentity} aria-hidden=\"true\">\n <span className={styles.triggerName}>{user.name}</span>\n {context ? (\n <span className={styles.triggerContext}>\n {context.icon ? (\n <span className={styles.triggerContextIcon} aria-hidden=\"true\">\n {context.icon}\n </span>\n ) : null}\n <span className={styles.triggerContextLabel}>{context.label}</span>\n </span>\n ) : null}\n </span>\n\n <CaretUpDownIcon\n size={14}\n weight=\"regular\"\n aria-hidden=\"true\"\n className={styles.triggerCaret}\n />\n </button>\n </DropdownMenuTrigger>\n\n <DropdownMenuContent\n side={contentSide}\n align=\"start\"\n sideOffset={6}\n className={styles.content}\n >\n {user.email ? (\n <DropdownMenuLabel className={styles.menuHeader}>\n <span className={styles.menuHeaderHint}>Signed in as</span>\n <span className={styles.menuHeaderEmail}>{user.email}</span>\n </DropdownMenuLabel>\n ) : null}\n\n {items.map((entry, index) => {\n if (entry.type === \"separator\") {\n return <DropdownMenuSeparator key={`sep-${index}`} />\n }\n if (entry.type === \"label\") {\n return (\n <DropdownMenuLabel key={`label-${index}`}>\n {entry.text}\n </DropdownMenuLabel>\n )\n }\n return (\n <DropdownMenuItem\n key={`item-${index}-${entry.label}`}\n variant={entry.variant ?? \"default\"}\n onSelect={entry.onSelect}\n className={styles.item}\n data-slot=\"profile-menu-item\"\n >\n {entry.icon ? (\n <span className={styles.itemIcon} aria-hidden=\"true\">\n {entry.icon}\n </span>\n ) : null}\n <span className={styles.itemLabel}>{entry.label}</span>\n {entry.badge != null ? (\n <span className={styles.itemBadge} data-slot=\"profile-menu-item-badge\">\n {entry.badge}\n </span>\n ) : null}\n {entry.shortcut ? (\n <DropdownMenuShortcut className={styles.itemShortcut}>\n {entry.shortcut}\n </DropdownMenuShortcut>\n ) : null}\n </DropdownMenuItem>\n )\n })}\n </DropdownMenuContent>\n </DropdownMenu>\n )\n}\n\nexport interface DefaultProfileMenuOptions {\n onSignOut?: () => void\n notificationCount?: number\n}\n\nexport function defaultProfileMenuItems(\n user: ProfileMenuUser,\n opts: DefaultProfileMenuOptions = {}\n): ProfileMenuItem[] {\n void user\n const items: ProfileMenuItem[] = [\n {\n type: \"item\",\n icon: <UserCircleIcon size={16} weight=\"regular\" />,\n label: \"Account settings\",\n },\n {\n type: \"item\",\n icon: <BellIcon size={16} weight=\"regular\" />,\n label: \"Notifications\",\n badge:\n typeof opts.notificationCount === \"number\" && opts.notificationCount > 0\n ? opts.notificationCount\n : undefined,\n },\n {\n type: \"item\",\n icon: <MoonIcon size={16} weight=\"regular\" />,\n label: \"Appearance\",\n },\n {\n type: \"item\",\n icon: <CommandIcon size={16} weight=\"regular\" />,\n label: \"Keyboard shortcuts\",\n shortcut: \"⌘/\",\n },\n {\n type: \"item\",\n icon: <QuestionIcon size={16} weight=\"regular\" />,\n label: \"Help & docs\",\n },\n { type: \"separator\" },\n {\n type: \"item\",\n icon: <SignOutIcon size={16} weight=\"regular\" />,\n label: \"Sign out\",\n shortcut: \"⌘⇧Q\",\n variant: \"destructive\",\n onSelect: opts.onSignOut,\n },\n ]\n return items\n}\n"
3574
+ },
3575
+ {
3576
+ "path": "blocks/profile-menu/profile-menu.module.css",
3577
+ "type": "registry:block",
3578
+ "content": "/* Trigger button */\n.trigger {\n display: flex;\n align-items: center;\n gap: var(--spacing-3, 0.75rem);\n width: 100%;\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n background: transparent;\n color: var(--text-primary, #111827);\n border: 1px solid transparent;\n border-radius: var(--radius-md, 0.375rem);\n font-family: inherit;\n font-size: var(--font-size-sm, 0.875rem);\n text-align: start;\n cursor: pointer;\n min-width: 0;\n transition:\n background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n border-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.trigger:hover {\n background: var(--surface-interactive-hover, var(--surface-hover, #f3f4f6));\n}\n\n.trigger:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, var(--accent-primary, #3b82f6));\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.trigger[data-state=\"open\"] {\n background: var(--surface-interactive-hover, var(--surface-hover, #f3f4f6));\n}\n\n.triggerAvatarWrap {\n position: relative;\n flex-shrink: 0;\n display: inline-flex;\n}\n\n.triggerAvatar {\n flex-shrink: 0;\n}\n\n.statusDot {\n position: absolute;\n bottom: 0;\n right: 0;\n width: var(--spacing-2-5, 0.625rem);\n height: var(--spacing-2-5, 0.625rem);\n border-radius: 999px;\n border: var(--stroke-width-medium, 2px) solid\n var(--surface-card, #ffffff);\n background: var(--text-tertiary, #6b7280);\n}\n\n.statusDot[data-status=\"online\"] {\n background: var(--status-success, #10b981);\n}\n\n.statusDot[data-status=\"away\"] {\n background: var(--status-warning, #f59e0b);\n}\n\n.statusDot[data-status=\"busy\"] {\n background: var(--status-error, #ef4444);\n}\n\n.statusDot[data-status=\"offline\"] {\n background: var(--text-tertiary, #6b7280);\n}\n\n.triggerIdentity {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n flex: 1 1 auto;\n min-width: 0;\n line-height: 1.2;\n}\n\n.triggerName {\n font-weight: var(--font-weight-semibold, 600);\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-primary, #111827);\n width: 100%;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.triggerContext {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n font-weight: var(--font-weight-regular, 400);\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--text-tertiary, var(--text-secondary, #6b7280));\n width: 100%;\n min-width: 0;\n margin-top: var(--spacing-0-5, 0.125rem);\n}\n\n.triggerContextIcon {\n display: inline-flex;\n flex-shrink: 0;\n width: 0.875rem;\n height: 0.875rem;\n}\n\n.triggerContextLabel {\n min-width: 0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.triggerCaret {\n flex-shrink: 0;\n margin-inline-start: auto;\n color: var(--text-tertiary, var(--text-secondary, #6b7280));\n}\n\n/* Menu content */\n.content {\n min-width: 14rem;\n}\n\n.menuHeader {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-0-5, 0.125rem);\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n}\n\n.menuHeaderHint {\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--text-tertiary, var(--text-secondary, #6b7280));\n}\n\n.menuHeaderEmail {\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n color: var(--text-primary, #111827);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* Item rows */\n.item {\n gap: var(--spacing-2, 0.5rem);\n}\n\n.itemIcon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 1rem;\n height: 1rem;\n flex-shrink: 0;\n color: var(--text-secondary, #6b7280);\n}\n\n.itemLabel {\n flex: 1 1 auto;\n min-width: 0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.itemBadge {\n margin-inline-start: auto;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 1.25rem;\n padding: 0 var(--spacing-1-5, 0.375rem);\n border-radius: 999px;\n background: var(--accent-subtle, color-mix(in srgb, var(--accent-primary, #3b82f6) 18%, transparent));\n color: var(--accent-primary, #3b82f6);\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-semibold, 600);\n line-height: 1.4;\n}\n\n/* When both badge and shortcut are present, keep shortcut after badge. */\n.itemBadge + .itemShortcut {\n margin-inline-start: var(--spacing-2, 0.5rem);\n}\n\n.itemShortcut {\n /* DropdownMenuShortcut already aligns to the end via margin-left: auto.\n When a badge precedes it, we override margin-left above to keep spacing. */\n}\n"
3579
+ }
3580
+ ]
3581
+ },
3582
+ {
3583
+ "name": "export-menu",
3584
+ "type": "registry:block",
3585
+ "description": "Export button composing a Popover that hosts a format-picker RadioGroup (CSV / JSON / PDF / custom), optional scope toggles (Include archived, Include suspended, …), and an async-aware Cancel/Export footer. Standardizes the export affordance across every admin list.",
3586
+ "category": "admin",
3587
+ "dependencies": [
3588
+ "@loworbitstudio/visor-core",
3589
+ "@phosphor-icons/react"
3590
+ ],
3591
+ "registryDependencies": [
3592
+ "utils",
3593
+ "button",
3594
+ "popover",
3595
+ "radio-group",
3596
+ "checkbox",
3597
+ "label",
3598
+ "tooltip"
3599
+ ],
3600
+ "files": [
3601
+ {
3602
+ "path": "blocks/export-menu/export-menu.tsx",
3603
+ "type": "registry:block",
3604
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { DownloadSimpleIcon, SpinnerGapIcon } from \"@phosphor-icons/react\"\nimport { cn } from \"../../lib/utils\"\nimport { Button, type ButtonProps } from \"../../components/ui/button/button\"\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"../../components/ui/popover/popover\"\nimport {\n RadioGroup,\n RadioGroupItem,\n} from \"../../components/ui/radio-group/radio-group\"\nimport { Checkbox } from \"../../components/ui/checkbox/checkbox\"\nimport { Label } from \"../../components/ui/label/label\"\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from \"../../components/ui/tooltip/tooltip\"\nimport styles from \"./export-menu.module.css\"\n\nexport interface ExportFormat {\n value: string\n label: React.ReactNode\n description?: React.ReactNode\n icon?: React.ReactNode\n disabled?: boolean\n disabledReason?: string\n}\n\nexport interface ExportScope {\n key: string\n label: React.ReactNode\n defaultChecked?: boolean\n description?: React.ReactNode\n}\n\nexport interface ExportMenuProps {\n /** Trigger label. @default \"Export\" */\n label?: React.ReactNode\n /** Trigger icon. @default <DownloadSimple /> */\n icon?: React.ReactNode\n /** Available export formats. */\n formats: ExportFormat[]\n /** Optional scope toggles (e.g., \"Include archived\", \"Include suspended\"). */\n scopes?: ExportScope[]\n /** Submit handler — receives selected format + scope state. */\n onExport: (\n format: string,\n scopes: Record<string, boolean>\n ) => void | Promise<void>\n /** Trigger variant. @default \"secondary\" */\n triggerVariant?: \"primary\" | \"secondary\" | \"ghost\"\n /** Override the popover header text. Defaults to the trigger `label`. */\n heading?: React.ReactNode\n /** Forwarded to the trigger button. */\n className?: string\n}\n\nconst TRIGGER_VARIANT_MAP: Record<\n NonNullable<ExportMenuProps[\"triggerVariant\"]>,\n NonNullable<ButtonProps[\"variant\"]>\n> = {\n primary: \"default\",\n secondary: \"secondary\",\n ghost: \"ghost\",\n}\n\nexport function ExportMenu({\n label = \"Export\",\n icon = <DownloadSimpleIcon size={16} weight=\"regular\" />,\n formats,\n scopes,\n onExport,\n triggerVariant = \"secondary\",\n heading,\n className,\n}: ExportMenuProps) {\n const [open, setOpen] = React.useState(false)\n const [submitting, setSubmitting] = React.useState(false)\n\n const initialFormat = React.useMemo(() => {\n const firstEnabled = formats.find((f) => !f.disabled)\n return firstEnabled?.value ?? formats[0]?.value ?? \"\"\n }, [formats])\n\n const initialScopeState = React.useMemo(() => {\n const init: Record<string, boolean> = {}\n for (const s of scopes ?? []) {\n init[s.key] = s.defaultChecked ?? false\n }\n return init\n }, [scopes])\n\n const [selectedFormat, setSelectedFormat] = React.useState(initialFormat)\n const [scopeState, setScopeState] =\n React.useState<Record<string, boolean>>(initialScopeState)\n\n // Reset state each time the popover opens fresh.\n React.useEffect(() => {\n if (open) {\n setSelectedFormat(initialFormat)\n setScopeState(initialScopeState)\n }\n }, [open, initialFormat, initialScopeState])\n\n const handleOpenChange = React.useCallback(\n (next: boolean) => {\n if (submitting && !next) return\n setOpen(next)\n },\n [submitting]\n )\n\n const handleExport = React.useCallback(async () => {\n if (!selectedFormat) return\n const result = onExport(selectedFormat, scopeState)\n if (result && typeof (result as Promise<void>).then === \"function\") {\n setSubmitting(true)\n try {\n await result\n setSubmitting(false)\n setOpen(false)\n } catch (err) {\n // Keep the popover open so the user can retry; clear pending state\n // and re-throw so consumer error handling (toast, etc.) can surface\n // the failure.\n setSubmitting(false)\n throw err\n }\n } else {\n setOpen(false)\n }\n }, [onExport, selectedFormat, scopeState])\n\n const handleContentKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>) => {\n if (event.key !== \"Enter\") return\n if (submitting) return\n const target = event.target as HTMLElement | null\n // Footer buttons handle Enter natively — let the browser fire them.\n if (target?.closest('[data-slot=\"export-menu-cancel\"]')) return\n if (target?.closest('[data-slot=\"export-menu-submit\"]')) return\n event.preventDefault()\n handleExport()\n },\n [submitting, handleExport]\n )\n\n const buttonVariant = TRIGGER_VARIANT_MAP[triggerVariant]\n const headerText = heading ?? label\n\n return (\n <Popover open={open} onOpenChange={handleOpenChange}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant={buttonVariant}\n aria-haspopup=\"dialog\"\n className={cn(styles.trigger, className)}\n data-slot=\"export-menu-trigger\"\n >\n {icon ? (\n <span className={styles.triggerIcon} aria-hidden=\"true\">\n {icon}\n </span>\n ) : null}\n <span className={styles.triggerLabel}>{label}</span>\n </Button>\n </PopoverTrigger>\n <PopoverContent\n align=\"end\"\n sideOffset={6}\n role=\"dialog\"\n aria-label={typeof headerText === \"string\" ? headerText : \"Export\"}\n className={styles.content}\n data-slot=\"export-menu-content\"\n onKeyDown={handleContentKeyDown}\n >\n <TooltipProvider delayDuration={200}>\n <div className={styles.header} data-slot=\"export-menu-header\">\n {headerText}\n </div>\n\n <RadioGroup\n value={selectedFormat}\n onValueChange={setSelectedFormat}\n className={styles.formatList}\n >\n {formats.map((fmt) => {\n const itemId = `export-fmt-${fmt.value}`\n const row = (\n <div\n className={styles.formatRow}\n data-disabled={fmt.disabled || undefined}\n data-slot=\"export-menu-format\"\n data-value={fmt.value}\n >\n <RadioGroupItem\n id={itemId}\n value={fmt.value}\n disabled={fmt.disabled}\n />\n <Label htmlFor={itemId} className={styles.formatLabel}>\n {fmt.icon ? (\n <span className={styles.formatIcon} aria-hidden=\"true\">\n {fmt.icon}\n </span>\n ) : null}\n <span className={styles.formatLabelText}>\n <span className={styles.formatLabelMain}>\n {fmt.label}\n </span>\n {fmt.description ? (\n <span className={styles.formatDescription}>\n {fmt.description}\n </span>\n ) : null}\n </span>\n </Label>\n </div>\n )\n\n if (fmt.disabled && fmt.disabledReason) {\n return (\n <Tooltip key={fmt.value}>\n <TooltipTrigger asChild>{row}</TooltipTrigger>\n <TooltipContent side=\"left\">\n {fmt.disabledReason}\n </TooltipContent>\n </Tooltip>\n )\n }\n return <React.Fragment key={fmt.value}>{row}</React.Fragment>\n })}\n </RadioGroup>\n\n {scopes && scopes.length > 0 ? (\n <div\n className={styles.scopeSection}\n data-slot=\"export-menu-scopes\"\n >\n {scopes.map((scope) => {\n const scopeId = `export-scope-${scope.key}`\n const checked = scopeState[scope.key] ?? false\n return (\n <div key={scope.key} className={styles.scopeRow}>\n <Checkbox\n id={scopeId}\n checked={checked}\n onCheckedChange={(next) =>\n setScopeState((s) => ({\n ...s,\n [scope.key]: next === true,\n }))\n }\n data-slot=\"export-menu-scope\"\n />\n <Label htmlFor={scopeId} className={styles.scopeLabel}>\n <span className={styles.scopeLabelMain}>\n {scope.label}\n </span>\n {scope.description ? (\n <span className={styles.scopeDescription}>\n {scope.description}\n </span>\n ) : null}\n </Label>\n </div>\n )\n })}\n </div>\n ) : null}\n\n <div className={styles.footer} data-slot=\"export-menu-footer\">\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={() => handleOpenChange(false)}\n disabled={submitting}\n data-slot=\"export-menu-cancel\"\n >\n Cancel\n </Button>\n <Button\n type=\"button\"\n variant=\"default\"\n onClick={handleExport}\n disabled={submitting || !selectedFormat}\n aria-busy={submitting || undefined}\n data-slot=\"export-menu-submit\"\n className={styles.submitButton}\n >\n {submitting ? (\n <SpinnerGapIcon\n size={14}\n weight=\"bold\"\n aria-hidden=\"true\"\n className={styles.spinner}\n data-slot=\"export-menu-spinner\"\n />\n ) : null}\n <span>Export</span>\n </Button>\n </div>\n </TooltipProvider>\n </PopoverContent>\n </Popover>\n )\n}\n\nexport function defaultExportFormats(): ExportFormat[] {\n return [\n {\n value: \"csv\",\n label: \"CSV\",\n description: \"Comma-separated values, opens in Excel\",\n },\n {\n value: \"json\",\n label: \"JSON\",\n description: \"Structured data for developers\",\n },\n {\n value: \"pdf\",\n label: \"PDF\",\n description: \"Printable document\",\n },\n ]\n}\n"
3605
+ },
3606
+ {
3607
+ "path": "blocks/export-menu/export-menu.module.css",
3608
+ "type": "registry:block",
3609
+ "content": "/* Trigger */\n.trigger {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n}\n\n.triggerIcon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n}\n\n.triggerLabel {\n white-space: nowrap;\n}\n\n/* Popover content */\n.content {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-3, 0.75rem);\n width: 18rem;\n max-width: calc(100vw - var(--spacing-4, 1rem) * 2);\n padding: var(--spacing-3, 0.75rem);\n}\n\n.header {\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-semibold, 600);\n color: var(--text-primary, #111827);\n padding: 0 var(--spacing-1, 0.25rem);\n}\n\n/* Format list */\n.formatList {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-1, 0.25rem);\n}\n\n.formatRow {\n display: flex;\n align-items: flex-start;\n gap: var(--spacing-2, 0.5rem);\n padding: var(--spacing-2, 0.5rem);\n border-radius: var(--radius-md, 0.375rem);\n transition:\n background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.formatRow:hover:not([data-disabled]) {\n background: var(--surface-interactive-hover, var(--surface-hover, #f3f4f6));\n}\n\n.formatRow[data-disabled] {\n opacity: var(--opacity-50, 0.5);\n cursor: not-allowed;\n}\n\n.formatLabel {\n display: flex;\n align-items: flex-start;\n gap: var(--spacing-2, 0.5rem);\n flex: 1 1 auto;\n min-width: 0;\n cursor: pointer;\n font-weight: var(--font-weight-regular, 400);\n}\n\n.formatRow[data-disabled] .formatLabel {\n cursor: not-allowed;\n}\n\n.formatIcon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 1rem;\n height: 1rem;\n flex-shrink: 0;\n color: var(--text-secondary, #6b7280);\n margin-top: 0.125rem;\n}\n\n.formatLabelText {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-0-5, 0.125rem);\n min-width: 0;\n line-height: 1.3;\n}\n\n.formatLabelMain {\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n color: var(--text-primary, #111827);\n}\n\n.formatDescription {\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--text-tertiary, var(--text-secondary, #6b7280));\n}\n\n/* Scope section */\n.scopeSection {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-2, 0.5rem);\n padding-top: var(--spacing-3, 0.75rem);\n border-top: var(--stroke-width-thin, 1px) solid\n var(--border-default, #e5e7eb);\n}\n\n.scopeRow {\n display: flex;\n align-items: flex-start;\n gap: var(--spacing-2, 0.5rem);\n padding: 0 var(--spacing-1, 0.25rem);\n}\n\n.scopeLabel {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-0-5, 0.125rem);\n flex: 1 1 auto;\n min-width: 0;\n cursor: pointer;\n font-weight: var(--font-weight-regular, 400);\n line-height: 1.3;\n}\n\n.scopeLabelMain {\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-primary, #111827);\n}\n\n.scopeDescription {\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--text-tertiary, var(--text-secondary, #6b7280));\n}\n\n/* Footer */\n.footer {\n display: flex;\n justify-content: flex-end;\n gap: var(--spacing-2, 0.5rem);\n padding-top: var(--spacing-3, 0.75rem);\n border-top: var(--stroke-width-thin, 1px) solid\n var(--border-default, #e5e7eb);\n}\n\n.submitButton {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1-5, 0.375rem);\n}\n\n/* Spinner */\n.spinner {\n animation: visor-export-menu-spin var(--motion-duration-800, 800ms)\n var(--motion-easing-linear, linear) infinite;\n flex-shrink: 0;\n}\n\n@keyframes visor-export-menu-spin {\n to {\n transform: rotate(360deg);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .spinner {\n animation-duration: calc(var(--motion-duration-800, 800ms) * 3);\n }\n .formatRow {\n transition: none;\n }\n}\n"
3610
+ }
3611
+ ]
3612
+ },
3202
3613
  {
3203
3614
  "name": "configuration-panel",
3204
3615
  "type": "registry:block",
@@ -3581,6 +3992,31 @@
3581
3992
  }
3582
3993
  ]
3583
3994
  },
3995
+ {
3996
+ "name": "avatar-stack",
3997
+ "type": "registry:block",
3998
+ "description": "Overlapping avatar group with `+N more` overflow indicator. Pure composition of the Avatar primitive — supports sm / default / lg sizes, configurable max, server-truncated counts, and an overridable accessible label.",
3999
+ "category": "data-display",
4000
+ "dependencies": [
4001
+ "@loworbitstudio/visor-core"
4002
+ ],
4003
+ "registryDependencies": [
4004
+ "utils",
4005
+ "avatar"
4006
+ ],
4007
+ "files": [
4008
+ {
4009
+ "path": "blocks/avatar-stack/avatar-stack.tsx",
4010
+ "type": "registry:block",
4011
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n Avatar,\n AvatarFallback,\n AvatarImage,\n} from \"../../components/ui/avatar/avatar\"\nimport { cn } from \"../../lib/utils\"\nimport styles from \"./avatar-stack.module.css\"\n\nexport interface AvatarStackProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"role\" | \"aria-label\"> {\n /**\n * Avatar image sources to render, in display order. `undefined` entries\n * render with the `·` fallback so server-truncated lists still occupy a\n * slot.\n */\n avatars: (string | undefined)[]\n /**\n * Total member count. May exceed `avatars.length` when the caller has\n * server-truncated the avatar URLs and only knows the count. The overflow\n * indicator is computed against this value.\n */\n total: number\n /**\n * Maximum number of avatar slots rendered before the `+N` overflow\n * indicator. Defaults to `6`.\n */\n max?: number\n /** Avatar size. Defaults to `\"sm\"`. */\n size?: \"sm\" | \"default\" | \"lg\"\n /**\n * Accessible label override. Defaults to ``${total} members``.\n */\n label?: string\n}\n\nconst AvatarStack = React.forwardRef<HTMLDivElement, AvatarStackProps>(\n function AvatarStack(\n {\n avatars,\n total,\n max = 6,\n size = \"sm\",\n label,\n className,\n ...rest\n },\n ref,\n ) {\n const visible = avatars.slice(0, max)\n const overflow = Math.max(0, total - visible.length)\n const ariaLabel = label ?? `${total} members`\n\n return (\n <div\n ref={ref}\n role=\"img\"\n aria-label={ariaLabel}\n data-slot=\"avatar-stack\"\n data-size={size}\n className={cn(styles.root, className)}\n {...rest}\n >\n {visible.map((src, index) => (\n <Avatar\n key={index}\n size={size}\n className={styles.avatar}\n data-stack-item=\"\"\n >\n {src ? (\n <AvatarImage src={src} alt=\"\" />\n ) : (\n <AvatarFallback>·</AvatarFallback>\n )}\n </Avatar>\n ))}\n {overflow > 0 ? (\n <Avatar\n size={size}\n className={styles.avatar}\n data-stack-overflow=\"\"\n >\n <AvatarFallback>+{overflow}</AvatarFallback>\n </Avatar>\n ) : null}\n </div>\n )\n },\n)\n\nAvatarStack.displayName = \"AvatarStack\"\n\nexport { AvatarStack }\n"
4012
+ },
4013
+ {
4014
+ "path": "blocks/avatar-stack/avatar-stack.module.css",
4015
+ "type": "registry:block",
4016
+ "content": "/* AvatarStack root */\n.root {\n display: inline-flex;\n flex-direction: row;\n align-items: center;\n isolation: isolate;\n}\n\n/* Each avatar gets a ring matching the parent surface so adjacent avatars\n read as separate circles. Avatar's own `overflow: hidden` clips inner\n shadows, so the ring is projected outward via box-shadow. */\n.avatar {\n box-shadow: 0 0 0 var(--stroke-width-medium, 2px) var(--surface-default, #ffffff);\n}\n\n/* Overlap every avatar except the first. Later siblings stack on top of\n earlier ones because `isolation: isolate` establishes a single stacking\n context and DOM order wins inside it. */\n.avatar:not(:first-child) {\n margin-inline-start: calc(-1 * var(--spacing-2, 0.5rem));\n}\n"
4017
+ }
4018
+ ]
4019
+ },
3584
4020
  {
3585
4021
  "name": "chip-group",
3586
4022
  "type": "registry:block",
@@ -3747,6 +4183,30 @@
3747
4183
  }
3748
4184
  ]
3749
4185
  },
4186
+ {
4187
+ "name": "prototype-review",
4188
+ "type": "registry:block",
4189
+ "description": "Drop-in chrome for design-review prototypes — theme switcher, light/dark mode toggle, brand color picker, treatment tabs, viewport switcher, and a multi-viewport iframe grid. Replaces hand-rolled review HTML (BL-193 style) with a Visor-native block. Implements a postMessage protocol so nested iframes can re-theme in sync.",
4190
+ "category": "documentation",
4191
+ "dependencies": [
4192
+ "@loworbitstudio/visor-core"
4193
+ ],
4194
+ "registryDependencies": [
4195
+ "utils"
4196
+ ],
4197
+ "files": [
4198
+ {
4199
+ "path": "blocks/prototype-review/prototype-review.tsx",
4200
+ "type": "registry:block",
4201
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"../../lib/utils\"\nimport styles from \"./prototype-review.module.css\"\n\n// Types\n\nexport type PrototypeReviewMode = \"light\" | \"dark\"\n\nexport interface PrototypeReviewTheme {\n /** Stable identifier (also used as the body/html class). */\n id: string\n /** Human-readable label. */\n label: string\n /** Class applied to documentElement while this theme is active. */\n themeClass: string\n}\n\nexport interface PrototypeReviewTreatment {\n /** Stable identifier, e.g. \"t1\". */\n id: string\n /** Short label, e.g. \"T1\". */\n label: string\n /** Long descriptive title. */\n title: string\n /** Long-form summary shown on the stage. */\n summary?: string\n /** Optional descriptive copy shown on the landing card. */\n description?: string\n /** Optional meta line shown on the landing card (e.g. \"Owns cases 2 + 3\"). */\n metaLabel?: string\n /** iframe src. Brand / theme / mode are appended via URL param. */\n src: string\n /** Optional inline tag pills shown above the iframe grid. */\n tags?: string[]\n}\n\nexport interface PrototypeReviewViewport {\n /** Stable identifier (e.g. \"mobile\"). */\n id: string\n /** Short label (e.g. \"Mobile\" or \"375\"). */\n label: string\n /** Width in pixels for the iframe and frame container. */\n width: number\n /** Height in pixels for the iframe. */\n height: number\n /** Display string for the dimensions chip, e.g. \"375 x 720\". */\n display?: string\n}\n\nexport interface PrototypeReviewBrandSwatch {\n /** Stable identifier. */\n id: string\n /** Hex string, format #RRGGBB. */\n hex: string\n /** Label / tooltip. */\n label: string\n}\n\nexport interface PrototypeReviewBrandConfig {\n /** Default hex if no URL param overrides it. */\n default: string\n /** Visible swatches. Default ships 6. */\n swatches?: PrototypeReviewBrandSwatch[]\n /** Show the hex input next to the swatches. Default true. */\n hexInput?: boolean\n /** Show the reset button. Default true. */\n reset?: boolean\n /** Disable the brand picker entirely. */\n disabled?: boolean\n}\n\nexport interface PrototypeReviewLandingCtxCard {\n heading: string\n body: React.ReactNode\n}\n\nexport interface PrototypeReviewLanding {\n /** Eyebrow text above the title. */\n eyebrow?: string\n /** Title for the landing page. */\n title: React.ReactNode\n /** Lede paragraph. */\n lede?: React.ReactNode\n /** Optional context cards beneath the treatment grid. */\n contextCards?: PrototypeReviewLandingCtxCard[]\n}\n\nexport interface PrototypeReviewFooter {\n /** Left-side text. */\n left?: React.ReactNode\n /** Right-side text. */\n right?: React.ReactNode\n}\n\nexport interface PrototypeReviewStatusPill {\n label: string\n /** Highlight with brand color. */\n brand?: boolean\n}\n\nexport interface PrototypeReviewState {\n treatmentId: string | null\n themeId: string\n mode: PrototypeReviewMode\n viewportId: string\n brand: string\n}\n\nexport interface PrototypeReviewHookResult extends PrototypeReviewState {\n setTreatment: (id: string | null) => void\n setTheme: (id: string) => void\n setMode: (mode: PrototypeReviewMode) => void\n setViewport: (id: string) => void\n setBrand: (hex: string) => void\n resetBrand: () => void\n}\n\nexport interface PrototypeReviewProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n /** Linear ticket id (e.g. \"BL-193\"). */\n ticketId: string\n /** Review label shown next to the ticket (e.g. \"Design Review\"). */\n reviewLabel?: string\n /** Sub-label / context line. */\n subLabel?: string\n /** Status pills shown top-right of the header. */\n statusPills?: PrototypeReviewStatusPill[]\n /** Treatments shown in the tab strip. */\n treatments: PrototypeReviewTreatment[]\n /** Landing-page content. */\n landing: PrototypeReviewLanding\n /** Viewport configurations. The first becomes the \"all\" preset. */\n viewports?: {\n items?: PrototypeReviewViewport[]\n /** Default selected viewport id. Default \"all\". */\n defaultId?: string\n /** Show the \"All\" mode that renders every viewport in a row. Default true. */\n allEnabled?: boolean\n }\n /** Brand picker configuration. */\n brand?: PrototypeReviewBrandConfig\n /** Theme definitions exposed in the theme switcher. */\n themes?: PrototypeReviewTheme[]\n /** Default theme id. Falls back to the first theme. */\n defaultThemeId?: string\n /** Default mode. Default \"dark\". */\n defaultMode?: PrototypeReviewMode\n /** Default treatment id. If unset, the landing is shown. */\n defaultTreatmentId?: string | null\n /** Footer slot config. */\n footer?: PrototypeReviewFooter\n /** Imperative hook for advanced consumers. */\n onStateChange?: (state: PrototypeReviewState) => void\n}\n\n// Defaults & helpers\n\nconst DEFAULT_VIEWPORTS: PrototypeReviewViewport[] = [\n { id: \"mobile\", label: \"375\", width: 375, height: 720, display: \"375 x 720\" },\n { id: \"tablet\", label: \"768\", width: 768, height: 900, display: \"768 x 900\" },\n {\n id: \"desktop\",\n label: \"1280\",\n width: 1280,\n height: 1100,\n display: \"1280 x 1100\",\n },\n]\n\nconst DEFAULT_BRAND_SWATCHES: PrototypeReviewBrandSwatch[] = [\n { id: \"gold\", hex: \"#FFBE26\", label: \"Gold\" },\n { id: \"orange\", hex: \"#FF5A1F\", label: \"Hazard Orange\" },\n { id: \"red\", hex: \"#E60000\", label: \"Brutalist Red\" },\n { id: \"lime\", hex: \"#1AFF8F\", label: \"Lab Lime\" },\n { id: \"violet\", hex: \"#B388FF\", label: \"Ultraviolet\" },\n { id: \"blue\", hex: \"#3B82F6\", label: \"Cyan Wire\" },\n]\n\nconst HEX_RE = /^#([0-9A-Fa-f]{6})$/\n\nexport function isValidHex(value: string): boolean {\n return HEX_RE.test(value)\n}\n\nfunction readUrlParam(key: string): string | null {\n if (typeof window === \"undefined\") return null\n try {\n return new URL(window.location.href).searchParams.get(key)\n } catch {\n return null\n }\n}\n\nfunction appendIframeParams(\n src: string,\n params: { brand: string; themeClass: string; mode: PrototypeReviewMode }\n): string {\n const sep = src.includes(\"?\") ? \"&\" : \"?\"\n const qs = [\n \"brand=\" + encodeURIComponent(params.brand),\n \"theme=\" + encodeURIComponent(params.themeClass),\n \"mode=\" + encodeURIComponent(params.mode),\n ].join(\"&\")\n return src + sep + qs\n}\n\n// State hook (also exported for advanced consumers)\n\nexport interface UsePrototypeReviewOptions {\n treatments: PrototypeReviewTreatment[]\n themes: PrototypeReviewTheme[]\n defaultThemeId?: string\n defaultMode?: PrototypeReviewMode\n defaultTreatmentId?: string | null\n defaultViewportId?: string\n defaultBrand: string\n}\n\nexport function usePrototypeReview(\n options: UsePrototypeReviewOptions\n): PrototypeReviewHookResult {\n const {\n themes,\n defaultThemeId,\n defaultMode = \"dark\",\n defaultTreatmentId = null,\n defaultViewportId = \"all\",\n defaultBrand,\n } = options\n\n const initialThemeId = React.useMemo(() => {\n const fromParam = readUrlParam(\"theme\")\n if (fromParam) {\n const found = themes.find(\n (t) => t.id === fromParam || t.themeClass === fromParam\n )\n if (found) return found.id\n }\n if (defaultThemeId) return defaultThemeId\n return themes[0]?.id ?? \"default\"\n }, [themes, defaultThemeId])\n\n const initialMode = React.useMemo<PrototypeReviewMode>(() => {\n const fromParam = readUrlParam(\"mode\")\n if (fromParam === \"light\" || fromParam === \"dark\") return fromParam\n return defaultMode\n }, [defaultMode])\n\n const initialBrand = React.useMemo(() => {\n const fromParam = readUrlParam(\"brand\")\n if (fromParam && isValidHex(fromParam)) return fromParam.toUpperCase()\n return isValidHex(defaultBrand) ? defaultBrand.toUpperCase() : \"#FFBE26\"\n }, [defaultBrand])\n\n const [treatmentId, setTreatmentId] = React.useState<string | null>(\n defaultTreatmentId\n )\n const [themeId, setThemeIdState] = React.useState(initialThemeId)\n const [mode, setModeState] = React.useState<PrototypeReviewMode>(initialMode)\n const [viewportId, setViewportIdState] = React.useState(defaultViewportId)\n const [brand, setBrandState] = React.useState(initialBrand)\n\n const setBrand = React.useCallback((hex: string) => {\n const normalized = hex.startsWith(\"#\") ? hex : \"#\" + hex\n if (isValidHex(normalized)) {\n setBrandState(normalized.toUpperCase())\n }\n }, [])\n\n const resetBrand = React.useCallback(() => {\n setBrandState(\n isValidHex(defaultBrand) ? defaultBrand.toUpperCase() : \"#FFBE26\"\n )\n }, [defaultBrand])\n\n return {\n treatmentId,\n themeId,\n mode,\n viewportId,\n brand,\n setTreatment: setTreatmentId,\n setTheme: setThemeIdState,\n setMode: setModeState,\n setViewport: setViewportIdState,\n setBrand,\n resetBrand,\n }\n}\n\n// PostMessage protocol\n\nexport interface PrototypeThemeMessage {\n type: \"prototype-theme\"\n themeClass: string\n mode: PrototypeReviewMode\n brand: string\n}\n\nfunction broadcastToIframes(\n container: HTMLElement | null,\n message: PrototypeThemeMessage\n): void {\n if (!container || typeof window === \"undefined\") return\n const iframes = container.querySelectorAll(\"iframe\")\n iframes.forEach((frame) => {\n if (frame.contentWindow) {\n try {\n frame.contentWindow.postMessage(message, \"*\")\n } catch {\n // Ignore cross-origin frames that refuse messages.\n }\n }\n })\n}\n\n// Header (internal)\n\ninterface HeaderProps {\n ticketId: string\n reviewLabel?: string\n subLabel?: string\n statusPills?: PrototypeReviewStatusPill[]\n brand: string\n}\n\nfunction Header({\n ticketId,\n reviewLabel,\n subLabel,\n statusPills = [],\n brand,\n}: HeaderProps): React.JSX.Element {\n return (\n <header\n className={styles.chrome}\n data-slot=\"prototype-review-header\"\n >\n <div className={styles.chromeRow}>\n <div className={styles.brand}>\n <div className={styles.brandMark} aria-hidden=\"true\">\n {ticketId.slice(0, 2)}\n </div>\n <div className={styles.brandText}>\n <span className={styles.brandName}>\n {ticketId}\n {reviewLabel ? \" · \" + reviewLabel : \"\"}\n </span>\n {subLabel ? (\n <span className={styles.brandSub}>{subLabel}</span>\n ) : null}\n </div>\n </div>\n <div className={styles.chromeActions}>\n {statusPills.map((pill, i) => (\n <span\n key={pill.label + \"-\" + i}\n className={cn(styles.pill, pill.brand && styles.pillBrand)}\n >\n {pill.label}\n </span>\n ))}\n <span\n className={cn(styles.pill, styles.pillBrand)}\n data-slot=\"prototype-review-brand-pill\"\n >\n {brand}\n </span>\n </div>\n </div>\n </header>\n )\n}\n\n// Controls (internal)\n\ninterface ControlsProps {\n treatments: PrototypeReviewTreatment[]\n treatmentId: string | null\n onTreatmentChange: (id: string | null) => void\n\n themes: PrototypeReviewTheme[]\n themeId: string\n onThemeChange: (id: string) => void\n\n mode: PrototypeReviewMode\n onModeChange: (mode: PrototypeReviewMode) => void\n\n viewports: PrototypeReviewViewport[]\n viewportId: string\n allEnabled: boolean\n onViewportChange: (id: string) => void\n\n brand: string\n brandConfig: Required<\n Pick<PrototypeReviewBrandConfig, \"swatches\" | \"hexInput\" | \"reset\">\n > &\n Pick<PrototypeReviewBrandConfig, \"disabled\">\n onBrandChange: (hex: string) => void\n onBrandReset: () => void\n}\n\nfunction Controls(props: ControlsProps): React.JSX.Element {\n const {\n treatments,\n treatmentId,\n onTreatmentChange,\n themes,\n themeId,\n onThemeChange,\n mode,\n onModeChange,\n viewports,\n viewportId,\n allEnabled,\n onViewportChange,\n brand,\n brandConfig,\n onBrandChange,\n onBrandReset,\n } = props\n\n const [hexDraft, setHexDraft] = React.useState(brand)\n\n React.useEffect(() => {\n setHexDraft(brand)\n }, [brand])\n\n return (\n <section\n className={styles.controls}\n data-slot=\"prototype-review-controls\"\n >\n <div className={styles.controlsRow}>\n <div\n className={styles.tabs}\n role=\"tablist\"\n aria-label=\"Prototype treatments\"\n >\n <button\n type=\"button\"\n role=\"tab\"\n aria-selected={treatmentId === null}\n className={styles.tab}\n onClick={() => onTreatmentChange(null)}\n data-active={treatmentId === null ? \"true\" : undefined}\n >\n Overview\n </button>\n {treatments.map((t) => {\n const selected = treatmentId === t.id\n return (\n <button\n key={t.id}\n type=\"button\"\n role=\"tab\"\n aria-selected={selected}\n className={styles.tab}\n onClick={() => onTreatmentChange(t.id)}\n data-active={selected ? \"true\" : undefined}\n >\n <span className={styles.tabId}>{t.label}</span>\n {t.title}\n </button>\n )\n })}\n </div>\n\n <div className={styles.controlGroup}>\n {themes.length > 1 ? (\n <div className={styles.themeGroup}>\n <span className={styles.controlLabel}>Theme</span>\n <select\n className={styles.themeSelect}\n value={themeId}\n onChange={(e) => onThemeChange(e.target.value)}\n aria-label=\"Theme\"\n >\n {themes.map((theme) => (\n <option key={theme.id} value={theme.id}>\n {theme.label}\n </option>\n ))}\n </select>\n </div>\n ) : null}\n\n <div className={styles.modeGroup} role=\"group\" aria-label=\"Mode\">\n <span className={styles.controlLabel}>Mode</span>\n <div className={styles.toggleButtons}>\n <button\n type=\"button\"\n className={styles.toggleButton}\n aria-pressed={mode === \"light\"}\n data-active={mode === \"light\" ? \"true\" : undefined}\n onClick={() => onModeChange(\"light\")}\n >\n Light\n </button>\n <button\n type=\"button\"\n className={styles.toggleButton}\n aria-pressed={mode === \"dark\"}\n data-active={mode === \"dark\" ? \"true\" : undefined}\n onClick={() => onModeChange(\"dark\")}\n >\n Dark\n </button>\n </div>\n </div>\n\n <div className={styles.vpGroup} role=\"group\" aria-label=\"Viewport\">\n <span className={styles.controlLabel}>View</span>\n <div className={styles.toggleButtons}>\n {allEnabled ? (\n <button\n type=\"button\"\n className={styles.toggleButton}\n aria-pressed={viewportId === \"all\"}\n data-active={viewportId === \"all\" ? \"true\" : undefined}\n onClick={() => onViewportChange(\"all\")}\n >\n All\n </button>\n ) : null}\n {viewports.map((vp) => {\n const pressed = viewportId === vp.id\n return (\n <button\n key={vp.id}\n type=\"button\"\n className={styles.toggleButton}\n aria-pressed={pressed}\n data-active={pressed ? \"true\" : undefined}\n onClick={() => onViewportChange(vp.id)}\n >\n {vp.label}\n </button>\n )\n })}\n </div>\n </div>\n </div>\n\n {!brandConfig.disabled ? (\n <div className={styles.picker} aria-label=\"Brand color\">\n <span className={styles.controlLabel}>Brand</span>\n <div\n className={styles.swatches}\n data-slot=\"prototype-review-swatches\"\n >\n {brandConfig.swatches.map((sw) => {\n const active = sw.hex.toUpperCase() === brand.toUpperCase()\n return (\n <button\n key={sw.id}\n type=\"button\"\n className={styles.swatch}\n data-active={active ? \"true\" : undefined}\n aria-label={\"Brand \" + sw.label}\n aria-pressed={active}\n title={sw.label}\n onClick={() => onBrandChange(sw.hex)}\n style={{ background: sw.hex }}\n />\n )\n })}\n </div>\n {brandConfig.hexInput ? (\n <input\n type=\"text\"\n className={styles.hexInput}\n value={hexDraft}\n maxLength={7}\n spellCheck={false}\n aria-label=\"Brand hex value\"\n onChange={(e) => {\n const next = e.target.value\n setHexDraft(next)\n const normalized = next.startsWith(\"#\") ? next : \"#\" + next\n if (isValidHex(normalized)) {\n onBrandChange(normalized)\n }\n }}\n />\n ) : null}\n {brandConfig.reset ? (\n <button\n type=\"button\"\n className={styles.resetButton}\n onClick={onBrandReset}\n >\n Reset\n </button>\n ) : null}\n </div>\n ) : null}\n </div>\n </section>\n )\n}\n\n// Landing (internal)\n\ninterface LandingProps {\n landing: PrototypeReviewLanding\n treatments: PrototypeReviewTreatment[]\n onTreatmentSelect: (id: string) => void\n}\n\nfunction Landing({\n landing,\n treatments,\n onTreatmentSelect,\n}: LandingProps): React.JSX.Element {\n return (\n <main\n className={styles.landing}\n data-slot=\"prototype-review-landing\"\n >\n {landing.eyebrow ? (\n <div className={styles.landingEyebrow}>{landing.eyebrow}</div>\n ) : null}\n <h1 className={styles.landingTitle}>{landing.title}</h1>\n {landing.lede ? (\n <p className={styles.landingLede}>{landing.lede}</p>\n ) : null}\n\n <div className={styles.protoGrid}>\n {treatments.map((t) => (\n <button\n key={t.id}\n type=\"button\"\n className={styles.protoCard}\n onClick={() => onTreatmentSelect(t.id)}\n >\n <span className={styles.protoCardId}>{t.label}</span>\n <span className={styles.protoCardTitle}>{t.title}</span>\n {t.description ? (\n <span className={styles.protoCardDesc}>{t.description}</span>\n ) : null}\n {t.metaLabel ? (\n <span className={styles.protoCardMeta}>{t.metaLabel}</span>\n ) : null}\n </button>\n ))}\n </div>\n\n {landing.contextCards && landing.contextCards.length > 0 ? (\n <div className={styles.ctxGrid}>\n {landing.contextCards.map((card, i) => (\n <div key={i} className={styles.ctxCard}>\n <h4>{card.heading}</h4>\n <p>{card.body}</p>\n </div>\n ))}\n </div>\n ) : null}\n </main>\n )\n}\n\n// Stage (internal)\n\ninterface StageProps {\n treatment: PrototypeReviewTreatment\n viewports: PrototypeReviewViewport[]\n viewportId: string\n brand: string\n themeClass: string\n mode: PrototypeReviewMode\n containerRef: React.RefObject<HTMLDivElement | null>\n}\n\nfunction Stage({\n treatment,\n viewports,\n viewportId,\n brand,\n themeClass,\n mode,\n containerRef,\n}: StageProps): React.JSX.Element {\n const activeViewports =\n viewportId === \"all\"\n ? viewports\n : viewports.filter((vp) => vp.id === viewportId)\n const solo = activeViewports.length === 1\n\n return (\n <section\n className={styles.stage}\n data-slot=\"prototype-review-stage\"\n >\n <div className={styles.stageMeta}>\n <div className={styles.stageTitleBlock}>\n <h2 className={styles.stageTitle}>\n <span>{treatment.label} · </span>\n <em>{treatment.title}</em>\n </h2>\n {treatment.summary ? (\n <p className={styles.stageSummary}>{treatment.summary}</p>\n ) : null}\n </div>\n {treatment.tags && treatment.tags.length > 0 ? (\n <div className={styles.stageTagRow}>\n {treatment.tags.map((tag, i) => (\n <span key={tag + \"-\" + i} className={styles.pill}>\n {tag}\n </span>\n ))}\n </div>\n ) : null}\n </div>\n\n <div\n ref={containerRef}\n className={cn(styles.viewportGrid, solo && styles.viewportGridSolo)}\n data-slot=\"prototype-review-viewport-grid\"\n data-solo={solo ? \"true\" : undefined}\n >\n {activeViewports.map((vp) => {\n const src = appendIframeParams(treatment.src, {\n brand,\n themeClass,\n mode,\n })\n return (\n <div\n key={vp.id}\n className={styles.vpFrame}\n data-vp={vp.id}\n style={solo ? { maxWidth: vp.width + \"px\" } : undefined}\n >\n <div className={styles.vpFrameHead}>\n <span className={styles.vpFrameName}>{vp.label}</span>\n {vp.display ? (\n <span className={styles.vpFrameDims}>{vp.display}</span>\n ) : null}\n </div>\n <div className={styles.vpIframeWrap}>\n <iframe\n src={src}\n width={vp.width}\n height={vp.height}\n loading=\"lazy\"\n title={treatment.label + \" \" + treatment.title + \" — \" + vp.label}\n className={styles.vpIframe}\n data-viewport={vp.id}\n />\n </div>\n </div>\n )\n })}\n </div>\n </section>\n )\n}\n\n// Footer (internal)\n\ninterface FooterPropsInternal {\n footer?: PrototypeReviewFooter\n brand: string\n}\n\nfunction Footer({\n footer,\n brand,\n}: FooterPropsInternal): React.JSX.Element | null {\n if (!footer) return null\n return (\n <footer className={styles.foot} data-slot=\"prototype-review-footer\">\n {footer.left ? <span>{footer.left}</span> : <span />}\n <span data-slot=\"prototype-review-active-brand\">Brand · {brand}</span>\n {footer.right ? <span>{footer.right}</span> : <span />}\n </footer>\n )\n}\n\n// Block\n\nconst FALLBACK_THEME: PrototypeReviewTheme = {\n id: \"visor-default\",\n label: \"Default\",\n themeClass: \"visor-theme-default\",\n}\n\nexport function PrototypeReview({\n ticketId,\n reviewLabel,\n subLabel,\n statusPills,\n treatments,\n landing,\n viewports: viewportsProp,\n brand: brandProp,\n themes: themesProp,\n defaultThemeId,\n defaultMode = \"dark\",\n defaultTreatmentId = null,\n footer,\n onStateChange,\n className,\n ...rest\n}: PrototypeReviewProps): React.JSX.Element {\n const themes = themesProp && themesProp.length > 0 ? themesProp : [FALLBACK_THEME]\n const viewports =\n viewportsProp?.items && viewportsProp.items.length > 0\n ? viewportsProp.items\n : DEFAULT_VIEWPORTS\n const allEnabled = viewportsProp?.allEnabled !== false\n const defaultViewportId =\n viewportsProp?.defaultId ?? (allEnabled ? \"all\" : viewports[0]?.id ?? \"all\")\n\n const brandConfig: Required<\n Pick<PrototypeReviewBrandConfig, \"swatches\" | \"hexInput\" | \"reset\">\n > &\n Pick<PrototypeReviewBrandConfig, \"disabled\"> = {\n swatches:\n brandProp?.swatches && brandProp.swatches.length > 0\n ? brandProp.swatches\n : DEFAULT_BRAND_SWATCHES,\n hexInput: brandProp?.hexInput !== false,\n reset: brandProp?.reset !== false,\n disabled: brandProp?.disabled,\n }\n const defaultBrand = brandProp?.default ?? \"#FFBE26\"\n\n const state = usePrototypeReview({\n treatments,\n themes,\n defaultThemeId,\n defaultMode,\n defaultTreatmentId,\n defaultViewportId,\n defaultBrand,\n })\n\n const activeTheme =\n themes.find((t) => t.id === state.themeId) ?? themes[0] ?? FALLBACK_THEME\n const activeTreatment =\n state.treatmentId != null\n ? treatments.find((t) => t.id === state.treatmentId) ?? null\n : null\n\n const stageContainerRef = React.useRef<HTMLDivElement | null>(null)\n\n // Apply theme + mode to the host page (document element).\n React.useEffect(() => {\n if (typeof document === \"undefined\") return\n const html = document.documentElement\n if (!html) return\n\n const previousMode = html.getAttribute(\"data-mode\")\n html.setAttribute(\"data-mode\", state.mode)\n\n const themeClass = activeTheme.themeClass\n html.classList.add(themeClass)\n\n return () => {\n html.classList.remove(themeClass)\n if (previousMode == null) html.removeAttribute(\"data-mode\")\n else html.setAttribute(\"data-mode\", previousMode)\n }\n }, [activeTheme.themeClass, state.mode])\n\n // Apply brand color CSS variables to the wrapper.\n const brandVars = React.useMemo<React.CSSProperties>(() => {\n const rgb = hexToRgb(state.brand)\n if (!rgb) return {}\n const luma = (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255\n const onText = luma > 0.55 ? \"#000\" : \"#fff\"\n return {\n [\"--prototype-review-brand\" as string]: state.brand,\n [\"--prototype-review-brand-soft\" as string]:\n \"rgba(\" + rgb.r + \", \" + rgb.g + \", \" + rgb.b + \", 0.12)\",\n [\"--prototype-review-brand-glow\" as string]:\n \"rgba(\" + rgb.r + \", \" + rgb.g + \", \" + rgb.b + \", 0.35)\",\n [\"--prototype-review-brand-on\" as string]: onText,\n } as React.CSSProperties\n }, [state.brand])\n\n // Notify advanced consumers and broadcast theme to nested iframes.\n React.useEffect(() => {\n onStateChange?.({\n treatmentId: state.treatmentId,\n themeId: state.themeId,\n mode: state.mode,\n viewportId: state.viewportId,\n brand: state.brand,\n })\n broadcastToIframes(stageContainerRef.current, {\n type: \"prototype-theme\",\n themeClass: activeTheme.themeClass,\n mode: state.mode,\n brand: state.brand,\n })\n }, [\n onStateChange,\n state.treatmentId,\n state.themeId,\n state.mode,\n state.viewportId,\n state.brand,\n activeTheme.themeClass,\n ])\n\n return (\n <div\n {...rest}\n className={cn(styles.root, className)}\n data-slot=\"prototype-review\"\n data-mode={state.mode}\n data-theme={state.themeId}\n style={{ ...brandVars, ...rest.style }}\n >\n <Header\n ticketId={ticketId}\n reviewLabel={reviewLabel}\n subLabel={subLabel}\n statusPills={statusPills}\n brand={state.brand}\n />\n <Controls\n treatments={treatments}\n treatmentId={state.treatmentId}\n onTreatmentChange={state.setTreatment}\n themes={themes}\n themeId={state.themeId}\n onThemeChange={state.setTheme}\n mode={state.mode}\n onModeChange={state.setMode}\n viewports={viewports}\n viewportId={state.viewportId}\n allEnabled={allEnabled}\n onViewportChange={state.setViewport}\n brand={state.brand}\n brandConfig={brandConfig}\n onBrandChange={state.setBrand}\n onBrandReset={state.resetBrand}\n />\n {activeTreatment ? (\n <Stage\n treatment={activeTreatment}\n viewports={viewports}\n viewportId={state.viewportId}\n brand={state.brand}\n themeClass={activeTheme.themeClass}\n mode={state.mode}\n containerRef={stageContainerRef}\n />\n ) : (\n <Landing\n landing={landing}\n treatments={treatments}\n onTreatmentSelect={state.setTreatment}\n />\n )}\n <Footer footer={footer} brand={state.brand} />\n </div>\n )\n}\n\nfunction hexToRgb(hex: string): { r: number; g: number; b: number } | null {\n const m = HEX_RE.exec(hex)\n if (!m) return null\n const v = m[1]\n return {\n r: parseInt(v.slice(0, 2), 16),\n g: parseInt(v.slice(2, 4), 16),\n b: parseInt(v.slice(4, 6), 16),\n }\n}\n"
4202
+ },
4203
+ {
4204
+ "path": "blocks/prototype-review/prototype-review.module.css",
4205
+ "type": "registry:block",
4206
+ "content": "/* ============================================================\n Prototype Review block\n Theme-agnostic. All colors come from Visor semantic tokens.\n The brand color is injected by the React block as\n --prototype-review-brand (per-instance override).\n ============================================================ */\n\n.root {\n display: flex;\n flex-direction: column;\n width: 100%;\n min-height: 100vh;\n background: var(--surface-page);\n color: var(--text-primary);\n font-family: var(\n --font-body,\n var(--font-family-body, ui-sans-serif, system-ui, sans-serif)\n );\n font-weight: 300;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n container-type: inline-size;\n}\n\n/* ─── Header ─────────────────────────────────────────────── */\n\n.chrome {\n position: sticky;\n top: 0;\n z-index: 100;\n background: var(--surface-card);\n border-bottom: var(--stroke-width-thin, 1px) solid var(--border-muted);\n}\n\n.chromeRow {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--spacing-6);\n padding: var(--spacing-3) var(--spacing-7);\n flex-wrap: wrap;\n}\n\n.brand {\n display: flex;\n align-items: center;\n gap: var(--spacing-3);\n min-width: 0;\n}\n\n.brandMark {\n width: 1.75rem;\n height: 1.75rem;\n background: var(--prototype-review-brand, var(--interactive-primary-bg));\n display: grid;\n place-items: center;\n color: var(--prototype-review-brand-on, var(--interactive-primary-text));\n font-family: var(\n --font-display,\n var(--font-family-display, var(--font-family-heading, inherit))\n );\n font-weight: 700;\n font-size: var(--font-size-xs);\n letter-spacing: -0.02em;\n}\n\n.brandText {\n display: flex;\n flex-direction: column;\n line-height: 1.15;\n}\n\n.brandName {\n font-family: var(\n --font-display,\n var(--font-family-display, var(--font-family-heading, inherit))\n );\n font-weight: 600;\n font-size: var(--font-size-sm);\n letter-spacing: 0.12em;\n text-transform: uppercase;\n}\n\n.brandSub {\n font-family: var(--font-mono, var(--font-family-mono, monospace));\n font-size: var(--font-size-xs);\n color: var(--text-tertiary);\n letter-spacing: 0.05em;\n}\n\n.chromeActions {\n display: flex;\n gap: var(--spacing-2);\n align-items: center;\n flex-wrap: wrap;\n}\n\n.pill {\n font-family: var(--font-mono, var(--font-family-mono, monospace));\n font-size: var(--font-size-xs);\n text-transform: uppercase;\n letter-spacing: 0.12em;\n padding: var(--spacing-1) var(--spacing-2);\n border: var(--stroke-width-thin, 1px) solid var(--border-default);\n color: var(--text-tertiary);\n border-radius: var(--radius-full, 9999px);\n}\n\n.pillBrand {\n background: var(--prototype-review-brand-soft, var(--surface-subtle));\n border-color: var(--prototype-review-brand, var(--border-default));\n color: var(--prototype-review-brand, var(--text-primary));\n}\n\n/* ─── Controls ───────────────────────────────────────────── */\n\n.controls {\n border-bottom: var(--stroke-width-thin, 1px) solid var(--border-muted);\n background: var(--surface-muted, var(--surface-subtle));\n}\n\n.controlsRow {\n display: grid;\n grid-template-columns: minmax(0, 1fr) auto auto;\n gap: var(--spacing-6);\n align-items: center;\n padding: var(--spacing-3) var(--spacing-7);\n}\n\n@container (max-width: 64rem) {\n .controlsRow {\n grid-template-columns: 1fr;\n }\n}\n\n.controlGroup {\n display: flex;\n align-items: center;\n gap: var(--spacing-4);\n flex-wrap: wrap;\n}\n\n.controlLabel {\n font-family: var(--font-mono, var(--font-family-mono, monospace));\n font-size: var(--font-size-xs);\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-tertiary);\n}\n\n/* Tabs */\n\n.tabs {\n display: flex;\n gap: 0;\n flex-wrap: wrap;\n align-items: stretch;\n border: var(--stroke-width-thin, 1px) solid var(--border-default);\n background: var(--surface-page);\n width: fit-content;\n border-radius: var(--radius-full, 9999px);\n overflow: hidden;\n}\n\n.tab {\n background: transparent;\n border: none;\n color: var(--text-tertiary);\n font-family: var(--font-mono, var(--font-family-mono, monospace));\n font-size: var(--font-size-xs);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n padding: var(--spacing-2) var(--spacing-4);\n cursor: pointer;\n transition:\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n background var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.tab:hover {\n color: var(--text-primary);\n}\n\n.tab[aria-selected=\"true\"] {\n background: var(--prototype-review-brand, var(--interactive-primary-bg));\n color: var(--prototype-review-brand-on, var(--interactive-primary-text));\n}\n\n.tab:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, var(--prototype-review-brand, var(--accent-primary)));\n outline-offset: calc(var(--focus-ring-offset, 2px) * -1);\n}\n\n.tabId {\n font-family: var(\n --font-display,\n var(--font-family-display, var(--font-family-heading, inherit))\n );\n font-weight: 600;\n letter-spacing: 0.02em;\n text-transform: none;\n font-size: var(--font-size-sm);\n margin-right: var(--spacing-2);\n}\n\n/* Theme select */\n\n.themeGroup {\n display: flex;\n align-items: center;\n gap: var(--spacing-2);\n}\n\n.themeSelect {\n background: var(--surface-page);\n border: var(--stroke-width-thin, 1px) solid var(--border-default);\n color: var(--text-primary);\n font-family: var(--font-mono, var(--font-family-mono, monospace));\n font-size: var(--font-size-xs);\n padding: var(--spacing-2) var(--spacing-3);\n border-radius: var(--radius-full, 9999px);\n cursor: pointer;\n letter-spacing: 0.05em;\n text-transform: uppercase;\n}\n\n.themeSelect:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, var(--prototype-review-brand, var(--accent-primary)));\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n/* Mode + viewport toggle buttons */\n\n.modeGroup,\n.vpGroup {\n display: flex;\n align-items: center;\n gap: var(--spacing-2);\n}\n\n.toggleButtons {\n display: flex;\n border: var(--stroke-width-thin, 1px) solid var(--border-default);\n background: var(--surface-page);\n border-radius: var(--radius-full, 9999px);\n overflow: hidden;\n}\n\n.toggleButton {\n background: transparent;\n border: none;\n color: var(--text-tertiary);\n font-family: var(--font-mono, var(--font-family-mono, monospace));\n font-size: var(--font-size-xs);\n letter-spacing: 0.05em;\n padding: var(--spacing-2) var(--spacing-3);\n cursor: pointer;\n text-transform: uppercase;\n transition:\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n background var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.toggleButton:hover {\n color: var(--text-primary);\n}\n\n.toggleButton[aria-pressed=\"true\"] {\n background: var(--prototype-review-brand, var(--interactive-primary-bg));\n color: var(--prototype-review-brand-on, var(--interactive-primary-text));\n}\n\n.toggleButton:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, var(--prototype-review-brand, var(--accent-primary)));\n outline-offset: calc(var(--focus-ring-offset, 2px) * -1);\n}\n\n/* Brand picker */\n\n.picker {\n display: flex;\n align-items: center;\n gap: var(--spacing-3);\n flex-wrap: wrap;\n}\n\n.swatches {\n display: flex;\n gap: var(--spacing-1);\n align-items: center;\n}\n\n.swatch {\n width: 1.375rem;\n height: 1.375rem;\n border-radius: var(--radius-full, 9999px);\n border: var(--stroke-width-medium, 2px) solid transparent;\n cursor: pointer;\n padding: 0;\n transition:\n transform var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n border-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.swatch:hover {\n transform: scale(1.12);\n}\n\n.swatch[data-active=\"true\"] {\n border-color: var(--text-primary);\n box-shadow: 0 0 0 var(--stroke-width-medium, 2px) var(--surface-page);\n}\n\n.swatch:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, var(--prototype-review-brand, var(--accent-primary)));\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.hexInput {\n background: var(--surface-page);\n border: var(--stroke-width-thin, 1px) solid var(--border-default);\n color: var(--text-primary);\n font-family: var(--font-mono, var(--font-family-mono, monospace));\n font-size: var(--font-size-xs);\n padding: var(--spacing-1) var(--spacing-3);\n width: 8.5rem;\n letter-spacing: 0.05em;\n text-transform: uppercase;\n border-radius: var(--radius-full, 9999px);\n}\n\n.hexInput:focus {\n outline: none;\n border-color: var(--prototype-review-brand, var(--border-default));\n}\n\n.hexInput:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, var(--prototype-review-brand, var(--accent-primary)));\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.resetButton {\n background: transparent;\n border: var(--stroke-width-thin, 1px) solid var(--border-default);\n color: var(--text-tertiary);\n font-family: var(--font-mono, var(--font-family-mono, monospace));\n font-size: var(--font-size-xs);\n letter-spacing: 0.1em;\n text-transform: uppercase;\n padding: var(--spacing-1) var(--spacing-3);\n border-radius: var(--radius-full, 9999px);\n cursor: pointer;\n transition:\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n border-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.resetButton:hover {\n color: var(--prototype-review-brand, var(--text-primary));\n border-color: var(--prototype-review-brand, var(--border-strong));\n}\n\n.resetButton:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, var(--prototype-review-brand, var(--accent-primary)));\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n/* ─── Stage ──────────────────────────────────────────────── */\n\n.stage {\n padding: var(--spacing-7);\n max-width: 100%;\n}\n\n.stageMeta {\n display: flex;\n gap: var(--spacing-6);\n align-items: flex-start;\n justify-content: space-between;\n margin-bottom: var(--spacing-5);\n flex-wrap: wrap;\n}\n\n.stageTitleBlock {\n min-width: 0;\n max-width: 56rem;\n}\n\n.stageTitle {\n font-family: var(\n --font-display,\n var(--font-family-display, var(--font-family-heading, inherit))\n );\n font-weight: 500;\n font-size: var(--font-size-2xl);\n letter-spacing: -0.01em;\n line-height: 1.2;\n margin-bottom: var(--spacing-1);\n}\n\n.stageTitle em {\n font-style: normal;\n color: var(--prototype-review-brand, var(--accent-primary));\n}\n\n.stageSummary {\n color: var(--text-secondary);\n font-size: var(--font-size-base);\n line-height: 1.6;\n}\n\n.stageTagRow {\n display: flex;\n gap: var(--spacing-2);\n flex-wrap: wrap;\n}\n\n/* Viewport grid */\n\n.viewportGrid {\n display: grid;\n gap: var(--spacing-6);\n grid-template-columns: 375px 768px 1280px;\n overflow-x: auto;\n padding-bottom: var(--spacing-4);\n}\n\n.viewportGridSolo {\n grid-template-columns: 1fr;\n}\n\n.vpFrame {\n background: var(--surface-card);\n border: var(--stroke-width-thin, 1px) solid var(--border-default);\n border-radius: var(--radius-lg);\n overflow: hidden;\n display: flex;\n flex-direction: column;\n min-width: 0;\n}\n\n.vpFrameHead {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: var(--spacing-2) var(--spacing-3);\n border-bottom: var(--stroke-width-thin, 1px) solid var(--border-muted);\n background: var(--surface-muted, var(--surface-subtle));\n}\n\n.vpFrameName {\n font-family: var(--font-mono, var(--font-family-mono, monospace));\n font-size: var(--font-size-xs);\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-tertiary);\n}\n\n.vpFrameDims {\n font-family: var(--font-mono, var(--font-family-mono, monospace));\n font-size: var(--font-size-xs);\n color: var(--text-disabled, var(--text-tertiary));\n}\n\n.vpIframeWrap {\n background: var(--surface-page);\n}\n\n.vpIframe {\n width: 100%;\n border: none;\n display: block;\n background: var(--surface-page);\n}\n\n/* ─── Landing ────────────────────────────────────────────── */\n\n.landing {\n max-width: 64rem;\n margin: 0 auto;\n padding: var(--spacing-12, 3.5rem) var(--spacing-7);\n}\n\n.landingEyebrow {\n font-family: var(--font-mono, var(--font-family-mono, monospace));\n font-size: var(--font-size-sm);\n letter-spacing: 0.18em;\n text-transform: uppercase;\n color: var(--prototype-review-brand, var(--accent-primary));\n margin-bottom: var(--spacing-4);\n}\n\n.landingTitle {\n font-family: var(\n --font-display,\n var(--font-family-display, var(--font-family-heading, inherit))\n );\n font-weight: 500;\n font-size: clamp(2rem, 4.5vw, 3.25rem);\n line-height: 1.05;\n letter-spacing: -0.02em;\n margin-bottom: var(--spacing-5);\n}\n\n.landingTitle em {\n font-style: normal;\n color: var(--prototype-review-brand, var(--accent-primary));\n}\n\n.landingLede {\n font-size: var(--font-size-lg);\n color: var(--text-secondary);\n line-height: 1.65;\n margin-bottom: var(--spacing-10, 2.5rem);\n max-width: 48rem;\n}\n\n.landingLede code {\n font-family: var(--font-mono, var(--font-family-mono, monospace));\n color: var(--prototype-review-brand, var(--accent-primary));\n font-size: 0.95em;\n}\n\n.protoGrid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));\n gap: var(--spacing-5);\n margin-bottom: var(--spacing-12, 3rem);\n}\n\n.protoCard {\n background: var(--surface-card);\n border: var(--stroke-width-thin, 1px) solid var(--border-default);\n border-radius: var(--radius-lg);\n padding: var(--spacing-6);\n cursor: pointer;\n text-align: left;\n font-family: inherit;\n color: inherit;\n display: flex;\n flex-direction: column;\n gap: var(--spacing-3);\n transition:\n border-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n background var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.protoCard:hover {\n border-color: var(--prototype-review-brand, var(--border-strong));\n background: var(--surface-interactive-hover, var(--surface-subtle));\n}\n\n.protoCard:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, var(--prototype-review-brand, var(--accent-primary)));\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.protoCardId {\n font-family: var(--font-mono, var(--font-family-mono, monospace));\n font-size: var(--font-size-xs);\n letter-spacing: 0.18em;\n text-transform: uppercase;\n color: var(--prototype-review-brand, var(--accent-primary));\n}\n\n.protoCardTitle {\n font-family: var(\n --font-display,\n var(--font-family-display, var(--font-family-heading, inherit))\n );\n font-weight: 500;\n font-size: var(--font-size-xl, 1.25rem);\n letter-spacing: -0.01em;\n}\n\n.protoCardDesc {\n color: var(--text-secondary);\n font-size: var(--font-size-sm);\n line-height: 1.55;\n}\n\n.protoCardMeta {\n margin-top: auto;\n font-family: var(--font-mono, var(--font-family-mono, monospace));\n font-size: var(--font-size-xs);\n color: var(--text-tertiary);\n letter-spacing: 0.05em;\n text-transform: uppercase;\n}\n\n.ctxGrid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));\n gap: var(--spacing-5);\n margin-top: var(--spacing-10, 2.5rem);\n padding-top: var(--spacing-8);\n border-top: var(--stroke-width-thin, 1px) solid var(--border-muted);\n}\n\n.ctxCard h4 {\n font-family: var(--font-mono, var(--font-family-mono, monospace));\n font-size: var(--font-size-sm);\n text-transform: uppercase;\n letter-spacing: 0.12em;\n color: var(--prototype-review-brand, var(--accent-primary));\n margin-bottom: var(--spacing-2);\n}\n\n.ctxCard p {\n color: var(--text-secondary);\n font-size: var(--font-size-sm);\n line-height: 1.6;\n}\n\n/* ─── Footer ─────────────────────────────────────────────── */\n\n.foot {\n border-top: var(--stroke-width-thin, 1px) solid var(--border-muted);\n padding: var(--spacing-6) var(--spacing-7);\n display: flex;\n justify-content: space-between;\n gap: var(--spacing-4);\n flex-wrap: wrap;\n font-family: var(--font-mono, var(--font-family-mono, monospace));\n font-size: var(--font-size-xs);\n color: var(--text-tertiary);\n letter-spacing: 0.06em;\n text-transform: uppercase;\n margin-top: auto;\n}\n"
4207
+ }
4208
+ ]
4209
+ },
3750
4210
  {
3751
4211
  "name": "sphere",
3752
4212
  "type": "registry:ui",
@@ -3824,7 +4284,7 @@
3824
4284
  {
3825
4285
  "path": "components/devtools/source-inspector/visor-component-names.generated.ts",
3826
4286
  "type": "registry:devtool",
3827
- "content": "// THIS FILE IS GENERATED BY scripts/generate-visor-component-names.ts.\n// Do not edit by hand. Re-run `npm run generate:component-names` after\n// adding, removing, or renaming a Visor component.\n//\n// Source of truth: registry/registry-{ui,blocks,deck,visual,devtools}.ts\n// Used by: components/devtools/source-inspector/* (VI-311)\n\nexport const VISOR_COMPONENT_NAMES: ReadonlySet<string> = new Set([\n \"AccessibilitySection\",\n \"AccessibilitySlide\",\n \"AccessibilitySpecimen\",\n \"Accordion\",\n \"AccordionContent\",\n \"AccordionItem\",\n \"AccordionTrigger\",\n \"ActivityFeed\",\n \"ActivityFeedContext\",\n \"ActivityFeedItem\",\n \"ActivityFeedRoot\",\n \"AdminDashboard\",\n \"AdminDetailDrawer\",\n \"AdminListPage\",\n \"AdminListPageInner\",\n \"AdminSettingsPage\",\n \"AdminShell\",\n \"AdminTabbedEditor\",\n \"AdminWizard\",\n \"Alert\",\n \"AlertDescription\",\n \"AlertTitle\",\n \"Avatar\",\n \"AvatarFallback\",\n \"AvatarImage\",\n \"Badge\",\n \"Banner\",\n \"BannerAction\",\n \"BannerDescription\",\n \"BannerTitle\",\n \"BentoGrid\",\n \"BentoTile\",\n \"BentoTileBody\",\n \"BentoTileDescription\",\n \"BentoTileFigure\",\n \"BentoTileHeadline\",\n \"BentoTileMedia\",\n \"BentoTileMeta\",\n \"BentoTileTitle\",\n \"Breadcrumb\",\n \"BreadcrumbEllipsis\",\n \"BreadcrumbItem\",\n \"BreadcrumbLink\",\n \"BreadcrumbList\",\n \"BreadcrumbPage\",\n \"BreadcrumbSeparator\",\n \"BulkActionBar\",\n \"Button\",\n \"ButtonSpecimenSection\",\n \"ButtonSpecimenSlide\",\n \"Calendar\",\n \"Card\",\n \"CardContent\",\n \"CardDescription\",\n \"CardFooter\",\n \"CardGrid\",\n \"CardHeader\",\n \"CardTitle\",\n \"Carousel\",\n \"CarouselContent\",\n \"CarouselContext\",\n \"CarouselGallery\",\n \"CarouselItem\",\n \"CarouselNext\",\n \"CarouselPrevious\",\n \"ChartContainer\",\n \"ChartContext\",\n \"ChartLegend\",\n \"ChartLegendContent\",\n \"ChartStyle\",\n \"ChartTooltip\",\n \"ChartTooltipContent\",\n \"Checkbox\",\n \"Chip\",\n \"ChipGroup\",\n \"ChipGroupContext\",\n \"ChipGroupItem\",\n \"ChoiceChip\",\n \"ClosingSlide\",\n \"CodeBlock\",\n \"Collapsible\",\n \"CollapsibleContent\",\n \"CollapsibleTrigger\",\n \"ColorBar\",\n \"ColorPaletteSection\",\n \"ColorSwatch\",\n \"ColorSwatchGrid\",\n \"Combobox\",\n \"ComboboxContent\",\n \"ComboboxContext\",\n \"ComboboxEmpty\",\n \"ComboboxGroup\",\n \"ComboboxInput\",\n \"ComboboxItem\",\n \"ComboboxSeparator\",\n \"Command\",\n \"CommandDialog\",\n \"CommandEmpty\",\n \"CommandGroup\",\n \"CommandInput\",\n \"CommandItem\",\n \"CommandList\",\n \"CommandLoading\",\n \"CommandSeparator\",\n \"CommandShortcut\",\n \"ComponentShowcaseContent\",\n \"ComponentShowcaseSection\",\n \"ComponentShowcaseSlide\",\n \"ConceptSlide\",\n \"ConfigurationPanel\",\n \"ConfirmDialog\",\n \"ContextMenu\",\n \"ContextMenuCheckboxItem\",\n \"ContextMenuContent\",\n \"ContextMenuGroup\",\n \"ContextMenuItem\",\n \"ContextMenuLabel\",\n \"ContextMenuPortal\",\n \"ContextMenuRadioGroup\",\n \"ContextMenuRadioItem\",\n \"ContextMenuSeparator\",\n \"ContextMenuShortcut\",\n \"ContextMenuSub\",\n \"ContextMenuSubContent\",\n \"ContextMenuSubTrigger\",\n \"ContextMenuTrigger\",\n \"CtaSection\",\n \"DataTable\",\n \"DataTableInner\",\n \"DatePicker\",\n \"DateRangePicker\",\n \"DeckContext\",\n \"DeckFooter\",\n \"DeckLayout\",\n \"DeckProvider\",\n \"DeckRenderer\",\n \"DesignSystemDeck\",\n \"DesignSystemSpecimen\",\n \"Dialog\",\n \"DialogClose\",\n \"DialogContent\",\n \"DialogDescription\",\n \"DialogHeader\",\n \"DialogOverlay\",\n \"DialogPortal\",\n \"DialogTitle\",\n \"DialogTrigger\",\n \"DotNav\",\n \"DropdownMenu\",\n \"DropdownMenuCheckboxItem\",\n \"DropdownMenuContent\",\n \"DropdownMenuGroup\",\n \"DropdownMenuItem\",\n \"DropdownMenuLabel\",\n \"DropdownMenuPortal\",\n \"DropdownMenuRadioGroup\",\n \"DropdownMenuRadioItem\",\n \"DropdownMenuSeparator\",\n \"DropdownMenuShortcut\",\n \"DropdownMenuSub\",\n \"DropdownMenuSubContent\",\n \"DropdownMenuSubTrigger\",\n \"DropdownMenuTrigger\",\n \"ElevationCard\",\n \"ElevationSlide\",\n \"EmptyState\",\n \"FeaturesGrid\",\n \"Field\",\n \"FieldDescription\",\n \"FieldError\",\n \"FieldLabel\",\n \"Fieldset\",\n \"FieldsetLegend\",\n \"FileUpload\",\n \"FilterBar\",\n \"FilterChip\",\n \"FontShowcase\",\n \"FontShowcaseGrid\",\n \"FooterSection\",\n \"Form\",\n \"FormField\",\n \"FormSpecimenSection\",\n \"FormSpecimenSlide\",\n \"FullscreenOverlay\",\n \"FullscreenOverlayContent\",\n \"FullscreenOverlayTrigger\",\n \"Heading\",\n \"HeroSection\",\n \"HeroSlide\",\n \"HoverCard\",\n \"HoverCardContent\",\n \"HoverCardTrigger\",\n \"IconGrid\",\n \"IconGridSection\",\n \"IconSizeRow\",\n \"IconsSlide\",\n \"Image\",\n \"Input\",\n \"Kbd\",\n \"Label\",\n \"Lightbox\",\n \"LightboxContent\",\n \"LightboxContext\",\n \"LightboxTrigger\",\n \"LoginForm\",\n \"Marquee\",\n \"MarqueeBandRenderer\",\n \"Menubar\",\n \"MenubarCheckboxItem\",\n \"MenubarContent\",\n \"MenubarGroup\",\n \"MenubarItem\",\n \"MenubarLabel\",\n \"MenubarMenu\",\n \"MenubarRadioGroup\",\n \"MenubarRadioItem\",\n \"MenubarSeparator\",\n \"MenubarShortcut\",\n \"MenubarSub\",\n \"MenubarSubContent\",\n \"MenubarSubTrigger\",\n \"MenubarTrigger\",\n \"MotionDuration\",\n \"MotionDurationSection\",\n \"MotionEasing\",\n \"MotionEasingSection\",\n \"MotionSlide\",\n \"NameRoster\",\n \"NameRosterItem\",\n \"Navbar\",\n \"NavbarBrand\",\n \"NavbarContent\",\n \"NavbarItem\",\n \"NavbarLink\",\n \"NavbarToggle\",\n \"NumberInput\",\n \"OpacityBar\",\n \"OpacitySlide\",\n \"OTPInput\",\n \"PageHeader\",\n \"Pagination\",\n \"PaginationContent\",\n \"PaginationEllipsis\",\n \"PaginationItem\",\n \"PaginationLink\",\n \"PaginationNext\",\n \"PaginationPrevious\",\n \"PasswordInput\",\n \"PhoneInput\",\n \"Popover\",\n \"PopoverAnchor\",\n \"PopoverContent\",\n \"PopoverTrigger\",\n \"PricingSection\",\n \"Progress\",\n \"RadioGroup\",\n \"RadioGroupItem\",\n \"RadiusScale\",\n \"RadiusSection\",\n \"RadiusSlide\",\n \"ScrollArea\",\n \"ScrollBar\",\n \"SearchInput\",\n \"Select\",\n \"SelectContent\",\n \"SelectGroup\",\n \"SelectItem\",\n \"SelectLabel\",\n \"SelectScrollDownButton\",\n \"SelectScrollUpButton\",\n \"SelectSeparator\",\n \"SelectTrigger\",\n \"SelectValue\",\n \"SemanticColorGrid\",\n \"SemanticColorItem\",\n \"SemanticTokensSlide\",\n \"Separator\",\n \"ShadowSection\",\n \"Sheet\",\n \"SheetClose\",\n \"SheetContent\",\n \"SheetDescription\",\n \"SheetFooter\",\n \"SheetHeader\",\n \"SheetOverlay\",\n \"SheetPortal\",\n \"SheetTitle\",\n \"SheetTrigger\",\n \"Sidebar\",\n \"SidebarContent\",\n \"SidebarContext\",\n \"SidebarFooter\",\n \"SidebarGroup\",\n \"SidebarGroupAction\",\n \"SidebarGroupContent\",\n \"SidebarGroupLabel\",\n \"SidebarHeader\",\n \"SidebarInset\",\n \"SidebarMenu\",\n \"SidebarMenuAction\",\n \"SidebarMenuBadge\",\n \"SidebarMenuButton\",\n \"SidebarMenuItem\",\n \"SidebarMenuSub\",\n \"SidebarMenuSubButton\",\n \"SidebarMenuSubItem\",\n \"SidebarProvider\",\n \"SidebarRail\",\n \"SidebarSeparator\",\n \"SidebarTrigger\",\n \"Skeleton\",\n \"Slide\",\n \"SlideHeader\",\n \"Slider\",\n \"SliderControl\",\n \"SlideThemeContext\",\n \"SlideThemeProvider\",\n \"SourceInspector\",\n \"SourceInspectorContext\",\n \"SourceInspectorDevImpl\",\n \"SourceInspectorProvider\",\n \"SourceInspectorRunner\",\n \"SourceInspectorToggle\",\n \"SpacingScale\",\n \"SpacingSection\",\n \"SpacingSlide\",\n \"Sphere\",\n \"SpherePlayground\",\n \"StatCard\",\n \"StatHero\",\n \"StationSpectrum\",\n \"StatusBadge\",\n \"StatusColorsSlide\",\n \"Stepper\",\n \"StepperContext\",\n \"StepperDescription\",\n \"StepperItem\",\n \"StepperSeparator\",\n \"StepperTitle\",\n \"StepperTrigger\",\n \"StepsSection\",\n \"SurfaceRow\",\n \"SurfaceSection\",\n \"Switch\",\n \"Table\",\n \"TableBody\",\n \"TableCaption\",\n \"TableCell\",\n \"TableFooter\",\n \"TableHead\",\n \"TableHeader\",\n \"TableRow\",\n \"Tabs\",\n \"TabsContent\",\n \"TabsList\",\n \"TabsTrigger\",\n \"TagInput\",\n \"TestimonialAttribution\",\n \"TestimonialSection\",\n \"Text\",\n \"Textarea\",\n \"ThemeArchitectureSlide\",\n \"ThemeColorsSlide\",\n \"ThemeSwitcher\",\n \"Timeline\",\n \"TimelineContent\",\n \"TimelineDescription\",\n \"TimelineIcon\",\n \"TimelineItem\",\n \"TimelineTimestamp\",\n \"TimelineTitle\",\n \"TitleSlide\",\n \"Toaster\",\n \"TOCSlide\",\n \"ToggleButton\",\n \"ToggleDevImpl\",\n \"ToggleGroup\",\n \"ToggleGroupContext\",\n \"ToggleGroupItem\",\n \"Tooltip\",\n \"TooltipContent\",\n \"TooltipProvider\",\n \"TooltipTrigger\",\n \"TypeBodySlide\",\n \"TypeDisplaySlide\",\n \"TypeSpecimen\",\n \"TypographySection\",\n \"WorkspaceSwitcher\",\n])\n"
4287
+ "content": "// THIS FILE IS GENERATED BY scripts/generate-visor-component-names.ts.\n// Do not edit by hand. Re-run `npm run generate:component-names` after\n// adding, removing, or renaming a Visor component.\n//\n// Source of truth: registry/registry-{ui,blocks,deck,visual,devtools}.ts\n// Used by: components/devtools/source-inspector/* (VI-311)\n\nexport const VISOR_COMPONENT_NAMES: ReadonlySet<string> = new Set([\n \"AccessibilitySection\",\n \"AccessibilitySlide\",\n \"AccessibilitySpecimen\",\n \"Accordion\",\n \"AccordionContent\",\n \"AccordionItem\",\n \"AccordionTrigger\",\n \"ActivityFeed\",\n \"ActivityFeedContext\",\n \"ActivityFeedItem\",\n \"ActivityFeedRoot\",\n \"AdminDashboard\",\n \"AdminDetailDrawer\",\n \"AdminListPage\",\n \"AdminListPageInner\",\n \"AdminSettingsPage\",\n \"AdminShell\",\n \"AdminTabbedEditor\",\n \"AdminWizard\",\n \"Alert\",\n \"AlertDescription\",\n \"AlertTitle\",\n \"Avatar\",\n \"AvatarFallback\",\n \"AvatarImage\",\n \"AvatarStack\",\n \"Badge\",\n \"Banner\",\n \"BannerAction\",\n \"BannerDescription\",\n \"BannerTitle\",\n \"BentoGrid\",\n \"BentoTile\",\n \"BentoTileBody\",\n \"BentoTileDescription\",\n \"BentoTileFigure\",\n \"BentoTileHeadline\",\n \"BentoTileMedia\",\n \"BentoTileMeta\",\n \"BentoTileTitle\",\n \"Box\",\n \"Breadcrumb\",\n \"BreadcrumbEllipsis\",\n \"BreadcrumbItem\",\n \"BreadcrumbLink\",\n \"BreadcrumbList\",\n \"BreadcrumbPage\",\n \"BreadcrumbSeparator\",\n \"BulkActionBar\",\n \"Button\",\n \"ButtonSpecimenSection\",\n \"ButtonSpecimenSlide\",\n \"Calendar\",\n \"Card\",\n \"CardContent\",\n \"CardDescription\",\n \"CardFooter\",\n \"CardGrid\",\n \"CardHeader\",\n \"CardTitle\",\n \"Carousel\",\n \"CarouselContent\",\n \"CarouselContext\",\n \"CarouselGallery\",\n \"CarouselItem\",\n \"CarouselNext\",\n \"CarouselPrevious\",\n \"ChartContainer\",\n \"ChartContext\",\n \"ChartLegend\",\n \"ChartLegendContent\",\n \"ChartStyle\",\n \"ChartTooltip\",\n \"ChartTooltipContent\",\n \"Checkbox\",\n \"Chip\",\n \"ChipGroup\",\n \"ChipGroupContext\",\n \"ChipGroupItem\",\n \"ChoiceChip\",\n \"ChromeButton\",\n \"ClosingSlide\",\n \"CodeBlock\",\n \"Collapsible\",\n \"CollapsibleContent\",\n \"CollapsibleTrigger\",\n \"ColorBar\",\n \"ColorPaletteSection\",\n \"ColorPicker\",\n \"ColorPickerSurface\",\n \"ColorSwatch\",\n \"ColorSwatchGrid\",\n \"Combobox\",\n \"ComboboxContent\",\n \"ComboboxContext\",\n \"ComboboxEmpty\",\n \"ComboboxGroup\",\n \"ComboboxInput\",\n \"ComboboxItem\",\n \"ComboboxSeparator\",\n \"Command\",\n \"CommandDialog\",\n \"CommandEmpty\",\n \"CommandGroup\",\n \"CommandInput\",\n \"CommandItem\",\n \"CommandList\",\n \"CommandLoading\",\n \"CommandSeparator\",\n \"CommandShortcut\",\n \"ComponentShowcaseContent\",\n \"ComponentShowcaseSection\",\n \"ComponentShowcaseSlide\",\n \"ConceptSlide\",\n \"ConfigurationPanel\",\n \"ConfirmDialog\",\n \"Container\",\n \"ContextMenu\",\n \"ContextMenuCheckboxItem\",\n \"ContextMenuContent\",\n \"ContextMenuGroup\",\n \"ContextMenuItem\",\n \"ContextMenuLabel\",\n \"ContextMenuPortal\",\n \"ContextMenuRadioGroup\",\n \"ContextMenuRadioItem\",\n \"ContextMenuSeparator\",\n \"ContextMenuShortcut\",\n \"ContextMenuSub\",\n \"ContextMenuSubContent\",\n \"ContextMenuSubTrigger\",\n \"ContextMenuTrigger\",\n \"Controls\",\n \"CtaSection\",\n \"DataTable\",\n \"DataTableInner\",\n \"DatePicker\",\n \"DateRangePicker\",\n \"DeckContext\",\n \"DeckFooter\",\n \"DeckLayout\",\n \"DeckProvider\",\n \"DeckRenderer\",\n \"DesignSystemDeck\",\n \"DesignSystemSpecimen\",\n \"Dialog\",\n \"DialogClose\",\n \"DialogContent\",\n \"DialogDescription\",\n \"DialogHeader\",\n \"DialogOverlay\",\n \"DialogPortal\",\n \"DialogTitle\",\n \"DialogTrigger\",\n \"DotNav\",\n \"DropdownMenu\",\n \"DropdownMenuCheckboxItem\",\n \"DropdownMenuContent\",\n \"DropdownMenuGroup\",\n \"DropdownMenuItem\",\n \"DropdownMenuLabel\",\n \"DropdownMenuPortal\",\n \"DropdownMenuRadioGroup\",\n \"DropdownMenuRadioItem\",\n \"DropdownMenuSeparator\",\n \"DropdownMenuShortcut\",\n \"DropdownMenuSub\",\n \"DropdownMenuSubContent\",\n \"DropdownMenuSubTrigger\",\n \"DropdownMenuTrigger\",\n \"ElevationCard\",\n \"ElevationSlide\",\n \"EmptyState\",\n \"ExportMenu\",\n \"FeaturesGrid\",\n \"Field\",\n \"FieldDescription\",\n \"FieldError\",\n \"FieldLabel\",\n \"Fieldset\",\n \"FieldsetLegend\",\n \"FileUpload\",\n \"FilterBar\",\n \"FilterChip\",\n \"FontShowcase\",\n \"FontShowcaseGrid\",\n \"Footer\",\n \"FooterSection\",\n \"Form\",\n \"FormField\",\n \"FormSpecimenSection\",\n \"FormSpecimenSlide\",\n \"FullscreenOverlay\",\n \"FullscreenOverlayContent\",\n \"FullscreenOverlayTrigger\",\n \"Grid\",\n \"Header\",\n \"Heading\",\n \"HeroSection\",\n \"HeroSlide\",\n \"HoverCard\",\n \"HoverCardContent\",\n \"HoverCardTrigger\",\n \"IconGrid\",\n \"IconGridSection\",\n \"IconSizeRow\",\n \"IconsSlide\",\n \"Image\",\n \"Inline\",\n \"Input\",\n \"Kbd\",\n \"Label\",\n \"Landing\",\n \"Lightbox\",\n \"LightboxContent\",\n \"LightboxContext\",\n \"LightboxTrigger\",\n \"LoginForm\",\n \"Marquee\",\n \"MarqueeBandRenderer\",\n \"Menubar\",\n \"MenubarCheckboxItem\",\n \"MenubarContent\",\n \"MenubarGroup\",\n \"MenubarItem\",\n \"MenubarLabel\",\n \"MenubarMenu\",\n \"MenubarRadioGroup\",\n \"MenubarRadioItem\",\n \"MenubarSeparator\",\n \"MenubarShortcut\",\n \"MenubarSub\",\n \"MenubarSubContent\",\n \"MenubarSubTrigger\",\n \"MenubarTrigger\",\n \"MotionDuration\",\n \"MotionDurationSection\",\n \"MotionEasing\",\n \"MotionEasingSection\",\n \"MotionSlide\",\n \"NameRoster\",\n \"NameRosterItem\",\n \"Navbar\",\n \"NavbarBrand\",\n \"NavbarContent\",\n \"NavbarItem\",\n \"NavbarLink\",\n \"NavbarToggle\",\n \"NumberInput\",\n \"OpacityBar\",\n \"OpacitySlide\",\n \"OTPInput\",\n \"PageHeader\",\n \"Pagination\",\n \"PaginationContent\",\n \"PaginationEllipsis\",\n \"PaginationItem\",\n \"PaginationLink\",\n \"PaginationNext\",\n \"PaginationPrevious\",\n \"PasswordInput\",\n \"PhoneInput\",\n \"Popover\",\n \"PopoverAnchor\",\n \"PopoverContent\",\n \"PopoverTrigger\",\n \"PricingSection\",\n \"ProfileMenu\",\n \"Progress\",\n \"PrototypeReview\",\n \"QuickActions\",\n \"RadioGroup\",\n \"RadioGroupItem\",\n \"RadiusScale\",\n \"RadiusSection\",\n \"RadiusSlide\",\n \"RightRailList\",\n \"ScoreIndicator\",\n \"ScrollArea\",\n \"ScrollBar\",\n \"SearchInput\",\n \"SectionHeader\",\n \"Select\",\n \"SelectContent\",\n \"SelectGroup\",\n \"SelectItem\",\n \"SelectLabel\",\n \"SelectScrollDownButton\",\n \"SelectScrollUpButton\",\n \"SelectSeparator\",\n \"SelectTrigger\",\n \"SelectValue\",\n \"SemanticColorGrid\",\n \"SemanticColorItem\",\n \"SemanticTokensSlide\",\n \"Separator\",\n \"ShadowSection\",\n \"Sheet\",\n \"SheetClose\",\n \"SheetContent\",\n \"SheetDescription\",\n \"SheetFooter\",\n \"SheetHeader\",\n \"SheetOverlay\",\n \"SheetPortal\",\n \"SheetTitle\",\n \"SheetTrigger\",\n \"Sidebar\",\n \"SidebarContent\",\n \"SidebarContext\",\n \"SidebarFooter\",\n \"SidebarGroup\",\n \"SidebarGroupAction\",\n \"SidebarGroupContent\",\n \"SidebarGroupLabel\",\n \"SidebarHeader\",\n \"SidebarInset\",\n \"SidebarMenu\",\n \"SidebarMenuAction\",\n \"SidebarMenuBadge\",\n \"SidebarMenuButton\",\n \"SidebarMenuItem\",\n \"SidebarMenuSub\",\n \"SidebarMenuSubButton\",\n \"SidebarMenuSubItem\",\n \"SidebarProvider\",\n \"SidebarRail\",\n \"SidebarSeparator\",\n \"SidebarTrigger\",\n \"Skeleton\",\n \"Slide\",\n \"SlideHeader\",\n \"Slider\",\n \"SliderControl\",\n \"SlideThemeContext\",\n \"SlideThemeProvider\",\n \"SourceInspector\",\n \"SourceInspectorContext\",\n \"SourceInspectorDevImpl\",\n \"SourceInspectorProvider\",\n \"SourceInspectorRunner\",\n \"SourceInspectorToggle\",\n \"SpacingScale\",\n \"SpacingSection\",\n \"SpacingSlide\",\n \"Sparkline\",\n \"Sphere\",\n \"SpherePlayground\",\n \"Stack\",\n \"Stage\",\n \"StatCard\",\n \"StatHero\",\n \"StationSpectrum\",\n \"StatusBadge\",\n \"StatusColorsSlide\",\n \"StatusDot\",\n \"Stepper\",\n \"StepperContext\",\n \"StepperDescription\",\n \"StepperItem\",\n \"StepperSeparator\",\n \"StepperTitle\",\n \"StepperTrigger\",\n \"StepsSection\",\n \"SurfaceRow\",\n \"SurfaceSection\",\n \"Switch\",\n \"Table\",\n \"TableBody\",\n \"TableCaption\",\n \"TableCell\",\n \"TableFooter\",\n \"TableHead\",\n \"TableHeader\",\n \"TableRow\",\n \"Tabs\",\n \"TabsContent\",\n \"TabsList\",\n \"TabsTrigger\",\n \"TagInput\",\n \"TestimonialAttribution\",\n \"TestimonialSection\",\n \"Text\",\n \"Textarea\",\n \"ThemeArchitectureSlide\",\n \"ThemeColorsSlide\",\n \"ThemeSwitcher\",\n \"Timeline\",\n \"TimelineContent\",\n \"TimelineDescription\",\n \"TimelineIcon\",\n \"TimelineItem\",\n \"TimelineTimestamp\",\n \"TimelineTitle\",\n \"TitleSlide\",\n \"Toaster\",\n \"TOCSlide\",\n \"ToggleButton\",\n \"ToggleDevImpl\",\n \"ToggleGroup\",\n \"ToggleGroupContext\",\n \"ToggleGroupItem\",\n \"Tooltip\",\n \"TooltipContent\",\n \"TooltipProvider\",\n \"TooltipTrigger\",\n \"TypeBodySlide\",\n \"TypeDisplaySlide\",\n \"TypeSpecimen\",\n \"TypographySection\",\n \"WorkspaceSwitcher\",\n])\n"
3828
4288
  }
3829
4289
  ]
3830
4290
  },
@@ -4323,8 +4783,8 @@
4323
4783
  {
4324
4784
  "name": "SectionHeader",
4325
4785
  "type": "registry:ui",
4326
- "description": "Section heading with title, optional subtitle, and optional trailing slot for secondary actions like \"View all\" links, filters, or count badges.",
4327
- "category": "layout",
4786
+ "description": "Compact section-divider primitive with an uppercase title and optional right-aligned meta label. Sized for in-page content sectioning, distinct from the page-level PageHeader.",
4787
+ "category": "navigation",
4328
4788
  "target": "flutter",
4329
4789
  "pubDependencies": [
4330
4790
  {