@loworbitstudio/visor 1.0.0 → 1.2.1

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": [
@@ -2151,12 +2154,12 @@
2151
2154
  {
2152
2155
  "path": "components/ui/data-table/data-table.tsx",
2153
2156
  "type": "registry:ui",
2154
- "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\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 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 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"
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"
2155
2158
  },
2156
2159
  {
2157
2160
  "path": "components/ui/data-table/data-table.module.css",
2158
2161
  "type": "registry:ui",
2159
- "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/* 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"
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"
2160
2163
  }
2161
2164
  ]
2162
2165
  },
@@ -2312,6 +2315,32 @@
2312
2315
  }
2313
2316
  ]
2314
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"
2341
+ }
2342
+ ]
2343
+ },
2315
2344
  {
2316
2345
  "name": "stat-card",
2317
2346
  "type": "registry:ui",
@@ -2456,6 +2485,156 @@
2456
2485
  }
2457
2486
  ]
2458
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
+ },
2459
2638
  {
2460
2639
  "name": "use-media-query",
2461
2640
  "type": "registry:hook",
@@ -3373,6 +3552,64 @@
3373
3552
  }
3374
3553
  ]
3375
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
+ },
3376
3613
  {
3377
3614
  "name": "configuration-panel",
3378
3615
  "type": "registry:block",
@@ -3755,6 +3992,31 @@
3755
3992
  }
3756
3993
  ]
3757
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
+ },
3758
4020
  {
3759
4021
  "name": "chip-group",
3760
4022
  "type": "registry:block",
@@ -3921,6 +4183,30 @@
3921
4183
  }
3922
4184
  ]
3923
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
+ },
3924
4210
  {
3925
4211
  "name": "sphere",
3926
4212
  "type": "registry:ui",
@@ -3998,7 +4284,7 @@
3998
4284
  {
3999
4285
  "path": "components/devtools/source-inspector/visor-component-names.generated.ts",
4000
4286
  "type": "registry:devtool",
4001
- "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 \"ChromeButton\",\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 \"QuickActions\",\n \"RadioGroup\",\n \"RadioGroupItem\",\n \"RadiusScale\",\n \"RadiusSection\",\n \"RadiusSlide\",\n \"RightRailList\",\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 \"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"
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"
4002
4288
  }
4003
4289
  ]
4004
4290
  },