@loworbitstudio/visor 0.6.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -158,13 +158,14 @@
158
158
  "@loworbitstudio/visor-core"
159
159
  ],
160
160
  "registryDependencies": [
161
- "utils"
161
+ "utils",
162
+ "password-managers-context"
162
163
  ],
163
164
  "files": [
164
165
  {
165
166
  "path": "components/ui/input/input.tsx",
166
167
  "type": "registry:ui",
167
- "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./input.module.css\"\n\nconst inputVariants = cva(styles.base, {\n variants: {\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n lg: styles.sizeLg,\n },\n },\n defaultVariants: {\n size: \"md\",\n },\n})\n\nexport interface InputProps\n extends Omit<React.InputHTMLAttributes<HTMLInputElement>, \"size\">,\n VariantProps<typeof inputVariants> {\n /**\n * Optional leading icon rendered inside the field (e.g. a Phosphor icon).\n * The input picks up extra left padding so its text clears the icon. The\n * icon itself is `aria-hidden` — the field still needs its own label.\n */\n leadingIcon?: React.ReactNode\n}\n\nconst Input = React.forwardRef<HTMLInputElement, InputProps>(\n ({ className, type, size, leadingIcon, ...props }, ref) => {\n const input = (\n <input\n type={type}\n data-slot=\"input\"\n className={cn(\n inputVariants({ size }),\n leadingIcon && styles.hasLeadingIcon,\n className\n )}\n ref={ref}\n {...props}\n />\n )\n\n if (!leadingIcon) return input\n\n return (\n <span className={styles.leadingIconWrapper} data-slot=\"input-wrapper\">\n <span className={styles.leadingIcon} aria-hidden=\"true\">\n {leadingIcon}\n </span>\n {input}\n </span>\n )\n }\n)\nInput.displayName = \"Input\"\n\nexport { Input, inputVariants }\n"
168
+ "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport { usePasswordManagersValue } from \"../../../lib/password-managers-context\"\nimport styles from \"./input.module.css\"\n\nconst inputVariants = cva(styles.base, {\n variants: {\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n lg: styles.sizeLg,\n },\n },\n defaultVariants: {\n size: \"md\",\n },\n})\n\nexport interface InputProps\n extends Omit<React.InputHTMLAttributes<HTMLInputElement>, \"size\">,\n VariantProps<typeof inputVariants> {\n /**\n * Optional leading icon rendered inside the field (e.g. a Phosphor icon).\n * The input picks up extra left padding so its text clears the icon. The\n * icon itself is `aria-hidden` — the field still needs its own label.\n */\n leadingIcon?: React.ReactNode\n /**\n * Whether password managers (1Password, Bitwarden, LastPass) should\n * offer to autofill this field. Defaults to `\"ignore\"` because most\n * Visor inputs live on non-auth forms (contact, marketing, settings)\n * where autofill icons are visual noise. Set to `\"allow\"` on login\n * and credential fields, or wrap the form in `<Form passwordManagers=\"allow\">`\n * to opt every descendant input in at once. The field-level prop always\n * wins over the form context. Browsers ignore `autocomplete=\"off\"` on\n * individual inputs, so `\"ignore\"` emits the per-manager data-*\n * attributes (`data-1p-ignore`, `data-bwignore`, `data-lpignore`,\n * `data-form-type=\"other\"`) that each manager respects.\n */\n passwordManagers?: \"ignore\" | \"allow\"\n}\n\nconst Input = React.forwardRef<HTMLInputElement, InputProps>(\n (\n {\n className,\n type,\n size,\n leadingIcon,\n passwordManagers,\n ...props\n },\n ref\n ) => {\n const resolved = usePasswordManagersValue(passwordManagers)\n const ignoreAttrs =\n resolved === \"ignore\"\n ? {\n \"data-1p-ignore\": \"true\",\n \"data-bwignore\": \"true\",\n \"data-lpignore\": \"true\",\n \"data-form-type\": \"other\",\n }\n : null\n const input = (\n <input\n type={type}\n data-slot=\"input\"\n className={cn(\n inputVariants({ size }),\n leadingIcon && styles.hasLeadingIcon,\n className\n )}\n ref={ref}\n {...ignoreAttrs}\n {...props}\n />\n )\n\n if (!leadingIcon) return input\n\n return (\n <span className={styles.leadingIconWrapper} data-slot=\"input-wrapper\">\n <span className={styles.leadingIcon} aria-hidden=\"true\">\n {leadingIcon}\n </span>\n {input}\n </span>\n )\n }\n)\nInput.displayName = \"Input\"\n\nexport { Input, inputVariants }\n"
168
169
  },
169
170
  {
170
171
  "path": "components/ui/input/input.module.css",
@@ -207,13 +208,14 @@
207
208
  "@loworbitstudio/visor-core"
208
209
  ],
209
210
  "registryDependencies": [
210
- "utils"
211
+ "utils",
212
+ "password-managers-context"
211
213
  ],
212
214
  "files": [
213
215
  {
214
216
  "path": "components/ui/textarea/textarea.tsx",
215
217
  "type": "registry:ui",
216
- "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./textarea.module.css\"\n\nconst textareaVariants = cva(styles.base, {\n variants: {\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n lg: styles.sizeLg,\n },\n },\n defaultVariants: {\n size: \"md\",\n },\n})\n\nexport interface TextareaProps\n extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, \"size\">,\n VariantProps<typeof textareaVariants> {}\n\nconst Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(\n ({ className, size, ...props }, ref) => {\n return (\n <textarea\n data-slot=\"textarea\"\n className={cn(textareaVariants({ size }), className)}\n ref={ref}\n {...props}\n />\n )\n }\n)\nTextarea.displayName = \"Textarea\"\n\nexport { Textarea, textareaVariants }\n"
218
+ "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport { usePasswordManagersValue } from \"../../../lib/password-managers-context\"\nimport styles from \"./textarea.module.css\"\n\nconst textareaVariants = cva(styles.base, {\n variants: {\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n lg: styles.sizeLg,\n },\n },\n defaultVariants: {\n size: \"md\",\n },\n})\n\nexport interface TextareaProps\n extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, \"size\">,\n VariantProps<typeof textareaVariants> {\n /**\n * Whether password managers (1Password, Bitwarden, LastPass) should\n * offer to autofill this field. Defaults to `\"ignore\"` because most\n * Visor textareas live on non-auth forms (contact, marketing, settings)\n * where autofill icons are visual noise. Set to `\"allow\"` on the rare\n * credential or notes-style field where autofill is desired, or wrap the\n * form in `<Form passwordManagers=\"allow\">` to opt every descendant field\n * in at once. The field-level prop always wins over the form context.\n * Browsers ignore `autocomplete=\"off\"` on individual fields, so `\"ignore\"`\n * emits the per-manager data-* attributes (`data-1p-ignore`, `data-bwignore`,\n * `data-lpignore`, `data-form-type=\"other\"`) that each manager respects.\n */\n passwordManagers?: \"ignore\" | \"allow\"\n}\n\nconst Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(\n ({ className, size, passwordManagers, ...props }, ref) => {\n const resolved = usePasswordManagersValue(passwordManagers)\n const ignoreAttrs =\n resolved === \"ignore\"\n ? {\n \"data-1p-ignore\": \"true\",\n \"data-bwignore\": \"true\",\n \"data-lpignore\": \"true\",\n \"data-form-type\": \"other\",\n }\n : null\n return (\n <textarea\n data-slot=\"textarea\"\n className={cn(textareaVariants({ size }), className)}\n ref={ref}\n {...ignoreAttrs}\n {...props}\n />\n )\n }\n)\nTextarea.displayName = \"Textarea\"\n\nexport { Textarea, textareaVariants }\n"
217
219
  },
218
220
  {
219
221
  "path": "components/ui/textarea/textarea.module.css",
@@ -322,7 +324,7 @@
322
324
  {
323
325
  "path": "components/ui/field/field.module.css",
324
326
  "type": "registry:ui",
325
- "content": "/* Field */\n.field {\n display: flex;\n width: 100%;\n gap: var(--spacing-2, 0.5rem);\n}\n\n.fieldVertical {\n flex-direction: column;\n}\n\n.fieldHorizontal {\n flex-direction: row;\n align-items: center;\n}\n\n/* Field Label */\n.fieldLabel {\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n color: var(--text-primary, #111827);\n line-height: 1.4;\n}\n\n/* Field Description */\n.fieldDescription {\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-normal, 400);\n color: var(--text-secondary, #9ca3af);\n line-height: 1.5;\n margin: 0;\n}\n\n/* Field Error */\n.fieldError {\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-normal, 400);\n color: var(--text-error, #ef4444);\n}\n\n.fieldErrorList {\n margin-left: var(--spacing-4, 1rem);\n list-style: disc;\n display: flex;\n flex-direction: column;\n gap: var(--spacing-1, 0.25rem);\n}\n"
327
+ "content": "/* Field */\n.field {\n display: flex;\n width: 100%;\n gap: var(--spacing-2, 0.5rem);\n}\n\n.fieldVertical {\n flex-direction: column;\n}\n\n.fieldHorizontal {\n flex-direction: row;\n align-items: center;\n}\n\n/* Field Label */\n.fieldLabel {\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n color: var(--text-primary, #111827);\n line-height: 1.4;\n}\n\n/* Field Description */\n.fieldDescription {\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-normal, 400);\n color: var(--text-secondary, #9ca3af);\n line-height: 1.5;\n margin: 0;\n}\n\n/* Field Error */\n.fieldError {\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-normal, 400);\n color: var(--text-error, #ef4444);\n}\n\n.fieldErrorList {\n margin-left: var(--spacing-4, 1rem);\n list-style: disc;\n display: flex;\n flex-direction: column;\n gap: var(--spacing-1, 0.25rem);\n}\n"
326
328
  }
327
329
  ]
328
330
  },
@@ -400,6 +402,32 @@
400
402
  }
401
403
  ]
402
404
  },
405
+ {
406
+ "name": "chip",
407
+ "type": "registry:ui",
408
+ "description": "A chip family — Chip (display), ChoiceChip (radio-style single-select), and FilterChip (toggle-style multi-select) — modeled on Flutter Material's Chip API.",
409
+ "category": "form",
410
+ "dependencies": [
411
+ "class-variance-authority",
412
+ "@phosphor-icons/react",
413
+ "@loworbitstudio/visor-core"
414
+ ],
415
+ "registryDependencies": [
416
+ "utils"
417
+ ],
418
+ "files": [
419
+ {
420
+ "path": "components/ui/chip/chip.tsx",
421
+ "type": "registry:ui",
422
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { XIcon } from \"@phosphor-icons/react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./chip.module.css\"\n\n/* ─── Chip (base) ────────────────────────────────────────────────────── */\n\nconst chipVariants = cva(styles.base, {\n variants: {\n variant: {\n default: styles.variantDefault,\n outlined: styles.variantOutlined,\n },\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n lg: styles.sizeLg,\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"md\",\n },\n})\n\nexport interface ChipProps\n extends React.HTMLAttributes<HTMLDivElement>,\n VariantProps<typeof chipVariants> {\n /** Optional avatar/icon rendered before the label. */\n avatar?: React.ReactNode\n /** Optional leading icon rendered before the label (after avatar). */\n leadingIcon?: React.ReactNode\n /** Label content. Renders as children if omitted. */\n label?: React.ReactNode\n /** Custom delete icon. Defaults to XIcon. */\n deleteIcon?: React.ReactNode\n /** Called when the delete button is activated. When provided the delete button is shown. */\n onDeleted?: () => void\n /** Aria label for the delete button. Defaults to \"Remove\". */\n deleteLabel?: string\n}\n\nconst Chip = React.forwardRef<HTMLDivElement, ChipProps>(\n (\n {\n className,\n variant,\n size,\n avatar,\n leadingIcon,\n label,\n children,\n deleteIcon,\n onDeleted,\n deleteLabel = \"Remove\",\n ...props\n },\n ref,\n ) => {\n const content = label ?? children\n\n return (\n <div\n ref={ref}\n data-slot=\"chip\"\n data-variant={variant ?? \"default\"}\n data-size={size ?? \"md\"}\n className={cn(chipVariants({ variant, size }), className)}\n {...props}\n >\n {avatar ? (\n <span className={styles.avatar} aria-hidden=\"true\">\n {avatar}\n </span>\n ) : null}\n {leadingIcon ? (\n <span className={styles.leadingIcon} aria-hidden=\"true\">\n {leadingIcon}\n </span>\n ) : null}\n <span className={styles.label}>{content}</span>\n {onDeleted ? (\n <button\n type=\"button\"\n aria-label={deleteLabel}\n className={styles.deleteButton}\n onClick={(e) => {\n e.stopPropagation()\n onDeleted()\n }}\n tabIndex={0}\n >\n {deleteIcon ?? (\n <XIcon\n size={12}\n weight=\"bold\"\n aria-hidden=\"true\"\n className={styles.deleteIcon}\n />\n )}\n </button>\n ) : null}\n </div>\n )\n },\n)\nChip.displayName = \"Chip\"\n\n/* ─── ChoiceChip (radio-style single-select) ─────────────────────────── */\n\nexport interface ChoiceChipProps\n extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"onClick\">,\n VariantProps<typeof chipVariants> {\n /** Whether this chip is currently selected. */\n selected?: boolean\n /** Optional avatar/icon rendered before the label. */\n avatar?: React.ReactNode\n /** Optional leading icon rendered before the label. */\n leadingIcon?: React.ReactNode\n /** Label text or content. */\n label?: React.ReactNode\n /** Called when the chip is pressed. */\n onPressed?: () => void\n /** Value used when in a ChipGroup. */\n value?: string\n}\n\nconst ChoiceChip = React.forwardRef<HTMLButtonElement, ChoiceChipProps>(\n (\n {\n className,\n variant,\n size,\n selected = false,\n avatar,\n leadingIcon,\n label,\n children,\n onPressed,\n value,\n disabled,\n ...props\n },\n ref,\n ) => {\n const content = label ?? children\n\n return (\n <button\n ref={ref}\n type=\"button\"\n role=\"radio\"\n aria-checked={selected}\n data-slot=\"choice-chip\"\n data-variant={variant ?? \"default\"}\n data-size={size ?? \"md\"}\n data-selected={selected ? \"true\" : \"false\"}\n data-value={value}\n disabled={disabled}\n className={cn(\n chipVariants({ variant, size }),\n styles.interactive,\n selected && styles.selected,\n className,\n )}\n onClick={onPressed}\n {...props}\n >\n {avatar ? (\n <span className={styles.avatar} aria-hidden=\"true\">\n {avatar}\n </span>\n ) : null}\n {leadingIcon ? (\n <span className={styles.leadingIcon} aria-hidden=\"true\">\n {leadingIcon}\n </span>\n ) : null}\n <span className={styles.label}>{content}</span>\n </button>\n )\n },\n)\nChoiceChip.displayName = \"ChoiceChip\"\n\n/* ─── FilterChip (multi-select toggle-style) ─────────────────────────── */\n\nexport interface FilterChipProps\n extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"onClick\">,\n VariantProps<typeof chipVariants> {\n /** Whether this chip is currently active/selected. */\n selected?: boolean\n /** Optional leading icon. */\n leadingIcon?: React.ReactNode\n /** Label text or content. */\n label?: React.ReactNode\n /** Called when the chip is toggled. */\n onPressed?: () => void\n /** Value used when in a ChipGroup. */\n value?: string\n}\n\nconst FilterChip = React.forwardRef<HTMLButtonElement, FilterChipProps>(\n (\n {\n className,\n variant,\n size,\n selected = false,\n leadingIcon,\n label,\n children,\n onPressed,\n value,\n disabled,\n ...props\n },\n ref,\n ) => {\n const content = label ?? children\n\n return (\n <button\n ref={ref}\n type=\"button\"\n role=\"checkbox\"\n aria-checked={selected}\n data-slot=\"filter-chip\"\n data-variant={variant ?? \"default\"}\n data-size={size ?? \"md\"}\n data-selected={selected ? \"true\" : \"false\"}\n data-value={value}\n disabled={disabled}\n className={cn(\n chipVariants({ variant, size }),\n styles.interactive,\n selected && styles.selected,\n className,\n )}\n onClick={onPressed}\n {...props}\n >\n {leadingIcon ? (\n <span className={styles.leadingIcon} aria-hidden=\"true\">\n {leadingIcon}\n </span>\n ) : null}\n <span className={styles.label}>{content}</span>\n </button>\n )\n },\n)\nFilterChip.displayName = \"FilterChip\"\n\nexport { Chip, chipVariants, ChoiceChip, FilterChip }\n"
423
+ },
424
+ {
425
+ "path": "components/ui/chip/chip.module.css",
426
+ "type": "registry:ui",
427
+ "content": "/* Chip base */\n.base {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n width: fit-content;\n flex-shrink: 0;\n border-radius: var(--radius-full, 9999px);\n border: var(--stroke-width-thin, 1px) solid var(--border-default, #e5e7eb);\n background-color: var(--surface-card, #ffffff);\n color: var(--text-primary, #111827);\n font-family: var(--font-body, inherit);\n font-weight: var(--font-weight-medium, 500);\n white-space: nowrap;\n user-select: none;\n transition:\n background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n/* Variant: default (filled/tonal surface) */\n.variantDefault {\n background-color: var(--surface-muted, #f3f4f6);\n border-color: transparent;\n color: var(--text-primary, #111827);\n}\n\n/* Variant: outlined */\n.variantOutlined {\n background-color: transparent;\n border-color: var(--border-default, #e5e7eb);\n color: var(--text-primary, #111827);\n}\n\n/* Sizes */\n.sizeSm {\n height: 1.5rem;\n padding: 0 var(--spacing-2, 0.5rem);\n font-size: var(--font-size-xs, 0.75rem);\n gap: var(--spacing-1, 0.25rem);\n}\n\n.sizeMd {\n height: 2rem;\n padding: 0 var(--spacing-3, 0.75rem);\n font-size: var(--font-size-sm, 0.875rem);\n gap: var(--spacing-1, 0.25rem);\n}\n\n.sizeLg {\n height: 2.5rem;\n padding: 0 var(--spacing-4, 1rem);\n font-size: var(--font-size-base, 1rem);\n gap: var(--spacing-2, 0.5rem);\n}\n\n/* Sub-parts */\n.avatar {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n}\n\n.leadingIcon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n}\n\n.label {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.deleteButton {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n background: transparent;\n border: none;\n padding: 0;\n cursor: pointer;\n color: var(--text-secondary, #6b7280);\n border-radius: var(--radius-full, 9999px);\n transition: color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n width: 1rem;\n height: 1rem;\n}\n\n@media (hover: hover) {\n .deleteButton:hover {\n color: var(--text-primary, #111827);\n background-color: color-mix(in srgb, var(--surface-interactive-hover, #f3f4f6) 60%, transparent);\n }\n}\n\n.deleteButton:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, #6b7280);\n outline-offset: var(--focus-ring-offset, 1px);\n}\n\n.deleteIcon {\n display: block;\n}\n\n/* Interactive chips (ChoiceChip, FilterChip) */\n.interactive {\n cursor: pointer;\n border: var(--stroke-width-thin, 1px) solid transparent;\n}\n\nbutton.base,\n.interactive {\n /* Inherit base layout; add button-specific resets */\n text-align: left;\n appearance: none;\n -webkit-appearance: none;\n}\n\n.interactive:disabled {\n cursor: not-allowed;\n opacity: var(--opacity-40, 0.4);\n pointer-events: none;\n}\n\n.interactive:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, #6b7280);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n@media (hover: hover) {\n .interactive:hover:not(:disabled):not([data-selected=\"true\"]) {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n border-color: var(--border-default, #e5e7eb);\n }\n}\n\n.interactive:active:not(:disabled) {\n transform: translateY(1px);\n transition: none;\n}\n\n/* Selected state */\n.selected {\n background-color: var(--surface-accent-subtle, #eff6ff);\n border-color: var(--surface-accent-default, #3b82f6);\n color: var(--text-link, #2563eb);\n}\n\n@media (hover: hover) {\n .interactive.selected:hover:not(:disabled) {\n background-color: color-mix(in srgb, var(--surface-accent-subtle, #eff6ff) 80%, var(--surface-interactive-hover, #f3f4f6) 20%);\n }\n}\n"
428
+ }
429
+ ]
430
+ },
403
431
  {
404
432
  "name": "avatar",
405
433
  "type": "registry:ui",
@@ -650,7 +678,7 @@
650
678
  {
651
679
  "path": "components/ui/tabs/tabs.module.css",
652
680
  "type": "registry:ui",
653
- "content": "/* Tabs root */\n.root {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-2, 0.5rem);\n}\n\n/* Tabs list */\n.list {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border-radius: var(--radius-full, 9999px);\n padding: calc(var(--spacing-1, 0.25rem) * 0.75);\n color: var(--text-secondary, #6b7280);\n height: 2.25rem;\n width: fit-content;\n}\n\n.listVariantDefault {\n background-color: var(--surface-muted, #f3f4f6);\n}\n\n.listVariantLine {\n position: relative;\n gap: var(--spacing-1, 0.25rem);\n background-color: transparent;\n border-radius: 0;\n border-bottom: 1px solid var(--border-default, #e5e7eb);\n height: auto;\n padding-bottom: 0;\n}\n\n/* Tabs trigger */\n.trigger {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: calc(var(--spacing-1, 0.25rem) * 1.5);\n border-radius: var(--radius-md, 0.375rem);\n border: 1px solid transparent;\n padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n white-space: nowrap;\n color: var(--text-secondary, #6b7280);\n background: transparent;\n cursor: pointer;\n transition: color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), transform var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n outline: none;\n flex: 1;\n height: calc(100% - 2px);\n}\n\n.trigger:active:not(:disabled) {\n transform: translateY(1px);\n transition: none;\n}\n\n@media (hover: hover) {\n .trigger:hover {\n color: var(--text-primary, #111827);\n }\n}\n\n.trigger:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.trigger:disabled {\n pointer-events: none;\n opacity: 0.5;\n}\n\n.trigger[data-state=\"active\"] {\n background-color: var(--surface-page, #ffffff);\n color: var(--text-primary, #111827);\n box-shadow: var(--shadow-sm);\n}\n\n/* Tabs content */\n.content {\n flex: 1;\n font-size: var(--font-size-sm, 0.875rem);\n outline: none;\n}\n\n.content:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n/* Line variant: active trigger loses shadow/bg, just text color */\n.listVariantLine .trigger[data-state=\"active\"] {\n background-color: transparent;\n box-shadow: none;\n}\n\n/* Animated sliding indicator for line variant */\n.indicator {\n position: absolute;\n bottom: -1px;\n height: 2px;\n background-color: var(--text-primary, #111827);\n border-radius: var(--radius-full, 9999px);\n transition: left var(--motion-duration-normal, 200ms) var(--motion-easing-default, ease-in-out),\n width var(--motion-duration-normal, 200ms) var(--motion-easing-default, ease-in-out);\n}\n"
681
+ "content": "/* Tabs root */\n.root {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-2, 0.5rem);\n}\n\n/* Tabs list */\n.list {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border-radius: var(--radius-full, 9999px);\n padding: calc(var(--spacing-1, 0.25rem) * 0.75);\n color: var(--text-secondary, #6b7280);\n height: 2.25rem;\n width: fit-content;\n}\n\n.listVariantDefault {\n background-color: var(--surface-muted, #f3f4f6);\n}\n\n.listVariantLine {\n position: relative;\n gap: var(--spacing-1, 0.25rem);\n background-color: transparent;\n border-radius: 0;\n border-bottom: 1px solid var(--border-default, #e5e7eb);\n height: auto;\n padding-bottom: 0;\n}\n\n/* Tabs trigger */\n.trigger {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: calc(var(--spacing-1, 0.25rem) * 1.5);\n border-radius: var(--radius-md, 0.375rem);\n border: 1px solid transparent;\n padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n white-space: nowrap;\n color: var(--text-secondary, #6b7280);\n background: transparent;\n cursor: pointer;\n transition: color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), transform var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n outline: none;\n flex: 1;\n height: calc(100% - 2px);\n}\n\n.trigger:active:not(:disabled) {\n transform: translateY(1px);\n transition: none;\n}\n\n@media (hover: hover) {\n .trigger:hover {\n color: var(--text-primary, #111827);\n }\n}\n\n.trigger:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.trigger:disabled {\n pointer-events: none;\n opacity: 0.5;\n}\n\n.trigger[data-state=\"active\"] {\n background-color: var(--surface-page, #ffffff);\n color: var(--text-primary, #111827);\n box-shadow: var(--shadow-sm);\n}\n\n/* Tabs content */\n.content {\n flex: 1;\n font-size: var(--font-size-sm, 0.875rem);\n outline: none;\n}\n\n.content:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n/* Line variant: active trigger loses shadow/bg, just text color */\n.listVariantLine .trigger[data-state=\"active\"] {\n background-color: transparent;\n box-shadow: none;\n}\n\n/* Animated sliding indicator for line variant */\n.indicator {\n position: absolute;\n bottom: -1px;\n height: 2px;\n background-color: var(--tabs-indicator-color, var(--text-primary, #111827));\n border-radius: var(--radius-full, 9999px);\n transition: left var(--motion-duration-normal, 200ms) var(--motion-easing-default, ease-in-out),\n width var(--motion-duration-normal, 200ms) var(--motion-easing-default, ease-in-out);\n}\n"
654
682
  }
655
683
  ]
656
684
  },
@@ -806,6 +834,40 @@
806
834
  }
807
835
  ]
808
836
  },
837
+ {
838
+ "name": "bento-grid",
839
+ "type": "registry:ui",
840
+ "description": "An asymmetric tile grid primitive with full/half span variants, per-tile aspect ratios, and contain/cover media fit modes. Designed for portfolio, case-study, and showcase layouts.",
841
+ "category": "data-display",
842
+ "dependencies": [
843
+ "@loworbitstudio/visor-core"
844
+ ],
845
+ "registryDependencies": [
846
+ "utils"
847
+ ],
848
+ "files": [
849
+ {
850
+ "path": "components/ui/bento-grid/bento-grid.tsx",
851
+ "type": "registry:ui",
852
+ "content": "import * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./bento-grid.module.css\"\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ResponsiveValue<T> {\n base: T\n sm?: T\n md?: T\n lg?: T\n xl?: T\n}\n\nexport type BentoSpan = \"full\" | \"half\" | number\n\nexport type BentoAspect = \"21/9\" | \"2/1\" | \"4/3\" | \"1/1\" | (string & {})\n\nexport type BentoFit = \"cover\" | \"contain\"\n\nexport type BentoLayout = \"stacked\" | \"overlay\"\n\n// ---------------------------------------------------------------------------\n// BentoGrid\n// ---------------------------------------------------------------------------\n\nexport interface BentoGridProps extends React.HTMLAttributes<HTMLDivElement> {\n /**\n * Number of columns. Accepts a plain number or a responsive breakpoint map.\n * Defaults to 2 columns.\n */\n cols?: number | ResponsiveValue<number>\n /**\n * Gap between tiles as a spacing token suffix (e.g. \"md\" → `--spacing-md`).\n * Defaults to \"4\" which resolves to `--spacing-4`.\n */\n gap?: string\n /**\n * When true, tiles fade + rise from 24px on viewport entry. Children are\n * staggered by their DOM order — left-to-right on a row, row-by-row down\n * the grid. Respects `prefers-reduced-motion`. Defaults to false.\n */\n reveal?: boolean\n /**\n * Per-tile reveal delay in milliseconds. Each tile's actual delay is\n * `revealStepMs × index`. Defaults to 110ms.\n */\n revealStepMs?: number\n /**\n * IntersectionObserver threshold for the entrance trigger. Defaults to 0.2.\n */\n revealThreshold?: number\n}\n\nconst BentoGrid = React.forwardRef<HTMLDivElement, BentoGridProps>(\n (\n {\n className,\n cols = 2,\n gap = \"4\",\n style,\n reveal = false,\n revealStepMs = 110,\n revealThreshold = 0.2,\n children,\n ...props\n },\n forwardedRef\n ) => {\n const innerRef = React.useRef<HTMLDivElement | null>(null)\n const [inView, setInView] = React.useState(false)\n\n const setRef = React.useCallback(\n (node: HTMLDivElement | null) => {\n innerRef.current = node\n if (typeof forwardedRef === \"function\") {\n forwardedRef(node)\n } else if (forwardedRef) {\n forwardedRef.current = node\n }\n },\n [forwardedRef]\n )\n\n React.useEffect(() => {\n if (!reveal) return\n const element = innerRef.current\n if (!element || typeof IntersectionObserver === \"undefined\") return\n\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (entry.isIntersecting) {\n setInView(true)\n observer.disconnect()\n }\n },\n { threshold: revealThreshold }\n )\n observer.observe(element)\n return () => observer.disconnect()\n }, [reveal, revealThreshold])\n\n const colCount = typeof cols === \"number\" ? cols : cols.base\n const smCols = typeof cols === \"object\" ? cols.sm : undefined\n const mdCols = typeof cols === \"object\" ? cols.md : undefined\n const lgCols = typeof cols === \"object\" ? cols.lg : undefined\n const xlCols = typeof cols === \"object\" ? cols.xl : undefined\n\n const cssVars: React.CSSProperties & Record<string, string | number> = {\n \"--bento-cols\": colCount,\n \"--bento-gap\": `var(--spacing-${gap}, 1rem)`,\n ...(smCols !== undefined ? { \"--bento-cols-sm\": smCols } : {}),\n ...(mdCols !== undefined ? { \"--bento-cols-md\": mdCols } : {}),\n ...(lgCols !== undefined ? { \"--bento-cols-lg\": lgCols } : {}),\n ...(xlCols !== undefined ? { \"--bento-cols-xl\": xlCols } : {}),\n ...(reveal ? { \"--bento-reveal-step\": `${revealStepMs}ms` } : {}),\n ...style,\n }\n\n // When reveal is on, walk children and inject a per-tile --reveal-index so\n // the cascade stagger reads in DOM order.\n const decoratedChildren = reveal\n ? React.Children.map(children, (child, idx) => {\n if (!React.isValidElement(child)) return child\n const childProps = child.props as React.HTMLAttributes<HTMLElement>\n return React.cloneElement(child, {\n style: {\n ...(childProps.style ?? {}),\n \"--reveal-index\": idx,\n },\n } as Partial<React.HTMLAttributes<HTMLElement>>)\n })\n : children\n\n return (\n <div\n ref={setRef}\n data-slot=\"bento-grid\"\n data-reveal={reveal ? \"true\" : undefined}\n data-revealed={reveal && inView ? \"true\" : undefined}\n className={cn(styles.bentoGrid, className)}\n style={cssVars}\n {...props}\n >\n {decoratedChildren}\n </div>\n )\n }\n)\nBentoGrid.displayName = \"BentoGrid\"\n\n// ---------------------------------------------------------------------------\n// BentoTile\n// ---------------------------------------------------------------------------\n\nexport interface BentoTileProps extends React.HTMLAttributes<HTMLElement> {\n /**\n * Visual layout of the tile.\n * - \"stacked\" (default): media renders on top with its own aspect ratio,\n * body is a sibling block below in document flow. Tile height = media + body.\n * - \"overlay\": the tile carries the aspect ratio, media fills it absolutely,\n * body floats over the lower portion.\n * @default \"stacked\"\n */\n layout?: BentoLayout\n /**\n * Column span: \"full\" (1/-1), \"half\" (1 col), or a numeric span count.\n * Defaults to \"half\".\n */\n span?: BentoSpan\n /**\n * Aspect ratio for the tile (e.g. \"21/9\", \"2/1\", \"4/3\", \"1/1\").\n * Applied to the media in \"stacked\" mode, and to the tile root in \"overlay\" mode.\n */\n aspect?: BentoAspect\n /**\n * Media fit mode. \"cover\" fills the tile; \"contain\" fits media inside\n * a surface-card background plate.\n * Defaults to \"cover\".\n */\n fit?: BentoFit\n /**\n * When provided, renders the tile root as an `<a>` element.\n */\n href?: string\n /**\n * Link target (e.g. \"_blank\"). Only relevant when `href` is set.\n */\n target?: string\n /**\n * Link rel attribute. Only relevant when `href` is set.\n */\n rel?: string\n}\n\nconst BentoTile = React.forwardRef<HTMLElement, BentoTileProps>(\n (\n {\n className,\n layout = \"stacked\",\n span = \"half\",\n aspect,\n fit = \"cover\",\n href,\n target,\n rel,\n style,\n children,\n ...props\n },\n ref\n ) => {\n const spanStyle: React.CSSProperties & Record<string, string | number> = {\n ...(aspect ? { \"--bento-tile-aspect\": aspect.replace(\"/\", \" / \") } : {}),\n ...style,\n }\n\n const spanClass = cn(\n styles.bentoTile,\n layout === \"stacked\" ? styles.bentoTileStacked : styles.bentoTileOverlay,\n span === \"full\" && styles.bentoTileFull,\n span === \"half\" && styles.bentoTileHalf,\n typeof span === \"number\" && styles.bentoTileNumeric,\n fit === \"contain\" && styles.bentoTileContain,\n className\n )\n\n const spanDataAttr =\n typeof span === \"number\" ? { \"--bento-tile-span\": span } : {}\n\n const combinedStyle = { ...spanStyle, ...spanDataAttr }\n\n if (href) {\n return (\n <a\n ref={ref as React.Ref<HTMLAnchorElement>}\n data-slot=\"bento-tile\"\n data-span={span}\n data-fit={fit}\n data-layout={layout}\n href={href}\n target={target}\n rel={rel ?? (target === \"_blank\" ? \"noopener noreferrer\" : undefined)}\n className={spanClass}\n style={combinedStyle}\n {...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}\n >\n {children}\n </a>\n )\n }\n\n return (\n <article\n ref={ref as React.Ref<HTMLElement>}\n data-slot=\"bento-tile\"\n data-span={span}\n data-fit={fit}\n data-layout={layout}\n className={spanClass}\n style={combinedStyle}\n {...props}\n >\n {children}\n </article>\n )\n }\n)\nBentoTile.displayName = \"BentoTile\"\n\n// ---------------------------------------------------------------------------\n// BentoTileMedia\n// ---------------------------------------------------------------------------\n\nexport interface BentoTileMediaProps\n extends React.ImgHTMLAttributes<HTMLImageElement> {\n src: string\n alt: string\n loading?: \"lazy\" | \"eager\"\n}\n\nconst BentoTileMedia = React.forwardRef<HTMLImageElement, BentoTileMediaProps>(\n ({ className, loading = \"lazy\", alt, ...props }, ref) => {\n return (\n <img\n ref={ref}\n data-slot=\"bento-tile-media\"\n className={cn(styles.bentoTileMedia, className)}\n loading={loading}\n alt={alt}\n {...props}\n />\n )\n }\n)\nBentoTileMedia.displayName = \"BentoTileMedia\"\n\n// ---------------------------------------------------------------------------\n// BentoTileBody\n// ---------------------------------------------------------------------------\n\nexport type BentoTileBodyProps = React.HTMLAttributes<HTMLDivElement>\n\nconst BentoTileBody = React.forwardRef<HTMLDivElement, BentoTileBodyProps>(\n ({ className, ...props }, ref) => {\n return (\n <div\n ref={ref}\n data-slot=\"bento-tile-body\"\n className={cn(styles.bentoTileBody, className)}\n {...props}\n />\n )\n }\n)\nBentoTileBody.displayName = \"BentoTileBody\"\n\n// ---------------------------------------------------------------------------\n// BentoTileMeta — eyebrow row (e.g. \"WEB · LOW ORBIT · 2026\")\n// ---------------------------------------------------------------------------\n\nexport interface BentoTileMetaProps extends React.HTMLAttributes<HTMLDivElement> {\n /**\n * Shorthand: provide an array of strings, each rendered as a span with a\n * `·` separator between siblings. When omitted, children are used as-is.\n */\n items?: React.ReactNode[]\n}\n\nconst BentoTileMeta = React.forwardRef<HTMLDivElement, BentoTileMetaProps>(\n ({ className, items, children, ...props }, ref) => {\n const content = items\n ? items.map((item, idx) => <span key={idx}>{item}</span>)\n : children\n return (\n <div\n ref={ref}\n data-slot=\"bento-tile-meta\"\n className={cn(styles.bentoTileMeta, className)}\n {...props}\n >\n {content}\n </div>\n )\n }\n)\nBentoTileMeta.displayName = \"BentoTileMeta\"\n\n// ---------------------------------------------------------------------------\n// BentoTileTitle — large display heading with optional hover arrow\n// ---------------------------------------------------------------------------\n\nexport interface BentoTileTitleProps\n extends React.HTMLAttributes<HTMLHeadingElement> {\n /** Heading level. @default \"h3\" */\n as?: \"h2\" | \"h3\" | \"h4\"\n /**\n * Render a ↗ arrow after the title that nudges on tile hover. Useful when\n * the tile is a link.\n * @default false\n */\n showArrow?: boolean\n}\n\nconst BentoTileTitle = React.forwardRef<HTMLHeadingElement, BentoTileTitleProps>(\n ({ className, as: Tag = \"h3\", showArrow = false, children, ...props }, ref) => {\n return (\n <Tag\n ref={ref}\n data-slot=\"bento-tile-title\"\n className={cn(styles.bentoTileTitle, className)}\n {...props}\n >\n {children}\n {showArrow ? (\n <span className={styles.bentoTileTitleArrow} aria-hidden=\"true\">\n {\"↗\"}\n </span>\n ) : null}\n </Tag>\n )\n }\n)\nBentoTileTitle.displayName = \"BentoTileTitle\"\n\n// ---------------------------------------------------------------------------\n// BentoTileFigure — non-image figure slot (charts, large numbers, custom SVG)\n// ---------------------------------------------------------------------------\n//\n// Drop-in replacement for BentoTileMedia when the tile's hero element is not\n// a photographic image — typical use cases: data charts, large statistic\n// numbers, illustrated SVGs, or any composed JSX. Inherits the same hover\n// scale behavior as BentoTileMedia (suppressed in fit=\"contain\" mode) and the\n// same layout-mode positioning (relative in stacked, absolute in overlay).\n\nexport type BentoTileFigureProps = React.HTMLAttributes<HTMLDivElement>\n\nconst BentoTileFigure = React.forwardRef<HTMLDivElement, BentoTileFigureProps>(\n ({ className, ...props }, ref) => {\n return (\n <div\n ref={ref}\n data-slot=\"bento-tile-figure\"\n className={cn(styles.bentoTileMedia, styles.bentoTileFigure, className)}\n {...props}\n />\n )\n }\n)\nBentoTileFigure.displayName = \"BentoTileFigure\"\n\n// ---------------------------------------------------------------------------\n// BentoTileHeadline — large display heading for headline-only tiles\n// ---------------------------------------------------------------------------\n//\n// Use BentoTileHeadline (not BentoTileTitle) when the tile's primary content\n// is the headline itself — manifesto pages, big-idea moments, statement\n// tiles. Scales from 2rem → 3.5rem and pairs naturally with BentoTileMeta or\n// BentoTileDescription as supporting copy.\n\nexport interface BentoTileHeadlineProps\n extends React.HTMLAttributes<HTMLHeadingElement> {\n /** Heading level. @default \"h2\" */\n as?: \"h1\" | \"h2\" | \"h3\"\n}\n\nconst BentoTileHeadline = React.forwardRef<\n HTMLHeadingElement,\n BentoTileHeadlineProps\n>(({ className, as: Tag = \"h2\", ...props }, ref) => {\n return (\n <Tag\n ref={ref}\n data-slot=\"bento-tile-headline\"\n className={cn(styles.bentoTileHeadline, className)}\n {...props}\n />\n )\n})\nBentoTileHeadline.displayName = \"BentoTileHeadline\"\n\n// ---------------------------------------------------------------------------\n// BentoTileDescription — muted body text under the title\n// ---------------------------------------------------------------------------\n//\n// Renders as <div> rather than <p> so MDX-authored children (which MDX\n// auto-wraps in <p>) don't create invalid <p>-in-<p> nesting and hydration\n// errors. The semantic loss is minor — the body content is still readable\n// prose, just hosted in a styled container.\n\nexport type BentoTileDescriptionProps =\n React.HTMLAttributes<HTMLDivElement>\n\nconst BentoTileDescription = React.forwardRef<\n HTMLDivElement,\n BentoTileDescriptionProps\n>(({ className, ...props }, ref) => {\n return (\n <div\n ref={ref}\n data-slot=\"bento-tile-description\"\n className={cn(styles.bentoTileDescription, className)}\n {...props}\n />\n )\n})\nBentoTileDescription.displayName = \"BentoTileDescription\"\n\n// ---------------------------------------------------------------------------\n// Exports\n// ---------------------------------------------------------------------------\n\nexport {\n BentoGrid,\n BentoTile,\n BentoTileMedia,\n BentoTileFigure,\n BentoTileBody,\n BentoTileMeta,\n BentoTileTitle,\n BentoTileHeadline,\n BentoTileDescription,\n}\n"
853
+ },
854
+ {
855
+ "path": "components/ui/bento-grid/bento-grid.module.css",
856
+ "type": "registry:ui",
857
+ "content": "/* ============================================================\n BentoGrid — asymmetric tile grid layout component\n ============================================================ */\n\n/* ---------------------------------------------------------------------------\n Grid container\n --------------------------------------------------------------------------- */\n\n.bentoGrid {\n display: grid;\n grid-template-columns: repeat(var(--bento-cols, 2), 1fr);\n gap: var(--bento-gap, var(--spacing-4, 1rem));\n /* Tiles hug their content vertically — they do NOT stretch to share row\n height. Matches the strata-v1 prototype's work-grid alignment. */\n align-items: start;\n}\n\n/* Responsive breakpoints — only apply when the CSS variable is set */\n@media (min-width: 640px) {\n .bentoGrid {\n grid-template-columns: repeat(\n var(--bento-cols-sm, var(--bento-cols, 2)),\n 1fr\n );\n }\n}\n\n@media (min-width: 768px) {\n .bentoGrid {\n grid-template-columns: repeat(\n var(--bento-cols-md, var(--bento-cols-sm, var(--bento-cols, 2))),\n 1fr\n );\n }\n}\n\n@media (min-width: 1024px) {\n .bentoGrid {\n grid-template-columns: repeat(\n var(--bento-cols-lg, var(--bento-cols-md, var(--bento-cols-sm, var(--bento-cols, 2)))),\n 1fr\n );\n }\n}\n\n@media (min-width: 1280px) {\n .bentoGrid {\n grid-template-columns: repeat(\n var(--bento-cols-xl, var(--bento-cols-lg, var(--bento-cols-md, var(--bento-cols-sm, var(--bento-cols, 2))))),\n 1fr\n );\n }\n}\n\n/* ---------------------------------------------------------------------------\n Tile base\n --------------------------------------------------------------------------- */\n\n.bentoTile {\n /* Customization slots — defaults match the strata-v1 marketing prototype.\n Override per-grid via a parent token-override scope or per-tile via style. */\n --bento-tile-body-padding-y: var(--spacing-3, 0.75rem); /* 12px — tight vertical rhythm */\n --bento-tile-body-padding-x: clamp(var(--spacing-3, 0.75rem), 1.6vw, var(--spacing-5, 1.25rem));\n --bento-tile-body-gap: var(--spacing-2, 0.5rem); /* 8px between meta / title / description */\n --bento-tile-radius: var(--radius-lg, 0.75rem);\n --bento-tile-media-scale-hover: 1.04;\n /* Default is no lift — hover affordance is the media scale + shadow bump.\n Consumers can opt in by overriding this slot (for example -3px). */\n --bento-tile-lift-hover: 0px;\n\n position: relative;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n isolation: isolate;\n border-radius: var(--bento-tile-radius);\n background-color: var(--surface-card, #ffffff);\n color: var(--text-primary, #111827);\n box-shadow: var(--shadow-sm);\n /* Aspect ratio lives on the layout modifier — overlay applies it to the tile root, stacked applies it to the media. */\n transition:\n box-shadow var(--motion-duration-normal, 200ms) var(--motion-easing-default, ease),\n transform var(--motion-duration-normal, 200ms) var(--motion-easing-default, ease);\n}\n\n/* Prose reset — when the tile renders inside a fumadocs prose (or any\n Tailwind-Typography) container, default img/p/h3 margins of ~2em swamp\n the tile's own spacing. Reset every child element we render to margin: 0;\n the tile's own padding + gap tokens become the only source of spacing. */\n.bentoTile :where(img),\n.bentoTile :where(p),\n.bentoTile :where(h2),\n.bentoTile :where(h3),\n.bentoTile :where(h4),\n.bentoTile :where(ul),\n.bentoTile :where(ol) {\n margin: 0;\n}\n\n/* Stacked (default): media on top with its own aspect ratio, body as a sibling block below. Total tile height = media height + body height. */\n.bentoTileStacked {\n /* No aspect-ratio on the tile root — height comes from media + body. */\n}\n\n/* Overlay: the tile root carries the aspect ratio. Media fills it absolutely; body floats over the lower portion. */\n.bentoTileOverlay {\n aspect-ratio: var(--bento-tile-aspect, auto);\n}\n\n/* Clickable tiles — anchor root */\na.bentoTile {\n text-decoration: none;\n cursor: pointer;\n}\n\n/* The hover affordance is the image scale (on .bentoTileMedia) and the\n box-shadow bump here. Tile lift is opt-in via the --bento-tile-lift-hover\n slot, default 0 — text and eyebrow do NOT shift by default. */\na.bentoTile:hover {\n box-shadow: var(--shadow-md);\n transform: translateY(var(--bento-tile-lift-hover, 0px));\n}\n\na.bentoTile:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, #6366f1);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n/* ---------------------------------------------------------------------------\n Span variants\n --------------------------------------------------------------------------- */\n\n.bentoTileFull {\n grid-column: 1 / -1;\n}\n\n.bentoTileHalf {\n grid-column: span 1;\n}\n\n.bentoTileNumeric {\n grid-column: span var(--bento-tile-span, 1);\n}\n\n/* Collapse all spans to single column on narrow viewports */\n@media (max-width: 639px) {\n .bentoTileFull,\n .bentoTileHalf,\n .bentoTileNumeric {\n grid-column: 1 / -1;\n }\n}\n\n/* ---------------------------------------------------------------------------\n Fit variants\n --------------------------------------------------------------------------- */\n\n.bentoTileContain {\n background-color: var(--surface-card, #ffffff);\n}\n\n/* ---------------------------------------------------------------------------\n Media — layout-scoped positioning + Ken-Burns-style hover zoom\n --------------------------------------------------------------------------- */\n\n.bentoTileMedia {\n display: block; /* Drop inline baseline space — avoids a hairline gap above/below the image. */\n width: 100%;\n object-fit: cover; /* default; overridden by contain modifier below */\n /* Slow ease-out zoom on tile hover — applied here so any tile (anchor or article) gets the effect. */\n transition: transform var(--motion-duration-slow, 600ms)\n var(--motion-easing-enter, cubic-bezier(0.19, 1, 0.22, 1));\n transform-origin: center center;\n}\n\n.bentoTile:hover .bentoTileMedia {\n transform: scale(var(--bento-tile-media-scale-hover));\n}\n\n/* Stacked: media is a normal block sized by its own aspect ratio. */\n.bentoTileStacked .bentoTileMedia {\n position: relative;\n aspect-ratio: var(--bento-tile-aspect, 16 / 10);\n height: auto;\n}\n\n/* Overlay: media absolutely fills the tile (which carries the aspect ratio). */\n.bentoTileOverlay .bentoTileMedia {\n position: absolute;\n inset: 0;\n height: 100%;\n}\n\n/* Contain mode in stacked layout — the media slot drops its aspect-ratio\n enforcement so the image's natural dimensions dictate the slot height.\n No letterboxing; the body sits directly under the image. */\n.bentoTileStacked.bentoTileContain .bentoTileMedia {\n aspect-ratio: auto;\n height: auto;\n object-fit: initial;\n}\n\n/* Contain in overlay mode keeps the tile's aspect ratio (the tile is the\n container), so we still need object-fit: contain to letterbox inside. */\n.bentoTileOverlay.bentoTileContain .bentoTileMedia {\n object-fit: contain;\n}\n\n/* ---------------------------------------------------------------------------\n Tile body — layout-scoped positioning\n --------------------------------------------------------------------------- */\n\n.bentoTileBody {\n display: grid;\n gap: var(--bento-tile-body-gap);\n padding-block: var(--bento-tile-body-padding-y);\n padding-inline: var(--bento-tile-body-padding-x);\n align-content: start;\n}\n\n/* Stacked: body sits below the media in normal flow at its natural height —\n no flex-grow, so the tile hugs its content. */\n.bentoTileStacked .bentoTileBody {\n position: static;\n}\n\n/* Overlay: body floats on top, pushed to bottom of the tile. A subtle\n bottom-anchored gradient keeps text legible against any image. */\n.bentoTileOverlay .bentoTileBody {\n position: relative;\n z-index: 1;\n margin-top: auto;\n background: linear-gradient(\n to top,\n var(--overlay-bg) 0%,\n var(--overlay-bg) 60%,\n transparent 100%\n );\n}\n\n/* ---------------------------------------------------------------------------\n Body content typography: meta (eyebrow), title, description\n --------------------------------------------------------------------------- */\n\n.bentoTileMeta {\n display: flex;\n flex-wrap: wrap;\n gap: var(--spacing-3, 0.75rem);\n font-size: 0.6875rem; /* 11px — intentional tracking-display size */\n font-weight: var(--font-weight-medium, 500);\n /* Tight line-height so when items wrap to a second line, the wrapped row sits close to the first instead of leaving a body-text-sized gap. */\n line-height: var(--line-height-tight, 1.25);\n letter-spacing: 0.18em;\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n font-variant-numeric: tabular-nums;\n /* When wrapping, give each row a small vertical breathing space. */\n row-gap: var(--spacing-1, 0.25rem);\n}\n\n.bentoTileMeta > * + *::before {\n content: \"·\";\n margin-right: var(--spacing-3, 0.75rem);\n margin-left: calc(var(--spacing-2, 0.5rem) * -1);\n color: var(--text-tertiary, #6b7280);\n opacity: 0.6;\n}\n\n.bentoTileTitle {\n margin: 0;\n font-size: clamp(1.125rem, 1.6vw, 1.5rem);\n font-weight: var(--font-weight-bold, 700);\n line-height: 1.1;\n letter-spacing: -0.02em;\n color: var(--text-primary, #111827);\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n}\n\n/* Headline tile variant — for tiles whose primary content IS the headline (manifesto, big-idea moment, statement). Bigger than .bentoTileTitle. */\n.bentoTileHeadline {\n margin: 0;\n font-size: clamp(2rem, 3.6vw, 3.5rem);\n font-weight: var(--font-weight-bold, 700);\n line-height: 1;\n letter-spacing: -0.03em;\n color: var(--text-primary, #111827);\n}\n\n/* Figure tile slot — non-image hero (chart, large number, custom SVG). Inherits .bentoTileMedia's hover-scale + layout-mode positioning; adds a flex centering pass so any child sits centered in the slot by default. */\n.bentoTileFigure {\n display: flex;\n align-items: center;\n justify-content: center;\n background-color: var(--surface-subtle, var(--surface-card, #ffffff));\n color: var(--text-primary, #111827);\n}\n\n.bentoTileTitleArrow {\n opacity: 0.45;\n transition:\n opacity var(--motion-duration-normal, 200ms) var(--motion-easing-default, ease),\n transform var(--motion-duration-normal, 200ms) var(--motion-easing-default, ease);\n}\n\n.bentoTile:hover .bentoTileTitleArrow {\n opacity: 1;\n transform: translate(4px, -4px);\n}\n\n.bentoTileDescription {\n margin: 0;\n color: var(--text-secondary, #6b7280);\n /* One notch down from the previous 0.9375rem — sits on the visor `sm` token. */\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-normal, 400);\n line-height: 1.5;\n max-width: 56ch;\n}\n\n/* ---------------------------------------------------------------------------\n Reveal cascade — fade + 24px rise on viewport entry, staggered by DOM order.\n --------------------------------------------------------------------------- */\n\n.bentoGrid[data-reveal] > .bentoTile {\n opacity: 0;\n transform: translate3d(0, 24px, 0);\n transition:\n opacity var(--motion-duration-slow, 800ms) var(--motion-easing-enter, cubic-bezier(0.19, 1, 0.22, 1)),\n transform var(--motion-duration-slow, 800ms) var(--motion-easing-enter, cubic-bezier(0.19, 1, 0.22, 1));\n transition-delay: calc(var(--reveal-index, 0) * var(--bento-reveal-step, 110ms));\n}\n\n.bentoGrid[data-reveal][data-revealed] > .bentoTile {\n opacity: 1;\n transform: translate3d(0, 0, 0);\n}\n\n/* ---------------------------------------------------------------------------\n Reduced motion — disable hover zoom, arrow nudge, and reveal cascade.\n --------------------------------------------------------------------------- */\n\n@media (prefers-reduced-motion: reduce) {\n .bentoTileMedia,\n .bentoTileTitleArrow,\n .bentoTile,\n a.bentoTile {\n transition: none;\n }\n\n .bentoTile:hover .bentoTileMedia,\n .bentoTile:hover .bentoTileTitleArrow,\n a.bentoTile:hover {\n transform: none;\n }\n\n .bentoGrid[data-reveal] > .bentoTile {\n opacity: 1;\n transform: none;\n transition: none;\n }\n}\n"
858
+ },
859
+ {
860
+ "path": "components/ui/bento-grid/bento-grid.module.css.d.ts",
861
+ "type": "registry:ui",
862
+ "content": "declare const styles: {\n readonly bentoGrid: string\n readonly bentoTile: string\n readonly bentoTileStacked: string\n readonly bentoTileOverlay: string\n readonly bentoTileFull: string\n readonly bentoTileHalf: string\n readonly bentoTileNumeric: string\n readonly bentoTileContain: string\n readonly bentoTileMedia: string\n readonly bentoTileFigure: string\n readonly bentoTileBody: string\n readonly bentoTileMeta: string\n readonly bentoTileTitle: string\n readonly bentoTileTitleArrow: string\n readonly bentoTileHeadline: string\n readonly bentoTileDescription: string\n}\n\nexport default styles\n"
863
+ },
864
+ {
865
+ "path": "components/ui/bento-grid/bento-grid.visor.yaml",
866
+ "type": "registry:ui",
867
+ "content": "name: BentoGrid\ndescription: An asymmetric tile grid primitive with full/half span variants, per-tile aspect ratios, and contain/cover media fit modes. Designed for portfolio, case-study, and showcase layouts.\nextends: HTMLDivElement\ncategory: data-display\n\nwhen_to_use:\n - Asymmetric portfolio or case-study grids where tiles have different visual weights\n - Hero-style layouts mixing full-width feature tiles with paired half-width tiles\n - Showcase grids with varied aspect ratios (21:9, 2:1, 4:3) and image fit modes\n - Marketing pages using Apple-style \"bento\" layouts\n - Any grid where tiles intentionally have different sizes or spans\n\nwhen_not_to_use:\n - Symmetric uniform grids where all tiles are the same size (use card-grid instead)\n - Simple two-column content layouts (use CSS grid or flexbox directly)\n - List-style data displays (use Table or DataTable)\n - Pinterest-style masonry layouts where tile heights vary based on content\n\nwhy: >\n BentoGrid fills the gap between card-grid (uniform tile sizes) and fully custom CSS grids.\n It provides a structured API for asymmetric layouts while keeping consumer code readable —\n span=\"full\" for hero tiles, span=\"half\" for paired tiles, aspect per tile for mixed ratios.\n All tiles collapse to single-column on narrow viewports automatically. The compound primitive\n pattern (Grid + Tile + TileMedia + TileBody) mirrors the Card pattern for consistency.\n\nprops:\n - name: cols\n type: \"number | { base: number; sm?: number; md?: number; lg?: number; xl?: number }\"\n default: \"2\"\n description: \"Number of grid columns. Accepts a plain number or a responsive breakpoint map.\"\n - name: gap\n type: string\n default: '\"4\"'\n description: \"Gap between tiles as a spacing token suffix. E.g. '4' resolves to var(--spacing-4).\"\n - name: reveal\n type: boolean\n default: 'false'\n description: \"Fade + rise each tile on viewport entry, staggered by DOM order. Suppressed under prefers-reduced-motion.\"\n - name: revealStepMs\n type: number\n default: '110'\n description: \"Per-tile delay in milliseconds. Each tile's actual delay is revealStepMs × index.\"\n - name: revealThreshold\n type: number\n default: '0.2'\n description: \"IntersectionObserver threshold for the entrance trigger.\"\n\nsub_components:\n - name: BentoTile\n description: Individual grid tile. Renders as <article> by default, or <a> when href is provided.\n props:\n - name: layout\n type: '\"stacked\" | \"overlay\"'\n default: '\"stacked\"'\n description: \"Visual layout. stacked (default): media on top with its own aspect ratio, body below as a sibling. overlay: media fills the tile (which carries the aspect ratio), body floats over the lower portion.\"\n - name: span\n type: '\"full\" | \"half\" | number'\n default: '\"half\"'\n description: \"Column span: full (1/-1), half (1 col), or a numeric span count.\"\n - name: aspect\n type: '\"21/9\" | \"2/1\" | \"4/3\" | \"1/1\" | string'\n description: \"Aspect ratio. In stacked mode it shapes the media; in overlay mode it shapes the entire tile root.\"\n - name: fit\n type: '\"cover\" | \"contain\"'\n default: '\"cover\"'\n description: \"Media fit mode. contain adds a surface-card background plate for logos/portraits.\"\n - name: href\n type: string\n description: \"When provided, renders the tile root as an anchor element.\"\n - name: target\n type: string\n description: \"Link target (e.g. _blank). Only relevant when href is set.\"\n - name: rel\n type: string\n description: \"Link rel attribute. Defaults to 'noopener noreferrer' when target is _blank.\"\n - name: BentoTileMedia\n description: Image element. Zooms 1.04× on tile hover (suppressed in fit=\"contain\" mode).\n props:\n - name: src\n type: string\n required: true\n description: \"Image source URL.\"\n - name: alt\n type: string\n required: true\n description: \"Image alt text for accessibility.\"\n - name: loading\n type: '\"lazy\" | \"eager\"'\n default: '\"lazy\"'\n description: \"Native image loading strategy.\"\n - name: BentoTileBody\n description: Body container. Stretches to fill the tile in stacked mode so tiles in the same row share height.\n - name: BentoTileMeta\n description: Eyebrow row — uppercase, tracked, with · separators between items. Accepts an `items` array shorthand or arbitrary children.\n props:\n - name: items\n type: ReactNode[]\n description: \"Array of items rendered as spans with `·` separators between siblings. When omitted, children are used as-is.\"\n - name: BentoTileTitle\n description: Display heading with optional hover arrow that nudges on tile hover.\n props:\n - name: as\n type: '\"h2\" | \"h3\" | \"h4\"'\n default: '\"h3\"'\n description: \"Heading element.\"\n - name: showArrow\n type: boolean\n default: 'false'\n description: \"Render a ↗ arrow after the title; nudges on tile hover.\"\n - name: BentoTileDescription\n description: Muted body text with max-width 56ch for comfortable reading. Renders as <div> (not <p>) so MDX-authored multi-line children don't create invalid <p>-in-<p> nesting.\n - name: BentoTileFigure\n description: Non-image hero slot — drop-in replacement for BentoTileMedia when the tile's hero is a chart, large number, or custom JSX. Inherits the same hover scale + layout-mode positioning as the media slot.\n - name: BentoTileHeadline\n description: Larger display heading (clamp 2rem → 3.5rem) for tiles where the heading is the primary content (manifesto / statement / headline tiles).\n props:\n - name: as\n type: '\"h1\" | \"h2\" | \"h3\"'\n default: '\"h2\"'\n description: \"Heading element.\"\n\ncustomization:\n description: >\n Each tile (.bentoTile) exposes the following CSS custom properties.\n Override per-tile via style, or per-grid via a parent token-override scope.\n properties:\n - name: --bento-tile-radius\n default: var(--radius-lg)\n description: Tile corner radius.\n - name: --bento-tile-body-padding\n default: clamp(1rem, 2vw, 1.75rem)\n description: Body padding (responsive clamp).\n - name: --bento-tile-body-gap\n default: var(--spacing-3, 0.75rem)\n description: Gap between meta / title / description.\n - name: --bento-tile-media-scale-hover\n default: '1.04'\n description: Image scale on tile hover. Set to 1 to disable.\n - name: --bento-tile-lift-hover\n default: '0px'\n description: Optional vertical translate on anchor-tile hover. Default is 0 — the canonical hover affordance is the image scale and box-shadow bump. Consumers can opt in to a tile lift by overriding this slot (e.g. -3px).\n\ndependencies:\n - \"@loworbitstudio/visor-core\"\n\npreview_url: \"https://visor.design/docs/components/data-display/bento-grid\"\n\nexample: |\n <BentoGrid cols={{ base: 1, md: 2 }} gap=\"4\">\n <BentoTile span=\"full\" aspect=\"21/9\">\n <BentoTileMedia src=\"/projects/knowmentum.jpg\" alt=\"Knowmentum project hero\" />\n <BentoTileBody>\n <h3>Knowmentum</h3>\n <p>Knowledge platform for high-performers.</p>\n </BentoTileBody>\n </BentoTile>\n\n <BentoTile span=\"half\" aspect=\"2/1\" href=\"https://animal.nyc/\" target=\"_blank\">\n <BentoTileMedia src=\"/projects/animal-nyc.jpg\" alt=\"Animal NYC\" />\n <BentoTileBody>\n <h3>Animal NYC</h3>\n </BentoTileBody>\n </BentoTile>\n\n <BentoTile span=\"half\" aspect=\"2/1\" href=\"https://animal.la/\" target=\"_blank\">\n <BentoTileMedia src=\"/projects/animal-la.jpg\" alt=\"Animal LA\" />\n <BentoTileBody>\n <h3>Animal LA</h3>\n </BentoTileBody>\n </BentoTile>\n\n <BentoTile span=\"half\" aspect=\"4/3\" fit=\"contain\">\n <BentoTileMedia src=\"/logos/client-logo.png\" alt=\"Client logo\" />\n <BentoTileBody>\n <p>Natural-fit logo tile</p>\n </BentoTileBody>\n </BentoTile>\n </BentoGrid>\n"
868
+ }
869
+ ]
870
+ },
809
871
  {
810
872
  "name": "banner",
811
873
  "type": "registry:ui",
@@ -1155,7 +1217,7 @@
1155
1217
  {
1156
1218
  "path": "components/ui/command/command.tsx",
1157
1219
  "type": "registry:ui",
1158
- "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Command as CommandPrimitive } from \"cmdk\"\nimport { MagnifyingGlassIcon } from \"@phosphor-icons/react\"\nimport { cn } from \"../../../lib/utils\"\nimport {\n Dialog,\n DialogContent,\n DialogTitle,\n} from \"../dialog/dialog\"\nimport styles from \"./command.module.css\"\n\nconst Command = React.forwardRef<\n React.ComponentRef<typeof CommandPrimitive>,\n React.ComponentProps<typeof CommandPrimitive>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive\n ref={ref}\n data-slot=\"command\"\n className={cn(styles.root, className)}\n {...props}\n />\n))\nCommand.displayName = \"Command\"\n\nfunction CommandDialog({\n children,\n ...props\n}: React.ComponentProps<typeof Dialog>) {\n return (\n <Dialog {...props}>\n <DialogContent className={styles.dialogContent}>\n <DialogTitle className={styles.srOnly}>Command Palette</DialogTitle>\n <Command className={styles.dialogCommand}>{children}</Command>\n </DialogContent>\n </Dialog>\n )\n}\nCommandDialog.displayName = \"CommandDialog\"\n\nconst CommandInput = React.forwardRef<\n React.ComponentRef<typeof CommandPrimitive.Input>,\n React.ComponentProps<typeof CommandPrimitive.Input>\n>(({ className, ...props }, ref) => (\n <div className={styles.inputWrapper} data-slot=\"command-input-wrapper\">\n <MagnifyingGlassIcon className={styles.inputIcon} />\n <CommandPrimitive.Input\n ref={ref}\n data-slot=\"command-input\"\n className={cn(styles.input, className)}\n {...props}\n />\n </div>\n))\nCommandInput.displayName = \"CommandInput\"\n\nconst CommandList = React.forwardRef<\n React.ComponentRef<typeof CommandPrimitive.List>,\n React.ComponentProps<typeof CommandPrimitive.List>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive.List\n ref={ref}\n data-slot=\"command-list\"\n className={cn(styles.list, className)}\n {...props}\n />\n))\nCommandList.displayName = \"CommandList\"\n\nconst CommandEmpty = React.forwardRef<\n React.ComponentRef<typeof CommandPrimitive.Empty>,\n React.ComponentProps<typeof CommandPrimitive.Empty>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive.Empty\n ref={ref}\n data-slot=\"command-empty\"\n className={cn(styles.empty, className)}\n {...props}\n />\n))\nCommandEmpty.displayName = \"CommandEmpty\"\n\nconst CommandGroup = React.forwardRef<\n React.ComponentRef<typeof CommandPrimitive.Group>,\n React.ComponentProps<typeof CommandPrimitive.Group>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive.Group\n ref={ref}\n data-slot=\"command-group\"\n className={cn(styles.group, className)}\n {...props}\n />\n))\nCommandGroup.displayName = \"CommandGroup\"\n\nconst CommandItem = React.forwardRef<\n React.ComponentRef<typeof CommandPrimitive.Item>,\n React.ComponentProps<typeof CommandPrimitive.Item>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive.Item\n ref={ref}\n data-slot=\"command-item\"\n className={cn(styles.item, className)}\n {...props}\n />\n))\nCommandItem.displayName = \"CommandItem\"\n\nconst CommandSeparator = React.forwardRef<\n React.ComponentRef<typeof CommandPrimitive.Separator>,\n React.ComponentProps<typeof CommandPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive.Separator\n ref={ref}\n data-slot=\"command-separator\"\n className={cn(styles.separator, className)}\n {...props}\n />\n))\nCommandSeparator.displayName = \"CommandSeparator\"\n\nfunction CommandShortcut({ className, ...props }: React.ComponentProps<\"span\">) {\n return (\n <span\n data-slot=\"command-shortcut\"\n className={cn(styles.shortcut, className)}\n {...props}\n />\n )\n}\nCommandShortcut.displayName = \"CommandShortcut\"\n\nconst CommandLoading = React.forwardRef<\n React.ComponentRef<typeof CommandPrimitive.Loading>,\n React.ComponentProps<typeof CommandPrimitive.Loading>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive.Loading\n ref={ref}\n data-slot=\"command-loading\"\n className={cn(styles.loading, className)}\n {...props}\n />\n))\nCommandLoading.displayName = \"CommandLoading\"\n\nexport {\n Command,\n CommandDialog,\n CommandInput,\n CommandList,\n CommandEmpty,\n CommandGroup,\n CommandItem,\n CommandSeparator,\n CommandShortcut,\n CommandLoading,\n}\n"
1220
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Command as CommandPrimitive } from \"cmdk\"\nimport { MagnifyingGlassIcon } from \"@phosphor-icons/react\"\nimport { cn } from \"../../../lib/utils\"\nimport {\n Dialog,\n DialogContent,\n DialogTitle,\n} from \"../dialog/dialog\"\nimport styles from \"./command.module.css\"\n\nconst Command = React.forwardRef<\n React.ComponentRef<typeof CommandPrimitive>,\n React.ComponentProps<typeof CommandPrimitive>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive\n ref={ref}\n data-slot=\"command\"\n className={cn(styles.root, className)}\n {...props}\n />\n))\nCommand.displayName = \"Command\"\n\nfunction CommandDialog({\n children,\n contentClassName,\n contentProps,\n ...props\n}: React.ComponentProps<typeof Dialog> & {\n contentClassName?: string;\n contentProps?: Omit<\n React.ComponentProps<typeof DialogContent>,\n \"className\" | \"children\"\n >;\n}) {\n return (\n <Dialog {...props}>\n <DialogContent\n className={cn(styles.dialogContent, contentClassName)}\n {...contentProps}\n >\n <DialogTitle className={styles.srOnly}>Command Palette</DialogTitle>\n <Command className={styles.dialogCommand}>{children}</Command>\n </DialogContent>\n </Dialog>\n )\n}\nCommandDialog.displayName = \"CommandDialog\"\n\nconst CommandInput = React.forwardRef<\n React.ComponentRef<typeof CommandPrimitive.Input>,\n React.ComponentProps<typeof CommandPrimitive.Input>\n>(({ className, ...props }, ref) => (\n <div className={styles.inputWrapper} data-slot=\"command-input-wrapper\">\n <MagnifyingGlassIcon className={styles.inputIcon} />\n <CommandPrimitive.Input\n ref={ref}\n data-slot=\"command-input\"\n className={cn(styles.input, className)}\n {...props}\n />\n </div>\n))\nCommandInput.displayName = \"CommandInput\"\n\nconst CommandList = React.forwardRef<\n React.ComponentRef<typeof CommandPrimitive.List>,\n React.ComponentProps<typeof CommandPrimitive.List>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive.List\n ref={ref}\n data-slot=\"command-list\"\n className={cn(styles.list, className)}\n {...props}\n />\n))\nCommandList.displayName = \"CommandList\"\n\nconst CommandEmpty = React.forwardRef<\n React.ComponentRef<typeof CommandPrimitive.Empty>,\n React.ComponentProps<typeof CommandPrimitive.Empty>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive.Empty\n ref={ref}\n data-slot=\"command-empty\"\n className={cn(styles.empty, className)}\n {...props}\n />\n))\nCommandEmpty.displayName = \"CommandEmpty\"\n\nconst CommandGroup = React.forwardRef<\n React.ComponentRef<typeof CommandPrimitive.Group>,\n React.ComponentProps<typeof CommandPrimitive.Group>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive.Group\n ref={ref}\n data-slot=\"command-group\"\n className={cn(styles.group, className)}\n {...props}\n />\n))\nCommandGroup.displayName = \"CommandGroup\"\n\nconst CommandItem = React.forwardRef<\n React.ComponentRef<typeof CommandPrimitive.Item>,\n React.ComponentProps<typeof CommandPrimitive.Item>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive.Item\n ref={ref}\n data-slot=\"command-item\"\n className={cn(styles.item, className)}\n {...props}\n />\n))\nCommandItem.displayName = \"CommandItem\"\n\nconst CommandSeparator = React.forwardRef<\n React.ComponentRef<typeof CommandPrimitive.Separator>,\n React.ComponentProps<typeof CommandPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive.Separator\n ref={ref}\n data-slot=\"command-separator\"\n className={cn(styles.separator, className)}\n {...props}\n />\n))\nCommandSeparator.displayName = \"CommandSeparator\"\n\nfunction CommandShortcut({ className, ...props }: React.ComponentProps<\"span\">) {\n return (\n <span\n data-slot=\"command-shortcut\"\n className={cn(styles.shortcut, className)}\n {...props}\n />\n )\n}\nCommandShortcut.displayName = \"CommandShortcut\"\n\nconst CommandLoading = React.forwardRef<\n React.ComponentRef<typeof CommandPrimitive.Loading>,\n React.ComponentProps<typeof CommandPrimitive.Loading>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive.Loading\n ref={ref}\n data-slot=\"command-loading\"\n className={cn(styles.loading, className)}\n {...props}\n />\n))\nCommandLoading.displayName = \"CommandLoading\"\n\nexport {\n Command,\n CommandDialog,\n CommandInput,\n CommandList,\n CommandEmpty,\n CommandGroup,\n CommandItem,\n CommandSeparator,\n CommandShortcut,\n CommandLoading,\n}\n"
1159
1221
  },
1160
1222
  {
1161
1223
  "path": "components/ui/command/command.module.css",
@@ -1375,6 +1437,30 @@
1375
1437
  }
1376
1438
  ]
1377
1439
  },
1440
+ {
1441
+ "name": "marquee",
1442
+ "type": "registry:ui",
1443
+ "description": "A multi-band counter-flow infinite-scroll primitive for continuous animated content strips. Pure CSS animation with pause-on-hover and prefers-reduced-motion support.",
1444
+ "category": "data-display",
1445
+ "dependencies": [
1446
+ "@loworbitstudio/visor-core"
1447
+ ],
1448
+ "registryDependencies": [
1449
+ "utils"
1450
+ ],
1451
+ "files": [
1452
+ {
1453
+ "path": "components/ui/marquee/marquee.tsx",
1454
+ "type": "registry:ui",
1455
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./marquee.module.css\"\n\nexport interface MarqueeBand {\n /** Items to display in this band. Rendered twice internally for seamless loop. */\n items: React.ReactNode[]\n /** Scroll direction. @default \"left\" */\n direction?: \"left\" | \"right\"\n /** Duration of one full scroll cycle in seconds. @default 40 */\n durationSec?: number\n /** Separator rendered between items. String or ReactNode. */\n separator?: React.ReactNode | string\n}\n\nexport interface MarqueeProps extends React.HTMLAttributes<HTMLDivElement> {\n /**\n * Multi-band configuration. When provided, `items`, `durationSec`, and\n * `separator` at the top level are ignored.\n */\n bands?: MarqueeBand[]\n /**\n * Single-band shorthand: items to display.\n * Used when `bands` is not provided.\n */\n items?: React.ReactNode[]\n /** Duration of one full scroll cycle in seconds. @default 40 */\n durationSec?: number\n /** Separator rendered between items. String or ReactNode. @default undefined */\n separator?: React.ReactNode | string\n /**\n * When true, scrolling pauses on hover.\n * @default true\n */\n pauseOnHover?: boolean\n /** Gap between items. Accepts any CSS gap value. @default \"var(--spacing-6, 1.5rem)\" */\n gap?: React.CSSProperties[\"gap\"]\n /** Custom render function for each item. */\n renderItem?: (item: React.ReactNode, index: number) => React.ReactNode\n /** Custom render function for the separator. */\n renderSeparator?: (separator: React.ReactNode | string, index: number) => React.ReactNode\n}\n\n// ── Internal band renderer ──────────────────────────────────────────────────\n\ninterface BandProps {\n band: MarqueeBand\n pauseOnHover: boolean\n gap: React.CSSProperties[\"gap\"]\n renderItem?: MarqueeProps[\"renderItem\"]\n renderSeparator?: MarqueeProps[\"renderSeparator\"]\n bandIndex: number\n}\n\nfunction MarqueeBandRenderer({\n band,\n pauseOnHover,\n gap,\n renderItem,\n renderSeparator,\n bandIndex,\n}: BandProps) {\n const { items, direction = \"left\", durationSec = 40, separator } = band\n\n const animationDirection = direction === \"right\" ? \"reverse\" : \"normal\"\n\n const trackStyle: React.CSSProperties = {\n \"--marquee-duration\": `${durationSec}s`,\n \"--marquee-gap\": gap,\n animationDirection,\n } as React.CSSProperties\n\n function renderTrackItems(keyPrefix: string) {\n return items.map((item, idx) => {\n const itemNode = renderItem ? renderItem(item, idx) : item\n const hasSeparator = separator !== undefined && separator !== null && separator !== \"\"\n const separatorNode = hasSeparator\n ? renderSeparator\n ? renderSeparator(separator, idx)\n : <span className={styles.separator} aria-hidden=\"true\">{separator}</span>\n : null\n\n return (\n <React.Fragment key={`${keyPrefix}-${idx}`}>\n <span className={styles.item}>{itemNode}</span>\n {hasSeparator && separatorNode}\n </React.Fragment>\n )\n })\n }\n\n return (\n <div\n className={cn(styles.band, pauseOnHover && styles.pauseOnHover)}\n data-slot=\"marquee-band\"\n data-band-index={bandIndex}\n data-direction={direction}\n >\n {/* Track contains items twice for seamless loop — aria-hidden on the whole track */}\n <div\n className={styles.track}\n style={trackStyle}\n aria-hidden=\"true\"\n >\n {/* First copy */}\n <div className={styles.set}>\n {renderTrackItems(\"a\")}\n </div>\n {/* Second copy — creates the seamless loop illusion */}\n <div className={styles.set} aria-hidden=\"true\">\n {renderTrackItems(\"b\")}\n </div>\n </div>\n </div>\n )\n}\n\n// ── Marquee component ────────────────────────────────────────────────────────\n\n/**\n * Marquee — a multi-band counter-flow infinite-scroll primitive.\n *\n * Renders one or more bands of items in a continuous animated scroll loop.\n * Per-band direction allows counter-flow patterns (e.g., \"trusted by\" strips).\n *\n * Animation is pure CSS keyframes. Direction reverses via `animation-direction: reverse`.\n * Pause-on-hover is applied via `:hover → animation-play-state: paused`.\n * prefers-reduced-motion: animation is disabled instantly.\n */\nconst Marquee = React.forwardRef<HTMLDivElement, MarqueeProps>(\n (\n {\n className,\n bands,\n items,\n durationSec = 40,\n separator,\n pauseOnHover = true,\n gap = \"var(--spacing-6, 1.5rem)\",\n renderItem,\n renderSeparator,\n ...props\n },\n ref\n ) => {\n // Normalise to band array for unified rendering\n const resolvedBands: MarqueeBand[] = bands ?? [\n {\n items: items ?? [],\n direction: \"left\",\n durationSec,\n separator,\n },\n ]\n\n // When an aria-label is provided, add role=\"region\" so the landmark is valid.\n // A plain <div> with aria-label violates aria-prohibited-attr.\n const role = props[\"aria-label\"] || props[\"aria-labelledby\"] ? \"region\" : undefined\n\n return (\n <div\n ref={ref}\n data-slot=\"marquee\"\n role={role}\n className={cn(styles.root, className)}\n {...props}\n >\n {resolvedBands.map((band, idx) => (\n <MarqueeBandRenderer\n key={idx}\n band={band}\n pauseOnHover={pauseOnHover}\n gap={gap}\n renderItem={renderItem}\n renderSeparator={renderSeparator}\n bandIndex={idx}\n />\n ))}\n </div>\n )\n }\n)\nMarquee.displayName = \"Marquee\"\n\nexport { Marquee }\n"
1456
+ },
1457
+ {
1458
+ "path": "components/ui/marquee/marquee.module.css",
1459
+ "type": "registry:ui",
1460
+ "content": "/* Marquee: multi-band counter-flow infinite-scroll primitive\n *\n * Layout strategy:\n * Each band is a masked overflow container. Inside sits a `track` which\n * contains two identical `set` elements laid out side-by-side. The\n * animation moves the track left by 50% (= one set width), then loops.\n *\n * Animation strategy:\n * @keyframes marquee-scroll: translateX(0) → translateX(-50%)\n * Direction reverses (right→left) via animation-direction: reverse.\n * Duration and gap are injected as CSS custom properties per-band.\n * pause-on-hover: .pauseOnHover:hover .track { animation-play-state: paused }\n *\n * prefers-reduced-motion:\n * Animation is disabled — items appear statically with no motion.\n */\n\n/* ── Keyframe ────────────────────────────────────────────────────────────── */\n\n@keyframes marquee-scroll {\n from {\n transform: translateX(0);\n }\n to {\n transform: translateX(-50%);\n }\n}\n\n/* ── Root ────────────────────────────────────────────────────────────────── */\n\n.root {\n --marquee-gap: var(--spacing-6, 1.5rem);\n --marquee-duration: 40s;\n --marquee-fade-width: var(--spacing-12, 3rem);\n\n /* Item typography slots — override on the root to scale up for marketing\n surfaces. Defaults resolve to the body-text size used by ticker strips. */\n --marquee-item-color: var(--text-primary, #111827);\n --marquee-item-font-size: var(--font-size-sm, 0.875rem);\n --marquee-item-font-weight: var(--font-weight-medium, 500);\n --marquee-item-line-height: var(--line-height-normal, 1.5);\n --marquee-item-letter-spacing: normal;\n\n /* Separator slots */\n --marquee-separator-color: var(--text-tertiary, #9ca3af);\n --marquee-separator-font-size: var(--marquee-item-font-size);\n\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n width: 100%;\n overflow: hidden;\n}\n\n/* ── Band ────────────────────────────────────────────────────────────────── */\n\n.band {\n position: relative;\n width: 100%;\n overflow: hidden;\n /* Fade edges for polished infinite-scroll effect */\n -webkit-mask-image: linear-gradient(\n to right,\n transparent 0,\n black var(--marquee-fade-width),\n black calc(100% - var(--marquee-fade-width)),\n transparent 100%\n );\n mask-image: linear-gradient(\n to right,\n transparent 0,\n black var(--marquee-fade-width),\n black calc(100% - var(--marquee-fade-width)),\n transparent 100%\n );\n}\n\n/* ── Track ────────────────────────────────────────────────────────────────── */\n\n/*\n * The track contains two `.set` elements (first copy + second copy) sitting\n * flush against each other. Each .set carries a trailing margin equal to the\n * inter-item gap, so the total track width is exactly 2 × (set + gap). The\n * translateX(-50%) wrap then lands at the exact start of the second set —\n * seamless. Putting a `gap` on .track itself would land the wrap mid-gap and\n * cause a visible jump every cycle.\n */\n.track {\n display: flex;\n flex-direction: row;\n align-items: center;\n width: max-content;\n animation: marquee-scroll var(--marquee-duration, 40s) var(--motion-easing-linear, linear) infinite;\n will-change: transform;\n}\n\n/* ── Item set ─────────────────────────────────────────────────────────────── */\n\n.set {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: var(--marquee-gap);\n flex-shrink: 0;\n /* Trailing gap that bridges this set's last item to the next set's first item — required for a seamless -50% wrap. See `.track` comment above. */\n margin-right: var(--marquee-gap);\n}\n\n/* ── Item ─────────────────────────────────────────────────────────────────── */\n\n.item {\n display: inline-flex;\n align-items: center;\n flex-shrink: 0;\n color: var(--marquee-item-color);\n font-size: var(--marquee-item-font-size);\n font-weight: var(--marquee-item-font-weight);\n letter-spacing: var(--marquee-item-letter-spacing);\n /* line-height: normal (not tight) so descenders (g, y, p, q, j) clear the band's overflow boundary at marketing-display font sizes */\n line-height: var(--marquee-item-line-height);\n white-space: nowrap;\n}\n\n/* ── Separator ─────────────────────────────────────────────────────────────── */\n\n.separator {\n display: inline-flex;\n align-items: center;\n flex-shrink: 0;\n color: var(--marquee-separator-color);\n font-size: var(--marquee-separator-font-size);\n line-height: var(--marquee-item-line-height);\n user-select: none;\n}\n\n/* ── Pause on hover ─────────────────────────────────────────────────────── */\n\n.pauseOnHover:hover .track {\n animation-play-state: paused;\n}\n\n/* ── Reduced motion ─────────────────────────────────────────────────────── */\n\n@media (prefers-reduced-motion: reduce) {\n .track {\n /* Disable animation entirely — items appear statically */\n animation: none;\n /* Show only the first set; overflow hidden on band clips the rest */\n transform: none;\n }\n\n /* Remove the fade mask when static to avoid clipping visible items */\n .band {\n -webkit-mask-image: none;\n mask-image: none;\n overflow: auto;\n }\n\n /* Wrap items for readability in static/accessible mode */\n .track {\n flex-wrap: wrap;\n }\n\n .set:last-child {\n /* Hide the duplicate set in reduced-motion mode */\n display: none;\n }\n}\n"
1461
+ }
1462
+ ]
1463
+ },
1378
1464
  {
1379
1465
  "name": "fieldset",
1380
1466
  "type": "registry:ui",
@@ -1415,13 +1501,14 @@
1415
1501
  "utils",
1416
1502
  "field",
1417
1503
  "input",
1418
- "button"
1504
+ "button",
1505
+ "password-managers-context"
1419
1506
  ],
1420
1507
  "files": [
1421
1508
  {
1422
1509
  "path": "components/ui/form/form.tsx",
1423
1510
  "type": "registry:ui",
1424
- "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n useForm,\n getFormProps,\n type FormMetadata,\n type SubmissionResult,\n type DefaultValue,\n} from \"@conform-to/react\"\nimport { parseWithZod } from \"@conform-to/zod\"\nimport type { z } from \"zod\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./form.module.css\"\n\n/* ─── Types ────────────────────────────────────────────────────────── */\n\nexport interface FormProps<Schema extends z.ZodType> {\n /** Zod schema for validation */\n schema: Schema\n /** Server action function */\n action: (prevState: unknown, formData: FormData) => Promise<unknown>\n /** Called with form and fields metadata for rendering */\n children: (context: {\n form: FormMetadata<z.infer<Schema>>\n fields: ReturnType<FormMetadata<z.infer<Schema>>[\"getFieldset\"]>\n }) => React.ReactNode\n /** Default values for form fields */\n defaultValue?: Partial<z.infer<Schema>>\n /** When to validate: \"onSubmit\" | \"onBlur\" | \"onInput\" */\n shouldValidate?: \"onSubmit\" | \"onBlur\" | \"onInput\"\n /** Additional CSS class name */\n className?: string\n}\n\n/* ─── Form ─────────────────────────────────────────────────────────── */\n\nfunction Form<Schema extends z.ZodType>({\n schema,\n action,\n children,\n defaultValue,\n shouldValidate = \"onBlur\",\n className,\n}: FormProps<Schema>) {\n const [lastResult, formAction] = React.useActionState(action, null)\n\n const [form, fields] = useForm<z.infer<Schema>>({\n lastResult: lastResult as SubmissionResult<string[]> | null | undefined,\n defaultValue: defaultValue as DefaultValue<z.infer<Schema>> | undefined,\n shouldValidate,\n onValidate({ formData }) {\n return parseWithZod(formData, { schema })\n },\n })\n\n return (\n <form\n {...getFormProps(form)}\n action={formAction}\n className={cn(styles.form, className)}\n noValidate\n >\n {children({ form, fields })}\n </form>\n )\n}\n\nexport { Form }\n"
1511
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n useForm,\n getFormProps,\n type FormMetadata,\n type SubmissionResult,\n type DefaultValue,\n} from \"@conform-to/react\"\nimport { parseWithZod } from \"@conform-to/zod\"\nimport type { z } from \"zod\"\nimport { cn } from \"../../../lib/utils\"\nimport {\n PasswordManagersProvider,\n type PasswordManagersValue,\n} from \"../../../lib/password-managers-context\"\nimport styles from \"./form.module.css\"\n\n/* ─── Types ────────────────────────────────────────────────────────── */\n\nexport interface FormProps<Schema extends z.ZodType> {\n /** Zod schema for validation */\n schema: Schema\n /** Server action function */\n action: (prevState: unknown, formData: FormData) => Promise<unknown>\n /** Called with form and fields metadata for rendering */\n children: (context: {\n form: FormMetadata<z.infer<Schema>>\n fields: ReturnType<FormMetadata<z.infer<Schema>>[\"getFieldset\"]>\n }) => React.ReactNode\n /** Default values for form fields */\n defaultValue?: Partial<z.infer<Schema>>\n /** When to validate: \"onSubmit\" | \"onBlur\" | \"onInput\" */\n shouldValidate?: \"onSubmit\" | \"onBlur\" | \"onInput\"\n /**\n * Whether password managers (1Password, Bitwarden, LastPass) should offer\n * autofill on descendant `Input` and `Textarea` fields. Sets the form-level\n * default so authors don't have to repeat the prop on every credential\n * field. Field-level `passwordManagers` always wins over this context value.\n * When omitted, fields fall back to their own default of `\"ignore\"`.\n */\n passwordManagers?: PasswordManagersValue\n /** Additional CSS class name */\n className?: string\n}\n\n/* ─── Form ─────────────────────────────────────────────────────────── */\n\nfunction Form<Schema extends z.ZodType>({\n schema,\n action,\n children,\n defaultValue,\n shouldValidate = \"onBlur\",\n passwordManagers,\n className,\n}: FormProps<Schema>) {\n const [lastResult, formAction] = React.useActionState(action, null)\n\n const [form, fields] = useForm<z.infer<Schema>>({\n lastResult: lastResult as SubmissionResult<string[]> | null | undefined,\n defaultValue: defaultValue as DefaultValue<z.infer<Schema>> | undefined,\n shouldValidate,\n onValidate({ formData }) {\n return parseWithZod(formData, { schema })\n },\n })\n\n const rendered = children({ form, fields })\n\n return (\n <form\n {...getFormProps(form)}\n action={formAction}\n className={cn(styles.form, className)}\n noValidate\n >\n {passwordManagers ? (\n <PasswordManagersProvider value={passwordManagers}>\n {rendered}\n </PasswordManagersProvider>\n ) : (\n rendered\n )}\n </form>\n )\n}\n\nexport { Form }\n"
1425
1512
  },
1426
1513
  {
1427
1514
  "path": "components/ui/form/form-field.tsx",
@@ -1487,6 +1574,30 @@
1487
1574
  }
1488
1575
  ]
1489
1576
  },
1577
+ {
1578
+ "name": "name-roster",
1579
+ "type": "registry:ui",
1580
+ "description": "A column-flow alphabetical list of named items with a dot-prefix indicator and a highlighted-row variant for featured or owned entries.",
1581
+ "category": "data-display",
1582
+ "dependencies": [
1583
+ "@loworbitstudio/visor-core"
1584
+ ],
1585
+ "registryDependencies": [
1586
+ "utils"
1587
+ ],
1588
+ "files": [
1589
+ {
1590
+ "path": "components/ui/name-roster/name-roster.tsx",
1591
+ "type": "registry:ui",
1592
+ "content": "import * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./name-roster.module.css\"\n\n// ------------------------------------------------------------------ Types\n\n/** A single item in the items-shorthand prop */\nexport interface NameRosterItemData {\n name: string\n highlighted?: boolean\n}\n\n/**\n * Responsive column value — accepts a plain number or a breakpoint map.\n * Breakpoints: base, sm (640px), md (768px), lg (1024px), xl (1280px).\n */\nexport type ResponsiveValue<T> =\n | T\n | {\n base?: T\n sm?: T\n md?: T\n lg?: T\n xl?: T\n }\n\n// ------------------------------------------------------------------ NameRosterItem\n\nexport interface NameRosterItemProps extends React.LiHTMLAttributes<HTMLLIElement> {\n /** Render this item with the highlighted dot color and type treatment. */\n highlighted?: boolean\n}\n\nconst NameRosterItem = React.forwardRef<HTMLLIElement, NameRosterItemProps>(\n ({ className, highlighted = false, ...props }, ref) => {\n return (\n <li\n ref={ref}\n data-slot=\"name-roster-item\"\n data-highlighted={highlighted ? \"true\" : undefined}\n className={cn(styles.item, highlighted && styles.itemHighlighted, className)}\n {...props}\n />\n )\n }\n)\nNameRosterItem.displayName = \"NameRosterItem\"\n\n// ------------------------------------------------------------------ NameRoster\n\nexport interface NameRosterProps extends React.HTMLAttributes<HTMLUListElement | HTMLOListElement> {\n /**\n * Number of CSS columns to display.\n * Pass a number for a fixed column count, or a breakpoint map for responsive layout.\n * Defaults to 1.\n */\n columns?: ResponsiveValue<number>\n /**\n * Sort order of items.\n * - \"alpha\" — sort children / items array alphabetically via localeCompare\n * - \"none\" — render in insertion order (default)\n */\n sort?: \"alpha\" | \"none\"\n /**\n * Shorthand: provide an array of items instead of JSX children.\n * When both items and children are provided, items takes precedence.\n */\n items?: NameRosterItemData[]\n /**\n * Show the dot prefix indicator on each item. Defaults to true.\n */\n dot?: boolean\n /**\n * HTML list element to render. Defaults to \"ul\".\n */\n as?: \"ul\" | \"ol\"\n}\n\n/** Build an inline style object that sets CSS custom properties for each breakpoint. */\nfunction buildColumnStyle(columns: ResponsiveValue<number>): React.CSSProperties {\n if (typeof columns === \"number\") {\n return { \"--roster-columns\": columns } as React.CSSProperties\n }\n\n const props: Record<string, number> = {}\n if (columns.base !== undefined) props[\"--roster-columns\"] = columns.base\n if (columns.sm !== undefined) props[\"--roster-columns-sm\"] = columns.sm\n if (columns.md !== undefined) props[\"--roster-columns-md\"] = columns.md\n if (columns.lg !== undefined) props[\"--roster-columns-lg\"] = columns.lg\n if (columns.xl !== undefined) props[\"--roster-columns-xl\"] = columns.xl\n return props as React.CSSProperties\n}\n\nconst NameRoster = React.forwardRef<\n HTMLUListElement | HTMLOListElement,\n NameRosterProps\n>(\n (\n {\n className,\n style,\n columns = 1,\n sort = \"none\",\n items,\n dot = true,\n as: Tag = \"ul\",\n children,\n ...props\n },\n ref\n ) => {\n const columnStyle = buildColumnStyle(columns)\n\n let content: React.ReactNode\n\n if (items !== undefined) {\n // items-shorthand mode\n let sorted = [...items]\n if (sort === \"alpha\") {\n sorted = sorted.sort((a, b) => a.name.localeCompare(b.name))\n }\n content = sorted.map((item) => (\n <NameRosterItem key={item.name} highlighted={item.highlighted}>\n {item.name}\n </NameRosterItem>\n ))\n } else {\n // children mode — sort if requested\n if (sort === \"alpha\") {\n const childArray = React.Children.toArray(children)\n childArray.sort((a, b) => {\n const getLabel = (node: React.ReactNode): string => {\n if (!React.isValidElement(node)) return \"\"\n const p = node.props as Record<string, unknown>\n return typeof p.children === \"string\" ? p.children : \"\"\n }\n return getLabel(a).localeCompare(getLabel(b))\n })\n content = childArray\n } else {\n content = children\n }\n }\n\n return (\n <Tag\n ref={ref as React.Ref<HTMLUListElement & HTMLOListElement>}\n data-slot=\"name-roster\"\n data-dot={dot ? \"true\" : \"false\"}\n className={cn(styles.roster, !dot && styles.rosterNoDot, className)}\n style={{ ...columnStyle, ...style }}\n {...props}\n >\n {content}\n </Tag>\n )\n }\n)\nNameRoster.displayName = \"NameRoster\"\n\nexport { NameRoster, NameRosterItem }\n"
1593
+ },
1594
+ {
1595
+ "path": "components/ui/name-roster/name-roster.module.css",
1596
+ "type": "registry:ui",
1597
+ "content": "/* NameRoster — column-flow alphabetical name list */\n\n/* ---- Container ---- */\n.roster {\n /* Customization slots — defaults resolve to current visual output so existing consumers see no change. Override these in a token-override scope to retheme the roster (typography, hover color, dot, transform). */\n\n /* Typography */\n --roster-item-font-size: var(--font-size-sm, 0.875rem);\n --roster-item-font-weight: var(--font-weight-normal, 400);\n --roster-item-letter-spacing: normal;\n --roster-item-line-height: var(--line-height-normal, 1.5);\n\n /* Colors */\n --roster-item-color: var(--text-primary, #111827);\n --roster-item-color-hover: var(--text-secondary, #6b7280);\n --roster-item-color-highlighted: var(--text-primary, #111827);\n\n /* Dot */\n --roster-dot-size: 0.375rem; /* 6px — decorative, not a spacing token */\n --roster-dot-color: var(--surface-accent-strong, #111827);\n --roster-dot-color-hover: var(--roster-dot-color);\n --roster-dot-color-highlighted: var(--surface-accent-default, #6b7280);\n --roster-dot-glow-hover: none;\n\n /* Item transform on hover (off by default; consumers can opt in) */\n --roster-item-hover-transform: none;\n\n /* Highlighted item weight */\n --roster-item-font-weight-highlighted: var(--font-weight-medium, 500);\n\n list-style: none;\n margin: 0;\n padding: 0;\n\n /* Default: single column */\n column-count: var(--roster-columns, 1);\n column-fill: balance;\n column-gap: var(--spacing-8, 2rem);\n}\n\n/* Responsive column breakpoints — set via CSS custom properties from the component */\n@media (min-width: 640px) {\n .roster {\n column-count: var(--roster-columns-sm, var(--roster-columns, 1));\n }\n}\n\n@media (min-width: 768px) {\n .roster {\n column-count: var(--roster-columns-md, var(--roster-columns-sm, var(--roster-columns, 1)));\n }\n}\n\n@media (min-width: 1024px) {\n .roster {\n column-count: var(--roster-columns-lg, var(--roster-columns-md, var(--roster-columns-sm, var(--roster-columns, 1))));\n }\n}\n\n@media (min-width: 1280px) {\n .roster {\n column-count: var(--roster-columns-xl, var(--roster-columns-lg, var(--roster-columns-md, var(--roster-columns-sm, var(--roster-columns, 1)))));\n }\n}\n\n/* ---- Item ---- */\n.item {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n padding-block: var(--spacing-1, 0.25rem);\n break-inside: avoid;\n\n font-size: var(--roster-item-font-size);\n font-weight: var(--roster-item-font-weight);\n letter-spacing: var(--roster-item-letter-spacing);\n color: var(--roster-item-color);\n line-height: var(--roster-item-line-height);\n\n cursor: default;\n transition:\n color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n transform var(--motion-duration-300, 300ms) var(--motion-easing-default, ease-in-out);\n}\n\n/* Dot indicator — ::before pseudo-element */\n.item::before {\n content: \"\";\n flex-shrink: 0;\n display: block;\n width: var(--roster-dot-size);\n height: var(--roster-dot-size);\n border-radius: var(--radius-full, 9999px);\n background-color: var(--roster-dot-color);\n transition:\n background-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/* Hover type + dot treatment — consumers override the relevant --roster-* slots to retheme */\n.item:hover {\n color: var(--roster-item-color-hover);\n transform: var(--roster-item-hover-transform);\n}\n\n.item:hover::before {\n background-color: var(--roster-dot-color-hover);\n box-shadow: var(--roster-dot-glow-hover);\n}\n\n/* Anchor children inherit item color and drop the underline — the dot is the only visual affordance the roster needs. Consumers wanting underlines can re-add them via global CSS. */\n.item a {\n color: inherit;\n text-decoration: none;\n}\n\n.item a:hover {\n color: inherit;\n}\n\n/* ---- No-dot modifier ---- */\n.rosterNoDot .item::before {\n display: none;\n}\n\n/* ---- Highlighted variant ---- */\n.itemHighlighted {\n font-weight: var(--roster-item-font-weight-highlighted);\n color: var(--roster-item-color-highlighted);\n}\n\n.itemHighlighted::before {\n background-color: var(--roster-dot-color-highlighted);\n}\n"
1598
+ }
1599
+ ]
1600
+ },
1490
1601
  {
1491
1602
  "name": "number-input",
1492
1603
  "type": "registry:ui",
@@ -1862,7 +1973,7 @@
1862
1973
  {
1863
1974
  "path": "components/ui/font-showcase/font-showcase.module.css",
1864
1975
  "type": "registry:ui",
1865
- "content": "/* ─── FontShowcase Card ──────────────────────────────────────────────────── */\n\n.card {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n padding: var(--spacing-6, 1.5rem);\n border: 1px solid var(--border-default, #e5e7eb);\n border-radius: var(--radius-xl, 0.75rem);\n background: var(--surface-card, #ffffff);\n transition: box-shadow var(--motion-duration-fast, 150ms) var(--motion-easing-default, ease);\n}\n\n@media (hover: hover) {\n .card:hover {\n box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1));\n }\n}\n\n/* ─── Header ─────────────────────────────────────────────────────────────── */\n\n.header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: var(--spacing-3, 0.75rem);\n}\n\n.meta {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-1, 0.25rem);\n}\n\n.familyName {\n font-size: var(--font-size-lg, 1.125rem);\n font-weight: 600;\n color: var(--text-primary, #111827);\n line-height: 1.25;\n}\n\n.token {\n font-family: var(--font-mono, monospace);\n font-size: var(--font-size-xs, 0.625rem);\n line-height: 1;\n color: var(--text-tertiary, #9ca3af);\n background: var(--surface-muted, #f3f4f6);\n padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);\n border-radius: var(--radius-sm, 0.25rem);\n display: inline-block;\n margin-top: var(--spacing-1, 0.25rem);\n width: fit-content;\n}\n\n/* ─── Hero ────────────────────────────────────────────────────────────────── */\n\n.hero {\n font-size: 4.5rem;\n line-height: 1;\n color: var(--text-primary, #111827);\n letter-spacing: -0.02em;\n user-select: none;\n opacity: 0.9;\n}\n\n/* ─── Divider ─────────────────────────────────────────────────────────────── */\n\n.divider {\n border: none;\n border-top: 1px solid var(--border-muted, #f3f4f6);\n}\n\n/* ─── Weight Specimens ────────────────────────────────────────────────────── */\n\n.weights {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-3, 0.75rem);\n}\n\n.weightRow {\n display: flex;\n align-items: baseline;\n gap: var(--spacing-4, 1rem);\n}\n\n.weightSample {\n font-size: var(--font-size-xl, 1.25rem);\n line-height: 1.3;\n color: var(--text-primary, #111827);\n flex: 1;\n min-width: 0;\n}\n\n.weightMeta {\n display: flex;\n flex-direction: column;\n align-items: flex-end;\n flex-shrink: 0;\n gap: var(--spacing-1, 0.25rem);\n}\n\n.weightValue {\n font-family: var(--font-mono, monospace);\n font-size: var(--font-size-xs, 0.625rem);\n line-height: 1;\n color: var(--text-tertiary, #9ca3af);\n}\n\n/* ─── Grid ────────────────────────────────────────────────────────────────── */\n\n.grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(22.5rem, 1fr));\n gap: var(--spacing-6, 1.5rem);\n}\n"
1976
+ "content": "/* ─── FontShowcase Card ──────────────────────────────────────────────────── */\n\n.card {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n padding: var(--spacing-6, 1.5rem);\n border: 1px solid var(--border-default, #e5e7eb);\n border-radius: var(--radius-xl, 0.75rem);\n background: var(--surface-card, #ffffff);\n transition: box-shadow var(--motion-duration-fast, 150ms) var(--motion-easing-default, ease);\n}\n\n@media (hover: hover) {\n .card:hover {\n box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1));\n }\n}\n\n/* ─── Header ─────────────────────────────────────────────────────────────── */\n\n.header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: var(--spacing-3, 0.75rem);\n}\n\n.meta {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-1, 0.25rem);\n}\n\n.familyName {\n font-size: var(--font-size-lg, 1.125rem);\n font-weight: 600;\n color: var(--text-primary, #111827);\n line-height: 1.25;\n}\n\n.token {\n font-family: var(--font-mono, monospace);\n font-size: var(--font-size-xs, 0.625rem);\n line-height: 1;\n color: var(--text-secondary, #4b5563);\n background: var(--surface-muted, #f3f4f6);\n padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);\n border-radius: var(--radius-sm, 0.25rem);\n display: inline-block;\n margin-top: var(--spacing-1, 0.25rem);\n width: fit-content;\n}\n\n/* ─── Hero ────────────────────────────────────────────────────────────────── */\n\n.hero {\n font-size: 4.5rem;\n line-height: 1;\n color: var(--text-primary, #111827);\n letter-spacing: -0.02em;\n user-select: none;\n opacity: 0.9;\n}\n\n/* ─── Divider ─────────────────────────────────────────────────────────────── */\n\n.divider {\n border: none;\n border-top: 1px solid var(--border-muted, #f3f4f6);\n}\n\n/* ─── Weight Specimens ────────────────────────────────────────────────────── */\n\n.weights {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-3, 0.75rem);\n}\n\n.weightRow {\n display: flex;\n align-items: baseline;\n gap: var(--spacing-4, 1rem);\n}\n\n.weightSample {\n font-size: var(--font-size-xl, 1.25rem);\n line-height: 1.3;\n color: var(--text-primary, #111827);\n flex: 1;\n min-width: 0;\n}\n\n.weightMeta {\n display: flex;\n flex-direction: column;\n align-items: flex-end;\n flex-shrink: 0;\n gap: var(--spacing-1, 0.25rem);\n}\n\n.weightValue {\n font-family: var(--font-mono, monospace);\n font-size: var(--font-size-xs, 0.625rem);\n line-height: 1;\n color: var(--text-secondary, #4b5563);\n}\n\n/* ─── Grid ────────────────────────────────────────────────────────────────── */\n\n.grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(22.5rem, 1fr));\n gap: var(--spacing-6, 1.5rem);\n}\n"
1866
1977
  }
1867
1978
  ]
1868
1979
  },
@@ -1993,12 +2104,12 @@
1993
2104
  {
1994
2105
  "path": "components/ui/data-table/data-table.tsx",
1995
2106
  "type": "registry:ui",
1996
- "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 DataTableProps<TData, TValue = unknown>\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n columns: ColumnDef<TData, TValue>[]\n data: TData[]\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 sorting: controlledSorting,\n onSortingChange,\n defaultSorting,\n pagination: controlledPagination,\n onPaginationChange,\n pageSize = 10,\n pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,\n enableRowSelection = false,\n rowSelection: controlledRowSelection,\n onRowSelectionChange,\n getRowId,\n globalFilter: controlledGlobalFilter,\n onGlobalFilterChange,\n loading = false,\n emptyState,\n stickyHeader = false,\n className,\n ...rest\n } = props\n\n // Uncontrolled sorting state\n const [internalSorting, setInternalSorting] = React.useState<SortingState>(\n defaultSorting ?? []\n )\n const sortingIsControlled = controlledSorting !== undefined\n const sorting = sortingIsControlled ? controlledSorting : internalSorting\n const handleSortingChange: OnChangeFn<SortingState> = (updater) => {\n if (!sortingIsControlled) {\n setInternalSorting((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onSortingChange?.(updater)\n }\n\n // Uncontrolled pagination state\n const [internalPagination, setInternalPagination] =\n React.useState<PaginationState>({ pageIndex: 0, pageSize })\n const paginationIsControlled = controlledPagination !== undefined\n const pagination = paginationIsControlled\n ? controlledPagination\n : internalPagination\n const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {\n if (!paginationIsControlled) {\n setInternalPagination((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onPaginationChange?.(updater)\n }\n\n // Uncontrolled selection state\n const [internalRowSelection, setInternalRowSelection] =\n React.useState<RowSelectionState>({})\n const selectionIsControlled = controlledRowSelection !== undefined\n const rowSelection = selectionIsControlled\n ? controlledRowSelection\n : internalRowSelection\n const handleRowSelectionChange: OnChangeFn<RowSelectionState> = (updater) => {\n if (!selectionIsControlled) {\n setInternalRowSelection((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onRowSelectionChange?.(updater)\n }\n\n // Global filter\n const [internalGlobalFilter, setInternalGlobalFilter] = React.useState(\"\")\n const globalFilterIsControlled = controlledGlobalFilter !== undefined\n const globalFilter = globalFilterIsControlled\n ? controlledGlobalFilter\n : internalGlobalFilter\n const handleGlobalFilterChange = (value: unknown) => {\n const next = typeof value === \"function\" ? (value as (p: string) => string)(globalFilter) : (value as string)\n if (!globalFilterIsControlled) {\n setInternalGlobalFilter(next ?? \"\")\n }\n onGlobalFilterChange?.(next ?? \"\")\n }\n\n // Inject a selection column when enabled\n const columns = React.useMemo<ColumnDef<TData, TValue>[]>(() => {\n if (!enableRowSelection) return userColumns\n const selectionColumn: ColumnDef<TData, TValue> = {\n id: \"__select\",\n enableSorting: false,\n size: 40,\n header: ({ table }) => (\n <Checkbox\n aria-label=\"Select all rows\"\n checked={\n table.getIsAllPageRowsSelected()\n ? true\n : table.getIsSomePageRowsSelected()\n ? \"indeterminate\"\n : false\n }\n onCheckedChange={(value) =>\n table.toggleAllPageRowsSelected(value === true)\n }\n />\n ),\n cell: ({ row }) => (\n <Checkbox\n aria-label=\"Select row\"\n checked={row.getIsSelected()}\n disabled={!row.getCanSelect()}\n onCheckedChange={(value) => row.toggleSelected(value === true)}\n />\n ),\n }\n return [selectionColumn, ...userColumns]\n }, [enableRowSelection, userColumns])\n\n const table: TanstackTable<TData> = useReactTable<TData>({\n data,\n columns,\n state: {\n sorting,\n pagination,\n rowSelection,\n globalFilter,\n },\n enableRowSelection,\n getRowId,\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 && data.length === 0\n const defaultEmpty = <EmptyState heading=\"No results\" tone=\"subtle\" />\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()\n const sortDir = header.column.getIsSorted()\n const ariaSort: React.AriaAttributes[\"aria-sort\"] =\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 ) : pageRows.length === 0 ? (\n <TableRow data-slot=\"data-table-empty-row\">\n <TableCell colSpan={colCount} className={styles.emptyCell}>\n {emptyState ?? defaultEmpty}\n </TableCell>\n </TableRow>\n ) : (\n pageRows.map((row) => (\n <TableRow\n key={row.id}\n data-state={row.getIsSelected() ? \"selected\" : undefined}\n >\n {row.getVisibleCells().map((cell) => (\n <TableCell key={cell.id}>\n {flexRender(cell.column.columnDef.cell, cell.getContext())}\n </TableCell>\n ))}\n </TableRow>\n ))\n )}\n </TableBody>\n </Table>\n\n <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 </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"
2107
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n flexRender,\n getCoreRowModel,\n getFilteredRowModel,\n getPaginationRowModel,\n getSortedRowModel,\n useReactTable,\n type ColumnDef,\n type OnChangeFn,\n type PaginationState,\n type RowSelectionState,\n type SortingState,\n type Table as TanstackTable,\n} from \"@tanstack/react-table\"\nimport {\n CaretDownIcon,\n CaretLeftIcon,\n CaretRightIcon,\n CaretUpIcon,\n CaretUpDownIcon,\n} from \"@phosphor-icons/react\"\n\nimport { cn } from \"../../../lib/utils\"\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"../table/table\"\nimport { Button } from \"../button/button\"\nimport { Checkbox } from \"../checkbox/checkbox\"\nimport { Skeleton } from \"../skeleton/skeleton\"\nimport { EmptyState } from \"../empty-state/empty-state\"\nimport styles from \"./data-table.module.css\"\n\nexport type {\n ColumnDef,\n SortingState,\n RowSelectionState,\n PaginationState,\n OnChangeFn,\n}\n\nexport interface DataTableGroupRow {\n kind: \"group\"\n id: string\n label: string\n count?: number\n}\n\nexport interface DataTableDataRow<TData> {\n kind: \"data\"\n id: string\n row: TData\n}\n\nexport type DataTableRow<TData> = DataTableGroupRow | DataTableDataRow<TData>\n\nexport interface DataTableProps<TData, TValue = unknown>\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n columns: ColumnDef<TData, TValue>[]\n data?: TData[]\n\n // Mixed render order with group-head separators. When provided, the caller\n // owns sort/grouping/windowing — sort UI and pagination footer are\n // suppressed. Group rows are excluded from selection state.\n rows?: DataTableRow<TData>[]\n groupRowRenderer?: (group: DataTableGroupRow) => React.ReactNode\n\n // Sorting\n sorting?: SortingState\n onSortingChange?: OnChangeFn<SortingState>\n defaultSorting?: SortingState\n\n // Pagination\n pagination?: PaginationState\n onPaginationChange?: OnChangeFn<PaginationState>\n pageSize?: number\n pageSizeOptions?: number[]\n\n // Selection\n enableRowSelection?: boolean\n rowSelection?: RowSelectionState\n onRowSelectionChange?: OnChangeFn<RowSelectionState>\n getRowId?: (row: TData, index: number) => string\n\n // Global filter\n globalFilter?: string\n onGlobalFilterChange?: (value: string) => void\n\n // States\n loading?: boolean\n emptyState?: React.ReactNode\n\n // Layout\n stickyHeader?: boolean\n}\n\nconst DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100]\n\nfunction DataTableInner<TData, TValue = unknown>(\n props: DataTableProps<TData, TValue>,\n ref: React.ForwardedRef<HTMLDivElement>\n) {\n const {\n columns: userColumns,\n data,\n rows,\n groupRowRenderer,\n sorting: controlledSorting,\n onSortingChange,\n defaultSorting,\n pagination: controlledPagination,\n onPaginationChange,\n pageSize = 10,\n pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,\n enableRowSelection = false,\n rowSelection: controlledRowSelection,\n onRowSelectionChange,\n getRowId,\n globalFilter: controlledGlobalFilter,\n onGlobalFilterChange,\n loading = false,\n emptyState,\n stickyHeader = false,\n className,\n ...rest\n } = props\n\n // When rows is provided, the caller owns sort/grouping/windowing. We bypass\n // TanStack pagination and column sort UI, but keep the table instance for\n // selection state and cell rendering on data rows.\n const hasRows = rows != null\n const dataItems = React.useMemo(() => {\n if (hasRows) {\n return rows!.flatMap((item) =>\n item.kind === \"data\" ? [item.row] : []\n )\n }\n return data ?? []\n }, [hasRows, rows, data])\n\n const internalGetRowId = React.useMemo(() => {\n if (getRowId) return getRowId\n if (hasRows) {\n const ids = rows!.flatMap((item) =>\n item.kind === \"data\" ? [item.id] : []\n )\n return (_row: TData, index: number) => ids[index] ?? String(index)\n }\n return undefined\n }, [getRowId, hasRows, rows])\n\n // Uncontrolled sorting state\n const [internalSorting, setInternalSorting] = React.useState<SortingState>(\n defaultSorting ?? []\n )\n const sortingIsControlled = controlledSorting !== undefined\n const sorting = sortingIsControlled ? controlledSorting : internalSorting\n const handleSortingChange: OnChangeFn<SortingState> = (updater) => {\n if (!sortingIsControlled) {\n setInternalSorting((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onSortingChange?.(updater)\n }\n\n // Uncontrolled pagination state\n const [internalPagination, setInternalPagination] =\n React.useState<PaginationState>({ pageIndex: 0, pageSize })\n const paginationIsControlled = controlledPagination !== undefined\n const pagination = paginationIsControlled\n ? controlledPagination\n : internalPagination\n const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {\n if (!paginationIsControlled) {\n setInternalPagination((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onPaginationChange?.(updater)\n }\n\n // Uncontrolled selection state\n const [internalRowSelection, setInternalRowSelection] =\n React.useState<RowSelectionState>({})\n const selectionIsControlled = controlledRowSelection !== undefined\n const rowSelection = selectionIsControlled\n ? controlledRowSelection\n : internalRowSelection\n const handleRowSelectionChange: OnChangeFn<RowSelectionState> = (updater) => {\n if (!selectionIsControlled) {\n setInternalRowSelection((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onRowSelectionChange?.(updater)\n }\n\n // Global filter\n const [internalGlobalFilter, setInternalGlobalFilter] = React.useState(\"\")\n const globalFilterIsControlled = controlledGlobalFilter !== undefined\n const globalFilter = globalFilterIsControlled\n ? controlledGlobalFilter\n : internalGlobalFilter\n const handleGlobalFilterChange = (value: unknown) => {\n const next = typeof value === \"function\" ? (value as (p: string) => string)(globalFilter) : (value as string)\n if (!globalFilterIsControlled) {\n setInternalGlobalFilter(next ?? \"\")\n }\n onGlobalFilterChange?.(next ?? \"\")\n }\n\n // Inject a selection column when enabled\n const columns = React.useMemo<ColumnDef<TData, TValue>[]>(() => {\n if (!enableRowSelection) return userColumns\n const selectionColumn: ColumnDef<TData, TValue> = {\n id: \"__select\",\n enableSorting: false,\n size: 40,\n header: ({ table }) => (\n <Checkbox\n aria-label=\"Select all rows\"\n checked={\n table.getIsAllPageRowsSelected()\n ? true\n : table.getIsSomePageRowsSelected()\n ? \"indeterminate\"\n : false\n }\n onCheckedChange={(value) =>\n table.toggleAllPageRowsSelected(value === true)\n }\n />\n ),\n cell: ({ row }) => (\n <Checkbox\n aria-label=\"Select row\"\n checked={row.getIsSelected()}\n disabled={!row.getCanSelect()}\n onCheckedChange={(value) => row.toggleSelected(value === true)}\n />\n ),\n }\n return [selectionColumn, ...userColumns]\n }, [enableRowSelection, userColumns])\n\n const table: TanstackTable<TData> = useReactTable<TData>({\n data: dataItems,\n columns,\n state: {\n sorting,\n pagination,\n rowSelection,\n globalFilter,\n },\n enableRowSelection,\n getRowId: internalGetRowId,\n onSortingChange: handleSortingChange,\n onPaginationChange: handlePaginationChange,\n onRowSelectionChange: handleRowSelectionChange,\n onGlobalFilterChange: handleGlobalFilterChange,\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n getPaginationRowModel: getPaginationRowModel(),\n getFilteredRowModel: getFilteredRowModel(),\n })\n\n const totalRows = table.getFilteredRowModel().rows.length\n const pageRows = table.getRowModel().rows\n const colCount = columns.length\n const pageIndex = table.getState().pagination.pageIndex\n const currentPageSize = table.getState().pagination.pageSize\n const pageCount = table.getPageCount()\n const firstRow = totalRows === 0 ? 0 : pageIndex * currentPageSize + 1\n const lastRow = Math.min((pageIndex + 1) * currentPageSize, totalRows)\n\n const isEmpty = !loading && dataItems.length === 0 && !hasRows\n const defaultEmpty = <EmptyState heading=\"No results\" tone=\"subtle\" />\n\n const defaultGroupRowContent = (group: DataTableGroupRow) => (\n <span data-slot=\"data-table-group-label\" className={styles.groupLabel}>\n {group.label}\n {group.count != null && (\n <span className={styles.groupCount}>{group.count}</span>\n )}\n </span>\n )\n\n return (\n <div\n ref={ref}\n data-slot=\"data-table\"\n className={cn(styles.root, className)}\n {...rest}\n >\n <Table>\n <TableHeader\n className={cn(stickyHeader && styles.stickyHeader)}\n data-sticky={stickyHeader || undefined}\n >\n {table.getHeaderGroups().map((headerGroup) => (\n <TableRow key={headerGroup.id}>\n {headerGroup.headers.map((header) => {\n const canSort = header.column.getCanSort() && !hasRows\n const sortDir = header.column.getIsSorted()\n const ariaSort: React.AriaAttributes[\"aria-sort\"] = hasRows\n ? undefined\n : sortDir === \"asc\"\n ? \"ascending\"\n : sortDir === \"desc\"\n ? \"descending\"\n : canSort\n ? \"none\"\n : undefined\n const headerContent = header.isPlaceholder\n ? null\n : flexRender(\n header.column.columnDef.header,\n header.getContext()\n )\n const columnLabel =\n typeof header.column.columnDef.header === \"string\"\n ? (header.column.columnDef.header as string)\n : header.column.id\n const nextSortStateLabel =\n sortDir === \"asc\"\n ? \"descending\"\n : sortDir === \"desc\"\n ? \"unsorted\"\n : \"ascending\"\n return (\n <TableHead\n key={header.id}\n aria-sort={ariaSort}\n style={{\n width:\n header.column.id === \"__select\" ? \"40px\" : undefined,\n }}\n >\n {canSort ? (\n <button\n type=\"button\"\n className={styles.sortButton}\n onClick={header.column.getToggleSortingHandler()}\n aria-label={`${columnLabel}, sort ${nextSortStateLabel}`}\n >\n <span className={styles.sortLabel}>\n {headerContent}\n </span>\n <span className={styles.sortIcon} aria-hidden=\"true\">\n {sortDir === \"asc\" ? (\n <CaretUpIcon weight=\"bold\" />\n ) : sortDir === \"desc\" ? (\n <CaretDownIcon weight=\"bold\" />\n ) : (\n <CaretUpDownIcon weight=\"bold\" />\n )}\n </span>\n </button>\n ) : (\n headerContent\n )}\n </TableHead>\n )\n })}\n </TableRow>\n ))}\n </TableHeader>\n <TableBody>\n {loading ? (\n Array.from({ length: currentPageSize }).map((_, rowIdx) => (\n <TableRow key={`skeleton-${rowIdx}`} data-slot=\"data-table-skeleton-row\">\n {columns.map((_col, colIdx) => (\n <TableCell key={`skeleton-${rowIdx}-${colIdx}`}>\n <Skeleton className={styles.skeletonCell} />\n </TableCell>\n ))}\n </TableRow>\n ))\n ) : isEmpty ? (\n <TableRow data-slot=\"data-table-empty-row\">\n <TableCell colSpan={colCount} className={styles.emptyCell}>\n {emptyState ?? defaultEmpty}\n </TableCell>\n </TableRow>\n ) : hasRows ? (\n rows!.map((item) => {\n if (item.kind === \"group\") {\n return (\n <TableRow\n key={`group-${item.id}`}\n data-slot=\"data-table-group-row\"\n className={styles.groupRow}\n >\n <TableCell\n colSpan={colCount}\n className={styles.groupCell}\n >\n {groupRowRenderer\n ? groupRowRenderer(item)\n : defaultGroupRowContent(item)}\n </TableCell>\n </TableRow>\n )\n }\n const tsRow = table.getRow(item.id)\n return (\n <TableRow\n key={item.id}\n data-state={tsRow.getIsSelected() ? \"selected\" : undefined}\n >\n {tsRow.getVisibleCells().map((cell) => (\n <TableCell key={cell.id}>\n {flexRender(\n cell.column.columnDef.cell,\n cell.getContext()\n )}\n </TableCell>\n ))}\n </TableRow>\n )\n })\n ) : pageRows.length === 0 ? (\n <TableRow data-slot=\"data-table-empty-row\">\n <TableCell colSpan={colCount} className={styles.emptyCell}>\n {emptyState ?? defaultEmpty}\n </TableCell>\n </TableRow>\n ) : (\n pageRows.map((row) => (\n <TableRow\n key={row.id}\n data-state={row.getIsSelected() ? \"selected\" : undefined}\n >\n {row.getVisibleCells().map((cell) => (\n <TableCell key={cell.id}>\n {flexRender(cell.column.columnDef.cell, cell.getContext())}\n </TableCell>\n ))}\n </TableRow>\n ))\n )}\n </TableBody>\n </Table>\n\n {!hasRows && (\n <div className={styles.footer} data-slot=\"data-table-footer\">\n <div className={styles.footerInfo} aria-live=\"polite\">\n {totalRows === 0\n ? \"No results\"\n : `Showing ${firstRow} to ${lastRow} of ${totalRows}`}\n </div>\n <div className={styles.footerControls}>\n <label className={styles.pageSizeLabel}>\n <span className={styles.pageSizeLabelText}>Rows per page</span>\n <select\n className={styles.pageSizeSelect}\n value={currentPageSize}\n onChange={(e) => table.setPageSize(Number(e.target.value))}\n aria-label=\"Rows per page\"\n >\n {pageSizeOptions.map((opt) => (\n <option key={opt} value={opt}>\n {opt}\n </option>\n ))}\n </select>\n </label>\n <div className={styles.pageNav}>\n <span className={styles.pageCounter}>\n Page {pageCount === 0 ? 0 : pageIndex + 1} of {pageCount}\n </span>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => table.previousPage()}\n disabled={!table.getCanPreviousPage()}\n aria-label=\"Previous page\"\n >\n <CaretLeftIcon weight=\"bold\" aria-hidden=\"true\" />\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => table.nextPage()}\n disabled={!table.getCanNextPage()}\n aria-label=\"Next page\"\n >\n <CaretRightIcon weight=\"bold\" aria-hidden=\"true\" />\n </Button>\n </div>\n </div>\n </div>\n )}\n </div>\n )\n}\n\n// forwardRef with generics — preserve TData through the cast\nconst DataTable = React.forwardRef(DataTableInner) as <\n TData,\n TValue = unknown,\n>(\n props: DataTableProps<TData, TValue> & {\n ref?: React.ForwardedRef<HTMLDivElement>\n }\n) => ReturnType<typeof DataTableInner>\n\n;(DataTable as unknown as { displayName: string }).displayName = \"DataTable\"\n\nexport { DataTable }\n"
1997
2108
  },
1998
2109
  {
1999
2110
  "path": "components/ui/data-table/data-table.module.css",
2000
2111
  "type": "registry:ui",
2001
- "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/* 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"
2112
+ "content": "/* DataTable root — inline-size container for responsive collapse */\n.root {\n display: flex;\n flex-direction: column;\n width: 100%;\n min-width: 0;\n gap: var(--spacing-3, 0.75rem);\n container-type: inline-size;\n container-name: data-table;\n color: var(--text-primary, #111827);\n}\n\n/* Sticky header wrapper — toggled when stickyHeader prop is true */\n.stickyHeader {\n position: sticky;\n top: 0;\n z-index: 1;\n background: var(--surface-card, #ffffff);\n}\n\n/* Sort button — renders inside the <th> */\n.sortButton {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n padding: 0;\n margin: 0;\n background: transparent;\n border: 0;\n color: inherit;\n font: inherit;\n font-weight: var(--font-weight-semibold, 600);\n cursor: pointer;\n line-height: var(--line-height-tight, 1.25);\n border-radius: var(--radius-sm, 0.25rem);\n transition:\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.sortButton:hover {\n color: var(--text-primary, #111827);\n}\n\n.sortButton:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.sortLabel {\n display: inline-block;\n}\n\n.sortIcon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: var(--spacing-4, 1rem);\n height: var(--spacing-4, 1rem);\n color: var(--text-tertiary, #6b7280);\n flex-shrink: 0;\n}\n\n.sortIcon svg {\n width: 100%;\n height: 100%;\n}\n\n/* Skeleton cell fills the cell width */\n.skeletonCell {\n width: 100%;\n height: var(--spacing-4, 1rem);\n}\n\n/* Empty-row cell centers the EmptyState slot */\n.emptyCell {\n padding: var(--spacing-6, 1.5rem) var(--spacing-4, 1rem);\n text-align: center;\n}\n\n/* Footer — info on the left, controls on the right */\n.footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--spacing-4, 1rem);\n padding: var(--spacing-2, 0.5rem) var(--spacing-1, 0.25rem);\n min-width: 0;\n flex-wrap: wrap;\n}\n\n.footerInfo {\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n min-width: 0;\n}\n\n.footerControls {\n display: flex;\n align-items: center;\n gap: var(--spacing-4, 1rem);\n flex-wrap: wrap;\n}\n\n.pageSizeLabel {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n}\n\n.pageSizeLabelText {\n white-space: nowrap;\n}\n\n/* Native <select> styled to match Visor input controls */\n.pageSizeSelect {\n appearance: none;\n -webkit-appearance: none;\n -moz-appearance: none;\n height: var(--spacing-8, 2rem);\n padding: 0 var(--spacing-6, 1.5rem) 0 var(--spacing-2, 0.5rem);\n border-radius: var(--radius-md, 0.375rem);\n border: 1px solid var(--border-default, #e5e7eb);\n background-color: var(--surface-card, #ffffff);\n background-image: linear-gradient(\n 45deg,\n transparent 50%,\n var(--text-tertiary, #6b7280) 50%\n ),\n linear-gradient(\n 135deg,\n var(--text-tertiary, #6b7280) 50%,\n transparent 50%\n );\n background-position:\n right var(--spacing-3, 0.75rem) center,\n right var(--spacing-2, 0.5rem) center;\n background-size:\n 5px 5px,\n 5px 5px;\n background-repeat: no-repeat;\n color: var(--text-primary, #111827);\n font-size: var(--font-size-sm, 0.875rem);\n line-height: var(--line-height-tight, 1.25);\n cursor: pointer;\n transition:\n border-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n box-shadow var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.pageSizeSelect:hover {\n border-color: var(--border-strong, #d1d5db);\n}\n\n.pageSizeSelect:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.pageNav {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n}\n\n.pageCounter {\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n white-space: nowrap;\n}\n\n/* Group-head row — full-width separator inside the table body. The colSpan\n cell carries the visual; the inner span owns the sticky positioning so it\n pins to the top of the scroll container as data rows scroll beneath. */\n.groupRow {\n /* No hover highlight — group rows are structural, not interactive */\n}\n\n.groupCell {\n padding: 0;\n}\n\n.groupLabel {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n height: 28px;\n padding: 0 var(--spacing-4, 1rem);\n background: var(--surface-subtle, #f3f4f6);\n font-size: var(--font-size-xs, 0.6875rem);\n font-weight: var(--font-weight-medium, 500);\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n\n.groupCount {\n font-size: var(--font-size-xs, 0.6875rem);\n color: var(--text-quaternary, #9ca3af);\n font-variant-numeric: tabular-nums;\n letter-spacing: 0;\n}\n\n/* Narrow containers — stack footer sections */\n@container data-table (max-width: 560px) {\n .footer {\n flex-direction: column;\n align-items: stretch;\n }\n\n .footerControls {\n justify-content: space-between;\n }\n}\n"
2002
2113
  }
2003
2114
  ]
2004
2115
  },
@@ -2121,12 +2232,36 @@
2121
2232
  {
2122
2233
  "path": "components/ui/stat-card/stat-card.tsx",
2123
2234
  "type": "registry:ui",
2124
- "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./stat-card.module.css\"\n\nconst statCardVariants = cva(styles.base, {\n variants: {\n variant: {\n default: styles.variantDefault,\n highlight: styles.variantHighlight,\n },\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"md\",\n },\n})\n\nexport type StatCardDeltaDirection = \"up\" | \"down\" | \"flat\"\n\nexport interface StatCardDelta {\n /** Display value, e.g. \"+12.4%\" or \"-$2.1K\". */\n value: React.ReactNode\n /** Semantic direction of the change. Drives color and glyph. */\n direction: StatCardDeltaDirection\n /** Optional context label, e.g. \"vs last month\". Visible and announced. */\n label?: string\n}\n\ntype StatCardElement = \"article\" | \"section\" | \"div\"\n\nexport interface StatCardProps\n extends React.HTMLAttributes<HTMLElement>,\n VariantProps<typeof statCardVariants> {\n /** Small uppercase label describing the metric, e.g. \"Total Revenue\". */\n label: React.ReactNode\n /** Prominent metric value, e.g. \"$48,120\". */\n value: React.ReactNode\n /** Optional change indicator. */\n delta?: StatCardDelta\n /** Optional slot for a sparkline, chart, or icon. */\n trend?: React.ReactNode\n /** Optional sublabel or link rendered beneath the value. */\n footer?: React.ReactNode\n /** Root element tag. Defaults to `article` for landmark semantics. */\n as?: StatCardElement\n}\n\nconst DELTA_GLYPH: Record<StatCardDeltaDirection, string> = {\n up: \"\\u2191\",\n down: \"\\u2193\",\n flat: \"\\u2192\",\n}\n\nconst DELTA_WORD: Record<StatCardDeltaDirection, string> = {\n up: \"up\",\n down: \"down\",\n flat: \"flat\",\n}\n\nconst StatCard = React.forwardRef<HTMLElement, StatCardProps>(\n (\n {\n className,\n variant,\n size,\n label,\n value,\n delta,\n trend,\n footer,\n as = \"article\",\n ...props\n },\n ref\n ) => {\n const Root = as as React.ElementType\n\n return (\n <Root\n ref={ref}\n data-slot=\"stat-card\"\n data-variant={variant ?? \"default\"}\n data-size={size ?? \"md\"}\n className={cn(statCardVariants({ variant, size }), className)}\n {...props}\n >\n <div data-slot=\"stat-card-header\" className={styles.header}>\n <p data-slot=\"stat-card-label\" className={styles.label}>\n {label}\n </p>\n {trend ? (\n <div\n data-slot=\"stat-card-trend\"\n className={styles.trend}\n aria-hidden=\"true\"\n >\n {trend}\n </div>\n ) : null}\n </div>\n\n <p data-slot=\"stat-card-value\" className={styles.value}>\n {value}\n </p>\n\n {delta ? (\n <div\n data-slot=\"stat-card-delta\"\n data-direction={delta.direction}\n className={styles.delta}\n >\n <span className={styles.deltaGlyph} aria-hidden=\"true\">\n {DELTA_GLYPH[delta.direction]}\n </span>\n <span className={styles.deltaValue}>{delta.value}</span>\n {delta.label ? (\n <span className={styles.deltaLabel}>{delta.label}</span>\n ) : null}\n <span className={styles.srOnly}>\n {DELTA_WORD[delta.direction]}\n {delta.label ? ` ${delta.label}` : \"\"}\n </span>\n </div>\n ) : null}\n\n {footer ? (\n <div data-slot=\"stat-card-footer\" className={styles.footer}>\n {footer}\n </div>\n ) : null}\n </Root>\n )\n }\n)\nStatCard.displayName = \"StatCard\"\n\nexport { StatCard, statCardVariants }\n"
2235
+ "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./stat-card.module.css\"\n\nconst statCardVariants = cva(styles.base, {\n variants: {\n variant: {\n default: styles.variantDefault,\n highlight: styles.variantHighlight,\n },\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"md\",\n },\n})\n\nexport type StatCardDeltaDirection = \"up\" | \"down\" | \"flat\"\n\nexport interface StatCardDelta {\n /** Display value, e.g. \"+12.4%\" or \"-$2.1K\". */\n value: React.ReactNode\n /** Semantic direction of the change. Drives color and glyph. */\n direction: StatCardDeltaDirection\n /** Optional context label, e.g. \"vs last month\". Visible and announced. */\n label?: string\n}\n\ntype StatCardElement = \"article\" | \"section\" | \"div\"\n\nexport interface StatCardProps\n extends React.HTMLAttributes<HTMLElement>,\n VariantProps<typeof statCardVariants> {\n /** Small uppercase label describing the metric, e.g. \"Total Revenue\". */\n label: React.ReactNode\n /** Prominent metric value, e.g. \"$48,120\". */\n value: React.ReactNode\n /** Optional change indicator. */\n delta?: StatCardDelta\n /** Optional slot for a sparkline, chart, or icon. */\n trend?: React.ReactNode\n /** Optional sublabel or link rendered beneath the value. */\n footer?: React.ReactNode\n /** Root element tag. Defaults to `article` for landmark semantics. */\n as?: StatCardElement\n /** Typography scale for the value. \"hero\" = marquee display treatment. Defaults to \"default\". */\n valueAs?: \"default\" | \"hero\" | \"compact\"\n /** Additional class names forwarded to the value element. */\n valueClassName?: string\n}\n\nconst DELTA_GLYPH: Record<StatCardDeltaDirection, string> = {\n up: \"\\u2191\",\n down: \"\\u2193\",\n flat: \"\\u2192\",\n}\n\nconst DELTA_WORD: Record<StatCardDeltaDirection, string> = {\n up: \"up\",\n down: \"down\",\n flat: \"flat\",\n}\n\nconst StatCard = React.forwardRef<HTMLElement, StatCardProps>(\n (\n {\n className,\n variant,\n size,\n label,\n value,\n delta,\n trend,\n footer,\n as = \"article\",\n valueAs,\n valueClassName,\n ...props\n },\n ref\n ) => {\n const Root = as as React.ElementType\n\n return (\n <Root\n ref={ref}\n data-slot=\"stat-card\"\n data-variant={variant ?? \"default\"}\n data-size={size ?? \"md\"}\n className={cn(statCardVariants({ variant, size }), className)}\n {...props}\n >\n <div data-slot=\"stat-card-header\" className={styles.header}>\n <p data-slot=\"stat-card-label\" className={styles.label}>\n {label}\n </p>\n {trend ? (\n <div\n data-slot=\"stat-card-trend\"\n className={styles.trend}\n aria-hidden=\"true\"\n >\n {trend}\n </div>\n ) : null}\n </div>\n\n <p\n data-slot=\"stat-card-value\"\n data-value-as={valueAs}\n className={cn(styles.value, valueClassName)}\n >\n {value}\n </p>\n\n {delta ? (\n <div\n data-slot=\"stat-card-delta\"\n data-direction={delta.direction}\n className={styles.delta}\n >\n <span className={styles.deltaGlyph} aria-hidden=\"true\">\n {DELTA_GLYPH[delta.direction]}\n </span>\n <span className={styles.deltaValue}>{delta.value}</span>\n {delta.label ? (\n <span className={styles.deltaLabel}>{delta.label}</span>\n ) : null}\n <span className={styles.srOnly}>\n {DELTA_WORD[delta.direction]}\n {delta.label ? ` ${delta.label}` : \"\"}\n </span>\n </div>\n ) : null}\n\n {footer ? (\n <div data-slot=\"stat-card-footer\" className={styles.footer}>\n {footer}\n </div>\n ) : null}\n </Root>\n )\n }\n)\nStatCard.displayName = \"StatCard\"\n\nexport { StatCard, statCardVariants }\n"
2125
2236
  },
2126
2237
  {
2127
2238
  "path": "components/ui/stat-card/stat-card.module.css",
2128
2239
  "type": "registry:ui",
2129
- "content": "/* Stat Card base: self-contained dashboard metric card\n *\n * Sizing strategy: hug content by default. Place stat cards in a grid\n * (`grid-template-columns: repeat(N, 1fr)`) or a flex row with `flex: 1`\n * per child when uniform widths are needed across a dashboard. A single\n * stat card in a generic flex-row parent renders at its natural content\n * width — the big value token drives the minimum width.\n *\n * No inline-size containment here: inline-size containment prevents the\n * element's intrinsic width from depending on its children, which means\n * it collapses to 0 inside a flex-row parent with `flex: 0 1 auto`. Since\n * the card's responsive variants are already driven by explicit props,\n * the container query buys nothing and costs the natural sizing behavior.\n */\n.base {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-2, 0.5rem);\n padding: var(--spacing-5, 1.25rem);\n background-color: var(--surface-card, #ffffff);\n border: 1px solid var(--border-default, #e5e7eb);\n border-radius: var(--radius-lg, 0.75rem);\n color: var(--text-primary, #111827);\n box-shadow: var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05));\n transition:\n box-shadow var(--motion-duration-150, 150ms)\n var(--motion-easing-standard, ease),\n border-color var(--motion-duration-150, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n/* Default variant — just the base styles */\n.variantDefault {\n /* inherits base */\n}\n\n/* Highlight variant — accent-tinted surface with elevation\n *\n * Uses color-mix over the theme's brand accent (--interactive-primary-bg)\n * plus the theme's card surface, so the variant reads as \"featured / primary\"\n * regardless of whether the active theme is light or dark and regardless of\n * which brand color the theme ships. No hardcoded gradient — gradients\n * conflict with dark modes where lighter colors at the top look like flares.\n */\n.variantHighlight {\n background-color: color-mix(\n in srgb,\n var(--interactive-primary-bg, #2563eb) 6%,\n var(--surface-card, #ffffff)\n );\n border-color: color-mix(\n in srgb,\n var(--interactive-primary-bg, #2563eb) 40%,\n var(--border-default, #e5e7eb)\n );\n box-shadow:\n var(--shadow-md, 0 4px 6px -1px rgb(0 0 0 / 0.1)),\n 0 0 24px -8px\n color-mix(\n in srgb,\n var(--interactive-primary-bg, #2563eb) 25%,\n transparent\n );\n}\n\n/* Size md — default — no overrides; uses the base padding/gap/font-size */\n.sizeMd {\n /* inherits base */\n}\n\n/* Size sm — denser padding, smaller value */\n.sizeSm {\n padding: var(--spacing-4, 1rem);\n gap: var(--spacing-1, 0.25rem);\n}\n\n/* Header row: label on the left, trend slot on the right */\n.header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: var(--spacing-3, 0.75rem);\n min-width: 0;\n}\n\n/* Label — small uppercase muted text */\n.label {\n margin: 0;\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-semibold, 600);\n letter-spacing: var(--letter-spacing-wide, 0.05em);\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n line-height: var(--line-height-tight, 1.25);\n min-width: 0;\n}\n\n/* Trend slot — sparkline / icon container */\n.trend {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n color: var(--text-tertiary, #6b7280);\n flex: 0 0 auto;\n}\n\n/* Value — big numeric display */\n.value {\n margin: 0;\n font-family: var(--font-family-heading, inherit);\n font-size: var(--font-size-3xl, 1.875rem);\n font-weight: var(--font-weight-semibold, 600);\n line-height: var(--line-height-tight, 1.1);\n letter-spacing: var(--letter-spacing-tight, -0.02em);\n color: var(--text-primary, #111827);\n font-variant-numeric: tabular-nums;\n}\n\n.sizeSm .value {\n font-size: var(--font-size-2xl, 1.5rem);\n}\n\n.sizeSm .label {\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n.variantHighlight .value {\n font-size: var(--font-size-4xl, 2.25rem);\n}\n\n/* Delta row — change indicator */\n.delta {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n line-height: var(--line-height-tight, 1.25);\n color: var(--text-secondary, #6b7280);\n flex-wrap: wrap;\n}\n\n.delta[data-direction=\"up\"] {\n color: var(--text-success, #16a34a);\n}\n\n.delta[data-direction=\"down\"] {\n color: var(--text-danger, #dc2626);\n}\n\n.delta[data-direction=\"flat\"] {\n color: var(--text-tertiary, #6b7280);\n}\n\n.deltaGlyph {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n font-size: 1em;\n line-height: 1;\n}\n\n.deltaValue {\n font-variant-numeric: tabular-nums;\n}\n\n.deltaLabel {\n color: var(--text-tertiary, #6b7280);\n font-weight: var(--font-weight-regular, 400);\n margin-left: var(--spacing-1, 0.25rem);\n}\n\n/* Visually hidden but announced to screen readers */\n.srOnly {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n}\n\n/* Footer slot */\n.footer {\n margin-top: var(--spacing-1, 0.25rem);\n padding-top: var(--spacing-3, 0.75rem);\n border-top: 1px solid var(--border-default, #e5e7eb);\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n line-height: var(--line-height-normal, 1.5);\n}\n\n.sizeSm .footer {\n padding-top: var(--spacing-2, 0.5rem);\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n/* Respect reduced motion */\n@media (prefers-reduced-motion: reduce) {\n .base {\n transition: none;\n }\n}\n"
2240
+ "content": "/* Stat Card base: self-contained dashboard metric card\n *\n * Sizing strategy: hug content by default. Place stat cards in a grid\n * (`grid-template-columns: repeat(N, 1fr)`) or a flex row with `flex: 1`\n * per child when uniform widths are needed across a dashboard. A single\n * stat card in a generic flex-row parent renders at its natural content\n * width — the big value token drives the minimum width.\n *\n * No inline-size containment here: inline-size containment prevents the\n * element's intrinsic width from depending on its children, which means\n * it collapses to 0 inside a flex-row parent with `flex: 0 1 auto`. Since\n * the card's responsive variants are already driven by explicit props,\n * the container query buys nothing and costs the natural sizing behavior.\n */\n.base {\n --stat-card-value-font: var(--font-display, var(--font-family-heading, inherit));\n --stat-card-value-size: var(--font-size-3xl, 1.875rem);\n\n display: flex;\n flex-direction: column;\n gap: var(--spacing-2, 0.5rem);\n padding: var(--spacing-5, 1.25rem);\n background-color: var(--surface-card, #ffffff);\n border: 1px solid var(--border-default, #e5e7eb);\n border-radius: var(--radius-lg, 0.75rem);\n color: var(--text-primary, #111827);\n box-shadow: var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05));\n transition:\n box-shadow var(--motion-duration-150, 150ms)\n var(--motion-easing-standard, ease),\n border-color var(--motion-duration-150, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n/* Default variant — just the base styles */\n.variantDefault {\n /* inherits base */\n}\n\n/* Highlight variant — accent-tinted surface with elevation\n *\n * Uses color-mix over the theme's brand accent (--interactive-primary-bg)\n * plus the theme's card surface, so the variant reads as \"featured / primary\"\n * regardless of whether the active theme is light or dark and regardless of\n * which brand color the theme ships. No hardcoded gradient — gradients\n * conflict with dark modes where lighter colors at the top look like flares.\n */\n.variantHighlight {\n background-color: color-mix(\n in srgb,\n var(--interactive-primary-bg, #2563eb) 6%,\n var(--surface-card, #ffffff)\n );\n border-color: color-mix(\n in srgb,\n var(--interactive-primary-bg, #2563eb) 40%,\n var(--border-default, #e5e7eb)\n );\n box-shadow:\n var(--shadow-md, 0 4px 6px -1px rgb(0 0 0 / 0.1)),\n 0 0 24px -8px\n color-mix(\n in srgb,\n var(--interactive-primary-bg, #2563eb) 25%,\n transparent\n );\n}\n\n/* Size md — default — no overrides; uses the base padding/gap/font-size */\n.sizeMd {\n /* inherits base */\n}\n\n/* Size sm — denser padding, smaller value */\n.sizeSm {\n padding: var(--spacing-4, 1rem);\n gap: var(--spacing-1, 0.25rem);\n}\n\n/* Header row: label on the left, trend slot on the right */\n.header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: var(--spacing-3, 0.75rem);\n min-width: 0;\n}\n\n/* Label — small uppercase muted text */\n.label {\n margin: 0;\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-semibold, 600);\n letter-spacing: var(--letter-spacing-wide, 0.05em);\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n line-height: var(--line-height-tight, 1.25);\n min-width: 0;\n}\n\n/* Trend slot — sparkline / icon container */\n.trend {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n color: var(--text-tertiary, #6b7280);\n flex: 0 0 auto;\n}\n\n/* Value — big numeric display */\n.value {\n margin: 0;\n font-family: var(--font-family-heading, inherit);\n font-size: var(--font-size-3xl, 1.875rem);\n font-weight: var(--font-weight-semibold, 600);\n line-height: var(--line-height-tight, 1.1);\n letter-spacing: var(--letter-spacing-tight, -0.02em);\n color: var(--text-primary, #111827);\n font-variant-numeric: tabular-nums;\n}\n\n/* valueAs=\"hero\" — marquee display treatment */\n.value[data-value-as=\"hero\"] {\n font-family: var(--stat-card-value-font);\n font-size: var(--stat-card-value-size, 3.5rem);\n font-weight: var(--font-weight-normal, 400);\n letter-spacing: 0;\n line-height: 1;\n}\n\n/* valueAs=\"compact\" — tighter than default (2xl) */\n.value[data-value-as=\"compact\"] {\n font-size: var(--font-size-2xl, 1.5rem);\n}\n\n.sizeSm .value {\n font-size: var(--font-size-2xl, 1.5rem);\n}\n\n.sizeSm .label {\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n.variantHighlight .value {\n font-size: var(--font-size-4xl, 2.25rem);\n}\n\n/* Delta row — change indicator */\n.delta {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n line-height: var(--line-height-tight, 1.25);\n color: var(--text-secondary, #6b7280);\n flex-wrap: wrap;\n}\n\n.delta[data-direction=\"up\"] {\n color: var(--text-success, #16a34a);\n}\n\n.delta[data-direction=\"down\"] {\n color: var(--text-danger, #dc2626);\n}\n\n.delta[data-direction=\"flat\"] {\n color: var(--text-tertiary, #6b7280);\n}\n\n.deltaGlyph {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n font-size: 1em;\n line-height: 1;\n}\n\n.deltaValue {\n font-variant-numeric: tabular-nums;\n}\n\n.deltaLabel {\n color: var(--text-tertiary, #6b7280);\n font-weight: var(--font-weight-regular, 400);\n margin-left: var(--spacing-1, 0.25rem);\n}\n\n/* Visually hidden but announced to screen readers */\n.srOnly {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n}\n\n/* Footer slot */\n.footer {\n margin-top: var(--spacing-1, 0.25rem);\n padding-top: var(--spacing-3, 0.75rem);\n border-top: 1px solid var(--border-default, #e5e7eb);\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n line-height: var(--line-height-normal, 1.5);\n}\n\n.sizeSm .footer {\n padding-top: var(--spacing-2, 0.5rem);\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n/* Respect reduced motion */\n@media (prefers-reduced-motion: reduce) {\n .base {\n transition: none;\n }\n}\n"
2241
+ }
2242
+ ]
2243
+ },
2244
+ {
2245
+ "name": "stat-hero",
2246
+ "type": "registry:ui",
2247
+ "description": "Hero-scale animated metric banner with a 35/65 grid layout, large headline value, and an animated trendline (CSS stroke-dasharray). Respects prefers-reduced-motion and updates trendline color live via theme class swap.",
2248
+ "category": "admin",
2249
+ "dependencies": [
2250
+ "@loworbitstudio/visor-core"
2251
+ ],
2252
+ "registryDependencies": [
2253
+ "utils"
2254
+ ],
2255
+ "files": [
2256
+ {
2257
+ "path": "components/ui/stat-hero/stat-hero.tsx",
2258
+ "type": "registry:ui",
2259
+ "content": "import * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./stat-hero.module.css\"\n\nexport type StatHeroDeltaDirection = \"up\" | \"down\" | \"flat\"\n\nexport interface StatHeroDelta {\n /** Display value, e.g. \"+12.4%\" or \"-$2.1K\". */\n value: React.ReactNode\n /** Semantic direction of the change. Drives color and glyph. */\n direction: StatHeroDeltaDirection\n}\n\nexport interface StatHeroProps extends React.HTMLAttributes<HTMLElement> {\n /** Small uppercase label describing the metric, e.g. \"Monthly Recurring Revenue\". */\n label: React.ReactNode\n /** Hero-scale metric value, e.g. \"$1,240,000\". */\n value: React.ReactNode\n /** Optional change indicator shown below the value. */\n delta?: StatHeroDelta\n /** Array of numeric data points driving the animated trendline (min 2). */\n values: number[]\n /** Optional caption rendered beneath the delta row. */\n caption?: React.ReactNode\n}\n\nconst DELTA_GLYPH: Record<StatHeroDeltaDirection, string> = {\n up: \"↑\",\n down: \"↓\",\n flat: \"→\",\n}\n\nconst DELTA_WORD: Record<StatHeroDeltaDirection, string> = {\n up: \"up\",\n down: \"down\",\n flat: \"flat\",\n}\n\n/**\n * Compute normalized SVG polyline points from a number array.\n * Returns null if values.length < 2.\n */\nfunction computePoints(\n values: number[],\n width: number,\n height: number\n): string | null {\n if (values.length < 2) return null\n const min = Math.min(...values)\n const max = Math.max(...values)\n const range = max - min || 1\n const stepX = width / (values.length - 1)\n return values\n .map((v, i) => {\n const x = i * stepX\n // Pad 4px top and bottom so the stroke doesn't clip at the SVG edge\n const y = 4 + ((max - v) / range) * (height - 8)\n return `${x.toFixed(1)},${y.toFixed(1)}`\n })\n .join(\" \")\n}\n\nconst StatHero = React.forwardRef<HTMLElement, StatHeroProps>(\n (\n {\n className,\n label,\n value,\n delta,\n values,\n caption,\n ...props\n },\n ref\n ) => {\n const SVG_WIDTH = 600\n const SVG_HEIGHT = 120\n const points = computePoints(values, SVG_WIDTH, SVG_HEIGHT)\n\n return (\n <article\n ref={ref}\n data-slot=\"stat-hero\"\n className={cn(styles.base, className)}\n {...props}\n >\n {/* Left column: label, value, delta, caption */}\n <div data-slot=\"stat-hero-body\" className={styles.body}>\n <p data-slot=\"stat-hero-label\" className={styles.label}>\n {label}\n </p>\n\n <p data-slot=\"stat-hero-value\" className={styles.value}>\n {value}\n </p>\n\n {delta ? (\n <div\n data-slot=\"stat-hero-delta\"\n data-direction={delta.direction}\n className={styles.delta}\n >\n <span className={styles.deltaGlyph} aria-hidden=\"true\">\n {DELTA_GLYPH[delta.direction]}\n </span>\n <span className={styles.deltaValue}>{delta.value}</span>\n <span className={styles.srOnly}>\n {DELTA_WORD[delta.direction]}\n </span>\n </div>\n ) : null}\n\n {caption ? (\n <p data-slot=\"stat-hero-caption\" className={styles.caption}>\n {caption}\n </p>\n ) : null}\n </div>\n\n {/* Right column: animated trendline SVG */}\n <div\n data-slot=\"stat-hero-chart\"\n className={styles.chart}\n aria-hidden=\"true\"\n >\n {points ? (\n <svg\n viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}\n preserveAspectRatio=\"none\"\n className={styles.svg}\n >\n <polyline\n className={styles.trendline}\n points={points}\n fill=\"none\"\n stroke=\"currentColor\"\n strokeLinejoin=\"round\"\n strokeLinecap=\"round\"\n />\n </svg>\n ) : null}\n </div>\n </article>\n )\n }\n)\nStatHero.displayName = \"StatHero\"\n\nexport { StatHero }\n"
2260
+ },
2261
+ {
2262
+ "path": "components/ui/stat-hero/stat-hero.module.css",
2263
+ "type": "registry:ui",
2264
+ "content": "/* Stat Hero: hero-scale animated metric banner\n *\n * Layout strategy: full-width banner with a 35/65 CSS grid split.\n * The left column holds the metric copy (label, value, delta, caption);\n * the right column holds a full-height animated trendline SVG.\n *\n * The trendline draw animation uses stroke-dasharray + stroke-dashoffset\n * so no JS animation loop is required. The SVG polyline uses\n * stroke=\"currentColor\" and color: var(--interactive-primary-bg) on the\n * chart region so the trendline color updates live when the theme class\n * swaps on :root.\n *\n * prefers-reduced-motion: reduce — animation is disabled; the trendline\n * is drawn at its full length instantly (stroke-dashoffset: 0, no transition).\n */\n\n.base {\n --stat-hero-value-size: 6rem; /* ~96px — overridable per consumer */\n\n display: grid;\n grid-template-columns: 35fr 65fr;\n gap: var(--spacing-8, 2rem);\n align-items: center;\n\n padding: var(--spacing-8, 2rem);\n background-color: var(--surface-card, #ffffff);\n border: 1px solid var(--border-default, #e5e7eb);\n border-radius: var(--radius-lg, 0.75rem);\n color: var(--text-primary, #111827);\n box-shadow: var(--shadow-sm, 0 1px 2px 0 rgb(0 0 0 / 0.05));\n overflow: hidden;\n}\n\n/* ── Left column: label, value, delta, caption ─────────────────────────── */\n\n.body {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-3, 0.75rem);\n min-width: 0;\n}\n\n.label {\n margin: 0;\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-semibold, 600);\n letter-spacing: var(--letter-spacing-wide, 0.05em);\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n line-height: var(--line-height-tight, 1.25);\n}\n\n.value {\n margin: 0;\n font-family: var(--font-family-heading, inherit);\n font-size: var(--stat-hero-value-size, 6rem);\n font-weight: var(--font-weight-semibold, 600);\n line-height: 1;\n letter-spacing: var(--letter-spacing-tight, -0.02em);\n color: var(--text-primary, #111827);\n font-variant-numeric: tabular-nums;\n}\n\n/* Delta row — change indicator */\n.delta {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n font-size: var(--font-size-lg, 1.125rem);\n font-weight: var(--font-weight-medium, 500);\n line-height: var(--line-height-tight, 1.25);\n color: var(--text-secondary, #6b7280);\n}\n\n.delta[data-direction=\"up\"] {\n color: var(--text-success, #15803d);\n}\n\n.delta[data-direction=\"down\"] {\n color: var(--text-danger, #b91c1c);\n}\n\n.delta[data-direction=\"flat\"] {\n color: var(--text-tertiary, #6b7280);\n}\n\n.deltaGlyph {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n font-size: 1em;\n line-height: 1;\n}\n\n.deltaValue {\n font-variant-numeric: tabular-nums;\n}\n\n.caption {\n margin: 0;\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-tertiary, #6b7280);\n line-height: var(--line-height-normal, 1.5);\n}\n\n/* Visually hidden but announced to screen readers */\n.srOnly {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n}\n\n/* ── Right column: animated trendline ──────────────────────────────────── */\n\n.chart {\n /* Drive trendline color via currentColor so theme swaps propagate. */\n color: var(--interactive-primary-bg, #2563eb);\n height: 100%;\n min-height: var(--spacing-20, 5rem);\n display: flex;\n align-items: stretch;\n}\n\n.svg {\n width: 100%;\n height: 100%;\n display: block;\n min-height: var(--spacing-20, 5rem);\n}\n\n/* Trendline draw animation:\n * stroke-dasharray is set to a large value that covers any polyline length.\n * stroke-dashoffset starts at that value (hidden) and transitions to 0\n * (fully drawn). Uses the slowest available motion token for a dramatic\n * flagship reveal effect.\n */\n.trendline {\n stroke-width: var(--stroke-width-medium, 2);\n stroke-dasharray: 2000;\n stroke-dashoffset: 2000;\n animation: draw-trendline var(--motion-duration-800, 800ms)\n var(--motion-easing-ease-out, cubic-bezier(0, 0, 0.2, 1)) forwards;\n}\n\n@keyframes draw-trendline {\n to {\n stroke-dashoffset: 0;\n }\n}\n\n/* Respect reduced motion — disable animation, show at full length instantly */\n@media (prefers-reduced-motion: reduce) {\n .trendline {\n animation: none;\n stroke-dashoffset: 0;\n }\n}\n"
2130
2265
  }
2131
2266
  ]
2132
2267
  },
@@ -2156,6 +2291,30 @@
2156
2291
  }
2157
2292
  ]
2158
2293
  },
2294
+ {
2295
+ "name": "station-spectrum",
2296
+ "type": "registry:ui",
2297
+ "description": "Animated N-station progress diagram with a hairline rail that draws on scroll-entry and dots that illuminate sequentially via CSS transition delays. Designed for 'process / phases / pipeline' marketing diagrams.",
2298
+ "category": "visual-elements",
2299
+ "dependencies": [
2300
+ "@loworbitstudio/visor-core"
2301
+ ],
2302
+ "registryDependencies": [
2303
+ "utils"
2304
+ ],
2305
+ "files": [
2306
+ {
2307
+ "path": "components/ui/station-spectrum/station-spectrum.tsx",
2308
+ "type": "registry:ui",
2309
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./station-spectrum.module.css\"\n\nexport interface Station {\n /** Numeric label displayed above the dot, e.g. \"01\". */\n num: string\n /** Station title displayed below the rail. */\n title: string\n /** Optional description text shown beneath the title. */\n description?: string\n}\n\nexport interface StationSpectrumProps extends React.HTMLAttributes<HTMLElement> {\n /** Array of stations to render. Minimum 2 recommended. */\n stations: Station[]\n /**\n * Visual density of the diagram.\n * `compact` reduces horizontal spacing for tighter layouts.\n * @default \"default\"\n */\n density?: \"compact\" | \"default\"\n /**\n * When true, the animation is active (rail draws, dots illuminate).\n * Pass explicitly to control the trigger from outside the component.\n * Ignored when `autoTrigger` is true.\n */\n inView?: boolean\n /**\n * When true (default), the component auto-wires an IntersectionObserver\n * to trigger the animation when the element enters the viewport.\n * Set to false to control the trigger manually via the `inView` prop.\n * @default true\n */\n autoTrigger?: boolean\n}\n\n/**\n * StationSpectrum — animated N-station progress diagram.\n *\n * Layout is two stacked bands sharing the same N-column grid:\n * - `.dotsBand` holds the per-station num + dot pair and the animated rail.\n * The rail is positioned `bottom: dot-size/2` from the band so it threads\n * through the dots' vertical centers regardless of font metrics.\n * - `.labelsBand` is the canonical `<ol>` exposing titles and descriptions\n * to assistive tech (the dotsBand is decorative, marked aria-hidden).\n *\n * Animation is triggered by the `inView` class on the root:\n * - `autoTrigger=true` (default): wired via IntersectionObserver\n * - `autoTrigger=false`: driven by the `inView` prop\n *\n * prefers-reduced-motion: animation is disabled; rail and dots appear\n * at their final state instantly.\n */\nconst StationSpectrum = React.forwardRef<HTMLElement, StationSpectrumProps>(\n (\n {\n className,\n stations,\n density = \"default\",\n inView,\n autoTrigger = true,\n ...props\n },\n forwardedRef\n ) => {\n const innerRef = React.useRef<HTMLElement | null>(null)\n const [autoInView, setAutoInView] = React.useState(false)\n\n const setRef = React.useCallback(\n (node: HTMLElement | null) => {\n innerRef.current = node\n if (typeof forwardedRef === \"function\") {\n forwardedRef(node)\n } else if (forwardedRef) {\n forwardedRef.current = node\n }\n },\n [forwardedRef]\n )\n\n React.useEffect(() => {\n if (!autoTrigger) return\n const element = innerRef.current\n if (!element || typeof IntersectionObserver === \"undefined\") return\n\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (entry.isIntersecting) {\n setAutoInView(true)\n observer.disconnect()\n }\n },\n { threshold: 0.3 }\n )\n\n observer.observe(element)\n return () => observer.disconnect()\n }, [autoTrigger])\n\n const isActive = autoTrigger ? autoInView : (inView ?? false)\n\n const { style: userStyle, ...restProps } = props\n\n return (\n <section\n ref={setRef}\n data-slot=\"station-spectrum\"\n data-density={density}\n className={cn(styles.base, isActive && styles.inView, className)}\n style={{\n ...(userStyle ?? {}),\n \"--station-count\": stations.length,\n } as React.CSSProperties}\n {...restProps}\n >\n {/* Decorative band: nums, dots, and the animated rail. */}\n <div className={styles.dotsBand} aria-hidden=\"true\">\n <div className={styles.dotsRow}>\n {stations.map((station, idx) => (\n <div\n key={station.num}\n className={styles.dotCell}\n style={{ \"--idx\": idx } as React.CSSProperties}\n >\n <span className={styles.num}>{station.num}</span>\n <span className={styles.dot} />\n </div>\n ))}\n </div>\n <div className={styles.rail} aria-hidden=\"true\">\n <div className={styles.railLine} />\n </div>\n </div>\n\n {/* Accessible ordered list — titles and descriptions. */}\n <ol className={styles.labelsBand} aria-label=\"Process stages\">\n {stations.map((station, idx) => (\n <li\n key={station.num}\n className={styles.labelCell}\n style={{ \"--idx\": idx } as React.CSSProperties}\n >\n <span className={styles.title}>{station.title}</span>\n {station.description ? (\n <span className={styles.description}>{station.description}</span>\n ) : null}\n </li>\n ))}\n </ol>\n </section>\n )\n }\n)\nStationSpectrum.displayName = \"StationSpectrum\"\n\nexport { StationSpectrum }\n"
2310
+ },
2311
+ {
2312
+ "path": "components/ui/station-spectrum/station-spectrum.module.css",
2313
+ "type": "registry:ui",
2314
+ "content": "/* StationSpectrum: animated N-station progress diagram\n *\n * Layout strategy:\n * Two stacked bands share the same N-column grid:\n * - `.dotsBand` contains the num+dot pairs and the rail. The rail is\n * positioned `bottom: dot-size/2` from the band, so the line threads\n * through the vertical centers of the dots without depending on font\n * metrics or magic offsets.\n * - `.labelsBand` is the accessible <ol> with titles and descriptions.\n *\n * Animation strategy:\n * Rail: transform: scaleX(0) → scaleX(1) with transform-origin: left.\n * Dots: background-color transitions staggered via `--idx` * delay-step.\n * Triggered by `.inView` on the root.\n *\n * prefers-reduced-motion:\n * All transitions suppressed; rail and dots render in their final state.\n */\n\n/* ── Root ──────────────────────────────────────────────────────────────── */\n\n.base {\n --station-spectrum-dot-size: var(--spacing-3, 0.75rem); /* 12px */\n --station-spectrum-dot-size-compact: var(--spacing-2, 0.5rem); /* 8px */\n --station-spectrum-rail-thickness: var(--stroke-width-thin, 1px);\n --station-spectrum-rail-color: var(--border-default, #e5e7eb);\n --station-spectrum-dot-color-off: var(--surface-card, #ffffff);\n --station-spectrum-dot-color-on: var(--interactive-primary-bg, #2563eb);\n --station-spectrum-dot-border-color: var(--border-default, #e5e7eb);\n --station-spectrum-dot-border-color-on: var(--interactive-primary-bg, #2563eb);\n --station-spectrum-num-color: var(--text-tertiary, #6b7280);\n --station-spectrum-title-color: var(--text-primary, #111827);\n --station-spectrum-desc-color: var(--text-secondary, #6b7280);\n\n /* Rail animation timing */\n --rail-duration: var(--motion-duration-slow, 500ms);\n --rail-easing: var(--motion-easing-enter, cubic-bezier(0, 0, 0.2, 1));\n --dot-delay-step: 80ms; /* per-dot stagger: intentional constant */\n --rail-delay: 200ms; /* head-start for rail before dots begin */\n\n position: relative;\n width: 100%;\n}\n\n/* ── Dots band (num + dot + rail) ──────────────────────────────────────── */\n\n.dotsBand {\n position: relative;\n width: 100%;\n}\n\n.dotsRow {\n display: grid;\n grid-template-columns: repeat(var(--station-count, 1), 1fr);\n position: relative;\n z-index: 1; /* above the rail */\n}\n\n.dotCell {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: var(--spacing-2, 0.5rem);\n /* The last dotCell drops its right padding so its dot aligns flex-start\n inside an equal-width column — the rail's right offset (computed from\n station count) takes care of meeting the last dot's center. */\n}\n\n.num {\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-semibold, 600);\n letter-spacing: var(--letter-spacing-wide, 0.05em);\n color: var(--station-spectrum-num-color);\n line-height: var(--line-height-tight, 1.25);\n font-variant-numeric: tabular-nums;\n transition: color var(--motion-duration-normal, 200ms) var(--motion-easing-default, ease)\n calc(var(--idx, 0) * var(--dot-delay-step) + var(--rail-delay));\n}\n\n.dot {\n width: var(--station-spectrum-dot-size);\n height: var(--station-spectrum-dot-size);\n border-radius: 50%;\n background-color: var(--station-spectrum-dot-color-off);\n border: var(--station-spectrum-rail-thickness) solid var(--station-spectrum-dot-border-color);\n transition:\n background-color var(--motion-duration-normal, 200ms) var(--motion-easing-default, ease)\n calc(var(--idx, 0) * var(--dot-delay-step) + var(--rail-delay)),\n border-color var(--motion-duration-normal, 200ms) var(--motion-easing-default, ease)\n calc(var(--idx, 0) * var(--dot-delay-step) + var(--rail-delay));\n}\n\n/* ── Rail ──────────────────────────────────────────────────────────────── */\n/*\n * The rail threads through the vertical center of the dots. Because the\n * dot sits at the bottom of each .dotCell (after the num row), and the\n * .dotsBand wraps only the dots row, positioning the rail by `bottom`\n * with an offset of `dot-size/2` puts the line exactly at the dots'\n * vertical center — no font-metric-dependent math required.\n *\n * Horizontally: dots are flex-start within equal-width columns, so the\n * first dot's center is `dot-size/2` from the band's left edge and the\n * last dot's center is `100% / N - dot-size/2` inset from the right edge.\n */\n\n.rail {\n position: absolute;\n left: calc(var(--station-spectrum-dot-size) / 2);\n right: calc(100% / var(--station-count, 1) - var(--station-spectrum-dot-size) / 2);\n bottom: calc(var(--station-spectrum-dot-size) / 2);\n /* Subtract half the rail's own thickness so the LINE's center (not its\n top edge) lands at the dots' vertical center. */\n margin-bottom: calc(var(--station-spectrum-rail-thickness) / -2);\n height: var(--station-spectrum-rail-thickness);\n pointer-events: none;\n z-index: 0;\n}\n\n.railLine {\n width: 100%;\n height: 100%;\n background-color: var(--station-spectrum-rail-color);\n transform-origin: left center;\n transform: scaleX(0);\n transition: transform var(--rail-duration) var(--rail-easing);\n}\n\n/* ── Labels band (titles + descriptions) ───────────────────────────────── */\n\n.labelsBand {\n display: grid;\n grid-template-columns: repeat(var(--station-count, 1), 1fr);\n list-style: none;\n margin: 0;\n padding: 0;\n margin-top: var(--spacing-3, 0.75rem);\n}\n\n.labelCell {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-1, 0.25rem);\n padding-right: var(--spacing-4, 1rem);\n}\n\n.labelCell:last-child {\n padding-right: 0;\n}\n\n.title {\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-semibold, 600);\n color: var(--station-spectrum-title-color);\n line-height: var(--line-height-tight, 1.25);\n}\n\n.description {\n display: block;\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--station-spectrum-desc-color);\n line-height: var(--line-height-normal, 1.5);\n}\n\n/* ── inView state — animation active ──────────────────────────────────── */\n\n.inView .railLine {\n transform: scaleX(1);\n}\n\n.inView .dot {\n background-color: var(--station-spectrum-dot-color-on);\n border-color: var(--station-spectrum-dot-border-color-on);\n}\n\n.inView .num {\n color: var(--interactive-primary-bg, #2563eb);\n}\n\n/* ── Density variant: compact ─────────────────────────────────────────── */\n\n.base[data-density=\"compact\"] {\n --station-spectrum-dot-size: var(--station-spectrum-dot-size-compact);\n}\n\n.base[data-density=\"compact\"] .labelCell {\n padding-right: var(--spacing-2, 0.5rem);\n}\n\n.base[data-density=\"compact\"] .title {\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n.base[data-density=\"compact\"] .description {\n display: none; /* descriptions hidden in compact mode */\n}\n\n/* ── Reduced motion ────────────────────────────────────────────────────── */\n\n@media (prefers-reduced-motion: reduce) {\n .railLine {\n transition: none;\n transform: scaleX(1);\n }\n\n .dot,\n .num {\n transition: none;\n }\n\n .dot {\n background-color: var(--station-spectrum-dot-color-on);\n border-color: var(--station-spectrum-dot-border-color-on);\n }\n\n .num {\n color: var(--interactive-primary-bg, #2563eb);\n }\n}\n"
2315
+ }
2316
+ ]
2317
+ },
2159
2318
  {
2160
2319
  "name": "theme-switcher",
2161
2320
  "type": "registry:ui",
@@ -2387,6 +2546,18 @@
2387
2546
  }
2388
2547
  ]
2389
2548
  },
2549
+ {
2550
+ "name": "password-managers-context",
2551
+ "type": "registry:lib",
2552
+ "description": "React context that lets Form set a passwordManagers default for descendant Input and Textarea fields.",
2553
+ "files": [
2554
+ {
2555
+ "path": "lib/password-managers-context.tsx",
2556
+ "type": "registry:lib",
2557
+ "content": "\"use client\"\n\nimport * as React from \"react\"\n\nexport type PasswordManagersValue = \"ignore\" | \"allow\"\n\nconst PasswordManagersContext = React.createContext<\n PasswordManagersValue | undefined\n>(undefined)\n\nexport interface PasswordManagersProviderProps {\n value: PasswordManagersValue\n children: React.ReactNode\n}\n\nexport function PasswordManagersProvider({\n value,\n children,\n}: PasswordManagersProviderProps) {\n return (\n <PasswordManagersContext.Provider value={value}>\n {children}\n </PasswordManagersContext.Provider>\n )\n}\n\nexport function usePasswordManagersContext(): PasswordManagersValue | undefined {\n return React.useContext(PasswordManagersContext)\n}\n\nexport function usePasswordManagersValue(\n prop?: PasswordManagersValue\n): PasswordManagersValue {\n const fromContext = usePasswordManagersContext()\n return prop ?? fromContext ?? \"ignore\"\n}\n"
2558
+ }
2559
+ ]
2560
+ },
2390
2561
  {
2391
2562
  "name": "deck-context",
2392
2563
  "type": "registry:ui",
@@ -2847,7 +3018,7 @@
2847
3018
  {
2848
3019
  "path": "blocks/admin-dashboard/admin-dashboard.tsx",
2849
3020
  "type": "registry:block",
2850
- "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"../../lib/utils\"\nimport {\n PageHeader,\n type PageHeaderProps,\n} from \"../../components/ui/page-header/page-header\"\nimport {\n StatCard,\n type StatCardDelta,\n} from \"../../components/ui/stat-card/stat-card\"\nimport {\n ActivityFeed,\n ActivityFeedItem,\n} from \"../../components/ui/activity-feed/activity-feed\"\nimport { EmptyState } from \"../../components/ui/empty-state/empty-state\"\nimport styles from \"./admin-dashboard.module.css\"\n\nexport interface AdminDashboardStat {\n id: string\n label: React.ReactNode\n value: React.ReactNode\n delta?: StatCardDelta\n trend?: React.ReactNode\n variant?: \"default\" | \"highlight\"\n size?: \"sm\" | \"md\"\n}\n\nexport interface AdminDashboardActivity {\n id: string\n leading?: React.ReactNode\n title: React.ReactNode\n description?: React.ReactNode\n actor?: React.ReactNode\n timestamp: React.ReactNode\n trailing?: React.ReactNode\n}\n\nexport interface AdminDashboardProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n /** Page title rendered inside the PageHeader. */\n title: React.ReactNode\n /** Optional small uppercase label above the title. */\n eyebrow?: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /** Optional actions slot rendered on the right side of the header. */\n actions?: React.ReactNode\n\n /** Stat cards rendered in the responsive grid. */\n stats: AdminDashboardStat[]\n\n /** Heading rendered above the activity feed. Defaults to \"Recent activity\". */\n activityTitle?: React.ReactNode\n /** Activity events rendered in the feed. */\n activities: AdminDashboardActivity[]\n /** If provided, renders a \"View all\" link in the activity section header. */\n activityViewAllHref?: string\n /** Replaces the default EmptyState when `activities` is empty. */\n activityEmptyState?: React.ReactNode\n /** Variant forwarded to the underlying ActivityFeed. */\n activityVariant?: \"default\" | \"compact\" | \"timeline\"\n\n /** Optional region rendered below the stat grid and above the activity feed. */\n secondaryRegion?: React.ReactNode\n\n /** Heading level used for the PageHeader title. Defaults to `h1`. */\n titleAs?: PageHeaderProps[\"titleAs\"]\n}\n\nexport function AdminDashboard({\n title,\n eyebrow,\n description,\n actions,\n stats,\n activityTitle = \"Recent activity\",\n activities,\n activityViewAllHref,\n activityEmptyState,\n activityVariant = \"default\",\n secondaryRegion,\n titleAs = \"h1\",\n className,\n ...props\n}: AdminDashboardProps) {\n const activityHeadingId = React.useId()\n const hasActivities = activities.length > 0\n\n return (\n <div\n className={cn(styles.root, className)}\n data-slot=\"admin-dashboard\"\n {...props}\n >\n <PageHeader\n eyebrow={eyebrow}\n title={title}\n description={description}\n actions={actions}\n titleAs={titleAs}\n />\n\n {stats.length > 0 ? (\n <div\n className={styles.statGrid}\n data-slot=\"admin-dashboard-stats\"\n data-count={stats.length}\n >\n {stats.map((stat) => (\n <StatCard\n key={stat.id}\n label={stat.label}\n value={stat.value}\n delta={stat.delta}\n trend={stat.trend}\n variant={stat.variant}\n size={stat.size}\n />\n ))}\n </div>\n ) : null}\n\n {secondaryRegion ? (\n <div\n className={styles.secondaryRegion}\n data-slot=\"admin-dashboard-secondary\"\n >\n {secondaryRegion}\n </div>\n ) : null}\n\n <section\n className={styles.activitySection}\n data-slot=\"admin-dashboard-activity\"\n aria-labelledby={activityHeadingId}\n >\n <header className={styles.activityHeader}>\n <h2 id={activityHeadingId} className={styles.activityTitle}>\n {activityTitle}\n </h2>\n {activityViewAllHref ? (\n <a\n href={activityViewAllHref}\n className={styles.activityViewAll}\n data-slot=\"admin-dashboard-activity-view-all\"\n >\n View all\n </a>\n ) : null}\n </header>\n\n {hasActivities ? (\n <ActivityFeed variant={activityVariant}>\n {activities.map((activity) => (\n <ActivityFeedItem\n key={activity.id}\n leading={activity.leading}\n title={activity.title}\n description={activity.description}\n actor={activity.actor}\n timestamp={activity.timestamp}\n trailing={activity.trailing}\n />\n ))}\n </ActivityFeed>\n ) : activityEmptyState !== undefined ? (\n activityEmptyState\n ) : (\n <EmptyState\n heading=\"No recent activity\"\n description=\"New events will show up here as they happen.\"\n tone=\"subtle\"\n />\n )}\n </section>\n </div>\n )\n}\n"
3021
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"../../lib/utils\"\nimport {\n PageHeader,\n type PageHeaderProps,\n} from \"../../components/ui/page-header/page-header\"\nimport {\n StatCard,\n type StatCardDelta,\n type StatCardProps,\n} from \"../../components/ui/stat-card/stat-card\"\nimport {\n ActivityFeed,\n ActivityFeedItem,\n} from \"../../components/ui/activity-feed/activity-feed\"\nimport { EmptyState } from \"../../components/ui/empty-state/empty-state\"\nimport styles from \"./admin-dashboard.module.css\"\n\nexport interface AdminDashboardStat {\n id: string\n label: React.ReactNode\n value: React.ReactNode\n delta?: StatCardDelta\n trend?: React.ReactNode\n variant?: \"default\" | \"highlight\"\n size?: \"sm\" | \"md\"\n valueAs?: StatCardProps[\"valueAs\"]\n}\n\nexport interface AdminDashboardActivity {\n id: string\n leading?: React.ReactNode\n title: React.ReactNode\n description?: React.ReactNode\n actor?: React.ReactNode\n timestamp: React.ReactNode\n trailing?: React.ReactNode\n}\n\nexport interface AdminDashboardProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n /** Page title rendered inside the PageHeader. */\n title: React.ReactNode\n /** Optional small uppercase label above the title. */\n eyebrow?: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /** Optional actions slot rendered on the right side of the header. */\n actions?: React.ReactNode\n\n /** Stat cards rendered in the responsive grid. */\n stats: AdminDashboardStat[]\n\n /** Heading rendered above the activity feed. Defaults to \"Recent activity\". */\n activityTitle?: React.ReactNode\n /** Activity events rendered in the feed. */\n activities: AdminDashboardActivity[]\n /** If provided, renders a \"View all\" link in the activity section header. */\n activityViewAllHref?: string\n /** Replaces the default EmptyState when `activities` is empty. */\n activityEmptyState?: React.ReactNode\n /** Variant forwarded to the underlying ActivityFeed. */\n activityVariant?: \"default\" | \"compact\" | \"timeline\"\n\n /** Optional region rendered below the stat grid and above the activity feed. */\n secondaryRegion?: React.ReactNode\n\n /** Heading level used for the PageHeader title. Defaults to `h1`. */\n titleAs?: PageHeaderProps[\"titleAs\"]\n}\n\nexport function AdminDashboard({\n title,\n eyebrow,\n description,\n actions,\n stats,\n activityTitle = \"Recent activity\",\n activities,\n activityViewAllHref,\n activityEmptyState,\n activityVariant = \"default\",\n secondaryRegion,\n titleAs = \"h1\",\n className,\n ...props\n}: AdminDashboardProps) {\n const activityHeadingId = React.useId()\n const hasActivities = activities.length > 0\n\n return (\n <div\n className={cn(styles.root, className)}\n data-slot=\"admin-dashboard\"\n {...props}\n >\n <PageHeader\n eyebrow={eyebrow}\n title={title}\n description={description}\n actions={actions}\n titleAs={titleAs}\n />\n\n {stats.length > 0 ? (\n <div\n className={styles.statGrid}\n data-slot=\"admin-dashboard-stats\"\n data-count={stats.length}\n >\n {stats.map((stat) => (\n <StatCard\n key={stat.id}\n label={stat.label}\n value={stat.value}\n delta={stat.delta}\n trend={stat.trend}\n variant={stat.variant}\n size={stat.size}\n valueAs={stat.valueAs}\n />\n ))}\n </div>\n ) : null}\n\n {secondaryRegion ? (\n <div\n className={styles.secondaryRegion}\n data-slot=\"admin-dashboard-secondary\"\n >\n {secondaryRegion}\n </div>\n ) : null}\n\n <section\n className={styles.activitySection}\n data-slot=\"admin-dashboard-activity\"\n aria-labelledby={activityHeadingId}\n >\n <header className={styles.activityHeader}>\n <h2 id={activityHeadingId} className={styles.activityTitle}>\n {activityTitle}\n </h2>\n {activityViewAllHref ? (\n <a\n href={activityViewAllHref}\n className={styles.activityViewAll}\n data-slot=\"admin-dashboard-activity-view-all\"\n >\n View all\n </a>\n ) : null}\n </header>\n\n {hasActivities ? (\n <ActivityFeed variant={activityVariant}>\n {activities.map((activity) => (\n <ActivityFeedItem\n key={activity.id}\n leading={activity.leading}\n title={activity.title}\n description={activity.description}\n actor={activity.actor}\n timestamp={activity.timestamp}\n trailing={activity.trailing}\n />\n ))}\n </ActivityFeed>\n ) : activityEmptyState !== undefined ? (\n activityEmptyState\n ) : (\n <EmptyState\n heading=\"No recent activity\"\n description=\"New events will show up here as they happen.\"\n tone=\"subtle\"\n />\n )}\n </section>\n </div>\n )\n}\n"
2851
3022
  },
2852
3023
  {
2853
3024
  "path": "blocks/admin-dashboard/admin-dashboard.module.css",
@@ -2905,12 +3076,12 @@
2905
3076
  {
2906
3077
  "path": "blocks/admin-detail-drawer/admin-detail-drawer.tsx",
2907
3078
  "type": "registry:block",
2908
- "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"../../lib/utils\"\nimport {\n Sheet,\n SheetContent,\n SheetDescription,\n SheetHeader,\n SheetTitle,\n} from \"../../components/ui/sheet/sheet\"\nimport { Button } from \"../../components/ui/button/button\"\nimport { ConfirmDialog } from \"../../components/ui/confirm-dialog/confirm-dialog\"\nimport styles from \"./admin-detail-drawer.module.css\"\n\nexport type AdminDetailDrawerWidth = \"sm\" | \"md\" | \"lg\" | \"xl\"\n\nexport interface AdminDetailDrawerProps {\n // ── Open state ──────────────────────────────────────────────────────────\n /** Controlled open state. */\n open: boolean\n /** Handler invoked when the drawer requests to open or close. */\n onOpenChange: (open: boolean) => void\n\n // ── Header ──────────────────────────────────────────────────────────────\n /** Drawer title — rendered inside SheetTitle. */\n title: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n\n // ── Body ────────────────────────────────────────────────────────────────\n /** Form or detail content rendered inside the scrollable body region. */\n children: React.ReactNode\n\n // ── Save / cancel actions ───────────────────────────────────────────────\n /** Save handler. Async-aware: returning a Promise puts the save button into pending state. */\n onSave?: () => void | Promise<void>\n /** Cancel handler. Called when the drawer closes cleanly via cancel/X/Escape/overlay. */\n onCancel?: () => void\n /** Save button label. Defaults to \"Save changes\". */\n saveLabel?: React.ReactNode\n /** Cancel button label. Defaults to \"Cancel\". */\n cancelLabel?: React.ReactNode\n /** Save button variant. Defaults to \"default\". */\n saveVariant?: \"default\" | \"destructive\"\n\n // ── State ───────────────────────────────────────────────────────────────\n /** If true, closing the drawer triggers the unsaved-changes guard. */\n dirty?: boolean\n /** Externally-controlled busy state — overrides internal async pending detection. */\n busy?: boolean\n /** Disable the save button. */\n disabled?: boolean\n\n // ── Footer customization ────────────────────────────────────────────────\n /** Middle slot inside the sticky footer — e.g. \"Last saved 2 minutes ago\". */\n footerStatus?: React.ReactNode\n /** Hide the footer entirely. Defaults to false. */\n hideFooter?: boolean\n\n // ── Width ───────────────────────────────────────────────────────────────\n /** Drawer width variant. Defaults to \"md\" (480px). */\n width?: AdminDetailDrawerWidth\n\n // ── Unsaved guard customization ─────────────────────────────────────────\n /** Title of the unsaved-changes confirm dialog. Defaults to \"Discard unsaved changes?\". */\n unsavedGuardTitle?: React.ReactNode\n /** Description of the unsaved-changes confirm dialog. */\n unsavedGuardDescription?: React.ReactNode\n /** Confirm (discard) label. Defaults to \"Discard\". */\n unsavedGuardConfirmLabel?: React.ReactNode\n /** Cancel (keep editing) label. Defaults to \"Keep editing\". */\n unsavedGuardCancelLabel?: React.ReactNode\n\n /** Additional className merged onto the SheetContent. */\n className?: string\n}\n\nconst WIDTH_CLASS: Record<AdminDetailDrawerWidth, string> = {\n sm: styles.widthSm,\n md: styles.widthMd,\n lg: styles.widthLg,\n xl: styles.widthXl,\n}\n\nconst DEFAULT_UNSAVED_DESCRIPTION =\n \"You have unsaved changes that will be lost if you close this drawer.\"\n\nconst AdminDetailDrawer = React.forwardRef<\n HTMLDivElement,\n AdminDetailDrawerProps\n>(function AdminDetailDrawer(\n {\n open,\n onOpenChange,\n title,\n description,\n children,\n onSave,\n onCancel,\n saveLabel = \"Save changes\",\n cancelLabel = \"Cancel\",\n saveVariant = \"default\",\n dirty = false,\n busy,\n disabled = false,\n footerStatus,\n hideFooter = false,\n width = \"md\",\n unsavedGuardTitle = \"Discard unsaved changes?\",\n unsavedGuardDescription = DEFAULT_UNSAVED_DESCRIPTION,\n unsavedGuardConfirmLabel = \"Discard\",\n unsavedGuardCancelLabel = \"Keep editing\",\n className,\n },\n ref\n) {\n const [isPending, setIsPending] = React.useState(false)\n const [showUnsavedGuard, setShowUnsavedGuard] = React.useState(false)\n\n const effectiveBusy = busy ?? isPending\n\n const handleSheetOpenChange = React.useCallback(\n (next: boolean) => {\n if (next) {\n onOpenChange(true)\n return\n }\n // Close request\n if (effectiveBusy) {\n // Don't allow closing mid-save.\n return\n }\n if (dirty) {\n setShowUnsavedGuard(true)\n return\n }\n onCancel?.()\n onOpenChange(false)\n },\n [effectiveBusy, dirty, onCancel, onOpenChange]\n )\n\n const handleCancelClick = React.useCallback(() => {\n if (effectiveBusy) return\n if (dirty) {\n setShowUnsavedGuard(true)\n return\n }\n onCancel?.()\n onOpenChange(false)\n }, [effectiveBusy, dirty, onCancel, onOpenChange])\n\n const handleSaveClick = React.useCallback(async () => {\n if (!onSave) {\n onOpenChange(false)\n return\n }\n const result = onSave()\n if (result && typeof (result as Promise<void>).then === \"function\") {\n setIsPending(true)\n try {\n await result\n setIsPending(false)\n onOpenChange(false)\n } catch (err) {\n // Keep the drawer open, clear pending state, re-throw so consumer's\n // error handling can surface the failure.\n setIsPending(false)\n throw err\n }\n } else {\n onOpenChange(false)\n }\n }, [onSave, onOpenChange])\n\n const handleDiscard = React.useCallback(() => {\n setShowUnsavedGuard(false)\n onCancel?.()\n onOpenChange(false)\n }, [onCancel, onOpenChange])\n\n const handleKeepEditing = React.useCallback(() => {\n setShowUnsavedGuard(false)\n }, [])\n\n const saveDisabled = disabled || effectiveBusy\n\n return (\n <>\n <Sheet open={open} onOpenChange={handleSheetOpenChange}>\n <SheetContent\n ref={ref}\n side=\"right\"\n className={cn(styles.content, WIDTH_CLASS[width], className)}\n data-slot=\"admin-detail-drawer\"\n >\n <SheetHeader className={styles.header}>\n <SheetTitle>{title}</SheetTitle>\n {description ? (\n <SheetDescription>{description}</SheetDescription>\n ) : null}\n </SheetHeader>\n\n <div\n className={styles.body}\n data-slot=\"admin-detail-drawer-body\"\n >\n {children}\n </div>\n\n {hideFooter ? null : (\n <div\n className={styles.footer}\n data-slot=\"admin-detail-drawer-footer\"\n role=\"group\"\n aria-label=\"Drawer actions\"\n >\n <div className={styles.footerCancel}>\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={handleCancelClick}\n disabled={effectiveBusy}\n data-slot=\"admin-detail-drawer-cancel\"\n >\n {cancelLabel}\n </Button>\n </div>\n {footerStatus ? (\n <div\n className={styles.footerStatus}\n data-slot=\"admin-detail-drawer-status\"\n >\n {footerStatus}\n </div>\n ) : null}\n <div className={styles.footerSave}>\n <Button\n type=\"button\"\n variant={saveVariant}\n onClick={handleSaveClick}\n disabled={saveDisabled}\n aria-busy={effectiveBusy || undefined}\n data-slot=\"admin-detail-drawer-save\"\n >\n {saveLabel}\n </Button>\n </div>\n </div>\n )}\n </SheetContent>\n </Sheet>\n\n <ConfirmDialog\n open={showUnsavedGuard}\n onOpenChange={(next) => {\n if (!next) setShowUnsavedGuard(false)\n }}\n severity=\"warning\"\n title={unsavedGuardTitle}\n description={unsavedGuardDescription}\n confirmLabel={unsavedGuardConfirmLabel}\n cancelLabel={unsavedGuardCancelLabel}\n onConfirm={handleDiscard}\n onCancel={handleKeepEditing}\n />\n </>\n )\n})\n\nAdminDetailDrawer.displayName = \"AdminDetailDrawer\"\n\nexport { AdminDetailDrawer }\n"
3079
+ "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"../../lib/utils\"\nimport {\n Sheet,\n SheetContent,\n SheetDescription,\n SheetHeader,\n SheetTitle,\n} from \"../../components/ui/sheet/sheet\"\nimport { Button } from \"../../components/ui/button/button\"\nimport { ConfirmDialog } from \"../../components/ui/confirm-dialog/confirm-dialog\"\nimport styles from \"./admin-detail-drawer.module.css\"\n\nexport type AdminDetailDrawerWidth = \"sm\" | \"md\" | \"lg\" | \"xl\"\n\nexport interface AdminDetailDrawerProps {\n // ── Open state ──────────────────────────────────────────────────────────\n /** Controlled open state. */\n open: boolean\n /** Handler invoked when the drawer requests to open or close. */\n onOpenChange: (open: boolean) => void\n\n // ── Header ──────────────────────────────────────────────────────────────\n /** Drawer title — always required; used as a11y label when header is hidden. */\n title: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /**\n * Replace the default SheetHeader with custom chrome content.\n * A visually-hidden SheetTitle wrapping `title` is still mounted to satisfy\n * Radix's DialogTitle a11y requirement.\n * Takes precedence over `hideHeader` when both are set.\n */\n customHeader?: React.ReactNode\n /**\n * Suppress the default SheetHeader entirely.\n * A visually-hidden SheetTitle wrapping `title` is still mounted to satisfy\n * Radix's DialogTitle a11y requirement.\n */\n hideHeader?: boolean\n\n // ── Body ────────────────────────────────────────────────────────────────\n /** Form or detail content rendered inside the scrollable body region. */\n children: React.ReactNode\n\n // ── Save / cancel actions ───────────────────────────────────────────────\n /** Save handler. Async-aware: returning a Promise puts the save button into pending state. */\n onSave?: () => void | Promise<void>\n /** Cancel handler. Called when the drawer closes cleanly via cancel/X/Escape/overlay. */\n onCancel?: () => void\n /** Save button label. Defaults to \"Save changes\". */\n saveLabel?: React.ReactNode\n /** Cancel button label. Defaults to \"Cancel\". */\n cancelLabel?: React.ReactNode\n /** Save button variant. Defaults to \"default\". */\n saveVariant?: \"default\" | \"destructive\"\n\n // ── State ───────────────────────────────────────────────────────────────\n /** If true, closing the drawer triggers the unsaved-changes guard. */\n dirty?: boolean\n /** Externally-controlled busy state — overrides internal async pending detection. */\n busy?: boolean\n /** Disable the save button. */\n disabled?: boolean\n\n // ── Footer customization ────────────────────────────────────────────────\n /** Middle slot inside the sticky footer — e.g. \"Last saved 2 minutes ago\". */\n footerStatus?: React.ReactNode\n /** Hide the footer entirely. Defaults to false. */\n hideFooter?: boolean\n\n // ── Width ───────────────────────────────────────────────────────────────\n /** Drawer width variant. Defaults to \"md\" (480px). */\n width?: AdminDetailDrawerWidth\n\n // ── Unsaved guard customization ─────────────────────────────────────────\n /** Title of the unsaved-changes confirm dialog. Defaults to \"Discard unsaved changes?\". */\n unsavedGuardTitle?: React.ReactNode\n /** Description of the unsaved-changes confirm dialog. */\n unsavedGuardDescription?: React.ReactNode\n /** Confirm (discard) label. Defaults to \"Discard\". */\n unsavedGuardConfirmLabel?: React.ReactNode\n /** Cancel (keep editing) label. Defaults to \"Keep editing\". */\n unsavedGuardCancelLabel?: React.ReactNode\n\n /** Additional className merged onto the SheetContent. */\n className?: string\n}\n\nconst WIDTH_CLASS: Record<AdminDetailDrawerWidth, string> = {\n sm: styles.widthSm,\n md: styles.widthMd,\n lg: styles.widthLg,\n xl: styles.widthXl,\n}\n\nconst DEFAULT_UNSAVED_DESCRIPTION =\n \"You have unsaved changes that will be lost if you close this drawer.\"\n\nconst AdminDetailDrawer = React.forwardRef<\n HTMLDivElement,\n AdminDetailDrawerProps\n>(function AdminDetailDrawer(\n {\n open,\n onOpenChange,\n title,\n description,\n customHeader,\n hideHeader = false,\n children,\n onSave,\n onCancel,\n saveLabel = \"Save changes\",\n cancelLabel = \"Cancel\",\n saveVariant = \"default\",\n dirty = false,\n busy,\n disabled = false,\n footerStatus,\n hideFooter = false,\n width = \"md\",\n unsavedGuardTitle = \"Discard unsaved changes?\",\n unsavedGuardDescription = DEFAULT_UNSAVED_DESCRIPTION,\n unsavedGuardConfirmLabel = \"Discard\",\n unsavedGuardCancelLabel = \"Keep editing\",\n className,\n },\n ref\n) {\n const [isPending, setIsPending] = React.useState(false)\n const [showUnsavedGuard, setShowUnsavedGuard] = React.useState(false)\n\n const effectiveBusy = busy ?? isPending\n\n const handleSheetOpenChange = React.useCallback(\n (next: boolean) => {\n if (next) {\n onOpenChange(true)\n return\n }\n // Close request\n if (effectiveBusy) {\n // Don't allow closing mid-save.\n return\n }\n if (dirty) {\n setShowUnsavedGuard(true)\n return\n }\n onCancel?.()\n onOpenChange(false)\n },\n [effectiveBusy, dirty, onCancel, onOpenChange]\n )\n\n const handleCancelClick = React.useCallback(() => {\n if (effectiveBusy) return\n if (dirty) {\n setShowUnsavedGuard(true)\n return\n }\n onCancel?.()\n onOpenChange(false)\n }, [effectiveBusy, dirty, onCancel, onOpenChange])\n\n const handleSaveClick = React.useCallback(async () => {\n if (!onSave) {\n onOpenChange(false)\n return\n }\n const result = onSave()\n if (result && typeof (result as Promise<void>).then === \"function\") {\n setIsPending(true)\n try {\n await result\n setIsPending(false)\n onOpenChange(false)\n } catch (err) {\n // Keep the drawer open, clear pending state, re-throw so consumer's\n // error handling can surface the failure.\n setIsPending(false)\n throw err\n }\n } else {\n onOpenChange(false)\n }\n }, [onSave, onOpenChange])\n\n const handleDiscard = React.useCallback(() => {\n setShowUnsavedGuard(false)\n onCancel?.()\n onOpenChange(false)\n }, [onCancel, onOpenChange])\n\n const handleKeepEditing = React.useCallback(() => {\n setShowUnsavedGuard(false)\n }, [])\n\n const saveDisabled = disabled || effectiveBusy\n\n return (\n <>\n <Sheet open={open} onOpenChange={handleSheetOpenChange}>\n <SheetContent\n ref={ref}\n side=\"right\"\n className={cn(styles.content, WIDTH_CLASS[width], className)}\n data-slot=\"admin-detail-drawer\"\n >\n {customHeader ? (\n <>\n <SheetTitle className={styles.srOnly}>{title}</SheetTitle>\n {customHeader}\n </>\n ) : hideHeader ? (\n <SheetTitle className={styles.srOnly}>{title}</SheetTitle>\n ) : (\n <SheetHeader className={styles.header}>\n <SheetTitle>{title}</SheetTitle>\n {description ? (\n <SheetDescription>{description}</SheetDescription>\n ) : null}\n </SheetHeader>\n )}\n\n <div\n className={styles.body}\n data-slot=\"admin-detail-drawer-body\"\n >\n {children}\n </div>\n\n {hideFooter ? null : (\n <div\n className={styles.footer}\n data-slot=\"admin-detail-drawer-footer\"\n role=\"group\"\n aria-label=\"Drawer actions\"\n >\n <div className={styles.footerCancel}>\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={handleCancelClick}\n disabled={effectiveBusy}\n data-slot=\"admin-detail-drawer-cancel\"\n >\n {cancelLabel}\n </Button>\n </div>\n {footerStatus ? (\n <div\n className={styles.footerStatus}\n data-slot=\"admin-detail-drawer-status\"\n >\n {footerStatus}\n </div>\n ) : null}\n <div className={styles.footerSave}>\n <Button\n type=\"button\"\n variant={saveVariant}\n onClick={handleSaveClick}\n disabled={saveDisabled}\n aria-busy={effectiveBusy || undefined}\n data-slot=\"admin-detail-drawer-save\"\n >\n {saveLabel}\n </Button>\n </div>\n </div>\n )}\n </SheetContent>\n </Sheet>\n\n <ConfirmDialog\n open={showUnsavedGuard}\n onOpenChange={(next) => {\n if (!next) setShowUnsavedGuard(false)\n }}\n severity=\"warning\"\n title={unsavedGuardTitle}\n description={unsavedGuardDescription}\n confirmLabel={unsavedGuardConfirmLabel}\n cancelLabel={unsavedGuardCancelLabel}\n onConfirm={handleDiscard}\n onCancel={handleKeepEditing}\n />\n </>\n )\n})\n\nAdminDetailDrawer.displayName = \"AdminDetailDrawer\"\n\nexport { AdminDetailDrawer }\n"
2909
3080
  },
2910
3081
  {
2911
3082
  "path": "blocks/admin-detail-drawer/admin-detail-drawer.module.css",
2912
3083
  "type": "registry:block",
2913
- "content": "/* Admin Detail Drawer\n * Composes Sheet's right-side content with a three-zone flex column:\n * header (flex:none) → body (flex:1 scrollable) → footer (flex:none sticky).\n * Width variants override Sheet's default max-width (24rem) so the block can\n * ship sm/md/lg/xl presets for different edit scenarios.\n */\n\n.content {\n /* Clamp against viewport so narrow screens still get a full-height drawer. */\n max-width: 100vw;\n padding: 0;\n gap: 0;\n}\n\n/* Width variants — override Sheet's default `max-width: 24rem`. */\n.widthSm {\n width: 100%;\n max-width: min(20rem, 100vw); /* 320px */\n}\n\n.widthMd {\n width: 100%;\n max-width: min(30rem, 100vw); /* 480px */\n}\n\n.widthLg {\n width: 100%;\n max-width: min(40rem, 100vw); /* 640px */\n}\n\n.widthXl {\n width: 100%;\n max-width: min(50rem, 100vw); /* 800px */\n}\n\n/* Header sits at the top with the default Sheet padding but forced to not grow. */\n.header {\n flex: 0 0 auto;\n padding: var(--spacing-6, 1.5rem);\n border-bottom: 1px solid var(--border-muted, #e5e7eb);\n}\n\n/* Body is the scrollable region; shrinks to fill available space. */\n.body {\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;\n padding: var(--spacing-6, 1.5rem);\n color: var(--text-primary, #111827);\n}\n\n/* Sticky footer — pinned to the bottom of the drawer, never scrolls out. */\n.footer {\n flex: 0 0 auto;\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: var(--spacing-3, 0.75rem);\n padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);\n border-top: 1px solid var(--border-muted, #e5e7eb);\n background-color: var(--surface-page, #ffffff);\n box-shadow: var(--shadow-sm);\n}\n\n.footerCancel {\n flex: 0 0 auto;\n}\n\n.footerStatus {\n flex: 1 1 auto;\n min-width: 0;\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n text-align: center;\n}\n\n/* When there's no footerStatus, push the save button all the way to the right. */\n.footerSave {\n flex: 0 0 auto;\n margin-left: auto;\n}\n\n/* If the footerStatus slot is present, reset the margin so it sits next to status. */\n.footerStatus + .footerSave {\n margin-left: 0;\n}\n"
3084
+ "content": "/* Admin Detail Drawer\n * Composes Sheet's right-side content with a three-zone flex column:\n * header (flex:none) → body (flex:1 scrollable) → footer (flex:none sticky).\n * Width variants override Sheet's default max-width (24rem) so the block can\n * ship sm/md/lg/xl presets for different edit scenarios.\n */\n\n.content {\n /* Clamp against viewport so narrow screens still get a full-height drawer. */\n max-width: 100vw;\n padding: 0;\n gap: 0;\n}\n\n/* Width variants — override Sheet's default `max-width: 24rem`. */\n.widthSm {\n width: 100%;\n max-width: min(20rem, 100vw); /* 320px */\n}\n\n.widthMd {\n width: 100%;\n max-width: min(30rem, 100vw); /* 480px */\n}\n\n.widthLg {\n width: 100%;\n max-width: min(40rem, 100vw); /* 640px */\n}\n\n.widthXl {\n width: 100%;\n max-width: min(50rem, 100vw); /* 800px */\n}\n\n/* Header sits at the top with the default Sheet padding but forced to not grow. */\n.header {\n flex: 0 0 auto;\n padding: var(--spacing-6, 1.5rem);\n border-bottom: 1px solid var(--border-muted, #e5e7eb);\n}\n\n/* Body is the scrollable region; shrinks to fill available space. */\n.body {\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;\n padding: var(--spacing-6, 1.5rem);\n color: var(--text-primary, #111827);\n}\n\n/* Sticky footer — pinned to the bottom of the drawer, never scrolls out. */\n.footer {\n flex: 0 0 auto;\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: var(--spacing-3, 0.75rem);\n padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);\n border-top: 1px solid var(--border-muted, #e5e7eb);\n background-color: var(--surface-page, #ffffff);\n box-shadow: var(--shadow-sm);\n}\n\n.footerCancel {\n flex: 0 0 auto;\n}\n\n.footerStatus {\n flex: 1 1 auto;\n min-width: 0;\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n text-align: center;\n}\n\n/* When there's no footerStatus, push the save button all the way to the right. */\n.footerSave {\n flex: 0 0 auto;\n margin-left: auto;\n}\n\n/* If the footerStatus slot is present, reset the margin so it sits next to status. */\n.footerStatus + .footerSave {\n margin-left: 0;\n}\n\n/* Visually-hidden element — satisfies Radix's DialogTitle a11y requirement\n * when the default SheetHeader is suppressed via hideHeader or customHeader. */\n.srOnly {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border-width: 0;\n}\n"
2914
3085
  }
2915
3086
  ]
2916
3087
  },
@@ -2964,12 +3135,12 @@
2964
3135
  {
2965
3136
  "path": "blocks/admin-settings-page/admin-settings-page.tsx",
2966
3137
  "type": "registry:block",
2967
- "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"../../lib/utils\"\nimport { PageHeader } from \"../../components/ui/page-header/page-header\"\nimport { Heading } from \"../../components/ui/heading/heading\"\nimport { Text } from \"../../components/ui/text/text\"\nimport { Separator } from \"../../components/ui/separator/separator\"\nimport { Button } from \"../../components/ui/button/button\"\nimport { ConfirmDialog } from \"../../components/ui/confirm-dialog/confirm-dialog\"\nimport styles from \"./admin-settings-page.module.css\"\n\nexport interface AdminSettingsSection {\n /** Stable identifier — used as DOM id anchor and nav highlight key. */\n id: string\n /** Label rendered inside the left nav / top tab bar. */\n label: React.ReactNode\n /** Section heading rendered above the content. */\n title: React.ReactNode\n /** Optional supporting copy rendered below the heading. */\n description?: React.ReactNode\n /** Optional leading icon rendered before the label in the nav. */\n icon?: React.ReactNode\n /** Section body — form fields or arbitrary content. */\n content: React.ReactNode\n\n // ── Per-section save mode ──────────────────────────────────────────────\n /** Dirty flag — only meaningful when the page runs in `perSectionSave` mode. */\n dirty?: boolean\n /** Busy flag — only meaningful when the page runs in `perSectionSave` mode. */\n busy?: boolean\n /** Save handler — only called in `perSectionSave` mode. Async-aware. */\n onSave?: () => void | Promise<void>\n /** Revert handler — only called in `perSectionSave` mode. */\n onRevert?: () => void\n /** Override the per-section save button label. Defaults to \"Save\". */\n saveLabel?: React.ReactNode\n /** Override the per-section revert button label. Defaults to \"Revert\". */\n revertLabel?: React.ReactNode\n}\n\nexport interface AdminSettingsPageProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n // ── Header ──────────────────────────────────────────────────────────────\n /** Page title rendered inside the PageHeader. */\n title: React.ReactNode\n /** Optional eyebrow rendered above the title. */\n eyebrow?: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /** Optional breadcrumb node rendered above the title row. */\n breadcrumb?: React.ReactNode\n /** Optional header actions slot rendered on the right side of the header. */\n headerActions?: React.ReactNode\n\n // ── Sections ────────────────────────────────────────────────────────────\n /** Ordered list of settings sections. */\n sections: AdminSettingsSection[]\n\n // ── Navigation ──────────────────────────────────────────────────────────\n /** Show the section nav. Defaults to true when there is more than one section. */\n showNav?: boolean\n /** Nav position — \"left\" (sticky side rail) or \"top\" (horizontal chip bar). Defaults to \"left\". */\n navPosition?: \"left\" | \"top\"\n\n // ── Mode ────────────────────────────────────────────────────────────────\n /**\n * If true, each section renders its own save/revert footer row and the global\n * sticky footer is not rendered. Defaults to false (global single-save mode).\n */\n perSectionSave?: boolean\n\n // ── Global save footer (single-save mode only) ──────────────────────────\n /** Save handler. Async-aware — a returned Promise drives the save button's pending state. */\n onSave?: () => void | Promise<void>\n /** Cancel handler. Protected by the unsaved-changes guard when `dirty` is true. */\n onCancel?: () => void\n /** Save button label. Defaults to \"Save changes\". */\n saveLabel?: React.ReactNode\n /** Cancel button label. Defaults to \"Cancel\". */\n cancelLabel?: React.ReactNode\n /** Dirty flag — drives the save button disabled state and the cancel guard. */\n dirty?: boolean\n /** Externally-controlled busy state. Overrides internal async pending detection. */\n busy?: boolean\n /** Middle slot inside the sticky footer — e.g. \"Last saved 2 minutes ago\". */\n footerStatus?: React.ReactNode\n /** Hide the global footer. */\n hideFooter?: boolean\n\n // ── Unsaved guard (global cancel only) ──────────────────────────────────\n /** Title of the unsaved-changes confirm dialog. */\n unsavedGuardTitle?: React.ReactNode\n /** Description of the unsaved-changes confirm dialog. */\n unsavedGuardDescription?: React.ReactNode\n /** Confirm (discard) label. Defaults to \"Discard\". */\n unsavedGuardConfirmLabel?: React.ReactNode\n /** Cancel (keep editing) label. Defaults to \"Keep editing\". */\n unsavedGuardCancelLabel?: React.ReactNode\n}\n\nconst DEFAULT_UNSAVED_DESCRIPTION =\n \"You have unsaved changes that will be lost if you leave this page.\"\n\nconst AdminSettingsPage = React.forwardRef<\n HTMLDivElement,\n AdminSettingsPageProps\n>(function AdminSettingsPage(\n {\n title,\n eyebrow,\n description,\n breadcrumb,\n headerActions,\n sections,\n showNav,\n navPosition = \"left\",\n perSectionSave = false,\n onSave,\n onCancel,\n saveLabel = \"Save changes\",\n cancelLabel = \"Cancel\",\n dirty = false,\n busy,\n footerStatus,\n hideFooter = false,\n unsavedGuardTitle = \"Discard unsaved changes?\",\n unsavedGuardDescription = DEFAULT_UNSAVED_DESCRIPTION,\n unsavedGuardConfirmLabel = \"Discard\",\n unsavedGuardCancelLabel = \"Keep editing\",\n className,\n ...rest\n },\n ref\n) {\n const shouldShowNav = showNav ?? sections.length > 1\n const firstSectionId = sections[0]?.id\n const [activeId, setActiveId] = React.useState<string | undefined>(\n firstSectionId\n )\n const [isGlobalPending, setIsGlobalPending] = React.useState(false)\n const [pendingCancel, setPendingCancel] = React.useState(false)\n const sectionRefs = React.useRef<Map<string, HTMLElement>>(new Map())\n\n const effectiveBusy = busy ?? isGlobalPending\n\n // Intersection observer to highlight the nav item whose section is in view.\n React.useEffect(() => {\n if (!shouldShowNav) return\n if (typeof window === \"undefined\") return\n if (typeof IntersectionObserver === \"undefined\") return\n\n const elements = Array.from(sectionRefs.current.values())\n if (elements.length === 0) return\n\n const observer = new IntersectionObserver(\n (entries) => {\n // Find the topmost visible entry.\n const visible = entries\n .filter((e) => e.isIntersecting)\n .sort(\n (a, b) =>\n a.boundingClientRect.top - b.boundingClientRect.top\n )\n if (visible[0]) {\n const id = (visible[0].target as HTMLElement).id\n if (id) setActiveId(id)\n }\n },\n {\n // Trigger when a section enters the top portion of the scroll container.\n rootMargin: \"0px 0px -60% 0px\",\n threshold: [0, 0.1, 0.5, 1],\n }\n )\n\n elements.forEach((el) => observer.observe(el))\n return () => observer.disconnect()\n }, [shouldShowNav, sections])\n\n const registerSectionRef = React.useCallback(\n (id: string) => (node: HTMLElement | null) => {\n const map = sectionRefs.current\n if (node) {\n map.set(id, node)\n } else {\n map.delete(id)\n }\n },\n []\n )\n\n const handleNavClick = React.useCallback(\n (event: React.MouseEvent<HTMLAnchorElement>, id: string) => {\n event.preventDefault()\n const target = sectionRefs.current.get(id)\n if (target) {\n target.scrollIntoView({ block: \"start\", behavior: \"smooth\" })\n // Update hash without jumping.\n if (typeof window !== \"undefined\") {\n window.history.replaceState(null, \"\", `#${id}`)\n }\n setActiveId(id)\n }\n },\n []\n )\n\n const handleGlobalCancelClick = React.useCallback(() => {\n if (effectiveBusy) return\n if (dirty) {\n setPendingCancel(true)\n return\n }\n onCancel?.()\n }, [dirty, effectiveBusy, onCancel])\n\n const handleGlobalSaveClick = React.useCallback(async () => {\n if (!onSave) return\n const result = onSave()\n if (result && typeof (result as Promise<void>).then === \"function\") {\n setIsGlobalPending(true)\n try {\n await result\n setIsGlobalPending(false)\n } catch (err) {\n setIsGlobalPending(false)\n throw err\n }\n }\n }, [onSave])\n\n const handleGuardConfirm = React.useCallback(() => {\n if (pendingCancel) {\n setPendingCancel(false)\n onCancel?.()\n }\n }, [pendingCancel, onCancel])\n\n const handleGuardCancel = React.useCallback(() => {\n setPendingCancel(false)\n }, [])\n\n const globalSaveDisabled = effectiveBusy || !dirty\n\n return (\n <>\n <div\n ref={ref}\n className={cn(\n styles.root,\n shouldShowNav && navPosition === \"left\"\n ? styles.withLeftNav\n : styles.noLeftNav,\n className\n )}\n data-slot=\"admin-settings-page\"\n data-nav-position={shouldShowNav ? navPosition : undefined}\n {...rest}\n >\n {firstSectionId ? (\n <a className={styles.skipLink} href={`#${firstSectionId}`}>\n Skip to content\n </a>\n ) : null}\n\n <PageHeader\n className={styles.header}\n eyebrow={eyebrow}\n title={title}\n description={description}\n breadcrumb={breadcrumb}\n actions={headerActions}\n />\n\n {shouldShowNav && navPosition === \"top\" ? (\n <nav\n aria-label=\"Settings sections\"\n className={styles.topNav}\n data-slot=\"admin-settings-page-nav\"\n >\n <ul className={styles.topNavList}>\n {sections.map((section) => {\n const isActive = section.id === activeId\n return (\n <li key={section.id} className={styles.topNavItem}>\n <a\n href={`#${section.id}`}\n className={cn(\n styles.topNavLink,\n isActive && styles.navLinkActive\n )}\n aria-current={isActive ? \"true\" : undefined}\n onClick={(e) => handleNavClick(e, section.id)}\n >\n {section.icon ? (\n <span\n className={styles.navIcon}\n aria-hidden=\"true\"\n >\n {section.icon}\n </span>\n ) : null}\n <span className={styles.navLabel}>\n {section.label}\n </span>\n </a>\n </li>\n )\n })}\n </ul>\n </nav>\n ) : null}\n\n <div className={styles.body}>\n {shouldShowNav && navPosition === \"left\" ? (\n <nav\n aria-label=\"Settings sections\"\n className={styles.sideNav}\n data-slot=\"admin-settings-page-nav\"\n >\n <ul className={styles.sideNavList}>\n {sections.map((section) => {\n const isActive = section.id === activeId\n return (\n <li key={section.id} className={styles.sideNavItem}>\n <a\n href={`#${section.id}`}\n className={cn(\n styles.sideNavLink,\n isActive && styles.navLinkActive\n )}\n aria-current={isActive ? \"true\" : undefined}\n onClick={(e) => handleNavClick(e, section.id)}\n >\n {section.icon ? (\n <span\n className={styles.navIcon}\n aria-hidden=\"true\"\n >\n {section.icon}\n </span>\n ) : null}\n <span className={styles.navLabel}>\n {section.label}\n </span>\n </a>\n </li>\n )\n })}\n </ul>\n </nav>\n ) : null}\n\n <div\n className={styles.main}\n data-slot=\"admin-settings-page-main\"\n >\n {sections.map((section, index) => {\n const titleId = `${section.id}-title`\n const showSectionFooter =\n perSectionSave && (section.onSave || section.onRevert)\n const sectionSaveDisabled =\n (section.busy ?? false) || !(section.dirty ?? false)\n\n return (\n <React.Fragment key={section.id}>\n {index > 0 ? (\n <Separator className={styles.sectionSeparator} />\n ) : null}\n <section\n id={section.id}\n ref={registerSectionRef(section.id)}\n aria-labelledby={titleId}\n className={styles.section}\n data-slot=\"admin-settings-page-section\"\n >\n <header className={styles.sectionHeader}>\n <Heading\n level={2}\n size=\"lg\"\n id={titleId}\n className={styles.sectionTitle}\n >\n {section.title}\n </Heading>\n {section.description ? (\n <Text\n size=\"sm\"\n color=\"secondary\"\n className={styles.sectionDescription}\n >\n {section.description}\n </Text>\n ) : null}\n </header>\n\n <div className={styles.sectionContent}>\n {section.content}\n </div>\n\n {showSectionFooter ? (\n <div\n className={styles.sectionFooter}\n role=\"group\"\n aria-label=\"Section actions\"\n data-slot=\"admin-settings-page-section-footer\"\n >\n {section.onRevert ? (\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={section.onRevert}\n disabled={\n (section.busy ?? false) ||\n !(section.dirty ?? false)\n }\n data-slot=\"admin-settings-page-section-revert\"\n >\n {section.revertLabel ?? \"Revert\"}\n </Button>\n ) : null}\n {section.onSave ? (\n <Button\n type=\"button\"\n onClick={() => {\n const result = section.onSave?.()\n if (\n result &&\n typeof (result as Promise<void>).then ===\n \"function\"\n ) {\n // Consumer owns busy state in per-section mode.\n void result\n }\n }}\n disabled={sectionSaveDisabled}\n aria-busy={\n (section.busy ?? false) || undefined\n }\n data-slot=\"admin-settings-page-section-save\"\n >\n {section.saveLabel ?? \"Save\"}\n </Button>\n ) : null}\n </div>\n ) : null}\n </section>\n </React.Fragment>\n )\n })}\n </div>\n </div>\n\n {!perSectionSave && !hideFooter ? (\n <div\n className={styles.footer}\n data-slot=\"admin-settings-page-footer\"\n role=\"group\"\n aria-label=\"Settings actions\"\n >\n <div className={styles.footerCancel}>\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={handleGlobalCancelClick}\n disabled={effectiveBusy}\n data-slot=\"admin-settings-page-cancel\"\n >\n {cancelLabel}\n </Button>\n </div>\n {footerStatus ? (\n <div\n className={styles.footerStatus}\n data-slot=\"admin-settings-page-status\"\n >\n {footerStatus}\n </div>\n ) : null}\n <div className={styles.footerSave}>\n <Button\n type=\"button\"\n onClick={handleGlobalSaveClick}\n disabled={globalSaveDisabled}\n aria-busy={effectiveBusy || undefined}\n data-slot=\"admin-settings-page-save\"\n >\n {saveLabel}\n </Button>\n </div>\n </div>\n ) : null}\n </div>\n\n {!perSectionSave ? (\n <ConfirmDialog\n open={pendingCancel}\n onOpenChange={(next) => {\n if (!next) handleGuardCancel()\n }}\n severity=\"warning\"\n title={unsavedGuardTitle}\n description={unsavedGuardDescription}\n confirmLabel={unsavedGuardConfirmLabel}\n cancelLabel={unsavedGuardCancelLabel}\n onConfirm={handleGuardConfirm}\n onCancel={handleGuardCancel}\n />\n ) : null}\n </>\n )\n})\n\nAdminSettingsPage.displayName = \"AdminSettingsPage\"\n\nexport { AdminSettingsPage }\n"
3138
+ "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"../../lib/utils\"\nimport { PageHeader } from \"../../components/ui/page-header/page-header\"\nimport { Heading } from \"../../components/ui/heading/heading\"\nimport { Text } from \"../../components/ui/text/text\"\nimport { Separator } from \"../../components/ui/separator/separator\"\nimport { Button } from \"../../components/ui/button/button\"\nimport { ConfirmDialog } from \"../../components/ui/confirm-dialog/confirm-dialog\"\nimport styles from \"./admin-settings-page.module.css\"\n\nexport interface AdminSettingsSection {\n /** Stable identifier — used as DOM id anchor and nav highlight key. */\n id: string\n /** Label rendered inside the left nav / top tab bar. */\n label: React.ReactNode\n /** Section heading rendered above the content. */\n title: React.ReactNode\n /** Optional supporting copy rendered below the heading. */\n description?: React.ReactNode\n /** Optional leading icon rendered before the label in the nav. */\n icon?: React.ReactNode\n /** Section body — form fields or arbitrary content. */\n content: React.ReactNode\n\n // ── Per-section save mode ──────────────────────────────────────────────\n /** Dirty flag — only meaningful when the page runs in `perSectionSave` mode. */\n dirty?: boolean\n /** Busy flag — only meaningful when the page runs in `perSectionSave` mode. */\n busy?: boolean\n /** Save handler — only called in `perSectionSave` mode. Async-aware. */\n onSave?: () => void | Promise<void>\n /** Revert handler — only called in `perSectionSave` mode. */\n onRevert?: () => void\n /** Override the per-section save button label. Defaults to \"Save\". */\n saveLabel?: React.ReactNode\n /** Override the per-section revert button label. Defaults to \"Revert\". */\n revertLabel?: React.ReactNode\n\n // ── Grouped nav extras ─────────────────────────────────────────────────\n /** Trailing badge in the nav item — e.g. member count \"8\" or status \"default\".\n * Rendered as a dim trailing element in the side rail; suppressed in the top chip bar. */\n meta?: React.ReactNode\n /** Mutes the nav item's text color via `--text-tertiary` — used for \"add\" / utility actions. */\n muted?: boolean\n}\n\nexport interface AdminSettingsSectionGroup {\n /** Categorical eyebrow label rendered above this group's nav items.\n * Omit to render an ungrouped cluster (no label). */\n label?: React.ReactNode\n /** Ordered list of settings sections that belong to this group. */\n sections: AdminSettingsSection[]\n}\n\nexport interface AdminSettingsPageProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n // ── Header ──────────────────────────────────────────────────────────────\n /** Page title rendered inside the PageHeader. */\n title: React.ReactNode\n /** Optional eyebrow rendered above the title. */\n eyebrow?: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /** Optional breadcrumb node rendered above the title row. */\n breadcrumb?: React.ReactNode\n /** Optional header actions slot rendered on the right side of the header. */\n headerActions?: React.ReactNode\n\n // ── Sections ────────────────────────────────────────────────────────────\n /** Flat ordered list of settings sections. Use when grouping is not needed. */\n sections?: AdminSettingsSection[]\n /** Grouped sections with categorical eyebrow labels.\n * Mutually exclusive with `sections` — if both are passed, `sectionGroups` wins\n * and a dev-mode console.warn fires. */\n sectionGroups?: AdminSettingsSectionGroup[]\n\n // ── Navigation ──────────────────────────────────────────────────────────\n /** Show the section nav. Defaults to true when there is more than one section. */\n showNav?: boolean\n /** Nav position — \"left\" (sticky side rail) or \"top\" (horizontal chip bar). Defaults to \"left\". */\n navPosition?: \"left\" | \"top\"\n\n // ── Mode ────────────────────────────────────────────────────────────────\n /**\n * If true, each section renders its own save/revert footer row and the global\n * sticky footer is not rendered. Defaults to false (global single-save mode).\n */\n perSectionSave?: boolean\n\n // ── Global save footer (single-save mode only) ──────────────────────────\n /** Save handler. Async-aware — a returned Promise drives the save button's pending state. */\n onSave?: () => void | Promise<void>\n /** Cancel handler. Protected by the unsaved-changes guard when `dirty` is true. */\n onCancel?: () => void\n /** Save button label. Defaults to \"Save changes\". */\n saveLabel?: React.ReactNode\n /** Cancel button label. Defaults to \"Cancel\". */\n cancelLabel?: React.ReactNode\n /** Dirty flag — drives the save button disabled state and the cancel guard. */\n dirty?: boolean\n /** Externally-controlled busy state. Overrides internal async pending detection. */\n busy?: boolean\n /** Middle slot inside the sticky footer — e.g. \"Last saved 2 minutes ago\". */\n footerStatus?: React.ReactNode\n /** Hide the global footer. */\n hideFooter?: boolean\n\n // ── Unsaved guard (global cancel only) ──────────────────────────────────\n /** Title of the unsaved-changes confirm dialog. */\n unsavedGuardTitle?: React.ReactNode\n /** Description of the unsaved-changes confirm dialog. */\n unsavedGuardDescription?: React.ReactNode\n /** Confirm (discard) label. Defaults to \"Discard\". */\n unsavedGuardConfirmLabel?: React.ReactNode\n /** Cancel (keep editing) label. Defaults to \"Keep editing\". */\n unsavedGuardCancelLabel?: React.ReactNode\n}\n\nconst DEFAULT_UNSAVED_DESCRIPTION =\n \"You have unsaved changes that will be lost if you leave this page.\"\n\n/** Internal helper: returns the flat section list used by the observer, refs,\n * and main-column renderer. When `sectionGroups` is provided it wins; `sections`\n * is the flat fallback. */\nfunction flattenSections(\n sections: AdminSettingsSection[] | undefined,\n sectionGroups: AdminSettingsSectionGroup[] | undefined\n): AdminSettingsSection[] {\n if (sectionGroups) return sectionGroups.flatMap((g) => g.sections)\n return sections ?? []\n}\n\nconst AdminSettingsPage = React.forwardRef<\n HTMLDivElement,\n AdminSettingsPageProps\n>(function AdminSettingsPage(\n {\n title,\n eyebrow,\n description,\n breadcrumb,\n headerActions,\n sections,\n sectionGroups,\n showNav,\n navPosition = \"left\",\n perSectionSave = false,\n onSave,\n onCancel,\n saveLabel = \"Save changes\",\n cancelLabel = \"Cancel\",\n dirty = false,\n busy,\n footerStatus,\n hideFooter = false,\n unsavedGuardTitle = \"Discard unsaved changes?\",\n unsavedGuardDescription = DEFAULT_UNSAVED_DESCRIPTION,\n unsavedGuardConfirmLabel = \"Discard\",\n unsavedGuardCancelLabel = \"Keep editing\",\n className,\n ...rest\n },\n ref\n) {\n // Dev-mode warning when both props are provided.\n if (\n process.env.NODE_ENV !== \"production\" &&\n sections !== undefined &&\n sectionGroups !== undefined\n ) {\n console.warn(\n \"[AdminSettingsPage] Both `sections` and `sectionGroups` were provided. \" +\n \"`sectionGroups` takes precedence and `sections` is ignored. \" +\n \"Pass only one of these props.\"\n )\n }\n\n const flatSections = flattenSections(sections, sectionGroups)\n const shouldShowNav = showNav ?? flatSections.length > 1\n const firstSectionId = flatSections[0]?.id\n const [activeId, setActiveId] = React.useState<string | undefined>(\n firstSectionId\n )\n const [isGlobalPending, setIsGlobalPending] = React.useState(false)\n const [pendingCancel, setPendingCancel] = React.useState(false)\n const sectionRefs = React.useRef<Map<string, HTMLElement>>(new Map())\n\n const effectiveBusy = busy ?? isGlobalPending\n\n // Intersection observer to highlight the nav item whose section is in view.\n React.useEffect(() => {\n if (!shouldShowNav) return\n if (typeof window === \"undefined\") return\n if (typeof IntersectionObserver === \"undefined\") return\n\n const elements = Array.from(sectionRefs.current.values())\n if (elements.length === 0) return\n\n const observer = new IntersectionObserver(\n (entries) => {\n // Find the topmost visible entry.\n const visible = entries\n .filter((e) => e.isIntersecting)\n .sort(\n (a, b) =>\n a.boundingClientRect.top - b.boundingClientRect.top\n )\n if (visible[0]) {\n const id = (visible[0].target as HTMLElement).id\n if (id) setActiveId(id)\n }\n },\n {\n // Trigger when a section enters the top portion of the scroll container.\n rootMargin: \"0px 0px -60% 0px\",\n threshold: [0, 0.1, 0.5, 1],\n }\n )\n\n elements.forEach((el) => observer.observe(el))\n return () => observer.disconnect()\n }, [shouldShowNav, flatSections])\n\n const registerSectionRef = React.useCallback(\n (id: string) => (node: HTMLElement | null) => {\n const map = sectionRefs.current\n if (node) {\n map.set(id, node)\n } else {\n map.delete(id)\n }\n },\n []\n )\n\n const handleNavClick = React.useCallback(\n (event: React.MouseEvent<HTMLAnchorElement>, id: string) => {\n event.preventDefault()\n const target = sectionRefs.current.get(id)\n if (target) {\n target.scrollIntoView({ block: \"start\", behavior: \"smooth\" })\n // Update hash without jumping.\n if (typeof window !== \"undefined\") {\n window.history.replaceState(null, \"\", `#${id}`)\n }\n setActiveId(id)\n }\n },\n []\n )\n\n const handleGlobalCancelClick = React.useCallback(() => {\n if (effectiveBusy) return\n if (dirty) {\n setPendingCancel(true)\n return\n }\n onCancel?.()\n }, [dirty, effectiveBusy, onCancel])\n\n const handleGlobalSaveClick = React.useCallback(async () => {\n if (!onSave) return\n const result = onSave()\n if (result && typeof (result as Promise<void>).then === \"function\") {\n setIsGlobalPending(true)\n try {\n await result\n setIsGlobalPending(false)\n } catch (err) {\n setIsGlobalPending(false)\n throw err\n }\n }\n }, [onSave])\n\n const handleGuardConfirm = React.useCallback(() => {\n if (pendingCancel) {\n setPendingCancel(false)\n onCancel?.()\n }\n }, [pendingCancel, onCancel])\n\n const handleGuardCancel = React.useCallback(() => {\n setPendingCancel(false)\n }, [])\n\n const globalSaveDisabled = effectiveBusy || !dirty\n\n return (\n <>\n <div\n ref={ref}\n className={cn(\n styles.root,\n shouldShowNav && navPosition === \"left\"\n ? styles.withLeftNav\n : styles.noLeftNav,\n className\n )}\n data-slot=\"admin-settings-page\"\n data-nav-position={shouldShowNav ? navPosition : undefined}\n {...rest}\n >\n {firstSectionId ? (\n <a className={styles.skipLink} href={`#${firstSectionId}`}>\n Skip to content\n </a>\n ) : null}\n\n <PageHeader\n className={styles.header}\n eyebrow={eyebrow}\n title={title}\n description={description}\n breadcrumb={breadcrumb}\n actions={headerActions}\n />\n\n {shouldShowNav && navPosition === \"top\" ? (\n <nav\n aria-label=\"Settings sections\"\n className={styles.topNav}\n data-slot=\"admin-settings-page-nav\"\n >\n <ul className={styles.topNavList}>\n {(sectionGroups ?? (flatSections.length > 0 ? [{ sections: flatSections }] : [])).map((group, gi) => (\n <React.Fragment key={gi}>\n {gi > 0 ? (\n <li\n role=\"separator\"\n aria-orientation=\"vertical\"\n aria-label={typeof group.label === \"string\" ? group.label : undefined}\n className={styles.topNavSeparator}\n />\n ) : null}\n {group.sections.map((section) => {\n const isActive = section.id === activeId\n return (\n <li key={section.id} className={styles.topNavItem}>\n <a\n href={`#${section.id}`}\n className={cn(\n styles.topNavLink,\n isActive && styles.navLinkActive\n )}\n aria-current={isActive ? \"true\" : undefined}\n onClick={(e) => handleNavClick(e, section.id)}\n >\n {section.icon ? (\n <span\n className={styles.navIcon}\n aria-hidden=\"true\"\n >\n {section.icon}\n </span>\n ) : null}\n <span className={styles.navLabel}>\n {section.label}\n </span>\n </a>\n </li>\n )\n })}\n </React.Fragment>\n ))}\n </ul>\n </nav>\n ) : null}\n\n <div className={styles.body}>\n {shouldShowNav && navPosition === \"left\" ? (\n <nav\n aria-label=\"Settings sections\"\n className={styles.sideNav}\n data-slot=\"admin-settings-page-nav\"\n >\n <ul className={styles.sideNavList}>\n {(sectionGroups ?? (flatSections.length > 0 ? [{ sections: flatSections }] : [])).map((group, gi) => (\n <li key={gi} className={styles.sideNavGroup}>\n {group.label ? (\n <div\n className={styles.navGroupLabel}\n aria-hidden=\"true\"\n >\n {group.label}\n </div>\n ) : null}\n <ul\n className={styles.sideNavGroupList}\n role=\"list\"\n aria-label={typeof group.label === \"string\" ? group.label : undefined}\n >\n {group.sections.map((section) => {\n const isActive = section.id === activeId\n return (\n <li key={section.id} className={styles.sideNavItem}>\n <a\n href={`#${section.id}`}\n className={cn(\n styles.sideNavLink,\n isActive && styles.navLinkActive,\n section.muted && styles.navItemMuted\n )}\n aria-current={isActive ? \"true\" : undefined}\n onClick={(e) => handleNavClick(e, section.id)}\n >\n {section.icon ? (\n <span\n className={styles.navIcon}\n aria-hidden=\"true\"\n >\n {section.icon}\n </span>\n ) : null}\n <span className={styles.navLabel}>\n {section.label}\n </span>\n {section.meta !== undefined ? (\n <span className={styles.navItemMeta}>\n {section.meta}\n </span>\n ) : null}\n </a>\n </li>\n )\n })}\n </ul>\n </li>\n ))}\n </ul>\n </nav>\n ) : null}\n\n <div\n className={styles.main}\n data-slot=\"admin-settings-page-main\"\n >\n {flatSections.map((section, index) => {\n const titleId = `${section.id}-title`\n const showSectionFooter =\n perSectionSave && (section.onSave || section.onRevert)\n const sectionSaveDisabled =\n (section.busy ?? false) || !(section.dirty ?? false)\n\n return (\n <React.Fragment key={section.id}>\n {index > 0 ? (\n <Separator className={styles.sectionSeparator} />\n ) : null}\n <section\n id={section.id}\n ref={registerSectionRef(section.id)}\n aria-labelledby={titleId}\n className={styles.section}\n data-slot=\"admin-settings-page-section\"\n >\n <header className={styles.sectionHeader}>\n <Heading\n level={2}\n size=\"lg\"\n id={titleId}\n className={styles.sectionTitle}\n >\n {section.title}\n </Heading>\n {section.description ? (\n <Text\n size=\"sm\"\n color=\"secondary\"\n className={styles.sectionDescription}\n >\n {section.description}\n </Text>\n ) : null}\n </header>\n\n <div className={styles.sectionContent}>\n {section.content}\n </div>\n\n {showSectionFooter ? (\n <div\n className={styles.sectionFooter}\n role=\"group\"\n aria-label=\"Section actions\"\n data-slot=\"admin-settings-page-section-footer\"\n >\n {section.onRevert ? (\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={section.onRevert}\n disabled={\n (section.busy ?? false) ||\n !(section.dirty ?? false)\n }\n data-slot=\"admin-settings-page-section-revert\"\n >\n {section.revertLabel ?? \"Revert\"}\n </Button>\n ) : null}\n {section.onSave ? (\n <Button\n type=\"button\"\n onClick={() => {\n const result = section.onSave?.()\n if (\n result &&\n typeof (result as Promise<void>).then ===\n \"function\"\n ) {\n // Consumer owns busy state in per-section mode.\n void result\n }\n }}\n disabled={sectionSaveDisabled}\n aria-busy={\n (section.busy ?? false) || undefined\n }\n data-slot=\"admin-settings-page-section-save\"\n >\n {section.saveLabel ?? \"Save\"}\n </Button>\n ) : null}\n </div>\n ) : null}\n </section>\n </React.Fragment>\n )\n })}\n </div>\n </div>\n\n {!perSectionSave && !hideFooter ? (\n <div\n className={styles.footer}\n data-slot=\"admin-settings-page-footer\"\n role=\"group\"\n aria-label=\"Settings actions\"\n >\n <div className={styles.footerCancel}>\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={handleGlobalCancelClick}\n disabled={effectiveBusy}\n data-slot=\"admin-settings-page-cancel\"\n >\n {cancelLabel}\n </Button>\n </div>\n {footerStatus ? (\n <div\n className={styles.footerStatus}\n data-slot=\"admin-settings-page-status\"\n >\n {footerStatus}\n </div>\n ) : null}\n <div className={styles.footerSave}>\n <Button\n type=\"button\"\n onClick={handleGlobalSaveClick}\n disabled={globalSaveDisabled}\n aria-busy={effectiveBusy || undefined}\n data-slot=\"admin-settings-page-save\"\n >\n {saveLabel}\n </Button>\n </div>\n </div>\n ) : null}\n </div>\n\n {!perSectionSave ? (\n <ConfirmDialog\n open={pendingCancel}\n onOpenChange={(next) => {\n if (!next) handleGuardCancel()\n }}\n severity=\"warning\"\n title={unsavedGuardTitle}\n description={unsavedGuardDescription}\n confirmLabel={unsavedGuardConfirmLabel}\n cancelLabel={unsavedGuardCancelLabel}\n onConfirm={handleGuardConfirm}\n onCancel={handleGuardCancel}\n />\n ) : null}\n </>\n )\n})\n\nAdminSettingsPage.displayName = \"AdminSettingsPage\"\n\nexport { AdminSettingsPage }\n"
2968
3139
  },
2969
3140
  {
2970
3141
  "path": "blocks/admin-settings-page/admin-settings-page.module.css",
2971
3142
  "type": "registry:block",
2972
- "content": "/* Admin Settings Page\n * Long scrollable settings archetype: PageHeader → optional nav + main\n * column of stacked sections → optional sticky global save footer. Nav can\n * live to the left (sticky rail) or above (horizontal chip bar). At narrow\n * container widths the side nav collapses above the main column.\n */\n\n.root {\n container-type: inline-size;\n display: flex;\n flex-direction: column;\n min-height: 0;\n width: 100%;\n gap: 0;\n background-color: var(--surface-page, #ffffff);\n color: var(--text-primary, #111827);\n scroll-behavior: smooth;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .root {\n scroll-behavior: auto;\n }\n}\n\n/* Visually hidden skip link — becomes visible on focus so keyboard users\n * can jump past the header and nav into the first section. */\n.skipLink {\n position: absolute;\n left: var(--spacing-2, 0.5rem);\n top: var(--spacing-2, 0.5rem);\n z-index: 2;\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n background-color: var(--surface-page, #ffffff);\n color: var(--text-primary, #111827);\n border: 1px solid var(--border-default, #d1d5db);\n border-radius: var(--radius-md, 0.5rem);\n box-shadow: var(--shadow-sm);\n font-size: var(--font-size-sm, 0.875rem);\n text-decoration: none;\n transform: translateY(-200%);\n transition:\n transform var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.skipLink:focus-visible {\n transform: translateY(0);\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.header {\n padding: var(--spacing-6, 1.5rem);\n border-bottom: 1px solid var(--border-muted, #e5e7eb);\n}\n\n/* Top nav variant — horizontal chip bar rendered below the header. */\n.topNav {\n flex: 0 0 auto;\n padding: var(--spacing-3, 0.75rem) var(--spacing-6, 1.5rem);\n border-bottom: 1px solid var(--border-muted, #e5e7eb);\n overflow-x: auto;\n overflow-y: hidden;\n}\n\n.topNavList {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.topNavItem {\n flex: 0 0 auto;\n}\n\n.topNavLink {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n border-radius: var(--radius-md, 0.5rem);\n color: var(--text-secondary, #6b7280);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: 500;\n text-decoration: none;\n white-space: nowrap;\n transition:\n background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.topNavLink:hover {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n color: var(--text-primary, #111827);\n}\n\n.topNavLink:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n/* Body = optional left nav + main column. */\n.body {\n flex: 1 1 auto;\n min-height: 0;\n display: grid;\n grid-template-columns: 1fr;\n width: 100%;\n}\n\n.withLeftNav .body {\n grid-template-columns: minmax(12rem, 16rem) 1fr;\n}\n\n/* Side nav — sticky rail pinned to the top of the scroll container. */\n.sideNav {\n position: sticky;\n top: 0;\n align-self: start;\n padding: var(--spacing-6, 1.5rem) var(--spacing-4, 1rem)\n var(--spacing-6, 1.5rem) var(--spacing-6, 1.5rem);\n border-right: 1px solid var(--border-muted, #e5e7eb);\n max-height: 100vh;\n overflow-y: auto;\n}\n\n.sideNavList {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-1, 0.25rem);\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.sideNavItem {\n display: block;\n}\n\n.sideNavLink {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n width: 100%;\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n border-radius: var(--radius-md, 0.5rem);\n color: var(--text-secondary, #6b7280);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: 500;\n text-decoration: none;\n transition:\n background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.sideNavLink:hover {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n color: var(--text-primary, #111827);\n}\n\n.sideNavLink:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.navLinkActive {\n background-color: var(--surface-interactive-active, #e5e7eb);\n color: var(--text-primary, #111827);\n font-weight: 600;\n}\n\n.navIcon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n width: 1em;\n height: 1em;\n font-size: var(--font-size-base, 1rem);\n line-height: 1;\n}\n\n.navLabel {\n display: inline-flex;\n align-items: center;\n min-width: 0;\n}\n\n/* Main column — stacked sections. */\n.main {\n min-width: 0;\n display: flex;\n flex-direction: column;\n padding: var(--spacing-6, 1.5rem);\n gap: var(--spacing-6, 1.5rem);\n}\n\n.section {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n scroll-margin-top: var(--spacing-6, 1.5rem);\n}\n\n.sectionHeader {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-1, 0.25rem);\n}\n\n.sectionTitle {\n margin: 0;\n}\n\n.sectionDescription {\n margin: 0;\n}\n\n.sectionContent {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n}\n\n.sectionSeparator {\n margin: 0;\n}\n\n.sectionFooter {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-end;\n gap: var(--spacing-2, 0.5rem);\n padding-top: var(--spacing-2, 0.5rem);\n}\n\n/* Sticky global footer — pinned to the bottom of the scroll container. */\n.footer {\n position: sticky;\n bottom: 0;\n z-index: 1;\n flex: 0 0 auto;\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: var(--spacing-3, 0.75rem);\n padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);\n border-top: 1px solid var(--border-muted, #e5e7eb);\n background-color: var(--surface-page, #ffffff);\n box-shadow: var(--shadow-sm);\n}\n\n.footerCancel {\n flex: 0 0 auto;\n}\n\n.footerStatus {\n flex: 1 1 auto;\n min-width: 0;\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n text-align: center;\n}\n\n.footerSave {\n flex: 0 0 auto;\n margin-left: auto;\n}\n\n.footerStatus + .footerSave {\n margin-left: 0;\n}\n\n/* Narrow container — collapse left nav above the main column and let the\n * footer wrap the status below the action buttons. */\n@container (max-width: 48rem) {\n .withLeftNav .body {\n grid-template-columns: 1fr;\n }\n\n .sideNav {\n position: static;\n max-height: none;\n padding: var(--spacing-3, 0.75rem) var(--spacing-6, 1.5rem);\n border-right: none;\n border-bottom: 1px solid var(--border-muted, #e5e7eb);\n overflow-x: auto;\n overflow-y: hidden;\n }\n\n .sideNavList {\n flex-direction: row;\n gap: var(--spacing-2, 0.5rem);\n }\n\n .sideNavItem {\n flex: 0 0 auto;\n }\n\n .footer {\n flex-wrap: wrap;\n }\n\n .footerStatus {\n order: 3;\n flex-basis: 100%;\n text-align: left;\n }\n}\n"
3143
+ "content": "/* Admin Settings Page\n * Long scrollable settings archetype: PageHeader → optional nav + main\n * column of stacked sections → optional sticky global save footer. Nav can\n * live to the left (sticky rail) or above (horizontal chip bar). At narrow\n * container widths the side nav collapses above the main column.\n */\n\n.root {\n container-type: inline-size;\n display: flex;\n flex-direction: column;\n min-height: 0;\n width: 100%;\n gap: 0;\n background-color: var(--surface-page, #ffffff);\n color: var(--text-primary, #111827);\n scroll-behavior: smooth;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .root {\n scroll-behavior: auto;\n }\n}\n\n/* Visually hidden skip link — becomes visible on focus so keyboard users\n * can jump past the header and nav into the first section. */\n.skipLink {\n position: absolute;\n left: var(--spacing-2, 0.5rem);\n top: var(--spacing-2, 0.5rem);\n z-index: 2;\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n background-color: var(--surface-page, #ffffff);\n color: var(--text-primary, #111827);\n border: 1px solid var(--border-default, #d1d5db);\n border-radius: var(--radius-md, 0.5rem);\n box-shadow: var(--shadow-sm);\n font-size: var(--font-size-sm, 0.875rem);\n text-decoration: none;\n transform: translateY(-200%);\n transition:\n transform var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.skipLink:focus-visible {\n transform: translateY(0);\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.header {\n padding: var(--spacing-6, 1.5rem);\n border-bottom: 1px solid var(--border-muted, #e5e7eb);\n}\n\n/* Top nav variant — horizontal chip bar rendered below the header. */\n.topNav {\n flex: 0 0 auto;\n padding: var(--spacing-3, 0.75rem) var(--spacing-6, 1.5rem);\n border-bottom: 1px solid var(--border-muted, #e5e7eb);\n overflow-x: auto;\n overflow-y: hidden;\n}\n\n.topNavList {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.topNavItem {\n flex: 0 0 auto;\n}\n\n/* Group separator in the top chip bar — 1px vertical hairline between clusters. */\n.topNavSeparator {\n flex: 0 0 auto;\n width: var(--stroke-width-thin, 1px);\n align-self: stretch;\n background-color: var(--border-muted, #e5e7eb);\n margin: 0 var(--spacing-2, 0.5rem);\n list-style: none;\n}\n\n.topNavLink {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n border-radius: var(--radius-md, 0.5rem);\n color: var(--text-secondary, #6b7280);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: 500;\n text-decoration: none;\n white-space: nowrap;\n transition:\n background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.topNavLink:hover {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n color: var(--text-primary, #111827);\n}\n\n.topNavLink:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n/* Body = optional left nav + main column. */\n.body {\n flex: 1 1 auto;\n min-height: 0;\n display: grid;\n grid-template-columns: 1fr;\n width: 100%;\n}\n\n.withLeftNav .body {\n grid-template-columns: minmax(12rem, 16rem) 1fr;\n}\n\n/* Side nav — sticky rail pinned to the top of the scroll container. */\n.sideNav {\n position: sticky;\n top: 0;\n align-self: start;\n padding: var(--spacing-6, 1.5rem) var(--spacing-4, 1rem)\n var(--spacing-6, 1.5rem) var(--spacing-6, 1.5rem);\n border-right: 1px solid var(--border-muted, #e5e7eb);\n max-height: 100vh;\n overflow-y: auto;\n}\n\n.sideNavList {\n display: flex;\n flex-direction: column;\n gap: 0;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n/* Group container — wraps the optional eyebrow label + a list of nav items. */\n.sideNavGroup {\n display: flex;\n flex-direction: column;\n}\n\n/* Categorical eyebrow label above a group's nav items.\n * Reuses the same token set as PageHeader.eyebrow for visual consistency. */\n.navGroupLabel {\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-semibold, 600);\n letter-spacing: var(--letter-spacing-wide, 0.05em);\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n padding: var(--spacing-5, 1.25rem) var(--spacing-3, 0.75rem)\n var(--spacing-2, 0.5rem);\n line-height: var(--line-height-tight, 1.25);\n}\n\n/* Nested list of sections within a group — no bullet, no extra padding. */\n.sideNavGroupList {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-1, 0.25rem);\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.sideNavItem {\n display: block;\n}\n\n/* Muted nav item — dim text for \"add\" / utility actions. */\n.navItemMuted.sideNavLink {\n color: var(--text-tertiary, #6b7280);\n}\n\n/* Trailing badge inside a side-nav link — e.g. member count or status. */\n.navItemMeta {\n margin-left: auto;\n flex: 0 0 auto;\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--text-tertiary, #6b7280);\n line-height: 1;\n}\n\n.sideNavLink {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n width: 100%;\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n border-radius: var(--radius-md, 0.5rem);\n color: var(--text-secondary, #6b7280);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: 500;\n text-decoration: none;\n transition:\n background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.sideNavLink:hover {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n color: var(--text-primary, #111827);\n}\n\n.sideNavLink:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.navLinkActive {\n background-color: var(--surface-interactive-active, #e5e7eb);\n color: var(--text-primary, #111827);\n font-weight: 600;\n}\n\n.navIcon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n width: 1em;\n height: 1em;\n font-size: var(--font-size-base, 1rem);\n line-height: 1;\n}\n\n.navLabel {\n display: inline-flex;\n align-items: center;\n min-width: 0;\n}\n\n/* Main column — stacked sections. */\n.main {\n min-width: 0;\n display: flex;\n flex-direction: column;\n padding: var(--spacing-6, 1.5rem);\n gap: var(--spacing-6, 1.5rem);\n}\n\n.section {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n scroll-margin-top: var(--spacing-6, 1.5rem);\n}\n\n.sectionHeader {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-1, 0.25rem);\n}\n\n.sectionTitle {\n margin: 0;\n}\n\n.sectionDescription {\n margin: 0;\n}\n\n.sectionContent {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n}\n\n.sectionSeparator {\n margin: 0;\n}\n\n.sectionFooter {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-end;\n gap: var(--spacing-2, 0.5rem);\n padding-top: var(--spacing-2, 0.5rem);\n}\n\n/* Sticky global footer — pinned to the bottom of the scroll container. */\n.footer {\n position: sticky;\n bottom: 0;\n z-index: 1;\n flex: 0 0 auto;\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: var(--spacing-3, 0.75rem);\n padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);\n border-top: 1px solid var(--border-muted, #e5e7eb);\n background-color: var(--surface-page, #ffffff);\n box-shadow: var(--shadow-sm);\n}\n\n.footerCancel {\n flex: 0 0 auto;\n}\n\n.footerStatus {\n flex: 1 1 auto;\n min-width: 0;\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n text-align: center;\n}\n\n.footerSave {\n flex: 0 0 auto;\n margin-left: auto;\n}\n\n.footerStatus + .footerSave {\n margin-left: 0;\n}\n\n/* Narrow container — collapse left nav above the main column and let the\n * footer wrap the status below the action buttons. */\n@container (max-width: 48rem) {\n .withLeftNav .body {\n grid-template-columns: 1fr;\n }\n\n .sideNav {\n position: static;\n max-height: none;\n padding: var(--spacing-3, 0.75rem) var(--spacing-6, 1.5rem);\n border-right: none;\n border-bottom: 1px solid var(--border-muted, #e5e7eb);\n overflow-x: auto;\n overflow-y: hidden;\n }\n\n .sideNavList {\n flex-direction: row;\n gap: var(--spacing-2, 0.5rem);\n align-items: center;\n }\n\n /* Groups collapse into an inline row — no visible grouping at narrow width. */\n .sideNavGroup {\n flex-direction: row;\n gap: var(--spacing-2, 0.5rem);\n align-items: center;\n }\n\n /* Hide eyebrow labels in collapsed horizontal strip — no room for them. */\n .navGroupLabel {\n display: none;\n }\n\n .sideNavGroupList {\n flex-direction: row;\n gap: var(--spacing-2, 0.5rem);\n }\n\n .sideNavItem {\n flex: 0 0 auto;\n }\n\n /* Hide meta badges in collapsed strip — space is too constrained. */\n .navItemMeta {\n display: none;\n }\n\n .footer {\n flex-wrap: wrap;\n }\n\n .footerStatus {\n order: 3;\n flex-basis: 100%;\n text-align: left;\n }\n}\n"
2973
3144
  }
2974
3145
  ]
2975
3146
  },
@@ -3001,6 +3172,33 @@
3001
3172
  }
3002
3173
  ]
3003
3174
  },
3175
+ {
3176
+ "name": "workspace-switcher",
3177
+ "type": "registry:block",
3178
+ "description": "Sidebar-header workspace switcher composing a button trigger and a DropdownMenu of available workspaces. Displays the current workspace's avatar, name, optional plan/role line, and a caret; opens to a list with the current workspace indicated by a check. Drop-in for the AdminShell logo slot in multi-tenant admin apps.",
3179
+ "category": "admin",
3180
+ "dependencies": [
3181
+ "@loworbitstudio/visor-core",
3182
+ "@phosphor-icons/react"
3183
+ ],
3184
+ "registryDependencies": [
3185
+ "utils",
3186
+ "avatar",
3187
+ "dropdown-menu"
3188
+ ],
3189
+ "files": [
3190
+ {
3191
+ "path": "blocks/workspace-switcher/workspace-switcher.tsx",
3192
+ "type": "registry:block",
3193
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { CaretUpDownIcon, CheckIcon } 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 DropdownMenuTrigger,\n} from \"../../components/ui/dropdown-menu/dropdown-menu\"\nimport styles from \"./workspace-switcher.module.css\"\n\nexport interface WorkspaceItem {\n /** Stable id passed to onSelect. */\n id: string\n /** Display name. Single line in trigger; single line + ellipsis in menu. */\n name: string\n /** Secondary line — plan + region, role, etc. Optional. CSS-truncated. */\n plan?: string\n /** Required. Used for AvatarFallback. Caller controls derivation rules. */\n initials: string\n /** Optional org logo. Rendered via AvatarImage; falls back to initials. */\n imageUrl?: string\n}\n\nexport interface WorkspaceSwitcherProps {\n /** The currently active workspace. Fully controlled by the parent. */\n current: WorkspaceItem\n /** All workspaces available to the user (may include `current`; may be empty). */\n workspaces: WorkspaceItem[]\n /** Fires when a workspace item is activated (click or keyboard). */\n onSelect: (id: string) => void\n /** Trigger presentation. \"full\" = avatar+name+plan+caret. \"compact\" = avatar+caret. Default \"full\". */\n trigger?: \"full\" | \"compact\"\n /** Forwarded to the trigger button's root. */\n className?: string\n}\n\nexport function WorkspaceSwitcher({\n current,\n workspaces,\n onSelect,\n trigger = \"full\",\n className,\n}: WorkspaceSwitcherProps) {\n // Other workspaces are all workspaces except the current one\n const otherWorkspaces = workspaces.filter((w) => w.id !== current.id)\n const hasOthers = otherWorkspaces.length > 0\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <button\n type=\"button\"\n aria-label={`Switch workspace · current: ${current.name}`}\n data-trigger={trigger}\n data-slot=\"workspace-switcher-trigger\"\n className={cn(\n styles.trigger,\n trigger === \"compact\" && styles.triggerCompact,\n className\n )}\n >\n <Avatar size=\"sm\" className={styles.triggerAvatar} aria-hidden=\"true\">\n {current.imageUrl ? (\n <AvatarImage src={current.imageUrl} alt=\"\" />\n ) : null}\n <AvatarFallback aria-hidden=\"true\">{current.initials}</AvatarFallback>\n </Avatar>\n {trigger === \"full\" ? (\n <span className={styles.triggerText} aria-hidden=\"true\">\n <span className={styles.triggerName}>{current.name}</span>\n {current.plan ? (\n <span className={styles.triggerPlan}>{current.plan}</span>\n ) : null}\n </span>\n ) : null}\n <CaretUpDownIcon\n size={12}\n weight=\"regular\"\n aria-hidden=\"true\"\n className={styles.triggerCaret}\n />\n </button>\n </DropdownMenuTrigger>\n\n <DropdownMenuContent align=\"start\" className={styles.content}>\n {/* Current workspace — always shown first with check indicator */}\n <DropdownMenuItem\n onSelect={() => onSelect(current.id)}\n className={styles.item}\n data-slot=\"workspace-switcher-item\"\n data-current=\"true\"\n >\n <Avatar size=\"sm\" className={styles.itemAvatar} aria-hidden=\"true\">\n {current.imageUrl ? (\n <AvatarImage src={current.imageUrl} alt=\"\" />\n ) : null}\n <AvatarFallback aria-hidden=\"true\">{current.initials}</AvatarFallback>\n </Avatar>\n <span className={styles.itemText}>\n <span className={styles.itemName}>{current.name}</span>\n {current.plan ? (\n <span className={styles.itemPlan}>{current.plan}</span>\n ) : null}\n </span>\n <CheckIcon\n size={14}\n weight=\"regular\"\n aria-hidden=\"true\"\n className={styles.itemCheck}\n />\n </DropdownMenuItem>\n\n {/* Other workspaces or empty state */}\n {hasOthers ? (\n otherWorkspaces.map((workspace) => (\n <DropdownMenuItem\n key={workspace.id}\n onSelect={() => onSelect(workspace.id)}\n className={styles.item}\n data-slot=\"workspace-switcher-item\"\n >\n <Avatar size=\"sm\" className={styles.itemAvatar} aria-hidden=\"true\">\n {workspace.imageUrl ? (\n <AvatarImage src={workspace.imageUrl} alt=\"\" />\n ) : null}\n <AvatarFallback aria-hidden=\"true\">{workspace.initials}</AvatarFallback>\n </Avatar>\n <span className={styles.itemText}>\n <span className={styles.itemName}>{workspace.name}</span>\n {workspace.plan ? (\n <span className={styles.itemPlan}>{workspace.plan}</span>\n ) : null}\n </span>\n </DropdownMenuItem>\n ))\n ) : (\n <DropdownMenuItem\n disabled\n className={cn(styles.item, styles.emptyItem)}\n data-slot=\"workspace-switcher-empty\"\n >\n No other workspaces\n </DropdownMenuItem>\n )}\n </DropdownMenuContent>\n </DropdownMenu>\n )\n}\n"
3194
+ },
3195
+ {
3196
+ "path": "blocks/workspace-switcher/workspace-switcher.module.css",
3197
+ "type": "registry:block",
3198
+ "content": "/* Trigger button */\n.trigger {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n width: 100%;\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n 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.triggerCompact {\n width: auto;\n padding: var(--spacing-2, 0.5rem);\n}\n\n.triggerAvatar {\n flex-shrink: 0;\n}\n\n.triggerText {\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.triggerPlan {\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 white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin-top: var(--spacing-0-5, 0.125rem);\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.triggerCompact .triggerCaret {\n margin-inline-start: var(--spacing-1, 0.25rem);\n}\n\n/* Menu content */\n.content {\n min-width: 14rem;\n}\n\n/* Menu item layout (composes onto DropdownMenuItem default styles) */\n.item {\n gap: var(--spacing-2, 0.5rem);\n}\n\n.itemAvatar {\n flex-shrink: 0;\n}\n\n.itemText {\n display: flex;\n flex-direction: column;\n flex: 1 1 auto;\n min-width: 0;\n line-height: 1.2;\n}\n\n.itemName {\n font-weight: var(--font-weight-medium, 500);\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-primary, #111827);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.itemPlan {\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 white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin-top: var(--spacing-0-5, 0.125rem);\n}\n\n.itemCheck {\n flex-shrink: 0;\n margin-inline-start: auto;\n color: var(--text-secondary, #6b7280);\n}\n\n.emptyItem {\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-tertiary, var(--text-secondary, #6b7280));\n}\n"
3199
+ }
3200
+ ]
3201
+ },
3004
3202
  {
3005
3203
  "name": "configuration-panel",
3006
3204
  "type": "registry:block",
@@ -3383,6 +3581,31 @@
3383
3581
  }
3384
3582
  ]
3385
3583
  },
3584
+ {
3585
+ "name": "chip-group",
3586
+ "type": "registry:block",
3587
+ "description": "A composable container managing selection state for ChoiceChip (single-select) and FilterChip (multi-select) chips. Mirrors Flutter Material's chip selection model with type=\"single\" (radio) and type=\"multiple\" (checkbox) modes.",
3588
+ "category": "form",
3589
+ "dependencies": [
3590
+ "@loworbitstudio/visor-core"
3591
+ ],
3592
+ "registryDependencies": [
3593
+ "utils",
3594
+ "chip"
3595
+ ],
3596
+ "files": [
3597
+ {
3598
+ "path": "blocks/chip-group/chip-group.tsx",
3599
+ "type": "registry:block",
3600
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"../../lib/utils\"\nimport styles from \"./chip-group.module.css\"\n\n/* ─── Context ────────────────────────────────────────────────────────── */\n\ntype ChipGroupContextValue = {\n type: \"single\" | \"multiple\"\n value: string[]\n onValueChange: (value: string[]) => void\n}\n\nconst ChipGroupContext = React.createContext<ChipGroupContextValue | null>(null)\n\nexport function useChipGroup() {\n return React.useContext(ChipGroupContext)\n}\n\n/* ─── ChipGroup ──────────────────────────────────────────────────────── */\n\nexport interface ChipGroupProps {\n /**\n * \"single\" — acts like a radio group: selecting one deselects the others.\n * \"multiple\" — acts like a checkbox group: each chip toggles independently.\n */\n type: \"single\" | \"multiple\"\n /**\n * Controlled value. For \"single\", at most one string. For \"multiple\", any\n * number of strings. Pass an empty array for \"no selection\".\n */\n value?: string[]\n /**\n * Default value for uncontrolled usage.\n */\n defaultValue?: string[]\n /**\n * Fires with the new value array after any selection change.\n */\n onValueChange?: (value: string[]) => void\n /** Layout direction. Defaults to \"horizontal\". */\n direction?: \"horizontal\" | \"vertical\"\n /** Extra class forwarded to the root element. */\n className?: string\n children: React.ReactNode\n /** Accessible label describing the group's purpose. */\n \"aria-label\"?: string\n /** Points to a labelling element when aria-label is insufficient. */\n \"aria-labelledby\"?: string\n}\n\nconst ChipGroup = React.forwardRef<HTMLDivElement, ChipGroupProps>(\n (\n {\n type,\n value: controlledValue,\n defaultValue,\n onValueChange,\n direction = \"horizontal\",\n className,\n children,\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledBy,\n },\n ref,\n ) => {\n const isControlled = controlledValue !== undefined\n const [internalValue, setInternalValue] = React.useState<string[]>(\n defaultValue ?? [],\n )\n\n const value = isControlled ? controlledValue : internalValue\n\n const handleValueChange = React.useCallback(\n (newValue: string[]) => {\n if (!isControlled) {\n setInternalValue(newValue)\n }\n onValueChange?.(newValue)\n },\n [isControlled, onValueChange],\n )\n\n const contextValue = React.useMemo(\n () => ({ type, value, onValueChange: handleValueChange }),\n [type, value, handleValueChange],\n )\n\n return (\n <ChipGroupContext.Provider value={contextValue}>\n <div\n ref={ref}\n role=\"group\"\n data-slot=\"chip-group\"\n data-type={type}\n data-direction={direction}\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledBy}\n className={cn(\n styles.root,\n direction === \"vertical\" && styles.vertical,\n className,\n )}\n >\n {children}\n </div>\n </ChipGroupContext.Provider>\n )\n },\n)\nChipGroup.displayName = \"ChipGroup\"\n\n/* ─── ChipGroupItem ──────────────────────────────────────────────────── */\n\nexport interface ChipGroupItemProps {\n /**\n * The value this item represents. Must be unique within the group.\n */\n value: string\n /** Whether this item is individually disabled. */\n disabled?: boolean\n className?: string\n children: React.ReactNode\n}\n\n/**\n * ChipGroupItem wraps any chip (ChoiceChip or FilterChip) in the group context.\n * It injects `selected`, `onPressed`, and `value` props automatically.\n */\nconst ChipGroupItem = React.forwardRef<HTMLElement, ChipGroupItemProps>(\n ({ value, disabled, className, children }, ref) => {\n const ctx = React.useContext(ChipGroupContext)\n const isSelected = ctx ? ctx.value.includes(value) : false\n\n const handlePress = React.useCallback(() => {\n if (!ctx || disabled) return\n if (ctx.type === \"single\") {\n ctx.onValueChange([value])\n } else {\n const next = ctx.value.includes(value)\n ? ctx.value.filter((v) => v !== value)\n : [...ctx.value, value]\n ctx.onValueChange(next)\n }\n }, [ctx, value, disabled])\n\n // Clone the child chip injecting the managed props\n const child = React.Children.only(children) as React.ReactElement<\n Record<string, unknown>\n >\n\n return React.cloneElement(child, {\n ref,\n value,\n selected: isSelected,\n onPressed: handlePress,\n disabled: disabled ?? child.props.disabled,\n className: cn(child.props.className as string | undefined, className),\n })\n },\n)\nChipGroupItem.displayName = \"ChipGroupItem\"\n\nexport { ChipGroup, ChipGroupItem }\n"
3601
+ },
3602
+ {
3603
+ "path": "blocks/chip-group/chip-group.module.css",
3604
+ "type": "registry:block",
3605
+ "content": "/* ChipGroup root */\n.root {\n display: flex;\n flex-direction: row;\n flex-wrap: wrap;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n}\n\n.vertical {\n flex-direction: column;\n align-items: flex-start;\n}\n"
3606
+ }
3607
+ ]
3608
+ },
3386
3609
  {
3387
3610
  "name": "cta-section",
3388
3611
  "type": "registry:block",
@@ -3586,7 +3809,7 @@
3586
3809
  {
3587
3810
  "path": "components/devtools/source-inspector/source-inspector.tsx",
3588
3811
  "type": "registry:devtool",
3589
- "content": "\"use client\"\n\nimport * as React from \"react\"\nimport styles from \"./source-inspector.module.css\"\nimport {\n classifyFile,\n DEFAULT_CLASSIFIERS,\n type Classifiers,\n type SourceLabel,\n} from \"./classify\"\n\nexport type { Classifiers, SourceLabel } from \"./classify\"\nexport { classifyFile, DEFAULT_CLASSIFIERS } from \"./classify\"\n\nexport type Mode = \"off\" | \"highlight-visor\" | \"highlight-non-visor\"\n\nconst MODE_CYCLE: Record<Mode, Mode> = {\n off: \"highlight-visor\",\n \"highlight-visor\": \"highlight-non-visor\",\n \"highlight-non-visor\": \"off\",\n}\n\ninterface SourceInspectorContextValue {\n mode: Mode\n setMode: (mode: Mode) => void\n cycleMode: () => void\n}\n\nexport const SourceInspectorContext =\n React.createContext<SourceInspectorContextValue | null>(null)\n\nexport interface SourceInspectorProviderProps {\n defaultMode?: Mode\n mode?: Mode\n onModeChange?: (mode: Mode) => void\n children?: React.ReactNode\n}\n\nexport function SourceInspectorProvider({\n defaultMode = \"off\",\n mode: controlledMode,\n onModeChange,\n children,\n}: SourceInspectorProviderProps) {\n const [internalMode, setInternalMode] = React.useState<Mode>(defaultMode)\n const isControlled = controlledMode !== undefined\n const mode = isControlled ? controlledMode : internalMode\n\n const setMode = React.useCallback(\n (next: Mode) => {\n if (!isControlled) setInternalMode(next)\n onModeChange?.(next)\n },\n [isControlled, onModeChange],\n )\n\n const cycleMode = React.useCallback(() => {\n setMode(MODE_CYCLE[mode])\n }, [mode, setMode])\n\n const value = React.useMemo(\n () => ({ mode, setMode, cycleMode }),\n [mode, setMode, cycleMode],\n )\n\n return (\n <SourceInspectorContext.Provider value={value}>\n {children}\n </SourceInspectorContext.Provider>\n )\n}\n\nexport function useSourceInspector(): SourceInspectorContextValue {\n const ctx = React.useContext(SourceInspectorContext)\n if (!ctx) {\n throw new Error(\n \"useSourceInspector must be used within <SourceInspectorProvider> or <SourceInspector>\",\n )\n }\n return ctx\n}\n\nexport function useOptionalSourceInspector(): SourceInspectorContextValue | null {\n return React.useContext(SourceInspectorContext)\n}\n\nexport interface SourceInspectorProps extends SourceInspectorProviderProps {\n classifiers?: Classifiers\n hotkey?: string | null\n debounceMs?: number\n}\n\nconst DATA_ATTR = \"data-source\"\n\ninterface FiberLike {\n child?: FiberLike | null\n sibling?: FiberLike | null\n stateNode?: unknown\n _debugSource?: { fileName?: string } | null\n return?: FiberLike | null\n}\n\nfunction getFiberFromNode(node: Element): FiberLike | null {\n for (const key of Object.keys(node)) {\n if (key.startsWith(\"__reactFiber$\")) {\n return (node as unknown as Record<string, FiberLike>)[key] ?? null\n }\n }\n return null\n}\n\nfunction findOwningFileName(fiber: FiberLike | null): string | undefined {\n let current: FiberLike | null | undefined = fiber\n while (current) {\n const fileName = current._debugSource?.fileName\n if (fileName) return fileName\n current = current.return\n }\n return undefined\n}\n\nfunction stampNode(el: Element, classifiers: Classifiers) {\n const fiber = getFiberFromNode(el)\n const fileName = findOwningFileName(fiber)\n const label: SourceLabel = classifyFile(fileName, classifiers)\n if (el.getAttribute(DATA_ATTR) !== label) {\n el.setAttribute(DATA_ATTR, label)\n }\n}\n\nfunction stampSubtree(root: Element, classifiers: Classifiers) {\n stampNode(root, classifiers)\n const all = root.querySelectorAll(\"*\")\n for (let i = 0; i < all.length; i++) {\n stampNode(all[i], classifiers)\n }\n}\n\nfunction clearStamps(root: Element) {\n root.removeAttribute(DATA_ATTR)\n const all = root.querySelectorAll(`[${DATA_ATTR}]`)\n for (let i = 0; i < all.length; i++) {\n all[i].removeAttribute(DATA_ATTR)\n }\n}\n\nfunction parseHotkey(spec: string): { ctrl: boolean; shift: boolean; alt: boolean; meta: boolean; key: string } | null {\n const parts = spec.toLowerCase().split(\"+\").map((p) => p.trim()).filter(Boolean)\n if (parts.length === 0) return null\n let ctrl = false\n let shift = false\n let alt = false\n let meta = false\n let key = \"\"\n for (const part of parts) {\n if (part === \"ctrl\" || part === \"control\") ctrl = true\n else if (part === \"shift\") shift = true\n else if (part === \"alt\" || part === \"option\") alt = true\n else if (part === \"meta\" || part === \"cmd\" || part === \"command\") meta = true\n else key = part\n }\n if (!key) return null\n return { ctrl, shift, alt, meta, key }\n}\n\nfunction matchesHotkey(event: KeyboardEvent, parsed: ReturnType<typeof parseHotkey>): boolean {\n if (!parsed) return false\n if (event.ctrlKey !== parsed.ctrl) return false\n if (event.shiftKey !== parsed.shift) return false\n if (event.altKey !== parsed.alt) return false\n if (event.metaKey !== parsed.meta) return false\n return event.key.toLowerCase() === parsed.key\n}\n\nfunction SourceInspectorRunner({\n classifiers = DEFAULT_CLASSIFIERS,\n hotkey = \"ctrl+shift+x\",\n debounceMs = 100,\n}: Pick<SourceInspectorProps, \"classifiers\" | \"hotkey\" | \"debounceMs\">) {\n const ctx = useSourceInspector()\n const { mode, cycleMode } = ctx\n\n const bodyClass =\n mode === \"highlight-visor\"\n ? styles.modeHighlightVisor\n : mode === \"highlight-non-visor\"\n ? styles.modeHighlightNonVisor\n : null\n\n const classifiersRef = React.useRef(classifiers)\n classifiersRef.current = classifiers\n\n // Stamp + observe whenever overlay is enabled.\n React.useEffect(() => {\n if (mode === \"off\") {\n clearStamps(document.body)\n return\n }\n\n let scheduled: ReturnType<typeof setTimeout> | null = null\n const scheduleStamp = () => {\n if (scheduled !== null) return\n scheduled = setTimeout(() => {\n scheduled = null\n stampSubtree(document.body, classifiersRef.current)\n }, debounceMs)\n }\n\n stampSubtree(document.body, classifiersRef.current)\n\n const observer = new MutationObserver(() => {\n scheduleStamp()\n })\n observer.observe(document.body, {\n childList: true,\n subtree: true,\n attributes: false,\n })\n\n return () => {\n observer.disconnect()\n if (scheduled !== null) clearTimeout(scheduled)\n clearStamps(document.body)\n }\n }, [mode, debounceMs])\n\n // Apply body class for the active overlay rules.\n React.useEffect(() => {\n if (!bodyClass) return\n document.body.classList.add(bodyClass)\n return () => {\n document.body.classList.remove(bodyClass)\n }\n }, [bodyClass])\n\n // Hotkey listener.\n React.useEffect(() => {\n if (!hotkey) return\n const parsed = parseHotkey(hotkey)\n if (!parsed) return\n const handler = (event: KeyboardEvent) => {\n if (matchesHotkey(event, parsed)) {\n event.preventDefault()\n cycleMode()\n }\n }\n window.addEventListener(\"keydown\", handler)\n return () => window.removeEventListener(\"keydown\", handler)\n }, [hotkey, cycleMode])\n\n return null\n}\n\nfunction SourceInspectorDevImpl({\n classifiers,\n hotkey = \"ctrl+shift+x\",\n debounceMs,\n defaultMode,\n mode,\n onModeChange,\n children,\n}: SourceInspectorProps) {\n const existing = React.useContext(SourceInspectorContext)\n const runner = (\n <SourceInspectorRunner classifiers={classifiers} hotkey={hotkey} debounceMs={debounceMs} />\n )\n if (existing) {\n return (\n <>\n {runner}\n {children}\n </>\n )\n }\n return (\n <SourceInspectorProvider defaultMode={defaultMode} mode={mode} onModeChange={onModeChange}>\n {runner}\n {children}\n </SourceInspectorProvider>\n )\n}\n\n// Active in development and test; no-op in production. Bundlers replace\n// process.env.NODE_ENV with a literal during the production build, allowing\n// dead-code elimination of the dev impl entirely.\nconst IS_PRODUCTION = process.env.NODE_ENV === \"production\"\n\nexport function SourceInspector(props: SourceInspectorProps) {\n if (IS_PRODUCTION) {\n return props.children ? <>{props.children}</> : null\n }\n return <SourceInspectorDevImpl {...props} />\n}\n"
3812
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport styles from \"./source-inspector.module.css\"\nimport {\n classifyByVisorName,\n classifyFile,\n DEFAULT_CLASSIFIERS,\n type Classifiers,\n type SourceLabel,\n} from \"./classify\"\n\nexport type { Classifiers, SourceLabel } from \"./classify\"\nexport { classifyByVisorName, classifyFile, DEFAULT_CLASSIFIERS } from \"./classify\"\n\nexport type Mode = \"off\" | \"highlight-visor\" | \"highlight-non-visor\"\n\nconst MODE_CYCLE: Record<Mode, Mode> = {\n off: \"highlight-visor\",\n \"highlight-visor\": \"highlight-non-visor\",\n \"highlight-non-visor\": \"off\",\n}\n\ninterface SourceInspectorContextValue {\n mode: Mode\n setMode: (mode: Mode) => void\n cycleMode: () => void\n}\n\nexport const SourceInspectorContext =\n React.createContext<SourceInspectorContextValue | null>(null)\n\nexport interface SourceInspectorProviderProps {\n defaultMode?: Mode\n mode?: Mode\n onModeChange?: (mode: Mode) => void\n children?: React.ReactNode\n}\n\nexport function SourceInspectorProvider({\n defaultMode = \"off\",\n mode: controlledMode,\n onModeChange,\n children,\n}: SourceInspectorProviderProps) {\n const [internalMode, setInternalMode] = React.useState<Mode>(defaultMode)\n const isControlled = controlledMode !== undefined\n const mode = isControlled ? controlledMode : internalMode\n\n const setMode = React.useCallback(\n (next: Mode) => {\n if (!isControlled) setInternalMode(next)\n onModeChange?.(next)\n },\n [isControlled, onModeChange],\n )\n\n const cycleMode = React.useCallback(() => {\n setMode(MODE_CYCLE[mode])\n }, [mode, setMode])\n\n const value = React.useMemo(\n () => ({ mode, setMode, cycleMode }),\n [mode, setMode, cycleMode],\n )\n\n return (\n <SourceInspectorContext.Provider value={value}>\n {children}\n </SourceInspectorContext.Provider>\n )\n}\n\nexport function useSourceInspector(): SourceInspectorContextValue {\n const ctx = React.useContext(SourceInspectorContext)\n if (!ctx) {\n throw new Error(\n \"useSourceInspector must be used within <SourceInspectorProvider> or <SourceInspector>\",\n )\n }\n return ctx\n}\n\nexport function useOptionalSourceInspector(): SourceInspectorContextValue | null {\n return React.useContext(SourceInspectorContext)\n}\n\nexport interface SourceInspectorProps extends SourceInspectorProviderProps {\n classifiers?: Classifiers\n hotkey?: string | null\n debounceMs?: number\n}\n\nconst DATA_ATTR = \"data-source\"\n\ninterface FiberLike {\n child?: FiberLike | null\n sibling?: FiberLike | null\n stateNode?: unknown\n type?: unknown\n _debugSource?: { fileName?: string } | null\n _debugOwner?: FiberLike | null\n _debugStack?: unknown\n return?: FiberLike | null\n}\n\nfunction getFiberFromNode(node: Element): FiberLike | null {\n for (const key of Object.keys(node)) {\n if (key.startsWith(\"__reactFiber$\")) {\n return (node as unknown as Record<string, FiberLike>)[key] ?? null\n }\n }\n return null\n}\n\n// React 19's JSX dev runtime hangs an Error on `_debugOwner._debugStack`.\n// Older runtimes set `_debugSource.fileName` directly on the fiber. Some\n// builds expose the stack as a plain string. Normalize all of those.\nfunction readStackString(stack: unknown): string | undefined {\n if (!stack) return undefined\n if (typeof stack === \"string\") return stack\n if (stack instanceof Error) return stack.stack ?? undefined\n if (typeof stack === \"object\" && \"stack\" in stack) {\n const s = (stack as { stack?: unknown }).stack\n if (typeof s === \"string\") return s\n }\n return undefined\n}\n\n// Frames whose URL or function name match these are the JSX dev runtime\n// itself, React's reconciler bottom frame, or the server-component runtime.\n// They appear above the user JSX call in `_debugStack` traces and must be\n// skipped to reach the meaningful source frame.\nconst REACT_INTERNAL_FRAME_PATTERN =\n /(react-stack-bottom-frame|react-server-dom|react-jsx-dev-runtime|react-jsx-runtime|\\bjsxDEV\\b|\\bjsxs?\\b)/\n\n// Turbopack/webpack-bundled runtime shims (Visor's `jsxDEV` shim, vendor\n// chunks) often appear in stacks as bare `at https://…` lines with no\n// function name, so REACT_INTERNAL_FRAME_PATTERN can't catch them. Match the\n// URL alone when the frame has no function name. Narrow on purpose: only\n// URLs that unambiguously point at a runtime/vendor chunk.\nconst RUNTIME_URL_PATTERN =\n /(react-server-dom|react-dom|react-jsx(?:-dev)?-runtime|\\/_next\\/dist\\/|\\/node_modules[_/])/\n\n/**\n * Parse a captured Error stack and return the URL of the first frame that\n * looks like user code. Returns undefined if every frame is a React-internal\n * frame, the stack is empty, or no URL is parseable.\n *\n * Exported for unit testing — not part of the SourceInspector public API.\n */\nexport function extractFirstUserUrl(stack: string): string | undefined {\n const lines = stack.split(\"\\n\")\n for (const rawLine of lines) {\n const line = rawLine.trim()\n if (!line.startsWith(\"at \")) continue\n\n let fnName = \"\"\n let location = \"\"\n\n const parenMatch = line.match(/^at\\s+(.+?)\\s+\\((.+)\\)\\s*$/)\n if (parenMatch) {\n fnName = parenMatch[1]\n location = parenMatch[2]\n } else {\n const bareMatch = line.match(/^at\\s+(.+)$/)\n if (!bareMatch) continue\n location = bareMatch[1]\n }\n\n if (!location) continue\n const url = location.replace(/:\\d+:\\d+$/, \"\")\n if (!url) continue\n if (REACT_INTERNAL_FRAME_PATTERN.test(fnName)) continue\n if (REACT_INTERNAL_FRAME_PATTERN.test(url)) continue\n // Unnamed frame at a known runtime URL — bundled jsxDEV shim or a\n // vendor chunk reaching us as a bare `at https://…` line. Skip.\n if (!fnName && RUNTIME_URL_PATTERN.test(url)) continue\n\n return url\n }\n return undefined\n}\n\nfunction findOwningSource(fiber: FiberLike | null): string | undefined {\n let current: FiberLike | null | undefined = fiber\n while (current) {\n const legacyFileName = current._debugSource?.fileName\n if (legacyFileName) return legacyFileName\n\n const owner: FiberLike | null | undefined = current._debugOwner\n if (owner) {\n const stackString = readStackString(owner._debugStack)\n if (stackString) {\n const url = extractFirstUserUrl(stackString)\n if (url) return url\n }\n current = owner\n continue\n }\n\n current = current.return\n }\n return undefined\n}\n\n// Walk the owner chain to find the nearest React component name. `displayName`\n// wins over `name` when both are present (matches React DevTools). Returns\n// undefined when we hit a host element with no component owner.\nfunction readOwnerName(owner: FiberLike): string | undefined {\n const type = owner.type as\n | { displayName?: unknown; name?: unknown }\n | null\n | undefined\n if (!type) return undefined\n const display = type.displayName\n if (typeof display === \"string\" && display) return display\n const name = type.name\n if (typeof name === \"string\" && name) return name\n return undefined\n}\n\n/**\n * Exported for unit testing — not part of the SourceInspector public API.\n * Walks the owner chain returning the first non-empty component name.\n */\nexport function findOwnerName(fiber: FiberLike | null): string | undefined {\n let current: FiberLike | null | undefined = fiber\n while (current) {\n const owner: FiberLike | null | undefined = current._debugOwner\n if (owner) {\n const name = readOwnerName(owner)\n if (name) return name\n current = owner\n continue\n }\n current = current.return\n }\n return undefined\n}\n\nfunction stampNode(\n el: Element,\n classifiers: Classifiers,\n hasCustomVisorClassifier: boolean,\n) {\n const fiber = getFiberFromNode(el)\n\n // Bundler-independent fast path: match the owning React component name\n // against the registry-derived set. Skipped when the host supplied a\n // custom `visor` predicate so consumer overrides win over both built-in\n // signals (the URL fallback already honors custom classifiers).\n if (!hasCustomVisorClassifier) {\n const ownerName = findOwnerName(fiber)\n const nameLabel = classifyByVisorName(ownerName)\n if (nameLabel) {\n if (el.getAttribute(DATA_ATTR) !== nameLabel) {\n el.setAttribute(DATA_ATTR, nameLabel)\n }\n return\n }\n }\n\n const source = findOwningSource(fiber)\n const label: SourceLabel = classifyFile(source, classifiers)\n if (el.getAttribute(DATA_ATTR) !== label) {\n el.setAttribute(DATA_ATTR, label)\n }\n}\n\n// React 19 server components and the document body itself produce fibers\n// with no `_debugOwner`, so per-element classification can't reach a\n// component name or source URL — every such element falls through to \"dom\".\n// Inherit the nearest stamped ancestor's label as a coverage fallback. Only\n// \"visor\" and \"local\" propagate; \"third-party\" and \"dom\" do not (the former\n// would mask gaps; the latter is a no-op). Stops at `root` so BODY-near\n// elements with no useful ancestor stay \"dom\".\n//\n// Exported for unit testing — not part of the SourceInspector public API.\nexport function inheritStamps(root: Element) {\n const dom = root.querySelectorAll(`[${DATA_ATTR}=\"dom\"]`)\n for (let i = 0; i < dom.length; i++) {\n const el = dom[i]\n let parent: Element | null = el.parentElement\n while (parent) {\n const parentLabel = parent.getAttribute(DATA_ATTR)\n if (parentLabel === \"visor\" || parentLabel === \"local\") {\n el.setAttribute(DATA_ATTR, parentLabel)\n break\n }\n if (parent === root) break\n parent = parent.parentElement\n }\n }\n}\n\n// Exported for unit testing — not part of the SourceInspector public API.\nexport function stampSubtree(\n root: Element,\n classifiers: Classifiers,\n hasCustomVisorClassifier: boolean,\n) {\n stampNode(root, classifiers, hasCustomVisorClassifier)\n const all = root.querySelectorAll(\"*\")\n for (let i = 0; i < all.length; i++) {\n stampNode(all[i], classifiers, hasCustomVisorClassifier)\n }\n inheritStamps(root)\n}\n\nfunction clearStamps(root: Element) {\n root.removeAttribute(DATA_ATTR)\n const all = root.querySelectorAll(`[${DATA_ATTR}]`)\n for (let i = 0; i < all.length; i++) {\n all[i].removeAttribute(DATA_ATTR)\n }\n}\n\nfunction parseHotkey(spec: string): { ctrl: boolean; shift: boolean; alt: boolean; meta: boolean; key: string } | null {\n const parts = spec.toLowerCase().split(\"+\").map((p) => p.trim()).filter(Boolean)\n if (parts.length === 0) return null\n let ctrl = false\n let shift = false\n let alt = false\n let meta = false\n let key = \"\"\n for (const part of parts) {\n if (part === \"ctrl\" || part === \"control\") ctrl = true\n else if (part === \"shift\") shift = true\n else if (part === \"alt\" || part === \"option\") alt = true\n else if (part === \"meta\" || part === \"cmd\" || part === \"command\") meta = true\n else key = part\n }\n if (!key) return null\n return { ctrl, shift, alt, meta, key }\n}\n\nfunction matchesHotkey(event: KeyboardEvent, parsed: ReturnType<typeof parseHotkey>): boolean {\n if (!parsed) return false\n if (event.ctrlKey !== parsed.ctrl) return false\n if (event.shiftKey !== parsed.shift) return false\n if (event.altKey !== parsed.alt) return false\n if (event.metaKey !== parsed.meta) return false\n return event.key.toLowerCase() === parsed.key\n}\n\nfunction SourceInspectorRunner({\n classifiers = DEFAULT_CLASSIFIERS,\n hotkey = \"ctrl+shift+x\",\n debounceMs = 100,\n}: Pick<SourceInspectorProps, \"classifiers\" | \"hotkey\" | \"debounceMs\">) {\n const ctx = useSourceInspector()\n const { mode, cycleMode } = ctx\n\n const bodyClass =\n mode === \"highlight-visor\"\n ? styles.modeHighlightVisor\n : mode === \"highlight-non-visor\"\n ? styles.modeHighlightNonVisor\n : null\n\n // Detect a host-supplied visor predicate so the name-based fast path can\n // step aside. `Classifiers` is a permissive shape — equality against the\n // default sentinel is the only signal that the host did NOT override it.\n const hasCustomVisorClassifier =\n classifiers.visor !== undefined &&\n classifiers.visor !== DEFAULT_CLASSIFIERS.visor\n\n const classifiersRef = React.useRef(classifiers)\n classifiersRef.current = classifiers\n const hasCustomVisorRef = React.useRef(hasCustomVisorClassifier)\n hasCustomVisorRef.current = hasCustomVisorClassifier\n\n // Stamp + observe whenever overlay is enabled.\n React.useEffect(() => {\n if (mode === \"off\") {\n clearStamps(document.body)\n return\n }\n\n let scheduled: ReturnType<typeof setTimeout> | null = null\n const scheduleStamp = () => {\n if (scheduled !== null) return\n scheduled = setTimeout(() => {\n scheduled = null\n stampSubtree(\n document.body,\n classifiersRef.current,\n hasCustomVisorRef.current,\n )\n }, debounceMs)\n }\n\n stampSubtree(\n document.body,\n classifiersRef.current,\n hasCustomVisorRef.current,\n )\n\n const observer = new MutationObserver(() => {\n scheduleStamp()\n })\n observer.observe(document.body, {\n childList: true,\n subtree: true,\n attributes: false,\n })\n\n return () => {\n observer.disconnect()\n if (scheduled !== null) clearTimeout(scheduled)\n clearStamps(document.body)\n }\n }, [mode, debounceMs])\n\n // Apply body class for the active overlay rules.\n React.useEffect(() => {\n if (!bodyClass) return\n document.body.classList.add(bodyClass)\n return () => {\n document.body.classList.remove(bodyClass)\n }\n }, [bodyClass])\n\n // Hotkey listener.\n React.useEffect(() => {\n if (!hotkey) return\n const parsed = parseHotkey(hotkey)\n if (!parsed) return\n const handler = (event: KeyboardEvent) => {\n if (matchesHotkey(event, parsed)) {\n event.preventDefault()\n cycleMode()\n }\n }\n window.addEventListener(\"keydown\", handler)\n return () => window.removeEventListener(\"keydown\", handler)\n }, [hotkey, cycleMode])\n\n return null\n}\n\nfunction SourceInspectorDevImpl({\n classifiers,\n hotkey = \"ctrl+shift+x\",\n debounceMs,\n defaultMode,\n mode,\n onModeChange,\n children,\n}: SourceInspectorProps) {\n const existing = React.useContext(SourceInspectorContext)\n const runner = (\n <SourceInspectorRunner classifiers={classifiers} hotkey={hotkey} debounceMs={debounceMs} />\n )\n if (existing) {\n return (\n <>\n {runner}\n {children}\n </>\n )\n }\n return (\n <SourceInspectorProvider defaultMode={defaultMode} mode={mode} onModeChange={onModeChange}>\n {runner}\n {children}\n </SourceInspectorProvider>\n )\n}\n\n// Active in development and test; no-op in production. Bundlers replace\n// process.env.NODE_ENV with a literal during the production build, allowing\n// dead-code elimination of the dev impl entirely.\nconst IS_PRODUCTION = process.env.NODE_ENV === \"production\"\n\nexport function SourceInspector(props: SourceInspectorProps) {\n if (IS_PRODUCTION) {\n return props.children ? <>{props.children}</> : null\n }\n return <SourceInspectorDevImpl {...props} />\n}\n"
3590
3813
  },
3591
3814
  {
3592
3815
  "path": "components/devtools/source-inspector/source-inspector.module.css",
@@ -3596,7 +3819,12 @@
3596
3819
  {
3597
3820
  "path": "components/devtools/source-inspector/classify.ts",
3598
3821
  "type": "registry:devtool",
3599
- "content": "/**\n * Pure classification of a source file path against host-supplied\n * predicates. Extracted from the SourceInspector runtime so the logic\n * is testable without instantiating React.\n */\n\nexport type SourceLabel = \"visor\" | \"local\" | \"third-party\" | \"dom\"\n\nexport interface Classifiers {\n visor?: (filePath: string) => boolean\n local?: (filePath: string) => boolean\n thirdParty?: (filePath: string) => boolean\n}\n\nexport const DEFAULT_CLASSIFIERS: Classifiers = {\n visor: (path) => path.includes(\"node_modules/@loworbitstudio/visor\"),\n local: (path) =>\n !path.includes(\"node_modules\") &&\n !path.includes(\"/.pnpm/\") &&\n !path.startsWith(\"<\"),\n thirdParty: (path) =>\n path.includes(\"node_modules\") &&\n !path.includes(\"@loworbitstudio/visor\"),\n}\n\nexport function classifyFile(\n fileName: string | undefined | null,\n classifiers: Classifiers = DEFAULT_CLASSIFIERS,\n): SourceLabel {\n if (!fileName) return \"dom\"\n const merged: Required<Classifiers> = {\n visor: classifiers.visor ?? DEFAULT_CLASSIFIERS.visor!,\n local: classifiers.local ?? DEFAULT_CLASSIFIERS.local!,\n thirdParty: classifiers.thirdParty ?? DEFAULT_CLASSIFIERS.thirdParty!,\n }\n if (merged.visor(fileName)) return \"visor\"\n if (merged.local(fileName)) return \"local\"\n if (merged.thirdParty(fileName)) return \"third-party\"\n return \"dom\"\n}\n"
3822
+ "content": "/**\n * Pure classification of a source identifier (file path or bundler chunk\n * URL) against host-supplied predicates. Extracted from the\n * SourceInspector runtime so the logic is testable without React.\n */\n\nimport { VISOR_COMPONENT_NAMES } from \"./visor-component-names.generated\"\n\nexport type SourceLabel = \"visor\" | \"local\" | \"third-party\" | \"dom\"\n\nexport interface Classifiers {\n visor?: (source: string) => boolean\n local?: (source: string) => boolean\n thirdParty?: (source: string) => boolean\n}\n\n// Underscore form (`loworbitstudio_visor`) covers Turbopack chunk URLs that\n// flatten path separators; slash form covers plain file paths and Webpack\n// chunks that preserve `node_modules/@loworbitstudio/visor`.\nconst VISOR_MARKERS = [\"@loworbitstudio/visor\", \"loworbitstudio_visor\"]\n\nfunction matchesVisor(source: string): boolean {\n return VISOR_MARKERS.some((marker) => source.includes(marker))\n}\n\nexport const DEFAULT_CLASSIFIERS: Classifiers = {\n visor: (source) => matchesVisor(source),\n local: (source) =>\n !source.includes(\"node_modules\") &&\n !source.includes(\"/.pnpm/\") &&\n !source.startsWith(\"<\"),\n thirdParty: (source) =>\n source.includes(\"node_modules\") && !matchesVisor(source),\n}\n\nexport function classifyFile(\n fileName: string | undefined | null,\n classifiers: Classifiers = DEFAULT_CLASSIFIERS,\n): SourceLabel {\n if (!fileName) return \"dom\"\n const merged: Required<Classifiers> = {\n visor: classifiers.visor ?? DEFAULT_CLASSIFIERS.visor!,\n local: classifiers.local ?? DEFAULT_CLASSIFIERS.local!,\n thirdParty: classifiers.thirdParty ?? DEFAULT_CLASSIFIERS.thirdParty!,\n }\n if (merged.visor(fileName)) return \"visor\"\n if (merged.local(fileName)) return \"local\"\n if (merged.thirdParty(fileName)) return \"third-party\"\n return \"dom\"\n}\n\n/**\n * Bundler-independent fast path. Returns \"visor\" when the React component\n * name (from `_debugOwner.type.displayName ?? _debugOwner.type.name`) is in\n * the registry-derived set, otherwise undefined so callers can fall back to\n * URL-based classification.\n *\n * Turbopack hashes away `@loworbitstudio/visor` from chunk URLs, so URL\n * substring matching cannot identify Visor renders under Next 16 dev. The\n * component name is stable across bundlers; this set is regenerated from\n * the registry by `scripts/generate-visor-component-names.ts`.\n */\nexport function classifyByVisorName(\n name: string | undefined | null,\n): \"visor\" | undefined {\n if (!name) return undefined\n return VISOR_COMPONENT_NAMES.has(name) ? \"visor\" : undefined\n}\n"
3823
+ },
3824
+ {
3825
+ "path": "components/devtools/source-inspector/visor-component-names.generated.ts",
3826
+ "type": "registry:devtool",
3827
+ "content": "// THIS FILE IS GENERATED BY scripts/generate-visor-component-names.ts.\n// Do not edit by hand. Re-run `npm run generate:component-names` after\n// adding, removing, or renaming a Visor component.\n//\n// Source of truth: registry/registry-{ui,blocks,deck,visual,devtools}.ts\n// Used by: components/devtools/source-inspector/* (VI-311)\n\nexport const VISOR_COMPONENT_NAMES: ReadonlySet<string> = new Set([\n \"AccessibilitySection\",\n \"AccessibilitySlide\",\n \"AccessibilitySpecimen\",\n \"Accordion\",\n \"AccordionContent\",\n \"AccordionItem\",\n \"AccordionTrigger\",\n \"ActivityFeed\",\n \"ActivityFeedContext\",\n \"ActivityFeedItem\",\n \"ActivityFeedRoot\",\n \"AdminDashboard\",\n \"AdminDetailDrawer\",\n \"AdminListPage\",\n \"AdminListPageInner\",\n \"AdminSettingsPage\",\n \"AdminShell\",\n \"AdminTabbedEditor\",\n \"AdminWizard\",\n \"Alert\",\n \"AlertDescription\",\n \"AlertTitle\",\n \"Avatar\",\n \"AvatarFallback\",\n \"AvatarImage\",\n \"Badge\",\n \"Banner\",\n \"BannerAction\",\n \"BannerDescription\",\n \"BannerTitle\",\n \"BentoGrid\",\n \"BentoTile\",\n \"BentoTileBody\",\n \"BentoTileDescription\",\n \"BentoTileFigure\",\n \"BentoTileHeadline\",\n \"BentoTileMedia\",\n \"BentoTileMeta\",\n \"BentoTileTitle\",\n \"Breadcrumb\",\n \"BreadcrumbEllipsis\",\n \"BreadcrumbItem\",\n \"BreadcrumbLink\",\n \"BreadcrumbList\",\n \"BreadcrumbPage\",\n \"BreadcrumbSeparator\",\n \"BulkActionBar\",\n \"Button\",\n \"ButtonSpecimenSection\",\n \"ButtonSpecimenSlide\",\n \"Calendar\",\n \"Card\",\n \"CardContent\",\n \"CardDescription\",\n \"CardFooter\",\n \"CardGrid\",\n \"CardHeader\",\n \"CardTitle\",\n \"Carousel\",\n \"CarouselContent\",\n \"CarouselContext\",\n \"CarouselGallery\",\n \"CarouselItem\",\n \"CarouselNext\",\n \"CarouselPrevious\",\n \"ChartContainer\",\n \"ChartContext\",\n \"ChartLegend\",\n \"ChartLegendContent\",\n \"ChartStyle\",\n \"ChartTooltip\",\n \"ChartTooltipContent\",\n \"Checkbox\",\n \"Chip\",\n \"ChipGroup\",\n \"ChipGroupContext\",\n \"ChipGroupItem\",\n \"ChoiceChip\",\n \"ClosingSlide\",\n \"CodeBlock\",\n \"Collapsible\",\n \"CollapsibleContent\",\n \"CollapsibleTrigger\",\n \"ColorBar\",\n \"ColorPaletteSection\",\n \"ColorSwatch\",\n \"ColorSwatchGrid\",\n \"Combobox\",\n \"ComboboxContent\",\n \"ComboboxContext\",\n \"ComboboxEmpty\",\n \"ComboboxGroup\",\n \"ComboboxInput\",\n \"ComboboxItem\",\n \"ComboboxSeparator\",\n \"Command\",\n \"CommandDialog\",\n \"CommandEmpty\",\n \"CommandGroup\",\n \"CommandInput\",\n \"CommandItem\",\n \"CommandList\",\n \"CommandLoading\",\n \"CommandSeparator\",\n \"CommandShortcut\",\n \"ComponentShowcaseContent\",\n \"ComponentShowcaseSection\",\n \"ComponentShowcaseSlide\",\n \"ConceptSlide\",\n \"ConfigurationPanel\",\n \"ConfirmDialog\",\n \"ContextMenu\",\n \"ContextMenuCheckboxItem\",\n \"ContextMenuContent\",\n \"ContextMenuGroup\",\n \"ContextMenuItem\",\n \"ContextMenuLabel\",\n \"ContextMenuPortal\",\n \"ContextMenuRadioGroup\",\n \"ContextMenuRadioItem\",\n \"ContextMenuSeparator\",\n \"ContextMenuShortcut\",\n \"ContextMenuSub\",\n \"ContextMenuSubContent\",\n \"ContextMenuSubTrigger\",\n \"ContextMenuTrigger\",\n \"CtaSection\",\n \"DataTable\",\n \"DataTableInner\",\n \"DatePicker\",\n \"DateRangePicker\",\n \"DeckContext\",\n \"DeckFooter\",\n \"DeckLayout\",\n \"DeckProvider\",\n \"DeckRenderer\",\n \"DesignSystemDeck\",\n \"DesignSystemSpecimen\",\n \"Dialog\",\n \"DialogClose\",\n \"DialogContent\",\n \"DialogDescription\",\n \"DialogHeader\",\n \"DialogOverlay\",\n \"DialogPortal\",\n \"DialogTitle\",\n \"DialogTrigger\",\n \"DotNav\",\n \"DropdownMenu\",\n \"DropdownMenuCheckboxItem\",\n \"DropdownMenuContent\",\n \"DropdownMenuGroup\",\n \"DropdownMenuItem\",\n \"DropdownMenuLabel\",\n \"DropdownMenuPortal\",\n \"DropdownMenuRadioGroup\",\n \"DropdownMenuRadioItem\",\n \"DropdownMenuSeparator\",\n \"DropdownMenuShortcut\",\n \"DropdownMenuSub\",\n \"DropdownMenuSubContent\",\n \"DropdownMenuSubTrigger\",\n \"DropdownMenuTrigger\",\n \"ElevationCard\",\n \"ElevationSlide\",\n \"EmptyState\",\n \"FeaturesGrid\",\n \"Field\",\n \"FieldDescription\",\n \"FieldError\",\n \"FieldLabel\",\n \"Fieldset\",\n \"FieldsetLegend\",\n \"FileUpload\",\n \"FilterBar\",\n \"FilterChip\",\n \"FontShowcase\",\n \"FontShowcaseGrid\",\n \"FooterSection\",\n \"Form\",\n \"FormField\",\n \"FormSpecimenSection\",\n \"FormSpecimenSlide\",\n \"FullscreenOverlay\",\n \"FullscreenOverlayContent\",\n \"FullscreenOverlayTrigger\",\n \"Heading\",\n \"HeroSection\",\n \"HeroSlide\",\n \"HoverCard\",\n \"HoverCardContent\",\n \"HoverCardTrigger\",\n \"IconGrid\",\n \"IconGridSection\",\n \"IconSizeRow\",\n \"IconsSlide\",\n \"Image\",\n \"Input\",\n \"Kbd\",\n \"Label\",\n \"Lightbox\",\n \"LightboxContent\",\n \"LightboxContext\",\n \"LightboxTrigger\",\n \"LoginForm\",\n \"Marquee\",\n \"MarqueeBandRenderer\",\n \"Menubar\",\n \"MenubarCheckboxItem\",\n \"MenubarContent\",\n \"MenubarGroup\",\n \"MenubarItem\",\n \"MenubarLabel\",\n \"MenubarMenu\",\n \"MenubarRadioGroup\",\n \"MenubarRadioItem\",\n \"MenubarSeparator\",\n \"MenubarShortcut\",\n \"MenubarSub\",\n \"MenubarSubContent\",\n \"MenubarSubTrigger\",\n \"MenubarTrigger\",\n \"MotionDuration\",\n \"MotionDurationSection\",\n \"MotionEasing\",\n \"MotionEasingSection\",\n \"MotionSlide\",\n \"NameRoster\",\n \"NameRosterItem\",\n \"Navbar\",\n \"NavbarBrand\",\n \"NavbarContent\",\n \"NavbarItem\",\n \"NavbarLink\",\n \"NavbarToggle\",\n \"NumberInput\",\n \"OpacityBar\",\n \"OpacitySlide\",\n \"OTPInput\",\n \"PageHeader\",\n \"Pagination\",\n \"PaginationContent\",\n \"PaginationEllipsis\",\n \"PaginationItem\",\n \"PaginationLink\",\n \"PaginationNext\",\n \"PaginationPrevious\",\n \"PasswordInput\",\n \"PhoneInput\",\n \"Popover\",\n \"PopoverAnchor\",\n \"PopoverContent\",\n \"PopoverTrigger\",\n \"PricingSection\",\n \"Progress\",\n \"RadioGroup\",\n \"RadioGroupItem\",\n \"RadiusScale\",\n \"RadiusSection\",\n \"RadiusSlide\",\n \"ScrollArea\",\n \"ScrollBar\",\n \"SearchInput\",\n \"Select\",\n \"SelectContent\",\n \"SelectGroup\",\n \"SelectItem\",\n \"SelectLabel\",\n \"SelectScrollDownButton\",\n \"SelectScrollUpButton\",\n \"SelectSeparator\",\n \"SelectTrigger\",\n \"SelectValue\",\n \"SemanticColorGrid\",\n \"SemanticColorItem\",\n \"SemanticTokensSlide\",\n \"Separator\",\n \"ShadowSection\",\n \"Sheet\",\n \"SheetClose\",\n \"SheetContent\",\n \"SheetDescription\",\n \"SheetFooter\",\n \"SheetHeader\",\n \"SheetOverlay\",\n \"SheetPortal\",\n \"SheetTitle\",\n \"SheetTrigger\",\n \"Sidebar\",\n \"SidebarContent\",\n \"SidebarContext\",\n \"SidebarFooter\",\n \"SidebarGroup\",\n \"SidebarGroupAction\",\n \"SidebarGroupContent\",\n \"SidebarGroupLabel\",\n \"SidebarHeader\",\n \"SidebarInset\",\n \"SidebarMenu\",\n \"SidebarMenuAction\",\n \"SidebarMenuBadge\",\n \"SidebarMenuButton\",\n \"SidebarMenuItem\",\n \"SidebarMenuSub\",\n \"SidebarMenuSubButton\",\n \"SidebarMenuSubItem\",\n \"SidebarProvider\",\n \"SidebarRail\",\n \"SidebarSeparator\",\n \"SidebarTrigger\",\n \"Skeleton\",\n \"Slide\",\n \"SlideHeader\",\n \"Slider\",\n \"SliderControl\",\n \"SlideThemeContext\",\n \"SlideThemeProvider\",\n \"SourceInspector\",\n \"SourceInspectorContext\",\n \"SourceInspectorDevImpl\",\n \"SourceInspectorProvider\",\n \"SourceInspectorRunner\",\n \"SourceInspectorToggle\",\n \"SpacingScale\",\n \"SpacingSection\",\n \"SpacingSlide\",\n \"Sphere\",\n \"SpherePlayground\",\n \"StatCard\",\n \"StatHero\",\n \"StationSpectrum\",\n \"StatusBadge\",\n \"StatusColorsSlide\",\n \"Stepper\",\n \"StepperContext\",\n \"StepperDescription\",\n \"StepperItem\",\n \"StepperSeparator\",\n \"StepperTitle\",\n \"StepperTrigger\",\n \"StepsSection\",\n \"SurfaceRow\",\n \"SurfaceSection\",\n \"Switch\",\n \"Table\",\n \"TableBody\",\n \"TableCaption\",\n \"TableCell\",\n \"TableFooter\",\n \"TableHead\",\n \"TableHeader\",\n \"TableRow\",\n \"Tabs\",\n \"TabsContent\",\n \"TabsList\",\n \"TabsTrigger\",\n \"TagInput\",\n \"TestimonialAttribution\",\n \"TestimonialSection\",\n \"Text\",\n \"Textarea\",\n \"ThemeArchitectureSlide\",\n \"ThemeColorsSlide\",\n \"ThemeSwitcher\",\n \"Timeline\",\n \"TimelineContent\",\n \"TimelineDescription\",\n \"TimelineIcon\",\n \"TimelineItem\",\n \"TimelineTimestamp\",\n \"TimelineTitle\",\n \"TitleSlide\",\n \"Toaster\",\n \"TOCSlide\",\n \"ToggleButton\",\n \"ToggleDevImpl\",\n \"ToggleGroup\",\n \"ToggleGroupContext\",\n \"ToggleGroupItem\",\n \"Tooltip\",\n \"TooltipContent\",\n \"TooltipProvider\",\n \"TooltipTrigger\",\n \"TypeBodySlide\",\n \"TypeDisplaySlide\",\n \"TypeSpecimen\",\n \"TypographySection\",\n \"WorkspaceSwitcher\",\n])\n"
3600
3828
  }
3601
3829
  ]
3602
3830
  },
@@ -3615,7 +3843,7 @@
3615
3843
  {
3616
3844
  "path": "components/devtools/source-inspector/source-inspector-toggle.tsx",
3617
3845
  "type": "registry:devtool",
3618
- "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Scan } from \"@phosphor-icons/react\"\nimport styles from \"./source-inspector-toggle.module.css\"\nimport {\n SourceInspectorProvider,\n SourceInspectorContext,\n type Mode,\n} from \"./source-inspector\"\n\nconst MODE_LABEL: Record<Mode, string> = {\n off: \"Source inspector off\",\n \"highlight-visor\": \"Highlighting Visor regions\",\n \"highlight-non-visor\": \"Highlighting non-Visor regions\",\n}\n\nexport interface SourceInspectorToggleProps\n extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"onClick\" | \"title\"> {\n className?: string\n}\n\nfunction ToggleButton({ className, ...props }: SourceInspectorToggleProps) {\n const ctx = React.useContext(SourceInspectorContext)\n if (!ctx) {\n throw new Error(\n \"SourceInspectorToggle internal error: missing context. Wrap in <SourceInspectorProvider>.\",\n )\n }\n const { mode, cycleMode } = ctx\n const dotClass =\n mode === \"highlight-visor\"\n ? styles.dotVisor\n : mode === \"highlight-non-visor\"\n ? styles.dotNonVisor\n : null\n\n return (\n <button\n type=\"button\"\n className={className ? `${styles.button} ${className}` : styles.button}\n title={MODE_LABEL[mode]}\n aria-label={MODE_LABEL[mode]}\n data-mode={mode}\n onClick={cycleMode}\n {...props}\n >\n <Scan size={16} weight=\"duotone\" aria-hidden=\"true\" />\n {dotClass ? <span className={`${styles.dot} ${dotClass}`} aria-hidden=\"true\" /> : null}\n </button>\n )\n}\n\nfunction ToggleDevImpl(props: SourceInspectorToggleProps) {\n const ctx = React.useContext(SourceInspectorContext)\n if (ctx) return <ToggleButton {...props} />\n return (\n <SourceInspectorProvider>\n <ToggleButton {...props} />\n </SourceInspectorProvider>\n )\n}\n\nconst IS_PRODUCTION = process.env.NODE_ENV === \"production\"\n\n/**\n * Phosphor `Scan` icon button that cycles the SourceInspector overlay through\n * off → highlight-visor → highlight-non-visor → off. Mounts a default\n * SourceInspectorProvider lazily if no provider is in scope, so apps can\n * mount this widget standalone without wiring up state explicitly.\n */\nexport function SourceInspectorToggle(props: SourceInspectorToggleProps) {\n if (IS_PRODUCTION) return null\n return <ToggleDevImpl {...props} />\n}\n"
3846
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Scan } from \"@phosphor-icons/react\"\nimport styles from \"./source-inspector-toggle.module.css\"\nimport {\n SourceInspector,\n SourceInspectorContext,\n type Mode,\n} from \"./source-inspector\"\n\nconst MODE_LABEL: Record<Mode, string> = {\n off: \"Source inspector off\",\n \"highlight-visor\": \"Highlighting Visor regions\",\n \"highlight-non-visor\": \"Highlighting non-Visor regions\",\n}\n\nexport interface SourceInspectorToggleProps\n extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"onClick\" | \"title\"> {\n className?: string\n}\n\nfunction ToggleButton({ className, ...props }: SourceInspectorToggleProps) {\n const ctx = React.useContext(SourceInspectorContext)\n if (!ctx) {\n throw new Error(\n \"SourceInspectorToggle internal error: missing context. Wrap in <SourceInspectorProvider>.\",\n )\n }\n const { mode, cycleMode } = ctx\n const dotClass =\n mode === \"highlight-visor\"\n ? styles.dotVisor\n : mode === \"highlight-non-visor\"\n ? styles.dotNonVisor\n : null\n\n return (\n <button\n type=\"button\"\n className={className ? `${styles.button} ${className}` : styles.button}\n title={MODE_LABEL[mode]}\n aria-label={MODE_LABEL[mode]}\n data-mode={mode}\n onClick={cycleMode}\n {...props}\n >\n <Scan size={16} weight=\"duotone\" aria-hidden=\"true\" />\n {dotClass ? <span className={`${styles.dot} ${dotClass}`} aria-hidden=\"true\" /> : null}\n </button>\n )\n}\n\nfunction ToggleDevImpl(props: SourceInspectorToggleProps) {\n const ctx = React.useContext(SourceInspectorContext)\n if (ctx) return <ToggleButton {...props} />\n // Use <SourceInspector> (not just the Provider) so the lazy mount path\n // also includes the Runner — DOM stamping, MutationObserver, and\n // body-class effects. Provider alone would cycle mode without applying\n // the overlay (VI-309).\n return (\n <SourceInspector>\n <ToggleButton {...props} />\n </SourceInspector>\n )\n}\n\nconst IS_PRODUCTION = process.env.NODE_ENV === \"production\"\n\n/**\n * Phosphor `Scan` icon button that cycles the SourceInspector overlay through\n * off → highlight-visor → highlight-non-visor → off. Mounts a default\n * `<SourceInspector>` (provider + runner) lazily if none is in scope, so\n * apps can mount this widget standalone and get the full overlay behavior\n * without wiring up state explicitly.\n */\nexport function SourceInspectorToggle(props: SourceInspectorToggleProps) {\n if (IS_PRODUCTION) return null\n return <ToggleDevImpl {...props} />\n}\n"
3619
3847
  },
3620
3848
  {
3621
3849
  "path": "components/devtools/source-inspector/source-inspector-toggle.module.css",
@@ -3654,7 +3882,7 @@
3654
3882
  {
3655
3883
  "path": "components/flutter/visor_avatar/visor_avatar_test.dart",
3656
3884
  "type": "registry:ui",
3657
- "content": "import 'package:cached_network_image/cached_network_image.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:phosphor_flutter/phosphor_flutter.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_avatar.dart';\n\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(body: Center(child: child)),\n );\n}\n\nvoid main() {\n group('VisorAvatar', () {\n // -------------------------------------------------------------------------\n // Smoke / default state\n // -------------------------------------------------------------------------\n\n testWidgets('renders without error using all defaults', (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar()));\n expect(find.byType(VisorAvatar), findsOneWidget);\n expect(find.byType(CircleAvatar), findsOneWidget);\n });\n\n testWidgets('uses default radius of 22', (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar()));\n final sizedBox = tester.widget<SizedBox>(find.byType(SizedBox).first);\n expect(sizedBox.width, 44); // 22 * 2\n expect(sizedBox.height, 44);\n final avatar = tester.widget<CircleAvatar>(find.byType(CircleAvatar));\n expect(avatar.radius, 22);\n });\n\n // -------------------------------------------------------------------------\n // Photo URL branch\n // -------------------------------------------------------------------------\n\n testWidgets('uses CachedNetworkImageProvider when photoUrl is set',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(\n photoUrl: 'https://example.com/photo.jpg',\n radius: 32,\n )));\n final avatar = tester.widget<CircleAvatar>(find.byType(CircleAvatar));\n expect(avatar.backgroundImage, isA<CachedNetworkImageProvider>());\n });\n\n testWidgets('SizedBox reflects custom radius when photoUrl is set',\n (tester) async {\n const testRadius = 40.0;\n await tester.pumpWidget(_wrap(const VisorAvatar(\n photoUrl: 'https://example.com/photo.jpg',\n radius: testRadius,\n )));\n final sizedBox = tester.widget<SizedBox>(find.byType(SizedBox).first);\n expect(sizedBox.width, testRadius * 2);\n expect(sizedBox.height, testRadius * 2);\n });\n\n testWidgets('does not show fallback child when photoUrl is set',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(\n photoUrl: 'https://example.com/photo.jpg',\n name: 'John Doe',\n radius: 32,\n )));\n // CircleAvatar child should be null — no initials Text visible.\n expect(find.text('JD'), findsNothing);\n });\n\n // -------------------------------------------------------------------------\n // Initials fallback\n // -------------------------------------------------------------------------\n\n testWidgets('shows initials when name is provided and no photoUrl',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(\n name: 'John Doe',\n radius: 32,\n )));\n expect(find.text('JD'), findsOneWidget);\n final avatar = tester.widget<CircleAvatar>(find.byType(CircleAvatar));\n expect(avatar.backgroundImage, isNull);\n });\n\n testWidgets('extracts up to 3 characters for single-word names',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(\n name: 'Madonna',\n radius: 32,\n )));\n expect(find.text('MAD'), findsOneWidget);\n });\n\n testWidgets('extracts first letter of up to 3 words for multi-word names',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(\n name: 'John Paul George Ringo',\n radius: 32,\n )));\n // First 3 words: John, Paul, George → JPG\n expect(find.text('JPG'), findsOneWidget);\n });\n\n testWidgets('extracts 2 characters for a 2-letter single-word name',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(\n name: 'Jo',\n radius: 32,\n )));\n expect(find.text('JO'), findsOneWidget);\n });\n\n testWidgets('handles names with multiple spaces gracefully', (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(\n name: ' John Doe ',\n radius: 32,\n )));\n expect(find.text('JD'), findsOneWidget);\n });\n\n // -------------------------------------------------------------------------\n // Default icon fallback\n // -------------------------------------------------------------------------\n\n testWidgets('shows user icon when no photoUrl and no name', (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(radius: 32)));\n expect(find.byIcon(PhosphorIconsBold.user), findsOneWidget);\n expect(find.byType(Text), findsNothing);\n });\n\n testWidgets('shows user icon when name is an empty string', (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(\n name: '',\n radius: 32,\n )));\n expect(find.byIcon(PhosphorIconsBold.user), findsOneWidget);\n expect(find.byType(Text), findsNothing);\n });\n\n // -------------------------------------------------------------------------\n // onTap / GestureDetector\n // -------------------------------------------------------------------------\n\n testWidgets('does not wrap in GestureDetector when onTap is null',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(radius: 32)));\n expect(find.byType(GestureDetector), findsNothing);\n });\n\n testWidgets('wraps in GestureDetector with opaque hit-test when onTap set',\n (tester) async {\n await tester.pumpWidget(_wrap(VisorAvatar(\n radius: 32,\n onTap: () {},\n )));\n final gd = tester.widget<GestureDetector>(find.byType(GestureDetector));\n expect(gd.behavior, HitTestBehavior.opaque);\n });\n\n testWidgets('invokes onTap callback when tapped', (tester) async {\n var tapped = false;\n await tester.pumpWidget(_wrap(VisorAvatar(\n radius: 32,\n onTap: () => tapped = true,\n )));\n await tester.tap(find.byType(VisorAvatar));\n await tester.pump();\n expect(tapped, isTrue);\n });\n\n testWidgets('uses Stack when onTap is provided', (tester) async {\n await tester.pumpWidget(_wrap(VisorAvatar(\n radius: 32,\n onTap: () {},\n )));\n expect(\n find.descendant(\n of: find.byType(VisorAvatar),\n matching: find.byType(Stack),\n ),\n findsOneWidget,\n );\n });\n\n testWidgets('does not use Stack when onTap is null', (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(radius: 32)));\n expect(\n find.descendant(\n of: find.byType(VisorAvatar),\n matching: find.byType(Stack),\n ),\n findsNothing,\n );\n });\n\n // -------------------------------------------------------------------------\n // Loading overlay\n // -------------------------------------------------------------------------\n\n testWidgets('shows CircularProgressIndicator overlay when isLoading true',\n (tester) async {\n await tester.pumpWidget(_wrap(VisorAvatar(\n photoUrl: 'https://example.com/photo.jpg',\n radius: 32,\n isLoading: true,\n onTap: () {},\n )));\n expect(find.byType(CircularProgressIndicator), findsOneWidget);\n expect(find.byType(Positioned), findsOneWidget);\n });\n\n testWidgets('hides loading overlay when isLoading false', (tester) async {\n await tester.pumpWidget(_wrap(VisorAvatar(\n photoUrl: 'https://example.com/photo.jpg',\n radius: 32,\n isLoading: false,\n onTap: () {},\n )));\n expect(find.byType(CircularProgressIndicator), findsNothing);\n });\n\n testWidgets(\n 'loading overlay is absent when isLoading true but onTap is null',\n (tester) async {\n // Overlay is only rendered inside the interactive Stack branch.\n await tester.pumpWidget(_wrap(const VisorAvatar(\n photoUrl: 'https://example.com/photo.jpg',\n radius: 32,\n isLoading: true,\n )));\n expect(find.byType(CircularProgressIndicator), findsNothing);\n });\n\n testWidgets(\n 'loading overlay uses static border when disableAnimations is true',\n (tester) async {\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: MediaQuery(\n data: const MediaQueryData(disableAnimations: true),\n child: Scaffold(\n body: VisorAvatar(\n photoUrl: 'https://example.com/photo.jpg',\n radius: 32,\n isLoading: true,\n onTap: () {},\n ),\n ),\n ),\n ),\n );\n // CircularProgressIndicator should NOT be present; static border is shown.\n expect(find.byType(CircularProgressIndicator), findsNothing);\n // At least one DecoratedBox is present (the overlay static border ring).\n expect(find.byType(DecoratedBox), findsWidgets);\n });\n\n // -------------------------------------------------------------------------\n // Semantics\n // -------------------------------------------------------------------------\n\n testWidgets('wraps interactive avatar in Semantics button', (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(VisorAvatar(\n radius: 32,\n onTap: () {},\n )));\n expect(find.bySemanticsLabel('Avatar'), findsOneWidget);\n handle.dispose();\n });\n\n testWidgets('uses custom semanticLabel when provided', (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(VisorAvatar(\n radius: 32,\n onTap: () {},\n semanticLabel: 'View profile',\n )));\n expect(find.bySemanticsLabel('View profile'), findsOneWidget);\n handle.dispose();\n });\n\n testWidgets('does not add button Semantics node when non-interactive',\n (tester) async {\n final handle = tester.ensureSemantics();\n // Non-interactive: onTap is null — no button semantics node should exist.\n await tester.pumpWidget(_wrap(const VisorAvatar(radius: 32)));\n // Assert no node with isButton flag is present in the avatar subtree.\n final semanticsData = tester.getSemantics(find.byType(VisorAvatar));\n expect(semanticsData.flagsCollection.isButton, isFalse);\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // Accessibility guidelines — interactive avatars with 48dp tap-target\n // -------------------------------------------------------------------------\n\n testWidgets(\n 'meetsGuideline: interactive avatar at radius 24 satisfies Android tap-target',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(VisorAvatar(\n radius: 24, // 48dp diameter — exactly M3 minimum\n onTap: () {},\n semanticLabel: 'Avatar',\n )));\n await expectLater(\n tester,\n meetsGuideline(androidTapTargetGuideline),\n );\n handle.dispose();\n });\n\n testWidgets(\n 'meetsGuideline: interactive avatar at radius 24 is labeled for accessibility',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(VisorAvatar(\n radius: 24,\n onTap: () {},\n semanticLabel: 'Avatar',\n )));\n await expectLater(\n tester,\n meetsGuideline(labeledTapTargetGuideline),\n );\n handle.dispose();\n });\n });\n}\n",
3885
+ "content": "import 'package:cached_network_image/cached_network_image.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:phosphor_flutter/phosphor_flutter.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_avatar.dart';\n\nWidget _wrap(Widget child, {TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(body: Center(child: child)),\n ),\n );\n}\n\nvoid main() {\n group('VisorAvatar', () {\n // -------------------------------------------------------------------------\n // Smoke / default state\n // -------------------------------------------------------------------------\n\n testWidgets('renders without error using all defaults', (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar()));\n expect(find.byType(VisorAvatar), findsOneWidget);\n expect(find.byType(CircleAvatar), findsOneWidget);\n });\n\n testWidgets('uses default radius of 22', (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar()));\n final sizedBox = tester.widget<SizedBox>(find.byType(SizedBox).first);\n expect(sizedBox.width, 44); // 22 * 2\n expect(sizedBox.height, 44);\n final avatar = tester.widget<CircleAvatar>(find.byType(CircleAvatar));\n expect(avatar.radius, 22);\n });\n\n // -------------------------------------------------------------------------\n // Photo URL branch\n // -------------------------------------------------------------------------\n\n testWidgets('uses CachedNetworkImageProvider when photoUrl is set',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(\n photoUrl: 'https://example.com/photo.jpg',\n radius: 32,\n )));\n final avatar = tester.widget<CircleAvatar>(find.byType(CircleAvatar));\n expect(avatar.backgroundImage, isA<CachedNetworkImageProvider>());\n });\n\n testWidgets('SizedBox reflects custom radius when photoUrl is set',\n (tester) async {\n const testRadius = 40.0;\n await tester.pumpWidget(_wrap(const VisorAvatar(\n photoUrl: 'https://example.com/photo.jpg',\n radius: testRadius,\n )));\n final sizedBox = tester.widget<SizedBox>(find.byType(SizedBox).first);\n expect(sizedBox.width, testRadius * 2);\n expect(sizedBox.height, testRadius * 2);\n });\n\n testWidgets('does not show fallback child when photoUrl is set',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(\n photoUrl: 'https://example.com/photo.jpg',\n name: 'John Doe',\n radius: 32,\n )));\n // CircleAvatar child should be null — no initials Text visible.\n expect(find.text('JD'), findsNothing);\n });\n\n // -------------------------------------------------------------------------\n // Initials fallback\n // -------------------------------------------------------------------------\n\n testWidgets('shows initials when name is provided and no photoUrl',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(\n name: 'John Doe',\n radius: 32,\n )));\n expect(find.text('JD'), findsOneWidget);\n final avatar = tester.widget<CircleAvatar>(find.byType(CircleAvatar));\n expect(avatar.backgroundImage, isNull);\n });\n\n testWidgets('extracts up to 3 characters for single-word names',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(\n name: 'Madonna',\n radius: 32,\n )));\n expect(find.text('MAD'), findsOneWidget);\n });\n\n testWidgets('extracts first letter of up to 3 words for multi-word names',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(\n name: 'John Paul George Ringo',\n radius: 32,\n )));\n // First 3 words: John, Paul, George → JPG\n expect(find.text('JPG'), findsOneWidget);\n });\n\n testWidgets('extracts 2 characters for a 2-letter single-word name',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(\n name: 'Jo',\n radius: 32,\n )));\n expect(find.text('JO'), findsOneWidget);\n });\n\n testWidgets('handles names with multiple spaces gracefully', (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(\n name: ' John Doe ',\n radius: 32,\n )));\n expect(find.text('JD'), findsOneWidget);\n });\n\n // -------------------------------------------------------------------------\n // Default icon fallback\n // -------------------------------------------------------------------------\n\n testWidgets('shows user icon when no photoUrl and no name', (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(radius: 32)));\n expect(find.byIcon(PhosphorIconsBold.user), findsOneWidget);\n expect(find.byType(Text), findsNothing);\n });\n\n testWidgets('shows user icon when name is an empty string', (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(\n name: '',\n radius: 32,\n )));\n expect(find.byIcon(PhosphorIconsBold.user), findsOneWidget);\n expect(find.byType(Text), findsNothing);\n });\n\n // -------------------------------------------------------------------------\n // onTap / GestureDetector\n // -------------------------------------------------------------------------\n\n testWidgets('does not wrap in GestureDetector when onTap is null',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(radius: 32)));\n expect(find.byType(GestureDetector), findsNothing);\n });\n\n testWidgets('wraps in GestureDetector with opaque hit-test when onTap set',\n (tester) async {\n await tester.pumpWidget(_wrap(VisorAvatar(\n radius: 32,\n onTap: () {},\n )));\n final gd = tester.widget<GestureDetector>(find.byType(GestureDetector));\n expect(gd.behavior, HitTestBehavior.opaque);\n });\n\n testWidgets('invokes onTap callback when tapped', (tester) async {\n var tapped = false;\n await tester.pumpWidget(_wrap(VisorAvatar(\n radius: 32,\n onTap: () => tapped = true,\n )));\n await tester.tap(find.byType(VisorAvatar));\n await tester.pump();\n expect(tapped, isTrue);\n });\n\n testWidgets('uses Stack when onTap is provided', (tester) async {\n await tester.pumpWidget(_wrap(VisorAvatar(\n radius: 32,\n onTap: () {},\n )));\n expect(\n find.descendant(\n of: find.byType(VisorAvatar),\n matching: find.byType(Stack),\n ),\n findsOneWidget,\n );\n });\n\n testWidgets('does not use Stack when onTap is null', (tester) async {\n await tester.pumpWidget(_wrap(const VisorAvatar(radius: 32)));\n expect(\n find.descendant(\n of: find.byType(VisorAvatar),\n matching: find.byType(Stack),\n ),\n findsNothing,\n );\n });\n\n // -------------------------------------------------------------------------\n // Loading overlay\n // -------------------------------------------------------------------------\n\n testWidgets('shows CircularProgressIndicator overlay when isLoading true',\n (tester) async {\n await tester.pumpWidget(_wrap(VisorAvatar(\n photoUrl: 'https://example.com/photo.jpg',\n radius: 32,\n isLoading: true,\n onTap: () {},\n )));\n expect(find.byType(CircularProgressIndicator), findsOneWidget);\n expect(find.byType(Positioned), findsOneWidget);\n });\n\n testWidgets('hides loading overlay when isLoading false', (tester) async {\n await tester.pumpWidget(_wrap(VisorAvatar(\n photoUrl: 'https://example.com/photo.jpg',\n radius: 32,\n isLoading: false,\n onTap: () {},\n )));\n expect(find.byType(CircularProgressIndicator), findsNothing);\n });\n\n testWidgets(\n 'loading overlay is absent when isLoading true but onTap is null',\n (tester) async {\n // Overlay is only rendered inside the interactive Stack branch.\n await tester.pumpWidget(_wrap(const VisorAvatar(\n photoUrl: 'https://example.com/photo.jpg',\n radius: 32,\n isLoading: true,\n )));\n expect(find.byType(CircularProgressIndicator), findsNothing);\n });\n\n testWidgets(\n 'loading overlay uses static border when disableAnimations is true',\n (tester) async {\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: MediaQuery(\n data: const MediaQueryData(disableAnimations: true),\n child: Scaffold(\n body: VisorAvatar(\n photoUrl: 'https://example.com/photo.jpg',\n radius: 32,\n isLoading: true,\n onTap: () {},\n ),\n ),\n ),\n ),\n );\n // CircularProgressIndicator should NOT be present; static border is shown.\n expect(find.byType(CircularProgressIndicator), findsNothing);\n // At least one DecoratedBox is present (the overlay static border ring).\n expect(find.byType(DecoratedBox), findsWidgets);\n });\n\n // -------------------------------------------------------------------------\n // Semantics\n // -------------------------------------------------------------------------\n\n testWidgets('wraps interactive avatar in Semantics button', (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(VisorAvatar(\n radius: 32,\n onTap: () {},\n )));\n expect(find.bySemanticsLabel('Avatar'), findsOneWidget);\n handle.dispose();\n });\n\n testWidgets('uses custom semanticLabel when provided', (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(VisorAvatar(\n radius: 32,\n onTap: () {},\n semanticLabel: 'View profile',\n )));\n expect(find.bySemanticsLabel('View profile'), findsOneWidget);\n handle.dispose();\n });\n\n testWidgets('does not add button Semantics node when non-interactive',\n (tester) async {\n final handle = tester.ensureSemantics();\n // Non-interactive: onTap is null — no button semantics node should exist.\n await tester.pumpWidget(_wrap(const VisorAvatar(radius: 32)));\n // Assert no node with isButton flag is present in the avatar subtree.\n final semanticsData = tester.getSemantics(find.byType(VisorAvatar));\n expect(semanticsData.flagsCollection.isButton, isFalse);\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // Accessibility guidelines — interactive avatars with 48dp tap-target\n // -------------------------------------------------------------------------\n\n testWidgets(\n 'meetsGuideline: interactive avatar at radius 24 satisfies Android tap-target',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(VisorAvatar(\n radius: 24, // 48dp diameter — exactly M3 minimum\n onTap: () {},\n semanticLabel: 'Avatar',\n )));\n await expectLater(\n tester,\n meetsGuideline(androidTapTargetGuideline),\n );\n handle.dispose();\n });\n\n testWidgets(\n 'meetsGuideline: interactive avatar at radius 24 is labeled for accessibility',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(VisorAvatar(\n radius: 24,\n onTap: () {},\n semanticLabel: 'Avatar',\n )));\n await expectLater(\n tester,\n meetsGuideline(labeledTapTargetGuideline),\n );\n handle.dispose();\n });\n\n // Rec5 — textContrastGuideline (VI-257)\n\n testWidgets('initials fallback renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorAvatar(\n name: 'John Doe',\n radius: 32,\n )));\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorAvatar(), textDirection: TextDirection.rtl),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorAvatar), findsOneWidget);\n });\n });\n}\n",
3658
3886
  "target": "flutter"
3659
3887
  }
3660
3888
  ]
@@ -3702,13 +3930,13 @@
3702
3930
  {
3703
3931
  "path": "components/flutter/visor_button/visor_button.dart",
3704
3932
  "type": "registry:ui",
3705
- "content": "import 'package:flutter/material.dart';\nimport 'package:visor_core/visor_core.dart';\n\n/// Which brand palette the button draws from.\n///\n/// Most apps use a single brand — leave this at [primary]. Apps that ship\n/// with dual brands (e.g. user-facing vs. operator-facing personas) can set\n/// [secondary] to route through the `surfaceAccent*` token slots.\nenum VisorButtonBrand { primary, secondary }\n\n/// The button's visual role.\n///\n/// - [primary] — solid filled button (Material `FilledButton`).\n/// - [secondary] — tonal filled button (Material `FilledButton.tonal`).\n/// - [ghost] — text-only button (Material `TextButton`).\n/// - [destructive] — solid filled button in the error palette.\nenum VisorButtonStyle { primary, secondary, ghost, destructive }\n\n/// Size presets map to padding + label text style.\nenum VisorButtonSize { sm, md, lg }\n\n/// Hug wraps the label; full expands to the parent's cross-axis width.\nenum VisorButtonWidth { hug, full }\n\n/// Visor's primary interactive button.\n///\n/// Wraps Material 3's button types with Visor's semantic color tokens and\n/// spacing scale. All styling reads from `Theme.of(context)` via the\n/// `visor_core` BuildContext extensions — no hard-coded colors, radii, or\n/// typography.\n///\n/// ```dart\n/// VisorButton(\n/// label: 'Save',\n/// onPressed: _save,\n/// style: VisorButtonStyle.primary,\n/// size: VisorButtonSize.md,\n/// width: VisorButtonWidth.full,\n/// )\n/// ```\nclass VisorButton extends StatelessWidget {\n const VisorButton({\n super.key,\n required this.label,\n required this.onPressed,\n this.brand = VisorButtonBrand.primary,\n this.style = VisorButtonStyle.primary,\n this.size = VisorButtonSize.md,\n this.width = VisorButtonWidth.hug,\n this.leadingIcon,\n this.trailingIcon,\n this.isLoading = false,\n this.semanticLabel,\n });\n\n final String label;\n final VoidCallback? onPressed;\n final VisorButtonBrand brand;\n final VisorButtonStyle style;\n final VisorButtonSize size;\n final VisorButtonWidth width;\n final Widget? leadingIcon;\n final Widget? trailingIcon;\n final bool isLoading;\n final String? semanticLabel;\n\n @override\n Widget build(BuildContext context) {\n final colors = context.visorColors;\n final opacity = context.visorOpacity;\n final spacing = context.visorSpacing;\n final textStyles = context.visorTextStyles;\n final strokeWidths = context.visorStrokeWidths;\n\n final palette = _palette(colors, opacity, style, brand);\n final padding = _padding(size, spacing);\n final labelStyle = _labelStyle(size, textStyles, palette.foreground);\n\n final effectiveOnPressed = isLoading ? null : onPressed;\n final child = _buildChild(\n labelStyle,\n palette.foreground,\n strokeWidths.medium,\n );\n\n final button = _buildButton(\n style: style,\n palette: palette,\n padding: padding,\n onPressed: effectiveOnPressed,\n child: child,\n );\n\n final sized = width == VisorButtonWidth.full\n ? SizedBox(width: double.infinity, child: button)\n : button;\n\n return Semantics(\n button: true,\n label: semanticLabel ?? label,\n enabled: effectiveOnPressed != null,\n child: sized,\n );\n }\n\n Widget _buildChild(\n TextStyle labelStyle,\n Color foreground,\n double loadingStrokeWidth,\n ) {\n if (isLoading) {\n return SizedBox(\n width: 16,\n height: 16,\n child: CircularProgressIndicator(\n strokeWidth: loadingStrokeWidth,\n valueColor: AlwaysStoppedAnimation<Color>(foreground),\n ),\n );\n }\n final text = Text(label, style: labelStyle);\n if (leadingIcon == null && trailingIcon == null) return text;\n return Row(\n mainAxisSize: MainAxisSize.min,\n children: [\n if (leadingIcon != null) ...[\n IconTheme.merge(\n data: IconThemeData(color: foreground, size: 18),\n child: leadingIcon!,\n ),\n const SizedBox(width: 8),\n ],\n text,\n if (trailingIcon != null) ...[\n const SizedBox(width: 8),\n IconTheme.merge(\n data: IconThemeData(color: foreground, size: 18),\n child: trailingIcon!,\n ),\n ],\n ],\n );\n }\n\n Widget _buildButton({\n required VisorButtonStyle style,\n required _ButtonPalette palette,\n required EdgeInsets padding,\n required VoidCallback? onPressed,\n required Widget child,\n }) {\n final shared = ButtonStyle(\n padding: WidgetStatePropertyAll(padding),\n backgroundColor: WidgetStatePropertyAll(palette.background),\n foregroundColor: WidgetStatePropertyAll(palette.foreground),\n overlayColor: WidgetStatePropertyAll(palette.overlay),\n );\n switch (style) {\n case VisorButtonStyle.primary:\n case VisorButtonStyle.destructive:\n return FilledButton(\n onPressed: onPressed,\n style: shared,\n child: child,\n );\n case VisorButtonStyle.secondary:\n return FilledButton.tonal(\n onPressed: onPressed,\n style: shared,\n child: child,\n );\n case VisorButtonStyle.ghost:\n return TextButton(\n onPressed: onPressed,\n style: shared.copyWith(\n backgroundColor: const WidgetStatePropertyAll(Colors.transparent),\n ),\n child: child,\n );\n }\n }\n\n EdgeInsets _padding(VisorButtonSize size, VisorSpacingData spacing) {\n switch (size) {\n case VisorButtonSize.sm:\n return EdgeInsets.symmetric(\n horizontal: spacing.md,\n vertical: spacing.xs,\n );\n case VisorButtonSize.md:\n return EdgeInsets.symmetric(\n horizontal: spacing.lg,\n vertical: spacing.sm,\n );\n case VisorButtonSize.lg:\n return EdgeInsets.symmetric(\n horizontal: spacing.xl,\n vertical: spacing.md,\n );\n }\n }\n\n TextStyle _labelStyle(\n VisorButtonSize size,\n VisorTextStylesData textStyles,\n Color foreground,\n ) {\n final base = switch (size) {\n VisorButtonSize.sm => textStyles.labelSmall,\n VisorButtonSize.md => textStyles.labelMedium,\n VisorButtonSize.lg => textStyles.labelLarge,\n };\n return base.copyWith(color: foreground);\n }\n\n _ButtonPalette _palette(\n VisorColorsData colors,\n VisorOpacityData opacity,\n VisorButtonStyle style,\n VisorButtonBrand brand,\n ) {\n // Pick the brand palette first, then apply the style role on top.\n final bg = brand == VisorButtonBrand.primary\n ? colors.interactivePrimaryBg\n : colors.surfaceAccentStrong;\n final bgHover = brand == VisorButtonBrand.primary\n ? colors.interactivePrimaryBgHover\n : colors.surfaceAccentDefault;\n final onBg = brand == VisorButtonBrand.primary\n ? colors.interactivePrimaryText\n : colors.textInverse;\n\n switch (style) {\n case VisorButtonStyle.primary:\n return _ButtonPalette(\n background: bg,\n foreground: onBg,\n overlay: bgHover.withValues(alpha: opacity.alpha12),\n );\n case VisorButtonStyle.secondary:\n return _ButtonPalette(\n background: brand == VisorButtonBrand.primary\n ? colors.interactiveSecondaryBg\n : colors.surfaceAccentSubtle,\n foreground: brand == VisorButtonBrand.primary\n ? colors.interactiveSecondaryText\n : colors.surfaceAccentStrong,\n overlay: (brand == VisorButtonBrand.primary\n ? colors.interactiveSecondaryBgHover\n : colors.surfaceAccentDefault)\n .withValues(alpha: opacity.alpha12),\n );\n case VisorButtonStyle.ghost:\n return _ButtonPalette(\n background: Colors.transparent,\n foreground: brand == VisorButtonBrand.primary\n ? colors.interactivePrimaryBg\n : colors.surfaceAccentStrong,\n overlay: bg.withValues(alpha: opacity.alpha10),\n );\n case VisorButtonStyle.destructive:\n return _ButtonPalette(\n background: colors.interactiveDestructiveBg,\n foreground: colors.interactiveDestructiveText,\n overlay: colors.interactiveDestructiveBgHover\n .withValues(alpha: opacity.alpha12),\n );\n }\n }\n}\n\nclass _ButtonPalette {\n const _ButtonPalette({\n required this.background,\n required this.foreground,\n required this.overlay,\n });\n final Color background;\n final Color foreground;\n final Color overlay;\n}\n",
3933
+ "content": "import 'package:flutter/material.dart';\nimport 'package:visor_core/visor_core.dart';\n\n/// The button's visual role.\n///\n/// - [primary] — solid filled button (Material `FilledButton`).\n/// - [secondary] — tonal filled button (Material `FilledButton.tonal`).\n/// - [ghost] — text-only button (Material `TextButton`).\n/// - [destructive] — solid filled button in the error palette.\nenum VisorButtonStyle { primary, secondary, ghost, destructive }\n\n/// Size presets map to padding + label text style.\nenum VisorButtonSize { sm, md, lg }\n\n/// Hug wraps the label; full expands to the parent's cross-axis width.\nenum VisorButtonWidth { hug, full }\n\n/// Visor's primary interactive button.\n///\n/// Wraps Material 3's button types with Visor's semantic color tokens and\n/// spacing scale. All styling reads from `Theme.of(context)` via the\n/// `visor_core` BuildContext extensions — no hard-coded colors, radii, or\n/// typography.\n///\n/// ```dart\n/// VisorButton(\n/// label: 'Save',\n/// onPressed: _save,\n/// style: VisorButtonStyle.primary,\n/// size: VisorButtonSize.md,\n/// width: VisorButtonWidth.full,\n/// )\n/// ```\nclass VisorButton extends StatelessWidget {\n const VisorButton({\n super.key,\n required this.label,\n required this.onPressed,\n this.style = VisorButtonStyle.primary,\n this.size = VisorButtonSize.md,\n this.width = VisorButtonWidth.hug,\n this.leadingIcon,\n this.trailingIcon,\n this.isLoading = false,\n this.semanticLabel,\n });\n\n final String label;\n final VoidCallback? onPressed;\n final VisorButtonStyle style;\n final VisorButtonSize size;\n final VisorButtonWidth width;\n final Widget? leadingIcon;\n final Widget? trailingIcon;\n final bool isLoading;\n final String? semanticLabel;\n\n @override\n Widget build(BuildContext context) {\n final colors = context.visorColors;\n final opacity = context.visorOpacity;\n final spacing = context.visorSpacing;\n final textStyles = context.visorTextStyles;\n final strokeWidths = context.visorStrokeWidths;\n\n final palette = _palette(colors, opacity, style);\n final padding = _padding(size, spacing);\n final labelStyle = _labelStyle(size, textStyles, palette.foreground);\n\n final effectiveOnPressed = isLoading ? null : onPressed;\n final child = _buildChild(\n labelStyle,\n palette.foreground,\n strokeWidths.medium,\n );\n\n final button = _buildButton(\n style: style,\n palette: palette,\n padding: padding,\n onPressed: effectiveOnPressed,\n child: child,\n );\n\n final sized = width == VisorButtonWidth.full\n ? SizedBox(width: double.infinity, child: button)\n : button;\n\n return Semantics(\n button: true,\n label: semanticLabel ?? label,\n enabled: effectiveOnPressed != null,\n child: sized,\n );\n }\n\n Widget _buildChild(\n TextStyle labelStyle,\n Color foreground,\n double loadingStrokeWidth,\n ) {\n if (isLoading) {\n return SizedBox(\n width: 16,\n height: 16,\n child: CircularProgressIndicator(\n strokeWidth: loadingStrokeWidth,\n valueColor: AlwaysStoppedAnimation<Color>(foreground),\n ),\n );\n }\n final text = Text(label, style: labelStyle);\n if (leadingIcon == null && trailingIcon == null) return text;\n return Row(\n mainAxisSize: MainAxisSize.min,\n children: [\n if (leadingIcon != null) ...[\n IconTheme.merge(\n data: IconThemeData(color: foreground, size: 18),\n child: leadingIcon!,\n ),\n const SizedBox(width: 8),\n ],\n text,\n if (trailingIcon != null) ...[\n const SizedBox(width: 8),\n IconTheme.merge(\n data: IconThemeData(color: foreground, size: 18),\n child: trailingIcon!,\n ),\n ],\n ],\n );\n }\n\n Widget _buildButton({\n required VisorButtonStyle style,\n required _ButtonPalette palette,\n required EdgeInsets padding,\n required VoidCallback? onPressed,\n required Widget child,\n }) {\n final shared = ButtonStyle(\n padding: WidgetStatePropertyAll(padding),\n backgroundColor: WidgetStatePropertyAll(palette.background),\n foregroundColor: WidgetStatePropertyAll(palette.foreground),\n overlayColor: WidgetStatePropertyAll(palette.overlay),\n );\n switch (style) {\n case VisorButtonStyle.primary:\n case VisorButtonStyle.destructive:\n return FilledButton(\n onPressed: onPressed,\n style: shared,\n child: child,\n );\n case VisorButtonStyle.secondary:\n return FilledButton.tonal(\n onPressed: onPressed,\n style: shared,\n child: child,\n );\n case VisorButtonStyle.ghost:\n return TextButton(\n onPressed: onPressed,\n style: shared.copyWith(\n backgroundColor: const WidgetStatePropertyAll(Colors.transparent),\n ),\n child: child,\n );\n }\n }\n\n EdgeInsets _padding(VisorButtonSize size, VisorSpacingData spacing) {\n switch (size) {\n case VisorButtonSize.sm:\n return EdgeInsets.symmetric(\n horizontal: spacing.md,\n vertical: spacing.xs,\n );\n case VisorButtonSize.md:\n return EdgeInsets.symmetric(\n horizontal: spacing.lg,\n vertical: spacing.sm,\n );\n case VisorButtonSize.lg:\n return EdgeInsets.symmetric(\n horizontal: spacing.xl,\n vertical: spacing.md,\n );\n }\n }\n\n TextStyle _labelStyle(\n VisorButtonSize size,\n VisorTextStylesData textStyles,\n Color foreground,\n ) {\n final base = switch (size) {\n VisorButtonSize.sm => textStyles.labelSmall,\n VisorButtonSize.md => textStyles.labelMedium,\n VisorButtonSize.lg => textStyles.labelLarge,\n };\n return base.copyWith(color: foreground);\n }\n\n _ButtonPalette _palette(\n VisorColorsData colors,\n VisorOpacityData opacity,\n VisorButtonStyle style,\n ) {\n switch (style) {\n case VisorButtonStyle.primary:\n return _ButtonPalette(\n background: colors.interactivePrimaryBg,\n foreground: colors.interactivePrimaryText,\n overlay: colors.interactivePrimaryBgHover\n .withValues(alpha: opacity.alpha12),\n );\n case VisorButtonStyle.secondary:\n return _ButtonPalette(\n background: colors.interactiveSecondaryBg,\n foreground: colors.interactiveSecondaryText,\n overlay: colors.interactiveSecondaryBgHover\n .withValues(alpha: opacity.alpha12),\n );\n case VisorButtonStyle.ghost:\n return _ButtonPalette(\n background: Colors.transparent,\n foreground: colors.interactivePrimaryBg,\n overlay: colors.interactivePrimaryBg.withValues(alpha: opacity.alpha10),\n );\n case VisorButtonStyle.destructive:\n return _ButtonPalette(\n background: colors.interactiveDestructiveBg,\n foreground: colors.interactiveDestructiveText,\n overlay: colors.interactiveDestructiveBgHover\n .withValues(alpha: opacity.alpha12),\n );\n }\n }\n}\n\nclass _ButtonPalette {\n const _ButtonPalette({\n required this.background,\n required this.foreground,\n required this.overlay,\n });\n final Color background;\n final Color foreground;\n final Color overlay;\n}\n",
3706
3934
  "target": "flutter"
3707
3935
  },
3708
3936
  {
3709
3937
  "path": "components/flutter/visor_button/visor_button_test.dart",
3710
3938
  "type": "registry:ui",
3711
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_button.dart';\n\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(body: Center(child: child)),\n );\n}\n\nvoid main() {\n group('VisorButton', () {\n testWidgets('renders the provided label', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorButton(label: 'Save', onPressed: () {})),\n );\n expect(find.text('Save'), findsOneWidget);\n });\n\n testWidgets('is disabled when onPressed is null', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorButton(label: 'Save', onPressed: null)),\n );\n final button = tester.widget<FilledButton>(find.byType(FilledButton));\n expect(button.onPressed, isNull);\n });\n\n testWidgets('swaps label for spinner when isLoading', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'Save',\n onPressed: () {},\n isLoading: true,\n )),\n );\n expect(find.text('Save'), findsNothing);\n expect(find.byType(CircularProgressIndicator), findsOneWidget);\n });\n\n testWidgets('isLoading disables onPressed', (tester) async {\n var tapped = false;\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'Save',\n onPressed: () => tapped = true,\n isLoading: true,\n )),\n );\n await tester.tap(find.byType(FilledButton));\n await tester.pump();\n expect(tapped, isFalse);\n });\n\n testWidgets('style.secondary renders as FilledButton.tonal',\n (tester) async {\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'Save',\n onPressed: () {},\n style: VisorButtonStyle.secondary,\n )),\n );\n // FilledButton.tonal creates a FilledButton under the hood; we verify\n // rendering succeeds and the button is a FilledButton.\n expect(find.byType(FilledButton), findsOneWidget);\n });\n\n testWidgets('style.ghost renders as TextButton', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'Cancel',\n onPressed: () {},\n style: VisorButtonStyle.ghost,\n )),\n );\n expect(find.byType(TextButton), findsOneWidget);\n expect(find.byType(FilledButton), findsNothing);\n });\n\n testWidgets('width.full expands to max width', (tester) async {\n await tester.pumpWidget(\n _wrap(\n SizedBox(\n width: 400,\n child: VisorButton(\n label: 'Save',\n onPressed: () {},\n width: VisorButtonWidth.full,\n ),\n ),\n ),\n );\n final sized = tester.widgetList<SizedBox>(find.byType(SizedBox)).firstWhere(\n (s) => s.width == double.infinity,\n orElse: () =>\n throw StateError('Expected an infinite-width SizedBox'),\n );\n expect(sized.width, double.infinity);\n });\n\n testWidgets('leading and trailing icons render alongside the label',\n (tester) async {\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'Save',\n onPressed: () {},\n leadingIcon: const Icon(Icons.save),\n trailingIcon: const Icon(Icons.arrow_forward),\n )),\n );\n expect(find.text('Save'), findsOneWidget);\n expect(find.byIcon(Icons.save), findsOneWidget);\n expect(find.byIcon(Icons.arrow_forward), findsOneWidget);\n });\n\n testWidgets('fires onPressed when tapped', (tester) async {\n var tapped = false;\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'Save',\n onPressed: () => tapped = true,\n )),\n );\n await tester.tap(find.byType(FilledButton));\n await tester.pump();\n expect(tapped, isTrue);\n });\n\n testWidgets('semanticLabel overrides button accessibility label',\n (tester) async {\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'OK',\n onPressed: () {},\n semanticLabel: 'Confirm deletion',\n )),\n );\n final semantics = tester.getSemantics(find.text('OK'));\n // Walk up to the enclosing button semantics.\n expect(\n find.bySemanticsLabel('Confirm deletion'),\n findsOneWidget,\n );\n expect(semantics, isNotNull);\n });\n\n // R11 — meetsGuideline tap-target + labeled-tap-target tests (VI-252)\n\n testWidgets('md size meets Android tap-target + labeled-tap-target guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorButton(label: 'Save', onPressed: () {})),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets('lg size meets Android tap-target + labeled-tap-target guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'Save',\n onPressed: () {},\n size: VisorButtonSize.lg,\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets(\n 'semanticLabel override still passes labeledTapTargetGuideline',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'OK',\n onPressed: () {},\n semanticLabel: 'Confirm deletion',\n )),\n );\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n // not-applicable: sm is a compact non-primary tap-target variant — see VI-252\n // sm uses vertical: spacing.xs padding and may yield a height under 48dp by\n // design. Bumping vertical padding would defeat the purpose of the variant.\n // R11 is satisfied by md + lg above; sm is documented as explicitly compact.\n });\n}\n",
3939
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_button.dart';\n\nWidget _wrap(Widget child, {TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(body: Center(child: child)),\n ),\n );\n}\n\nvoid main() {\n group('VisorButton', () {\n testWidgets('renders the provided label', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorButton(label: 'Save', onPressed: () {})),\n );\n expect(find.text('Save'), findsOneWidget);\n });\n\n testWidgets('is disabled when onPressed is null', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorButton(label: 'Save', onPressed: null)),\n );\n final button = tester.widget<FilledButton>(find.byType(FilledButton));\n expect(button.onPressed, isNull);\n });\n\n testWidgets('swaps label for spinner when isLoading', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'Save',\n onPressed: () {},\n isLoading: true,\n )),\n );\n expect(find.text('Save'), findsNothing);\n expect(find.byType(CircularProgressIndicator), findsOneWidget);\n });\n\n testWidgets('isLoading disables onPressed', (tester) async {\n var tapped = false;\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'Save',\n onPressed: () => tapped = true,\n isLoading: true,\n )),\n );\n await tester.tap(find.byType(FilledButton));\n await tester.pump();\n expect(tapped, isFalse);\n });\n\n testWidgets('style.secondary renders as FilledButton.tonal',\n (tester) async {\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'Save',\n onPressed: () {},\n style: VisorButtonStyle.secondary,\n )),\n );\n // FilledButton.tonal creates a FilledButton under the hood; we verify\n // rendering succeeds and the button is a FilledButton.\n expect(find.byType(FilledButton), findsOneWidget);\n });\n\n testWidgets('style.ghost renders as TextButton', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'Cancel',\n onPressed: () {},\n style: VisorButtonStyle.ghost,\n )),\n );\n expect(find.byType(TextButton), findsOneWidget);\n expect(find.byType(FilledButton), findsNothing);\n });\n\n testWidgets('width.full expands to max width', (tester) async {\n await tester.pumpWidget(\n _wrap(\n SizedBox(\n width: 400,\n child: VisorButton(\n label: 'Save',\n onPressed: () {},\n width: VisorButtonWidth.full,\n ),\n ),\n ),\n );\n final sized = tester.widgetList<SizedBox>(find.byType(SizedBox)).firstWhere(\n (s) => s.width == double.infinity,\n orElse: () =>\n throw StateError('Expected an infinite-width SizedBox'),\n );\n expect(sized.width, double.infinity);\n });\n\n testWidgets('leading and trailing icons render alongside the label',\n (tester) async {\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'Save',\n onPressed: () {},\n leadingIcon: const Icon(Icons.save),\n trailingIcon: const Icon(Icons.arrow_forward),\n )),\n );\n expect(find.text('Save'), findsOneWidget);\n expect(find.byIcon(Icons.save), findsOneWidget);\n expect(find.byIcon(Icons.arrow_forward), findsOneWidget);\n });\n\n testWidgets('fires onPressed when tapped', (tester) async {\n var tapped = false;\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'Save',\n onPressed: () => tapped = true,\n )),\n );\n await tester.tap(find.byType(FilledButton));\n await tester.pump();\n expect(tapped, isTrue);\n });\n\n testWidgets('semanticLabel overrides button accessibility label',\n (tester) async {\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'OK',\n onPressed: () {},\n semanticLabel: 'Confirm deletion',\n )),\n );\n final semantics = tester.getSemantics(find.text('OK'));\n // Walk up to the enclosing button semantics.\n expect(\n find.bySemanticsLabel('Confirm deletion'),\n findsOneWidget,\n );\n expect(semantics, isNotNull);\n });\n\n // R11 — meetsGuideline tap-target + labeled-tap-target tests (VI-252)\n\n testWidgets('md size meets Android tap-target + labeled-tap-target guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorButton(label: 'Save', onPressed: () {})),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets('lg size meets Android tap-target + labeled-tap-target guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'Save',\n onPressed: () {},\n size: VisorButtonSize.lg,\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets(\n 'semanticLabel override still passes labeledTapTargetGuideline',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'OK',\n onPressed: () {},\n semanticLabel: 'Confirm deletion',\n )),\n );\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n // not-applicable: sm is a compact non-primary tap-target variant — see VI-252\n // sm uses vertical: spacing.xs padding and may yield a height under 48dp by\n // design. Bumping vertical padding would defeat the purpose of the variant.\n // R11 is satisfied by md + lg above; sm is documented as explicitly compact.\n\n // Rec5 — textContrastGuideline (VI-257)\n\n testWidgets('primary style renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorButton(label: 'Save', onPressed: () {})),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('secondary style renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'Save',\n onPressed: () {},\n style: VisorButtonStyle.secondary,\n )),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('ghost style renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorButton(\n label: 'Cancel',\n onPressed: () {},\n style: VisorButtonStyle.ghost,\n )),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorButton(label: 'Save', onPressed: () {}),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorButton), findsOneWidget);\n });\n });\n}\n",
3712
3940
  "target": "flutter"
3713
3941
  },
3714
3942
  {
@@ -3741,7 +3969,7 @@
3741
3969
  {
3742
3970
  "path": "components/flutter/visor_chip/visor_chip_test.dart",
3743
3971
  "type": "registry:ui",
3744
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_chip.dart';\n\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(body: Center(child: child)),\n );\n}\n\nvoid main() {\n group('VisorChip', () {\n // -------------------------------------------------------------------------\n // Smoke render\n // -------------------------------------------------------------------------\n\n testWidgets('renders the provided label', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorChip(label: 'Modern', onPressed: null)),\n );\n expect(find.text('Modern'), findsOneWidget);\n });\n\n // -------------------------------------------------------------------------\n // Interaction\n // -------------------------------------------------------------------------\n\n testWidgets('fires onPressed when tapped', (tester) async {\n var tapped = false;\n await tester.pumpWidget(\n _wrap(VisorChip(label: 'Tag', onPressed: () => tapped = true)),\n );\n await tester.tap(find.byType(VisorChip));\n await tester.pump();\n expect(tapped, isTrue);\n });\n\n testWidgets('does not throw when onPressed is null', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorChip(label: 'Tag', onPressed: null)),\n );\n await tester.tap(find.byType(VisorChip));\n await tester.pump();\n // No exception = pass\n });\n\n // -------------------------------------------------------------------------\n // Selected / unselected states\n // -------------------------------------------------------------------------\n\n testWidgets('renders unselected state by default', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorChip(label: 'Tag', onPressed: null)),\n );\n final container = tester.widget<AnimatedContainer>(\n find.byType(AnimatedContainer),\n );\n final decoration = container.decoration as BoxDecoration;\n // Unselected suggestion chip uses surfaceCard (white in light theme)\n expect(decoration.color, isNotNull);\n expect(decoration.border, isNotNull);\n });\n\n testWidgets('selected state changes background', (tester) async {\n // Measure unselected bg\n await tester.pumpWidget(\n _wrap(const VisorChip(label: 'Tag', onPressed: null)),\n );\n final unselectedDecoration = (tester\n .widget<AnimatedContainer>(find.byType(AnimatedContainer))\n .decoration as BoxDecoration);\n final unselectedBg = unselectedDecoration.color;\n\n // Measure selected bg\n await tester.pumpWidget(\n _wrap(const VisorChip(\n label: 'Tag',\n isSelected: true,\n onPressed: null,\n )),\n );\n final selectedDecoration = (tester\n .widget<AnimatedContainer>(find.byType(AnimatedContainer))\n .decoration as BoxDecoration);\n final selectedBg = selectedDecoration.color;\n\n expect(selectedBg, isNotNull);\n expect(selectedBg, isNot(equals(unselectedBg)));\n });\n\n testWidgets(\n 'dimensions are consistent between selected and unselected states',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const Align(\n alignment: Alignment.topLeft,\n child: VisorChip(label: 'Tag', onPressed: null),\n )),\n );\n final unselectedSize = tester.getSize(find.byType(VisorChip));\n\n await tester.pumpWidget(\n _wrap(const Align(\n alignment: Alignment.topLeft,\n child: VisorChip(\n label: 'Tag',\n isSelected: true,\n onPressed: null,\n ),\n )),\n );\n final selectedSize = tester.getSize(find.byType(VisorChip));\n\n // Width may differ slightly due to border-radius animation, but\n // height should remain consistent.\n expect(selectedSize.height, closeTo(unselectedSize.height, 2));\n },\n );\n\n // -------------------------------------------------------------------------\n // Variant — suggestion\n // -------------------------------------------------------------------------\n\n testWidgets('suggestion variant unselected uses xl radius', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorChip(\n label: 'Tag',\n variant: VisorChipVariant.suggestion,\n onPressed: null,\n )),\n );\n final decoration = (tester\n .widget<AnimatedContainer>(find.byType(AnimatedContainer))\n .decoration as BoxDecoration);\n final br = decoration.borderRadius! as BorderRadius;\n // xl is ~20–24 depending on theme; just check it's > 8\n expect(br.topLeft.x, greaterThan(8));\n });\n\n testWidgets('suggestion variant selected uses pill radius', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorChip(\n label: 'Tag',\n variant: VisorChipVariant.suggestion,\n isSelected: true,\n onPressed: null,\n )),\n );\n final decoration = (tester\n .widget<AnimatedContainer>(find.byType(AnimatedContainer))\n .decoration as BoxDecoration);\n final br = decoration.borderRadius! as BorderRadius;\n // pill is 9999 or similar very large value\n expect(br.topLeft.x, greaterThan(20));\n });\n\n testWidgets('suggestion selected has transparent border', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorChip(\n label: 'Tag',\n variant: VisorChipVariant.suggestion,\n isSelected: true,\n onPressed: null,\n )),\n );\n final decoration = (tester\n .widget<AnimatedContainer>(find.byType(AnimatedContainer))\n .decoration as BoxDecoration);\n final border = decoration.border! as Border;\n expect(border.top.color, Colors.transparent);\n });\n\n // -------------------------------------------------------------------------\n // Variant — filter\n // -------------------------------------------------------------------------\n\n testWidgets('filter variant always has a border', (tester) async {\n for (final isSelected in [false, true]) {\n await tester.pumpWidget(\n _wrap(VisorChip(\n label: 'Tag',\n variant: VisorChipVariant.filter,\n isSelected: isSelected,\n onPressed: null,\n )),\n );\n final decoration = (tester\n .widget<AnimatedContainer>(find.byType(AnimatedContainer))\n .decoration as BoxDecoration);\n final border = decoration.border! as Border;\n expect(border.top.color.a, greaterThan(0),\n reason: 'filter variant border should be visible (isSelected=$isSelected)');\n }\n });\n\n testWidgets('filter variant uses md radius for md size', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorChip(\n label: 'Tag',\n variant: VisorChipVariant.filter,\n size: VisorChipSize.md,\n onPressed: null,\n )),\n );\n final decoration = (tester\n .widget<AnimatedContainer>(find.byType(AnimatedContainer))\n .decoration as BoxDecoration);\n final br = decoration.borderRadius! as BorderRadius;\n // md radius is ~8\n expect(br.topLeft.x, greaterThan(0));\n expect(br.topLeft.x, lessThan(20));\n });\n\n testWidgets('filter variant uses sm radius for sm size', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorChip(\n label: 'Tag',\n variant: VisorChipVariant.filter,\n size: VisorChipSize.sm,\n onPressed: null,\n )),\n );\n final decoration = (tester\n .widget<AnimatedContainer>(find.byType(AnimatedContainer))\n .decoration as BoxDecoration);\n final br = decoration.borderRadius! as BorderRadius;\n // sm radius is ~4–6\n expect(br.topLeft.x, greaterThan(0));\n expect(br.topLeft.x, lessThan(12));\n });\n\n // -------------------------------------------------------------------------\n // Size variants\n // -------------------------------------------------------------------------\n\n testWidgets('sm uses smaller text style than md', (tester) async {\n // Both sizes share the same 48dp minimum-height outer SizedBox (R7),\n // so getSize on VisorChip itself returns 48 in both cases. Instead we\n // verify the AnimatedContainer (the visual chip body) is shorter for sm.\n await tester.pumpWidget(\n _wrap(const Align(\n alignment: Alignment.topLeft,\n child: VisorChip(label: 'Tag', size: VisorChipSize.md, onPressed: null),\n )),\n );\n final mdContainerHeight =\n tester.getSize(find.byType(AnimatedContainer)).height;\n\n await tester.pumpWidget(\n _wrap(const Align(\n alignment: Alignment.topLeft,\n child: VisorChip(label: 'Tag', size: VisorChipSize.sm, onPressed: null),\n )),\n );\n final smContainerHeight =\n tester.getSize(find.byType(AnimatedContainer)).height;\n\n expect(smContainerHeight, lessThan(mdContainerHeight));\n });\n\n // -------------------------------------------------------------------------\n // Semantics — R6 + R11\n // -------------------------------------------------------------------------\n\n testWidgets('uses label as semantic label by default', (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorChip(\n label: 'Accessible Tag',\n onPressed: () {},\n )),\n );\n expect(find.bySemanticsLabel('Accessible Tag'), findsOneWidget);\n handle.dispose();\n });\n\n testWidgets('semanticLabel override is announced instead of label',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorChip(\n label: 'Modern',\n semanticLabel: 'Select Modern style',\n onPressed: () {},\n )),\n );\n expect(find.bySemanticsLabel('Select Modern style'), findsOneWidget);\n // The text child is excluded from the semantics tree so it does not\n // produce a duplicate label node — only the Semantics wrapper label fires.\n expect(find.bySemanticsLabel('Modern'), findsNothing);\n handle.dispose();\n });\n\n testWidgets('md size meets Android tap-target + labeled-tap-target guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorChip(\n label: 'Tag',\n size: VisorChipSize.md,\n onPressed: () {},\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n // not-applicable: sm is a compact non-primary variant — similar to\n // VisorButton.sm, it may yield a height under 48dp by design.\n // R11 is satisfied by md above; sm is documented as explicitly compact.\n });\n}\n",
3972
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_chip.dart';\n\nWidget _wrap(Widget child, {TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(body: Center(child: child)),\n ),\n );\n}\n\nvoid main() {\n group('VisorChip', () {\n // -------------------------------------------------------------------------\n // Smoke render\n // -------------------------------------------------------------------------\n\n testWidgets('renders the provided label', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorChip(label: 'Modern', onPressed: null)),\n );\n expect(find.text('Modern'), findsOneWidget);\n });\n\n // -------------------------------------------------------------------------\n // Interaction\n // -------------------------------------------------------------------------\n\n testWidgets('fires onPressed when tapped', (tester) async {\n var tapped = false;\n await tester.pumpWidget(\n _wrap(VisorChip(label: 'Tag', onPressed: () => tapped = true)),\n );\n await tester.tap(find.byType(VisorChip));\n await tester.pump();\n expect(tapped, isTrue);\n });\n\n testWidgets('does not throw when onPressed is null', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorChip(label: 'Tag', onPressed: null)),\n );\n await tester.tap(find.byType(VisorChip));\n await tester.pump();\n // No exception = pass\n });\n\n // -------------------------------------------------------------------------\n // Selected / unselected states\n // -------------------------------------------------------------------------\n\n testWidgets('renders unselected state by default', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorChip(label: 'Tag', onPressed: null)),\n );\n final container = tester.widget<AnimatedContainer>(\n find.byType(AnimatedContainer),\n );\n final decoration = container.decoration as BoxDecoration;\n // Unselected suggestion chip uses surfaceCard (white in light theme)\n expect(decoration.color, isNotNull);\n expect(decoration.border, isNotNull);\n });\n\n testWidgets('selected state changes background', (tester) async {\n // Measure unselected bg\n await tester.pumpWidget(\n _wrap(const VisorChip(label: 'Tag', onPressed: null)),\n );\n final unselectedDecoration = (tester\n .widget<AnimatedContainer>(find.byType(AnimatedContainer))\n .decoration as BoxDecoration);\n final unselectedBg = unselectedDecoration.color;\n\n // Measure selected bg\n await tester.pumpWidget(\n _wrap(const VisorChip(\n label: 'Tag',\n isSelected: true,\n onPressed: null,\n )),\n );\n final selectedDecoration = (tester\n .widget<AnimatedContainer>(find.byType(AnimatedContainer))\n .decoration as BoxDecoration);\n final selectedBg = selectedDecoration.color;\n\n expect(selectedBg, isNotNull);\n expect(selectedBg, isNot(equals(unselectedBg)));\n });\n\n testWidgets(\n 'dimensions are consistent between selected and unselected states',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const Align(\n alignment: Alignment.topLeft,\n child: VisorChip(label: 'Tag', onPressed: null),\n )),\n );\n final unselectedSize = tester.getSize(find.byType(VisorChip));\n\n await tester.pumpWidget(\n _wrap(const Align(\n alignment: Alignment.topLeft,\n child: VisorChip(\n label: 'Tag',\n isSelected: true,\n onPressed: null,\n ),\n )),\n );\n final selectedSize = tester.getSize(find.byType(VisorChip));\n\n // Width may differ slightly due to border-radius animation, but\n // height should remain consistent.\n expect(selectedSize.height, closeTo(unselectedSize.height, 2));\n },\n );\n\n // -------------------------------------------------------------------------\n // Variant — suggestion\n // -------------------------------------------------------------------------\n\n testWidgets('suggestion variant unselected uses xl radius', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorChip(\n label: 'Tag',\n variant: VisorChipVariant.suggestion,\n onPressed: null,\n )),\n );\n final decoration = (tester\n .widget<AnimatedContainer>(find.byType(AnimatedContainer))\n .decoration as BoxDecoration);\n final br = decoration.borderRadius! as BorderRadius;\n // xl is ~20–24 depending on theme; just check it's > 8\n expect(br.topLeft.x, greaterThan(8));\n });\n\n testWidgets('suggestion variant selected uses pill radius', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorChip(\n label: 'Tag',\n variant: VisorChipVariant.suggestion,\n isSelected: true,\n onPressed: null,\n )),\n );\n final decoration = (tester\n .widget<AnimatedContainer>(find.byType(AnimatedContainer))\n .decoration as BoxDecoration);\n final br = decoration.borderRadius! as BorderRadius;\n // pill is 9999 or similar very large value\n expect(br.topLeft.x, greaterThan(20));\n });\n\n testWidgets('suggestion selected has transparent border', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorChip(\n label: 'Tag',\n variant: VisorChipVariant.suggestion,\n isSelected: true,\n onPressed: null,\n )),\n );\n final decoration = (tester\n .widget<AnimatedContainer>(find.byType(AnimatedContainer))\n .decoration as BoxDecoration);\n final border = decoration.border! as Border;\n expect(border.top.color, Colors.transparent);\n });\n\n // -------------------------------------------------------------------------\n // Variant — filter\n // -------------------------------------------------------------------------\n\n testWidgets('filter variant always has a border', (tester) async {\n for (final isSelected in [false, true]) {\n await tester.pumpWidget(\n _wrap(VisorChip(\n label: 'Tag',\n variant: VisorChipVariant.filter,\n isSelected: isSelected,\n onPressed: null,\n )),\n );\n final decoration = (tester\n .widget<AnimatedContainer>(find.byType(AnimatedContainer))\n .decoration as BoxDecoration);\n final border = decoration.border! as Border;\n expect(border.top.color.a, greaterThan(0),\n reason: 'filter variant border should be visible (isSelected=$isSelected)');\n }\n });\n\n testWidgets('filter variant uses md radius for md size', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorChip(\n label: 'Tag',\n variant: VisorChipVariant.filter,\n size: VisorChipSize.md,\n onPressed: null,\n )),\n );\n final decoration = (tester\n .widget<AnimatedContainer>(find.byType(AnimatedContainer))\n .decoration as BoxDecoration);\n final br = decoration.borderRadius! as BorderRadius;\n // md radius is ~8\n expect(br.topLeft.x, greaterThan(0));\n expect(br.topLeft.x, lessThan(20));\n });\n\n testWidgets('filter variant uses sm radius for sm size', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorChip(\n label: 'Tag',\n variant: VisorChipVariant.filter,\n size: VisorChipSize.sm,\n onPressed: null,\n )),\n );\n final decoration = (tester\n .widget<AnimatedContainer>(find.byType(AnimatedContainer))\n .decoration as BoxDecoration);\n final br = decoration.borderRadius! as BorderRadius;\n // sm radius is ~4–6\n expect(br.topLeft.x, greaterThan(0));\n expect(br.topLeft.x, lessThan(12));\n });\n\n // -------------------------------------------------------------------------\n // Size variants\n // -------------------------------------------------------------------------\n\n testWidgets('sm uses smaller text style than md', (tester) async {\n // Both sizes share the same 48dp minimum-height outer SizedBox (R7),\n // so getSize on VisorChip itself returns 48 in both cases. Instead we\n // verify the AnimatedContainer (the visual chip body) is shorter for sm.\n await tester.pumpWidget(\n _wrap(const Align(\n alignment: Alignment.topLeft,\n child: VisorChip(label: 'Tag', size: VisorChipSize.md, onPressed: null),\n )),\n );\n final mdContainerHeight =\n tester.getSize(find.byType(AnimatedContainer)).height;\n\n await tester.pumpWidget(\n _wrap(const Align(\n alignment: Alignment.topLeft,\n child: VisorChip(label: 'Tag', size: VisorChipSize.sm, onPressed: null),\n )),\n );\n final smContainerHeight =\n tester.getSize(find.byType(AnimatedContainer)).height;\n\n expect(smContainerHeight, lessThan(mdContainerHeight));\n });\n\n // -------------------------------------------------------------------------\n // Semantics — R6 + R11\n // -------------------------------------------------------------------------\n\n testWidgets('uses label as semantic label by default', (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorChip(\n label: 'Accessible Tag',\n onPressed: () {},\n )),\n );\n expect(find.bySemanticsLabel('Accessible Tag'), findsOneWidget);\n handle.dispose();\n });\n\n testWidgets('semanticLabel override is announced instead of label',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorChip(\n label: 'Modern',\n semanticLabel: 'Select Modern style',\n onPressed: () {},\n )),\n );\n expect(find.bySemanticsLabel('Select Modern style'), findsOneWidget);\n // The text child is excluded from the semantics tree so it does not\n // produce a duplicate label node — only the Semantics wrapper label fires.\n expect(find.bySemanticsLabel('Modern'), findsNothing);\n handle.dispose();\n });\n\n testWidgets('md size meets Android tap-target + labeled-tap-target guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorChip(\n label: 'Tag',\n size: VisorChipSize.md,\n onPressed: () {},\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n // not-applicable: sm is a compact non-primary variant — similar to\n // VisorButton.sm, it may yield a height under 48dp by design.\n // R11 is satisfied by md above; sm is documented as explicitly compact.\n\n // Rec5 — textContrastGuideline (VI-257)\n\n testWidgets('suggestion variant (unselected) renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorChip(\n label: 'Modern',\n variant: VisorChipVariant.suggestion,\n onPressed: () {},\n )),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('suggestion variant (selected) renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorChip(\n label: 'Modern',\n variant: VisorChipVariant.suggestion,\n isSelected: true,\n onPressed: () {},\n )),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('filter variant (unselected) renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorChip(\n label: 'Tag',\n variant: VisorChipVariant.filter,\n onPressed: () {},\n )),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('filter variant (selected) renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorChip(\n label: 'Tag',\n variant: VisorChipVariant.filter,\n isSelected: true,\n onPressed: () {},\n )),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorChip(label: 'Modern', onPressed: null),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorChip), findsOneWidget);\n });\n });\n}\n",
3745
3973
  "target": "flutter"
3746
3974
  }
3747
3975
  ]
@@ -3768,7 +3996,7 @@
3768
3996
  {
3769
3997
  "path": "components/flutter/visor_chip_search_input/visor_chip_search_input_test.dart",
3770
3998
  "type": "registry:ui",
3771
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_chip_search_input.dart';\n\n// ---------------------------------------------------------------------------\n// Test helpers\n// ---------------------------------------------------------------------------\n\n/// A simple test item type — validates the generic `<T>` type parameter works\n/// with any consumer-defined model.\nclass _TestItem {\n const _TestItem({required this.id, required this.label});\n final String id;\n final String label;\n}\n\nconst _item1 = _TestItem(id: 'a', label: 'Flutter');\nconst _item2 = _TestItem(id: 'b', label: 'Dart');\nconst _item3 = _TestItem(id: 'c', label: 'Visor');\n\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(body: Center(child: child)),\n );\n}\n\n// ---------------------------------------------------------------------------\n// Tests\n// ---------------------------------------------------------------------------\n\nvoid main() {\n group('VisorChipSearchInput', () {\n // -----------------------------------------------------------------------\n // Smoke render\n // -----------------------------------------------------------------------\n\n testWidgets('renders with no selected items', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n expect(find.byType(VisorChipSearchInput<_TestItem>), findsOneWidget);\n expect(find.text('Search...'), findsOneWidget);\n });\n\n testWidgets('renders chips for each selected item', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1, _item2],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n expect(find.text('Flutter'), findsOneWidget);\n expect(find.text('Dart'), findsOneWidget);\n });\n\n testWidgets('hides hint when chips are present', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n final tf = tester.widget<TextField>(find.byType(TextField));\n expect(tf.decoration?.hintText, isEmpty);\n });\n\n // -----------------------------------------------------------------------\n // Generic type parameter\n // -----------------------------------------------------------------------\n\n testWidgets('works with a String type parameter', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<String>(\n selectedItems: const ['one', 'two'],\n labelBuilder: (s) => s,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n expect(find.text('one'), findsOneWidget);\n expect(find.text('two'), findsOneWidget);\n });\n\n testWidgets('works with an int type parameter', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<int>(\n selectedItems: const [42, 7],\n labelBuilder: (n) => '#$n',\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n expect(find.text('#42'), findsOneWidget);\n expect(find.text('#7'), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // Text input behaviour\n // -----------------------------------------------------------------------\n\n testWidgets('calls onQueryChanged when text is entered', (tester) async {\n String? captured;\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (q) => captured = q,\n onItemRemoved: (_) {},\n ),\n ),\n );\n await tester.enterText(find.byType(TextField), 'flutter');\n await tester.pump();\n expect(captured, 'flutter');\n });\n\n testWidgets('clear button appears after text is entered', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n\n await tester.enterText(find.byType(TextField), 'dart');\n await tester.pump();\n await tester.pump(const Duration(milliseconds: 300));\n\n expect(find.widgetWithIcon(IconButton, Icons.close), findsOneWidget);\n });\n\n testWidgets('clear button clears text and calls onQueryChanged', (tester) async {\n String? captured;\n\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (q) => captured = q,\n onItemRemoved: (_) {},\n ),\n ),\n );\n\n await tester.enterText(find.byType(TextField), 'dart');\n await tester.pump();\n await tester.pump(const Duration(milliseconds: 300));\n\n await tester.tap(find.widgetWithIcon(IconButton, Icons.close));\n await tester.pump();\n\n expect(captured, '');\n final tf = tester.widget<TextField>(find.byType(TextField));\n expect(tf.controller?.text, isEmpty);\n });\n\n // -----------------------------------------------------------------------\n // Chip removal\n // -----------------------------------------------------------------------\n\n testWidgets('calls onItemRemoved when chip remove button is tapped',\n (tester) async {\n _TestItem? removed;\n\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (item) => removed = item,\n ),\n ),\n );\n\n // Semantics button for \"Remove Flutter\"\n final removeBtn = find.byWidgetPredicate(\n (w) =>\n w is Semantics &&\n (w.properties.button ?? false) &&\n (w.properties.label?.contains('Remove Flutter') ?? false),\n );\n expect(removeBtn, findsOneWidget);\n await tester.tap(removeBtn);\n await tester.pump();\n expect(removed, _item1);\n });\n\n testWidgets('backspace on empty field removes the last chip', (tester) async {\n _TestItem? removed;\n final controller = TextEditingController();\n\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1, _item2],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n controller: controller,\n onQueryChanged: (_) {},\n onItemRemoved: (item) => removed = item,\n ),\n ),\n );\n\n await tester.tap(find.byType(TextField));\n await tester.pumpAndSettle();\n await tester.sendKeyEvent(LogicalKeyboardKey.backspace);\n await tester.pumpAndSettle();\n\n expect(removed, _item2);\n controller.dispose();\n });\n\n testWidgets('backspace with text in field does NOT remove a chip',\n (tester) async {\n _TestItem? removed;\n final controller = TextEditingController(text: 'hello');\n\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n controller: controller,\n onQueryChanged: (_) {},\n onItemRemoved: (item) => removed = item,\n ),\n ),\n );\n\n await tester.tap(find.byType(TextField));\n await tester.pumpAndSettle();\n await tester.sendKeyEvent(LogicalKeyboardKey.backspace);\n await tester.pumpAndSettle();\n\n expect(removed, isNull);\n controller.dispose();\n });\n\n testWidgets('backspace on empty field with no chips is a no-op',\n (tester) async {\n var called = false;\n final controller = TextEditingController();\n\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n controller: controller,\n onQueryChanged: (_) {},\n onItemRemoved: (_) => called = true,\n ),\n ),\n );\n\n await tester.tap(find.byType(TextField));\n await tester.pumpAndSettle();\n await tester.sendKeyEvent(LogicalKeyboardKey.backspace);\n await tester.pumpAndSettle();\n\n expect(called, isFalse);\n controller.dispose();\n });\n\n // -----------------------------------------------------------------------\n // Auto-clear on chip add (didUpdateWidget)\n // -----------------------------------------------------------------------\n\n testWidgets('clears text when a new chip is added', (tester) async {\n final controller = TextEditingController(text: 'flutt');\n\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n controller: controller,\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n\n expect(controller.text, 'flutt');\n\n // Simulate the parent adding a chip\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n controller: controller,\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n await tester.pump();\n\n expect(controller.text, isEmpty);\n controller.dispose();\n });\n\n // -----------------------------------------------------------------------\n // Disabled state\n // -----------------------------------------------------------------------\n\n testWidgets('text field is disabled when enabled is false', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n enabled: false,\n ),\n ),\n );\n final tf = tester.widget<TextField>(find.byType(TextField));\n expect(tf.enabled, isFalse);\n });\n\n // -----------------------------------------------------------------------\n // Focus\n // -----------------------------------------------------------------------\n\n testWidgets('calls onFocusChanged when focus changes', (tester) async {\n bool? focused;\n final focusNode = FocusNode();\n\n await tester.pumpWidget(\n _wrap(\n Column(\n children: [\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n focusNode: focusNode,\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n onFocusChanged: (f) => focused = f,\n ),\n const TextField(key: Key('other')),\n ],\n ),\n ),\n );\n\n await tester.tap(find.byType(TextField).first);\n await tester.pumpAndSettle();\n expect(focused, isTrue);\n\n await tester.tap(find.byKey(const Key('other')));\n await tester.pumpAndSettle();\n expect(focused, isFalse);\n\n focusNode.dispose();\n });\n\n // -----------------------------------------------------------------------\n // Accessibility\n // -----------------------------------------------------------------------\n\n testWidgets('chip has Semantics button + remove label', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n\n final btn = find.byWidgetPredicate(\n (w) =>\n w is Semantics &&\n (w.properties.button ?? false) &&\n (w.properties.label?.contains('Remove Flutter') ?? false),\n );\n expect(btn, findsOneWidget);\n });\n\n testWidgets('multiple chips each have unique remove labels', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1, _item2, _item3],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n\n for (final label in ['Remove Flutter', 'Remove Dart', 'Remove Visor']) {\n expect(\n find.byWidgetPredicate(\n (w) =>\n w is Semantics &&\n (w.properties.button ?? false) &&\n (w.properties.label == label),\n ),\n findsOneWidget,\n reason: '$label not found',\n );\n }\n });\n });\n}\n",
3999
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_chip_search_input.dart';\n\n// ---------------------------------------------------------------------------\n// Test helpers\n// ---------------------------------------------------------------------------\n\n/// A simple test item type — validates the generic `<T>` type parameter works\n/// with any consumer-defined model.\nclass _TestItem {\n const _TestItem({required this.id, required this.label});\n final String id;\n final String label;\n}\n\nconst _item1 = _TestItem(id: 'a', label: 'Flutter');\nconst _item2 = _TestItem(id: 'b', label: 'Dart');\nconst _item3 = _TestItem(id: 'c', label: 'Visor');\n\nWidget _wrap(Widget child, {TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(body: Center(child: child)),\n ),\n );\n}\n\n// ---------------------------------------------------------------------------\n// Tests\n// ---------------------------------------------------------------------------\n\nvoid main() {\n group('VisorChipSearchInput', () {\n // -----------------------------------------------------------------------\n // Smoke render\n // -----------------------------------------------------------------------\n\n testWidgets('renders with no selected items', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n expect(find.byType(VisorChipSearchInput<_TestItem>), findsOneWidget);\n expect(find.text('Search...'), findsOneWidget);\n });\n\n testWidgets('renders chips for each selected item', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1, _item2],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n expect(find.text('Flutter'), findsOneWidget);\n expect(find.text('Dart'), findsOneWidget);\n });\n\n testWidgets('hides hint when chips are present', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n final tf = tester.widget<TextField>(find.byType(TextField));\n expect(tf.decoration?.hintText, isEmpty);\n });\n\n // -----------------------------------------------------------------------\n // Generic type parameter\n // -----------------------------------------------------------------------\n\n testWidgets('works with a String type parameter', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<String>(\n selectedItems: const ['one', 'two'],\n labelBuilder: (s) => s,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n expect(find.text('one'), findsOneWidget);\n expect(find.text('two'), findsOneWidget);\n });\n\n testWidgets('works with an int type parameter', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<int>(\n selectedItems: const [42, 7],\n labelBuilder: (n) => '#$n',\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n expect(find.text('#42'), findsOneWidget);\n expect(find.text('#7'), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // Text input behaviour\n // -----------------------------------------------------------------------\n\n testWidgets('calls onQueryChanged when text is entered', (tester) async {\n String? captured;\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (q) => captured = q,\n onItemRemoved: (_) {},\n ),\n ),\n );\n await tester.enterText(find.byType(TextField), 'flutter');\n await tester.pump();\n expect(captured, 'flutter');\n });\n\n testWidgets('clear button appears after text is entered', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n\n await tester.enterText(find.byType(TextField), 'dart');\n await tester.pump();\n await tester.pump(const Duration(milliseconds: 300));\n\n expect(find.widgetWithIcon(IconButton, Icons.close), findsOneWidget);\n });\n\n testWidgets('clear button clears text and calls onQueryChanged', (tester) async {\n String? captured;\n\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (q) => captured = q,\n onItemRemoved: (_) {},\n ),\n ),\n );\n\n await tester.enterText(find.byType(TextField), 'dart');\n await tester.pump();\n await tester.pump(const Duration(milliseconds: 300));\n\n await tester.tap(find.widgetWithIcon(IconButton, Icons.close));\n await tester.pump();\n\n expect(captured, '');\n final tf = tester.widget<TextField>(find.byType(TextField));\n expect(tf.controller?.text, isEmpty);\n });\n\n // -----------------------------------------------------------------------\n // Chip removal\n // -----------------------------------------------------------------------\n\n testWidgets('calls onItemRemoved when chip remove button is tapped',\n (tester) async {\n _TestItem? removed;\n\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (item) => removed = item,\n ),\n ),\n );\n\n // Semantics button for \"Remove Flutter\"\n final removeBtn = find.byWidgetPredicate(\n (w) =>\n w is Semantics &&\n (w.properties.button ?? false) &&\n (w.properties.label?.contains('Remove Flutter') ?? false),\n );\n expect(removeBtn, findsOneWidget);\n await tester.tap(removeBtn);\n await tester.pump();\n expect(removed, _item1);\n });\n\n testWidgets('backspace on empty field removes the last chip', (tester) async {\n _TestItem? removed;\n final controller = TextEditingController();\n\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1, _item2],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n controller: controller,\n onQueryChanged: (_) {},\n onItemRemoved: (item) => removed = item,\n ),\n ),\n );\n\n await tester.tap(find.byType(TextField));\n await tester.pumpAndSettle();\n await tester.sendKeyEvent(LogicalKeyboardKey.backspace);\n await tester.pumpAndSettle();\n\n expect(removed, _item2);\n controller.dispose();\n });\n\n testWidgets('backspace with text in field does NOT remove a chip',\n (tester) async {\n _TestItem? removed;\n final controller = TextEditingController(text: 'hello');\n\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n controller: controller,\n onQueryChanged: (_) {},\n onItemRemoved: (item) => removed = item,\n ),\n ),\n );\n\n await tester.tap(find.byType(TextField));\n await tester.pumpAndSettle();\n await tester.sendKeyEvent(LogicalKeyboardKey.backspace);\n await tester.pumpAndSettle();\n\n expect(removed, isNull);\n controller.dispose();\n });\n\n testWidgets('backspace on empty field with no chips is a no-op',\n (tester) async {\n var called = false;\n final controller = TextEditingController();\n\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n controller: controller,\n onQueryChanged: (_) {},\n onItemRemoved: (_) => called = true,\n ),\n ),\n );\n\n await tester.tap(find.byType(TextField));\n await tester.pumpAndSettle();\n await tester.sendKeyEvent(LogicalKeyboardKey.backspace);\n await tester.pumpAndSettle();\n\n expect(called, isFalse);\n controller.dispose();\n });\n\n // -----------------------------------------------------------------------\n // Auto-clear on chip add (didUpdateWidget)\n // -----------------------------------------------------------------------\n\n testWidgets('clears text when a new chip is added', (tester) async {\n final controller = TextEditingController(text: 'flutt');\n\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n controller: controller,\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n\n expect(controller.text, 'flutt');\n\n // Simulate the parent adding a chip\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n controller: controller,\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n await tester.pump();\n\n expect(controller.text, isEmpty);\n controller.dispose();\n });\n\n // -----------------------------------------------------------------------\n // Disabled state\n // -----------------------------------------------------------------------\n\n testWidgets('text field is disabled when enabled is false', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n enabled: false,\n ),\n ),\n );\n final tf = tester.widget<TextField>(find.byType(TextField));\n expect(tf.enabled, isFalse);\n });\n\n // -----------------------------------------------------------------------\n // Focus\n // -----------------------------------------------------------------------\n\n testWidgets('calls onFocusChanged when focus changes', (tester) async {\n bool? focused;\n final focusNode = FocusNode();\n\n await tester.pumpWidget(\n _wrap(\n Column(\n children: [\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n focusNode: focusNode,\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n onFocusChanged: (f) => focused = f,\n ),\n const TextField(key: Key('other')),\n ],\n ),\n ),\n );\n\n await tester.tap(find.byType(TextField).first);\n await tester.pumpAndSettle();\n expect(focused, isTrue);\n\n await tester.tap(find.byKey(const Key('other')));\n await tester.pumpAndSettle();\n expect(focused, isFalse);\n\n focusNode.dispose();\n });\n\n // -----------------------------------------------------------------------\n // Accessibility\n // -----------------------------------------------------------------------\n\n testWidgets('chip has Semantics button + remove label', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n\n final btn = find.byWidgetPredicate(\n (w) =>\n w is Semantics &&\n (w.properties.button ?? false) &&\n (w.properties.label?.contains('Remove Flutter') ?? false),\n );\n expect(btn, findsOneWidget);\n });\n\n testWidgets('multiple chips each have unique remove labels', (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1, _item2, _item3],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n\n for (final label in ['Remove Flutter', 'Remove Dart', 'Remove Visor']) {\n expect(\n find.byWidgetPredicate(\n (w) =>\n w is Semantics &&\n (w.properties.button ?? false) &&\n (w.properties.label == label),\n ),\n findsOneWidget,\n reason: '$label not found',\n );\n }\n });\n\n // Rec5 — textContrastGuideline (VI-257)\n\n testWidgets('empty state renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('with selected chips renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1, _item2],\n labelBuilder: (item) => item.label,\n hintText: 'Search...',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n ),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorChipSearchInput<_TestItem>(\n selectedItems: const [_item1],\n labelBuilder: (item) => item.label,\n hintText: 'Search',\n onQueryChanged: (_) {},\n onItemRemoved: (_) {},\n ),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n });\n });\n}\n",
3772
4000
  "target": "flutter"
3773
4001
  }
3774
4002
  ]
@@ -3799,7 +4027,7 @@
3799
4027
  {
3800
4028
  "path": "components/flutter/visor_confirm_sheet/visor_confirm_sheet_test.dart",
3801
4029
  "type": "registry:ui",
3802
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_confirm_sheet.dart';\n\n/// Wraps [child] in a [MaterialApp] + [Scaffold] with the test Visor theme.\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(body: Center(child: child)),\n );\n}\n\n/// Pumps a [VisorConfirmSheet] directly (not via [show]) so we can inspect the\n/// widget tree without a route transition.\nWidget _sheet({\n String title = 'Confirm action',\n String message = 'Are you sure you want to do this?',\n String confirmLabel = 'Confirm',\n String cancelLabel = 'Cancel',\n VisorConfirmSheetVariant variant = VisorConfirmSheetVariant.standard,\n IconData? icon,\n VoidCallback? onConfirm,\n VoidCallback? onCancel,\n}) {\n return _wrap(\n VisorConfirmSheet(\n title: title,\n message: message,\n confirmLabel: confirmLabel,\n cancelLabel: cancelLabel,\n variant: variant,\n icon: icon,\n onConfirm: onConfirm ?? () {},\n onCancel: onCancel,\n ),\n );\n}\n\nvoid main() {\n group('VisorConfirmSheet', () {\n // -----------------------------------------------------------------------\n // Render — basic content\n // -----------------------------------------------------------------------\n\n testWidgets('renders title and message', (tester) async {\n await tester.pumpWidget(_sheet(\n title: 'Delete item',\n message: 'This cannot be undone.',\n ));\n\n expect(find.text('Delete item'), findsOneWidget);\n expect(find.text('This cannot be undone.'), findsOneWidget);\n });\n\n testWidgets('renders confirm and cancel buttons', (tester) async {\n await tester.pumpWidget(_sheet(\n confirmLabel: 'Remove',\n cancelLabel: 'Go back',\n ));\n\n expect(find.text('Remove'), findsOneWidget);\n expect(find.text('Go back'), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // Callbacks\n // -----------------------------------------------------------------------\n\n testWidgets('onConfirm fires when confirm button is tapped', (tester) async {\n var confirmCalled = false;\n\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: VisorConfirmSheet(\n title: 'Confirm',\n message: 'Are you sure?',\n confirmLabel: 'Yes',\n onConfirm: () => confirmCalled = true,\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('Yes'));\n await tester.pumpAndSettle();\n\n expect(confirmCalled, isTrue);\n });\n\n testWidgets('onCancel fires when cancel button is tapped', (tester) async {\n var cancelCalled = false;\n\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: VisorConfirmSheet(\n title: 'Confirm',\n message: 'Are you sure?',\n confirmLabel: 'Yes',\n onConfirm: () {},\n onCancel: () => cancelCalled = true,\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('Cancel'));\n await tester.pumpAndSettle();\n\n expect(cancelCalled, isTrue);\n });\n\n testWidgets('onCancel is optional — no crash when null', (tester) async {\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: VisorConfirmSheet(\n title: 'Confirm',\n message: 'Are you sure?',\n confirmLabel: 'Yes',\n onConfirm: () {},\n // onCancel intentionally omitted\n ),\n ),\n ),\n );\n\n // Should not throw.\n await tester.tap(find.text('Cancel'));\n await tester.pumpAndSettle();\n });\n\n // -----------------------------------------------------------------------\n // Variants\n // -----------------------------------------------------------------------\n\n testWidgets('standard variant renders without error', (tester) async {\n await tester.pumpWidget(_sheet(\n variant: VisorConfirmSheetVariant.standard,\n confirmLabel: 'Archive',\n ));\n expect(find.text('Archive'), findsOneWidget);\n });\n\n testWidgets('destructive variant renders without error', (tester) async {\n await tester.pumpWidget(_sheet(\n variant: VisorConfirmSheetVariant.destructive,\n confirmLabel: 'Delete',\n ));\n expect(find.text('Delete'), findsOneWidget);\n });\n\n testWidgets('custom icon appears on confirm button', (tester) async {\n await tester.pumpWidget(_sheet(\n icon: Icons.warning_amber_rounded,\n confirmLabel: 'Proceed',\n ));\n expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // Adaptive presenter — routes by viewport width\n // -----------------------------------------------------------------------\n\n testWidgets('show() presents a bottom sheet on compact viewport',\n (tester) async {\n // Set a compact screen size (width < 600).\n tester.view.physicalSize = const Size(390 * 3, 844 * 3);\n tester.view.devicePixelRatio = 3.0;\n addTearDown(tester.view.reset);\n\n var confirmed = false;\n\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Builder(\n builder: (ctx) => Scaffold(\n body: Center(\n child: ElevatedButton(\n onPressed: () => VisorConfirmSheet.show(\n context: ctx,\n title: 'Bottom sheet',\n message: 'Compact viewport.',\n confirmLabel: 'OK',\n onConfirm: () => confirmed = true,\n ),\n child: const Text('Open'),\n ),\n ),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('Open'));\n await tester.pumpAndSettle();\n\n // Content should be visible in the bottom sheet.\n expect(find.text('Bottom sheet'), findsOneWidget);\n expect(find.text('Compact viewport.'), findsOneWidget);\n\n await tester.tap(find.text('OK'));\n await tester.pumpAndSettle();\n\n expect(confirmed, isTrue);\n });\n\n testWidgets('show() presents a dialog on wide viewport', (tester) async {\n // Set a wide screen size (width >= 600).\n tester.view.physicalSize = const Size(1024 * 3, 768 * 3);\n tester.view.devicePixelRatio = 3.0;\n addTearDown(tester.view.reset);\n\n var confirmed = false;\n\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Builder(\n builder: (ctx) => Scaffold(\n body: Center(\n child: ElevatedButton(\n onPressed: () => VisorConfirmSheet.show(\n context: ctx,\n title: 'Dialog title',\n message: 'Wide viewport.',\n confirmLabel: 'Confirm',\n onConfirm: () => confirmed = true,\n ),\n child: const Text('Open'),\n ),\n ),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('Open'));\n await tester.pumpAndSettle();\n\n // Content should be visible in the dialog.\n expect(find.text('Dialog title'), findsOneWidget);\n expect(find.text('Wide viewport.'), findsOneWidget);\n\n await tester.tap(find.text('Confirm'));\n await tester.pumpAndSettle();\n\n expect(confirmed, isTrue);\n });\n\n // -----------------------------------------------------------------------\n // Semantics\n // -----------------------------------------------------------------------\n\n testWidgets('title text is present in the widget tree for accessibility',\n (tester) async {\n await tester.pumpWidget(_sheet(title: 'Dangerous action'));\n\n // The title is rendered as a Text widget that screen readers can\n // announce. We verify it's in the tree (semantic accessibility).\n expect(find.text('Dangerous action'), findsOneWidget);\n });\n });\n}\n",
4030
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_confirm_sheet.dart';\n\n/// Wraps [child] in a [MaterialApp] + [Scaffold] with the test Visor theme.\nWidget _wrap(Widget child, {TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(body: Center(child: child)),\n ),\n );\n}\n\n/// Pumps a [VisorConfirmSheet] directly (not via [show]) so we can inspect the\n/// widget tree without a route transition.\nWidget _sheet({\n String title = 'Confirm action',\n String message = 'Are you sure you want to do this?',\n String confirmLabel = 'Confirm',\n String cancelLabel = 'Cancel',\n VisorConfirmSheetVariant variant = VisorConfirmSheetVariant.standard,\n IconData? icon,\n VoidCallback? onConfirm,\n VoidCallback? onCancel,\n}) {\n return _wrap(\n VisorConfirmSheet(\n title: title,\n message: message,\n confirmLabel: confirmLabel,\n cancelLabel: cancelLabel,\n variant: variant,\n icon: icon,\n onConfirm: onConfirm ?? () {},\n onCancel: onCancel,\n ),\n );\n}\n\nvoid main() {\n group('VisorConfirmSheet', () {\n // -----------------------------------------------------------------------\n // Render — basic content\n // -----------------------------------------------------------------------\n\n testWidgets('renders title and message', (tester) async {\n await tester.pumpWidget(_sheet(\n title: 'Delete item',\n message: 'This cannot be undone.',\n ));\n\n expect(find.text('Delete item'), findsOneWidget);\n expect(find.text('This cannot be undone.'), findsOneWidget);\n });\n\n testWidgets('renders confirm and cancel buttons', (tester) async {\n await tester.pumpWidget(_sheet(\n confirmLabel: 'Remove',\n cancelLabel: 'Go back',\n ));\n\n expect(find.text('Remove'), findsOneWidget);\n expect(find.text('Go back'), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // Callbacks\n // -----------------------------------------------------------------------\n\n testWidgets('onConfirm fires when confirm button is tapped', (tester) async {\n var confirmCalled = false;\n\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: VisorConfirmSheet(\n title: 'Confirm',\n message: 'Are you sure?',\n confirmLabel: 'Yes',\n onConfirm: () => confirmCalled = true,\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('Yes'));\n await tester.pumpAndSettle();\n\n expect(confirmCalled, isTrue);\n });\n\n testWidgets('onCancel fires when cancel button is tapped', (tester) async {\n var cancelCalled = false;\n\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: VisorConfirmSheet(\n title: 'Confirm',\n message: 'Are you sure?',\n confirmLabel: 'Yes',\n onConfirm: () {},\n onCancel: () => cancelCalled = true,\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('Cancel'));\n await tester.pumpAndSettle();\n\n expect(cancelCalled, isTrue);\n });\n\n testWidgets('onCancel is optional — no crash when null', (tester) async {\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: VisorConfirmSheet(\n title: 'Confirm',\n message: 'Are you sure?',\n confirmLabel: 'Yes',\n onConfirm: () {},\n // onCancel intentionally omitted\n ),\n ),\n ),\n );\n\n // Should not throw.\n await tester.tap(find.text('Cancel'));\n await tester.pumpAndSettle();\n });\n\n // -----------------------------------------------------------------------\n // Variants\n // -----------------------------------------------------------------------\n\n testWidgets('standard variant renders without error', (tester) async {\n await tester.pumpWidget(_sheet(\n variant: VisorConfirmSheetVariant.standard,\n confirmLabel: 'Archive',\n ));\n expect(find.text('Archive'), findsOneWidget);\n });\n\n testWidgets('destructive variant renders without error', (tester) async {\n await tester.pumpWidget(_sheet(\n variant: VisorConfirmSheetVariant.destructive,\n confirmLabel: 'Delete',\n ));\n expect(find.text('Delete'), findsOneWidget);\n });\n\n testWidgets('custom icon appears on confirm button', (tester) async {\n await tester.pumpWidget(_sheet(\n icon: Icons.warning_amber_rounded,\n confirmLabel: 'Proceed',\n ));\n expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // Adaptive presenter — routes by viewport width\n // -----------------------------------------------------------------------\n\n testWidgets('show() presents a bottom sheet on compact viewport',\n (tester) async {\n // Set a compact screen size (width < 600).\n tester.view.physicalSize = const Size(390 * 3, 844 * 3);\n tester.view.devicePixelRatio = 3.0;\n addTearDown(tester.view.reset);\n\n var confirmed = false;\n\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Builder(\n builder: (ctx) => Scaffold(\n body: Center(\n child: ElevatedButton(\n onPressed: () => VisorConfirmSheet.show(\n context: ctx,\n title: 'Bottom sheet',\n message: 'Compact viewport.',\n confirmLabel: 'OK',\n onConfirm: () => confirmed = true,\n ),\n child: const Text('Open'),\n ),\n ),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('Open'));\n await tester.pumpAndSettle();\n\n // Content should be visible in the bottom sheet.\n expect(find.text('Bottom sheet'), findsOneWidget);\n expect(find.text('Compact viewport.'), findsOneWidget);\n\n await tester.tap(find.text('OK'));\n await tester.pumpAndSettle();\n\n expect(confirmed, isTrue);\n });\n\n testWidgets('show() presents a dialog on wide viewport', (tester) async {\n // Set a wide screen size (width >= 600).\n tester.view.physicalSize = const Size(1024 * 3, 768 * 3);\n tester.view.devicePixelRatio = 3.0;\n addTearDown(tester.view.reset);\n\n var confirmed = false;\n\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Builder(\n builder: (ctx) => Scaffold(\n body: Center(\n child: ElevatedButton(\n onPressed: () => VisorConfirmSheet.show(\n context: ctx,\n title: 'Dialog title',\n message: 'Wide viewport.',\n confirmLabel: 'Confirm',\n onConfirm: () => confirmed = true,\n ),\n child: const Text('Open'),\n ),\n ),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('Open'));\n await tester.pumpAndSettle();\n\n // Content should be visible in the dialog.\n expect(find.text('Dialog title'), findsOneWidget);\n expect(find.text('Wide viewport.'), findsOneWidget);\n\n await tester.tap(find.text('Confirm'));\n await tester.pumpAndSettle();\n\n expect(confirmed, isTrue);\n });\n\n // -----------------------------------------------------------------------\n // Semantics\n // -----------------------------------------------------------------------\n\n testWidgets('title text is present in the widget tree for accessibility',\n (tester) async {\n await tester.pumpWidget(_sheet(title: 'Dangerous action'));\n\n // The title is rendered as a Text widget that screen readers can\n // announce. We verify it's in the tree (semantic accessibility).\n expect(find.text('Dangerous action'), findsOneWidget);\n });\n\n // Rec5 — textContrastGuideline (VI-257)\n\n testWidgets('standard variant renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_sheet(\n variant: VisorConfirmSheetVariant.standard,\n title: 'Archive item',\n message: 'This will archive the item.',\n confirmLabel: 'Archive',\n ));\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('destructive variant renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_sheet(\n variant: VisorConfirmSheetVariant.destructive,\n title: 'Delete item',\n message: 'This cannot be undone.',\n confirmLabel: 'Delete',\n ));\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorConfirmSheet(\n title: 'Confirm action',\n message: 'Are you sure?',\n confirmLabel: 'Confirm',\n cancelLabel: 'Cancel',\n onConfirm: () {},\n ),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorConfirmSheet), findsOneWidget);\n });\n });\n}\n",
3803
4031
  "target": "flutter"
3804
4032
  }
3805
4033
  ]
@@ -3826,7 +4054,7 @@
3826
4054
  {
3827
4055
  "path": "components/flutter/visor_empty_state/visor_empty_state_test.dart",
3828
4056
  "type": "registry:ui",
3829
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_empty_state.dart';\n\nWidget _wrap(Widget child, {Size surfaceSize = const Size(400, 800)}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: Center(\n child: SizedBox(\n width: surfaceSize.width,\n height: surfaceSize.height,\n child: child,\n ),\n ),\n ),\n );\n}\n\nvoid main() {\n group('VisorEmptyState', () {\n // ──────────────────────────────────────────────────────────────────────\n // Baseline / backwards-compatibility\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('renders icon and headline', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n )));\n expect(find.byIcon(Icons.inbox_outlined), findsOneWidget);\n expect(find.text('No messages'), findsOneWidget);\n });\n\n testWidgets('renders body when provided', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n body: 'You are all caught up.',\n )));\n expect(find.text('You are all caught up.'), findsOneWidget);\n });\n\n testWidgets('omits body when null', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n )));\n // Only one Text widget (the headline) is shown.\n expect(find.byType(Text), findsOneWidget);\n });\n\n testWidgets('renders action widget when provided', (tester) async {\n await tester.pumpWidget(_wrap(VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n action: FilledButton(\n onPressed: () {},\n child: const Text('Refresh'),\n ),\n )));\n expect(find.byType(FilledButton), findsOneWidget);\n expect(find.text('Refresh'), findsOneWidget);\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Secondary action slot\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('renders secondary action when both action and secondaryAction provided',\n (tester) async {\n await tester.pumpWidget(_wrap(VisorEmptyState(\n icon: Icons.folder_open,\n headline: 'No projects',\n action: FilledButton(\n onPressed: () {},\n child: const Text('Create new'),\n ),\n secondaryAction: OutlinedButton(\n onPressed: () {},\n child: const Text('Import existing'),\n ),\n )));\n expect(find.text('Create new'), findsOneWidget);\n expect(find.text('Import existing'), findsOneWidget);\n });\n\n testWidgets('omits secondary action when not provided', (tester) async {\n await tester.pumpWidget(_wrap(VisorEmptyState(\n icon: Icons.folder_open,\n headline: 'No projects',\n action: FilledButton(\n onPressed: () {},\n child: const Text('Create new'),\n ),\n )));\n expect(find.text('Create new'), findsOneWidget);\n expect(find.byType(Wrap), findsNothing);\n });\n\n testWidgets('wraps dual actions in a Wrap widget', (tester) async {\n await tester.pumpWidget(_wrap(VisorEmptyState(\n icon: Icons.folder_open,\n headline: 'No projects',\n action: FilledButton(\n onPressed: () {},\n child: const Text('Create new'),\n ),\n secondaryAction: OutlinedButton(\n onPressed: () {},\n child: const Text('Import existing'),\n ),\n )));\n expect(find.byType(Wrap), findsOneWidget);\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Compact layout — forceCompact override\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('forceCompact renders Row layout', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n body: 'Nothing here.',\n forceCompact: true,\n )));\n // The compact layout uses a top-level Row; standard uses Column.\n expect(find.byType(Row), findsAtLeastNWidgets(1));\n });\n\n testWidgets('standard layout uses Column (not forceCompact)', (tester) async {\n // Surface height is 800 — well above the 400 px threshold.\n await tester.pumpWidget(_wrap(\n const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n ),\n surfaceSize: const Size(400, 800),\n ));\n // Standard layout wraps content in a Column (no outer Row).\n expect(find.byType(Column), findsAtLeastNWidgets(1));\n });\n\n testWidgets('compact layout activates automatically below 400 px height',\n (tester) async {\n // Constrain the surface to 300 px — below the compact threshold.\n await tester.pumpWidget(_wrap(\n const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n ),\n surfaceSize: const Size(400, 300),\n ));\n // The compact layout leads with a Row containing the icon.\n expect(find.byType(Row), findsAtLeastNWidgets(1));\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // iconSize\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('defaults iconSize to 48', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n )));\n final icon = tester.widget<Icon>(find.byIcon(Icons.inbox_outlined));\n expect(icon.size, 48);\n });\n\n testWidgets('respects custom iconSize', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n iconSize: 32,\n )));\n final icon = tester.widget<Icon>(find.byIcon(Icons.inbox_outlined));\n // Standard layout uses the full iconSize; compact scales it down.\n // With surfaceSize height=800 (standard), size should be 32.\n expect(icon.size, 32);\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Semantics\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('wraps content in a Semantics container with headline as default label',\n (tester) async {\n final handle = tester.ensureSemantics();\n\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n )));\n\n final semanticsNode =\n tester.getSemantics(find.byType(VisorEmptyState));\n // The Semantics container label defaults to headline.\n expect(semanticsNode.label, 'No messages');\n\n handle.dispose();\n });\n\n testWidgets('semanticLabel param overrides the default label', (tester) async {\n final handle = tester.ensureSemantics();\n\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n semanticLabel: 'Inbox is empty',\n )));\n\n final semanticsNode =\n tester.getSemantics(find.byType(VisorEmptyState));\n expect(semanticsNode.label, 'Inbox is empty');\n\n handle.dispose();\n });\n });\n}\n",
4057
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_empty_state.dart';\n\nWidget _wrap(Widget child, {Size surfaceSize = const Size(400, 800), TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(\n body: Center(\n child: SizedBox(\n width: surfaceSize.width,\n height: surfaceSize.height,\n child: child,\n ),\n ),\n ),\n ),\n );\n}\n\nvoid main() {\n group('VisorEmptyState', () {\n // ──────────────────────────────────────────────────────────────────────\n // Baseline / backwards-compatibility\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('renders icon and headline', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n )));\n expect(find.byIcon(Icons.inbox_outlined), findsOneWidget);\n expect(find.text('No messages'), findsOneWidget);\n });\n\n testWidgets('renders body when provided', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n body: 'You are all caught up.',\n )));\n expect(find.text('You are all caught up.'), findsOneWidget);\n });\n\n testWidgets('omits body when null', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n )));\n // Only one Text widget (the headline) is shown.\n expect(find.byType(Text), findsOneWidget);\n });\n\n testWidgets('renders action widget when provided', (tester) async {\n await tester.pumpWidget(_wrap(VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n action: FilledButton(\n onPressed: () {},\n child: const Text('Refresh'),\n ),\n )));\n expect(find.byType(FilledButton), findsOneWidget);\n expect(find.text('Refresh'), findsOneWidget);\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Secondary action slot\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('renders secondary action when both action and secondaryAction provided',\n (tester) async {\n await tester.pumpWidget(_wrap(VisorEmptyState(\n icon: Icons.folder_open,\n headline: 'No projects',\n action: FilledButton(\n onPressed: () {},\n child: const Text('Create new'),\n ),\n secondaryAction: OutlinedButton(\n onPressed: () {},\n child: const Text('Import existing'),\n ),\n )));\n expect(find.text('Create new'), findsOneWidget);\n expect(find.text('Import existing'), findsOneWidget);\n });\n\n testWidgets('omits secondary action when not provided', (tester) async {\n await tester.pumpWidget(_wrap(VisorEmptyState(\n icon: Icons.folder_open,\n headline: 'No projects',\n action: FilledButton(\n onPressed: () {},\n child: const Text('Create new'),\n ),\n )));\n expect(find.text('Create new'), findsOneWidget);\n expect(find.byType(Wrap), findsNothing);\n });\n\n testWidgets('wraps dual actions in a Wrap widget', (tester) async {\n await tester.pumpWidget(_wrap(VisorEmptyState(\n icon: Icons.folder_open,\n headline: 'No projects',\n action: FilledButton(\n onPressed: () {},\n child: const Text('Create new'),\n ),\n secondaryAction: OutlinedButton(\n onPressed: () {},\n child: const Text('Import existing'),\n ),\n )));\n expect(find.byType(Wrap), findsOneWidget);\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Compact layout — forceCompact override\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('forceCompact renders Row layout', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n body: 'Nothing here.',\n forceCompact: true,\n )));\n // The compact layout uses a top-level Row; standard uses Column.\n expect(find.byType(Row), findsAtLeastNWidgets(1));\n });\n\n testWidgets('standard layout uses Column (not forceCompact)', (tester) async {\n // Surface height is 800 — well above the 400 px threshold.\n await tester.pumpWidget(_wrap(\n const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n ),\n surfaceSize: const Size(400, 800),\n ));\n // Standard layout wraps content in a Column (no outer Row).\n expect(find.byType(Column), findsAtLeastNWidgets(1));\n });\n\n testWidgets('compact layout activates automatically below 400 px height',\n (tester) async {\n // Constrain the surface to 300 px — below the compact threshold.\n await tester.pumpWidget(_wrap(\n const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n ),\n surfaceSize: const Size(400, 300),\n ));\n // The compact layout leads with a Row containing the icon.\n expect(find.byType(Row), findsAtLeastNWidgets(1));\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // iconSize\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('defaults iconSize to 48', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n )));\n final icon = tester.widget<Icon>(find.byIcon(Icons.inbox_outlined));\n expect(icon.size, 48);\n });\n\n testWidgets('respects custom iconSize', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n iconSize: 32,\n )));\n final icon = tester.widget<Icon>(find.byIcon(Icons.inbox_outlined));\n // Standard layout uses the full iconSize; compact scales it down.\n // With surfaceSize height=800 (standard), size should be 32.\n expect(icon.size, 32);\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Semantics\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('wraps content in a Semantics container with headline as default label',\n (tester) async {\n final handle = tester.ensureSemantics();\n\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n )));\n\n final semanticsNode =\n tester.getSemantics(find.byType(VisorEmptyState));\n // The Semantics container label defaults to headline.\n expect(semanticsNode.label, 'No messages');\n\n handle.dispose();\n });\n\n testWidgets('semanticLabel param overrides the default label', (tester) async {\n final handle = tester.ensureSemantics();\n\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n semanticLabel: 'Inbox is empty',\n )));\n\n final semanticsNode =\n tester.getSemantics(find.byType(VisorEmptyState));\n expect(semanticsNode.label, 'Inbox is empty');\n\n handle.dispose();\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Rec5 — textContrastGuideline (VI-257)\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('renders with sufficient text contrast (standard layout)',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n body: 'You are all caught up.',\n )));\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('renders with sufficient text contrast (compact layout)',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(\n const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n forceCompact: true,\n ),\n ));\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorEmptyState(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n ),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorEmptyState), findsOneWidget);\n });\n });\n}\n",
3830
4058
  "target": "flutter"
3831
4059
  }
3832
4060
  ]
@@ -3853,7 +4081,7 @@
3853
4081
  {
3854
4082
  "path": "components/flutter/visor_empty_state_card/visor_empty_state_card_test.dart",
3855
4083
  "type": "registry:ui",
3856
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport '../visor_empty_state/visor_empty_state.dart';\nimport 'visor_empty_state_card.dart';\n\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: Center(\n child: SizedBox(\n width: 360,\n // Give plenty of height so the card itself has room; the inner\n // VisorEmptyState forces compact via forceCompact regardless.\n height: 800,\n child: child,\n ),\n ),\n ),\n );\n}\n\nvoid main() {\n group('VisorEmptyStateCard', () {\n testWidgets('renders icon and headline', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyStateCard(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n )));\n expect(find.byIcon(Icons.inbox_outlined), findsOneWidget);\n expect(find.text('No messages'), findsOneWidget);\n });\n\n testWidgets('renders body when provided', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyStateCard(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n body: 'All caught up.',\n )));\n expect(find.text('All caught up.'), findsOneWidget);\n });\n\n testWidgets('always uses compact layout via forceCompact', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyStateCard(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n )));\n // VisorEmptyStateCard always delegates with forceCompact: true.\n final emptyState = tester.widget<VisorEmptyState>(\n find.byType(VisorEmptyState),\n );\n expect(emptyState.forceCompact, isTrue);\n });\n\n testWidgets('renders inside a decorated Container (card chrome)', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyStateCard(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n )));\n // The card chrome is a Container with a BoxDecoration.\n final containers = tester\n .widgetList<Container>(find.byType(Container))\n .where((c) => c.decoration is BoxDecoration)\n .toList();\n expect(containers, isNotEmpty);\n });\n\n testWidgets('renders action when provided', (tester) async {\n await tester.pumpWidget(_wrap(VisorEmptyStateCard(\n icon: Icons.folder_open,\n headline: 'No projects',\n action: FilledButton(\n onPressed: () {},\n child: const Text('Create project'),\n ),\n )));\n expect(find.text('Create project'), findsOneWidget);\n });\n\n testWidgets('renders both action and secondaryAction', (tester) async {\n await tester.pumpWidget(_wrap(VisorEmptyStateCard(\n icon: Icons.folder_open,\n headline: 'No projects',\n action: FilledButton(\n onPressed: () {},\n child: const Text('Create new'),\n ),\n secondaryAction: OutlinedButton(\n onPressed: () {},\n child: const Text('Import existing'),\n ),\n )));\n expect(find.text('Create new'), findsOneWidget);\n expect(find.text('Import existing'), findsOneWidget);\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Semantics — inherited from inner VisorEmptyState (VI-247 / VI-249)\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('announces as a single Semantics container with headline as default label',\n (tester) async {\n final handle = tester.ensureSemantics();\n\n await tester.pumpWidget(_wrap(const VisorEmptyStateCard(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n )));\n\n // The container is provided by the inner VisorEmptyState; the card\n // chrome must NOT add a second Semantics(container: true). The inner\n // node's label defaults to the headline.\n final node = tester.getSemantics(find.byType(VisorEmptyState));\n expect(node.label, 'No messages');\n\n // Single-announcement invariant: exactly one container Semantics in\n // the card's subtree. A second one (e.g., on the card's outer\n // Container) would cause double announcements on TalkBack/VoiceOver.\n final containerSemantics = tester\n .widgetList<Semantics>(find.descendant(\n of: find.byType(VisorEmptyStateCard),\n matching: find.byType(Semantics),\n ))\n .where((s) => s.container)\n .toList();\n expect(containerSemantics, hasLength(1));\n\n handle.dispose();\n });\n\n testWidgets('semanticLabel param overrides the announced label', (tester) async {\n final handle = tester.ensureSemantics();\n\n await tester.pumpWidget(_wrap(const VisorEmptyStateCard(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n semanticLabel: 'Inbox is empty',\n )));\n\n final node = tester.getSemantics(find.byType(VisorEmptyState));\n expect(node.label, 'Inbox is empty');\n\n handle.dispose();\n });\n\n testWidgets('forwards semanticLabel to inner VisorEmptyState', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyStateCard(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n semanticLabel: 'custom card label',\n )));\n\n final inner = tester.widget<VisorEmptyState>(find.byType(VisorEmptyState));\n expect(inner.semanticLabel, 'custom card label');\n });\n });\n}\n",
4084
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport '../visor_empty_state/visor_empty_state.dart';\nimport 'visor_empty_state_card.dart';\n\nWidget _wrap(Widget child, {TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(\n body: Center(\n child: SizedBox(\n width: 360,\n // Give plenty of height so the card itself has room; the inner\n // VisorEmptyState forces compact via forceCompact regardless.\n height: 800,\n child: child,\n ),\n ),\n ),\n ),\n );\n}\n\nvoid main() {\n group('VisorEmptyStateCard', () {\n testWidgets('renders icon and headline', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyStateCard(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n )));\n expect(find.byIcon(Icons.inbox_outlined), findsOneWidget);\n expect(find.text('No messages'), findsOneWidget);\n });\n\n testWidgets('renders body when provided', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyStateCard(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n body: 'All caught up.',\n )));\n expect(find.text('All caught up.'), findsOneWidget);\n });\n\n testWidgets('always uses compact layout via forceCompact', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyStateCard(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n )));\n // VisorEmptyStateCard always delegates with forceCompact: true.\n final emptyState = tester.widget<VisorEmptyState>(\n find.byType(VisorEmptyState),\n );\n expect(emptyState.forceCompact, isTrue);\n });\n\n testWidgets('renders inside a decorated Container (card chrome)', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyStateCard(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n )));\n // The card chrome is a Container with a BoxDecoration.\n final containers = tester\n .widgetList<Container>(find.byType(Container))\n .where((c) => c.decoration is BoxDecoration)\n .toList();\n expect(containers, isNotEmpty);\n });\n\n testWidgets('renders action when provided', (tester) async {\n await tester.pumpWidget(_wrap(VisorEmptyStateCard(\n icon: Icons.folder_open,\n headline: 'No projects',\n action: FilledButton(\n onPressed: () {},\n child: const Text('Create project'),\n ),\n )));\n expect(find.text('Create project'), findsOneWidget);\n });\n\n testWidgets('renders both action and secondaryAction', (tester) async {\n await tester.pumpWidget(_wrap(VisorEmptyStateCard(\n icon: Icons.folder_open,\n headline: 'No projects',\n action: FilledButton(\n onPressed: () {},\n child: const Text('Create new'),\n ),\n secondaryAction: OutlinedButton(\n onPressed: () {},\n child: const Text('Import existing'),\n ),\n )));\n expect(find.text('Create new'), findsOneWidget);\n expect(find.text('Import existing'), findsOneWidget);\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Semantics — inherited from inner VisorEmptyState (VI-247 / VI-249)\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('announces as a single Semantics container with headline as default label',\n (tester) async {\n final handle = tester.ensureSemantics();\n\n await tester.pumpWidget(_wrap(const VisorEmptyStateCard(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n )));\n\n // The container is provided by the inner VisorEmptyState; the card\n // chrome must NOT add a second Semantics(container: true). The inner\n // node's label defaults to the headline.\n final node = tester.getSemantics(find.byType(VisorEmptyState));\n expect(node.label, 'No messages');\n\n // Single-announcement invariant: exactly one container Semantics in\n // the card's subtree. A second one (e.g., on the card's outer\n // Container) would cause double announcements on TalkBack/VoiceOver.\n final containerSemantics = tester\n .widgetList<Semantics>(find.descendant(\n of: find.byType(VisorEmptyStateCard),\n matching: find.byType(Semantics),\n ))\n .where((s) => s.container)\n .toList();\n expect(containerSemantics, hasLength(1));\n\n handle.dispose();\n });\n\n testWidgets('semanticLabel param overrides the announced label', (tester) async {\n final handle = tester.ensureSemantics();\n\n await tester.pumpWidget(_wrap(const VisorEmptyStateCard(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n semanticLabel: 'Inbox is empty',\n )));\n\n final node = tester.getSemantics(find.byType(VisorEmptyState));\n expect(node.label, 'Inbox is empty');\n\n handle.dispose();\n });\n\n testWidgets('forwards semanticLabel to inner VisorEmptyState', (tester) async {\n await tester.pumpWidget(_wrap(const VisorEmptyStateCard(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n semanticLabel: 'custom card label',\n )));\n\n final inner = tester.widget<VisorEmptyState>(find.byType(VisorEmptyState));\n expect(inner.semanticLabel, 'custom card label');\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorEmptyStateCard(\n icon: Icons.inbox_outlined,\n headline: 'No messages',\n ),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorEmptyStateCard), findsOneWidget);\n });\n });\n}\n",
3857
4085
  "target": "flutter"
3858
4086
  }
3859
4087
  ]
@@ -3880,7 +4108,7 @@
3880
4108
  {
3881
4109
  "path": "components/flutter/visor_error_view/visor_error_view_test.dart",
3882
4110
  "type": "registry:ui",
3883
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_error_view.dart';\n\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: SizedBox(\n width: 400,\n height: 800,\n child: child,\n ),\n ),\n );\n}\n\nvoid main() {\n group('VisorErrorView', () {\n // ──────────────────────────────────────────────────────────────────────\n // Smoke + basic render\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('renders message text', (tester) async {\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Something went wrong.',\n )));\n expect(find.text('Something went wrong.'), findsOneWidget);\n });\n\n testWidgets('renders default icon', (tester) async {\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Something went wrong.',\n )));\n expect(find.byIcon(Icons.error_outline), findsOneWidget);\n });\n\n testWidgets('renders custom icon when provided', (tester) async {\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'No network.',\n icon: Icons.wifi_off,\n )));\n expect(find.byIcon(Icons.wifi_off), findsOneWidget);\n expect(find.byIcon(Icons.error_outline), findsNothing);\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Optional body copy\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('renders body copy when provided', (tester) async {\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Could not load your timeline.',\n body: 'Check your connection and try again.',\n )));\n expect(find.text('Check your connection and try again.'), findsOneWidget);\n });\n\n testWidgets('omits body copy when null', (tester) async {\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Something went wrong.',\n )));\n // Only one Text widget: the message. No body.\n expect(\n tester.widgetList<Text>(find.byType(Text)).length,\n equals(1),\n );\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Retry button — presence / absence\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('omits retry button when retryCallback is null', (tester) async {\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Something went wrong.',\n )));\n expect(find.byType(OutlinedButton), findsNothing);\n });\n\n testWidgets('renders retry button when retryCallback is provided',\n (tester) async {\n await tester.pumpWidget(_wrap(VisorErrorView(\n message: 'Something went wrong.',\n retryCallback: () {},\n )));\n expect(find.byType(OutlinedButton), findsOneWidget);\n expect(find.text('Try again'), findsOneWidget);\n });\n\n testWidgets('retry button uses custom retryLabel when provided',\n (tester) async {\n await tester.pumpWidget(_wrap(VisorErrorView(\n message: 'Something went wrong.',\n retryCallback: () {},\n retryLabel: 'Reload',\n )));\n expect(find.text('Reload'), findsOneWidget);\n expect(find.text('Try again'), findsNothing);\n });\n\n testWidgets('retry button fires retryCallback on tap', (tester) async {\n var tapped = false;\n await tester.pumpWidget(_wrap(VisorErrorView(\n message: 'Something went wrong.',\n retryCallback: () => tapped = true,\n )));\n await tester.tap(find.byType(OutlinedButton));\n await tester.pump();\n expect(tapped, isTrue);\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Scaffold wrap\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('does not render Scaffold-owned AppBar by default',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Something went wrong.',\n )));\n // The outer _wrap() provides a Scaffold; there should be no AppBar.\n expect(find.byType(AppBar), findsNothing);\n });\n\n testWidgets('renders AppBar when wrapWithScaffold is true', (tester) async {\n await tester.pumpWidget(MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: const VisorErrorView(\n message: 'Something went wrong.',\n wrapWithScaffold: true,\n ),\n ));\n expect(find.byType(AppBar), findsOneWidget);\n });\n\n testWidgets('renders scaffold title when provided', (tester) async {\n await tester.pumpWidget(MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: const VisorErrorView(\n message: 'Something went wrong.',\n wrapWithScaffold: true,\n scaffoldTitle: 'Error',\n ),\n ));\n // AppBar title text.\n expect(find.text('Error'), findsOneWidget);\n });\n\n testWidgets('renders full error view with all props', (tester) async {\n var retried = false;\n await tester.pumpWidget(MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: VisorErrorView(\n message: 'Network error.',\n body: 'Please check your connection.',\n icon: Icons.wifi_off,\n retryCallback: () => retried = true,\n retryLabel: 'Reconnect',\n wrapWithScaffold: true,\n scaffoldTitle: 'Network Error',\n ),\n ));\n expect(find.text('Network Error'), findsOneWidget);\n expect(find.byIcon(Icons.wifi_off), findsOneWidget);\n expect(find.text('Network error.'), findsOneWidget);\n expect(find.text('Please check your connection.'), findsOneWidget);\n expect(find.text('Reconnect'), findsOneWidget);\n await tester.tap(find.byType(OutlinedButton));\n await tester.pump();\n expect(retried, isTrue);\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Semantics — R6, R11, Rec7\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('Semantics container label defaults to the message',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Something went wrong.',\n )));\n // The error region wraps in Semantics(container: true, liveRegion: true,\n // excludeSemantics: true). We locate the Semantics widget with\n // container: true that is a descendant of VisorErrorView.\n final node = tester.getSemantics(\n find.descendant(\n of: find.byType(VisorErrorView),\n matching: find.byWidgetPredicate(\n (w) => w is Semantics && w.container == true,\n ),\n ),\n );\n expect(node.label, 'Something went wrong.');\n handle.dispose();\n });\n\n testWidgets('semanticLabel param overrides the announced label',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Something went wrong.',\n semanticLabel: 'Error: failed to load feed',\n )));\n final node = tester.getSemantics(\n find.descendant(\n of: find.byType(VisorErrorView),\n matching: find.byWidgetPredicate(\n (w) => w is Semantics && w.container == true,\n ),\n ),\n );\n expect(node.label, 'Error: failed to load feed');\n handle.dispose();\n });\n\n testWidgets('retry button exposes Semantics(button: true, label: …)',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(VisorErrorView(\n message: 'Something went wrong.',\n retryCallback: () {},\n retryLabel: 'Try again',\n )));\n final node = tester.getSemantics(find.byType(OutlinedButton));\n // Button must be labeled so the tap target is also labeled (R11).\n expect(node.flagsCollection.isButton, isTrue);\n handle.dispose();\n });\n\n // R11 — meetsGuideline for interactive retry button\n testWidgets(\n 'retry button meets Android and labeled tap target guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(VisorErrorView(\n message: 'Something went wrong.',\n retryCallback: () {},\n )));\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n // R11 — non-interactive variant (no retry): tap target check not\n // applicable to the static error view.\n // not applicable: non-interactive (no retryCallback)\n\n // ──────────────────────────────────────────────────────────────────────\n // RTL layout (R9)\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('renders without overflow in RTL directionality', (tester) async {\n await tester.pumpWidget(MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: TextDirection.rtl,\n child: Scaffold(\n body: VisorErrorView(\n message: 'حدث خطأ ما.',\n body: 'يرجى التحقق من اتصالك والمحاولة مرة أخرى.',\n retryCallback: () {},\n retryLabel: 'حاول مجدداً',\n ),\n ),\n ),\n ));\n // No overflow errors: the widget renders to completion.\n expect(tester.takeException(), isNull);\n expect(find.text('حدث خطأ ما.'), findsOneWidget);\n });\n });\n}\n",
4111
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_error_view.dart';\n\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: SizedBox(\n width: 400,\n height: 800,\n child: child,\n ),\n ),\n );\n}\n\nvoid main() {\n group('VisorErrorView', () {\n // ──────────────────────────────────────────────────────────────────────\n // Smoke + basic render\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('renders message text', (tester) async {\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Something went wrong.',\n )));\n expect(find.text('Something went wrong.'), findsOneWidget);\n });\n\n testWidgets('renders default icon', (tester) async {\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Something went wrong.',\n )));\n expect(find.byIcon(Icons.error_outline), findsOneWidget);\n });\n\n testWidgets('renders custom icon when provided', (tester) async {\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'No network.',\n icon: Icons.wifi_off,\n )));\n expect(find.byIcon(Icons.wifi_off), findsOneWidget);\n expect(find.byIcon(Icons.error_outline), findsNothing);\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Optional body copy\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('renders body copy when provided', (tester) async {\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Could not load your timeline.',\n body: 'Check your connection and try again.',\n )));\n expect(find.text('Check your connection and try again.'), findsOneWidget);\n });\n\n testWidgets('omits body copy when null', (tester) async {\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Something went wrong.',\n )));\n // Only one Text widget: the message. No body.\n expect(\n tester.widgetList<Text>(find.byType(Text)).length,\n equals(1),\n );\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Retry button — presence / absence\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('omits retry button when retryCallback is null', (tester) async {\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Something went wrong.',\n )));\n expect(find.byType(OutlinedButton), findsNothing);\n });\n\n testWidgets('renders retry button when retryCallback is provided',\n (tester) async {\n await tester.pumpWidget(_wrap(VisorErrorView(\n message: 'Something went wrong.',\n retryCallback: () {},\n )));\n expect(find.byType(OutlinedButton), findsOneWidget);\n expect(find.text('Try again'), findsOneWidget);\n });\n\n testWidgets('retry button uses custom retryLabel when provided',\n (tester) async {\n await tester.pumpWidget(_wrap(VisorErrorView(\n message: 'Something went wrong.',\n retryCallback: () {},\n retryLabel: 'Reload',\n )));\n expect(find.text('Reload'), findsOneWidget);\n expect(find.text('Try again'), findsNothing);\n });\n\n testWidgets('retry button fires retryCallback on tap', (tester) async {\n var tapped = false;\n await tester.pumpWidget(_wrap(VisorErrorView(\n message: 'Something went wrong.',\n retryCallback: () => tapped = true,\n )));\n await tester.tap(find.byType(OutlinedButton));\n await tester.pump();\n expect(tapped, isTrue);\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Scaffold wrap\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('does not render Scaffold-owned AppBar by default',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Something went wrong.',\n )));\n // The outer _wrap() provides a Scaffold; there should be no AppBar.\n expect(find.byType(AppBar), findsNothing);\n });\n\n testWidgets('renders AppBar when wrapWithScaffold is true', (tester) async {\n await tester.pumpWidget(MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: const VisorErrorView(\n message: 'Something went wrong.',\n wrapWithScaffold: true,\n ),\n ));\n expect(find.byType(AppBar), findsOneWidget);\n });\n\n testWidgets('renders scaffold title when provided', (tester) async {\n await tester.pumpWidget(MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: const VisorErrorView(\n message: 'Something went wrong.',\n wrapWithScaffold: true,\n scaffoldTitle: 'Error',\n ),\n ));\n // AppBar title text.\n expect(find.text('Error'), findsOneWidget);\n });\n\n testWidgets('renders full error view with all props', (tester) async {\n var retried = false;\n await tester.pumpWidget(MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: VisorErrorView(\n message: 'Network error.',\n body: 'Please check your connection.',\n icon: Icons.wifi_off,\n retryCallback: () => retried = true,\n retryLabel: 'Reconnect',\n wrapWithScaffold: true,\n scaffoldTitle: 'Network Error',\n ),\n ));\n expect(find.text('Network Error'), findsOneWidget);\n expect(find.byIcon(Icons.wifi_off), findsOneWidget);\n expect(find.text('Network error.'), findsOneWidget);\n expect(find.text('Please check your connection.'), findsOneWidget);\n expect(find.text('Reconnect'), findsOneWidget);\n await tester.tap(find.byType(OutlinedButton));\n await tester.pump();\n expect(retried, isTrue);\n });\n\n // ──────────────────────────────────────────────────────────────────────\n // Semantics — R6, R11, Rec7\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('Semantics container label defaults to the message',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Something went wrong.',\n )));\n // The error region wraps in Semantics(container: true, liveRegion: true,\n // excludeSemantics: true). We locate the Semantics widget with\n // container: true that is a descendant of VisorErrorView.\n final node = tester.getSemantics(\n find.descendant(\n of: find.byType(VisorErrorView),\n matching: find.byWidgetPredicate(\n (w) => w is Semantics && w.container == true,\n ),\n ),\n );\n expect(node.label, 'Something went wrong.');\n handle.dispose();\n });\n\n testWidgets('semanticLabel param overrides the announced label',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Something went wrong.',\n semanticLabel: 'Error: failed to load feed',\n )));\n final node = tester.getSemantics(\n find.descendant(\n of: find.byType(VisorErrorView),\n matching: find.byWidgetPredicate(\n (w) => w is Semantics && w.container == true,\n ),\n ),\n );\n expect(node.label, 'Error: failed to load feed');\n handle.dispose();\n });\n\n testWidgets('retry button exposes Semantics(button: true, label: …)',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(VisorErrorView(\n message: 'Something went wrong.',\n retryCallback: () {},\n retryLabel: 'Try again',\n )));\n final node = tester.getSemantics(find.byType(OutlinedButton));\n // Button must be labeled so the tap target is also labeled (R11).\n expect(node.flagsCollection.isButton, isTrue);\n handle.dispose();\n });\n\n // R11 — meetsGuideline for interactive retry button\n testWidgets(\n 'retry button meets Android and labeled tap target guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(VisorErrorView(\n message: 'Something went wrong.',\n retryCallback: () {},\n )));\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n // R11 — non-interactive variant (no retry): tap target check not\n // applicable to the static error view.\n // not applicable: non-interactive (no retryCallback)\n\n // ──────────────────────────────────────────────────────────────────────\n // RTL layout (R9)\n // ──────────────────────────────────────────────────────────────────────\n\n testWidgets('renders without overflow in RTL directionality', (tester) async {\n await tester.pumpWidget(MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: TextDirection.rtl,\n child: Scaffold(\n body: VisorErrorView(\n message: 'حدث خطأ ما.',\n body: 'يرجى التحقق من اتصالك والمحاولة مرة أخرى.',\n retryCallback: () {},\n retryLabel: 'حاول مجدداً',\n ),\n ),\n ),\n ));\n // No overflow errors: the widget renders to completion.\n expect(tester.takeException(), isNull);\n expect(find.text('حدث خطأ ما.'), findsOneWidget);\n });\n\n // Rec5 — textContrastGuideline (VI-257)\n\n testWidgets('static error view renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorErrorView(\n message: 'Something went wrong.',\n body: 'Please try again.',\n )));\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('error view with retry button renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(VisorErrorView(\n message: 'Something went wrong.',\n retryCallback: () {},\n )));\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n });\n}\n",
3884
4112
  "target": "flutter"
3885
4113
  }
3886
4114
  ]
@@ -3907,7 +4135,7 @@
3907
4135
  {
3908
4136
  "path": "components/flutter/visor_form_dialog/visor_form_dialog_test.dart",
3909
4137
  "type": "registry:ui",
3910
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_form_dialog.dart';\n\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: child,\n ),\n );\n}\n\n/// Pumps a [VisorFormDialog] via [showDialog] and settles the animation.\nFuture<void> _showDialog(WidgetTester tester, VisorFormDialog dialog) async {\n await tester.pumpWidget(_wrap(Builder(\n builder: (context) {\n return TextButton(\n onPressed: () => showDialog<void>(\n context: context,\n builder: (_) => dialog,\n ),\n child: const Text('open'),\n );\n },\n )));\n await tester.tap(find.text('open'));\n await tester.pumpAndSettle();\n}\n\n/// Finds all [ConstrainedBox] widgets that are direct children of [Dialog]\n/// by locating the Dialog in the tree and inspecting its child hierarchy.\nList<ConstrainedBox> _constrainedBoxesUnderDialog(WidgetTester tester) {\n return tester\n .widgetList<ConstrainedBox>(\n find.descendant(\n of: find.byType(Dialog),\n matching: find.byType(ConstrainedBox),\n ),\n )\n .toList();\n}\n\nvoid main() {\n group('VisorFormDialog', () {\n testWidgets('renders child widget', (tester) async {\n await _showDialog(\n tester,\n const VisorFormDialog(child: Text('Form content')),\n );\n expect(find.text('Form content'), findsOneWidget);\n });\n\n testWidgets('applies default maxWidth constraint', (tester) async {\n await _showDialog(\n tester,\n const VisorFormDialog(child: Text('Form content')),\n );\n\n // The ConstrainedBox we insert is the first direct child of Dialog;\n // find all ConstrainedBoxes under Dialog and filter for the one capped\n // at 480 (the Dialog internals use Infinity as maxWidth).\n final boxes = _constrainedBoxesUnderDialog(tester);\n final capped = boxes.where((b) => b.constraints.maxWidth == 480.0);\n expect(capped, isNotEmpty,\n reason: 'Expected a ConstrainedBox with maxWidth 480 inside Dialog');\n });\n\n testWidgets('respects custom maxWidth', (tester) async {\n await _showDialog(\n tester,\n const VisorFormDialog(maxWidth: 320, child: Text('Narrow form')),\n );\n\n final boxes = _constrainedBoxesUnderDialog(tester);\n final capped = boxes.where((b) => b.constraints.maxWidth == 320.0);\n expect(capped, isNotEmpty,\n reason: 'Expected a ConstrainedBox with maxWidth 320 inside Dialog');\n });\n\n testWidgets('applies default padding from spacing token', (tester) async {\n await _showDialog(\n tester,\n const VisorFormDialog(child: Text('Padded form')),\n );\n\n // Find the Padding widget that is a direct child of our ConstrainedBox.\n // We locate our ConstrainedBox (maxWidth 480) then traverse to its child.\n final boxes = _constrainedBoxesUnderDialog(tester);\n final ourBox =\n boxes.firstWhere((b) => b.constraints.maxWidth == 480.0);\n\n // The immediate child of our ConstrainedBox must be a Padding.\n expect(\n ourBox.child,\n isA<Padding>(),\n reason: 'ConstrainedBox child should be a Padding widget',\n );\n final padding = ourBox.child! as Padding;\n\n // Default padding is spacing.xl = 24.0.\n expect(padding.padding, const EdgeInsets.all(24.0));\n });\n\n testWidgets('respects custom padding override', (tester) async {\n const customPadding = EdgeInsets.symmetric(horizontal: 32, vertical: 16);\n await _showDialog(\n tester,\n const VisorFormDialog(\n padding: customPadding,\n child: Text('Custom padded form'),\n ),\n );\n\n final boxes = _constrainedBoxesUnderDialog(tester);\n // With custom maxWidth not set, it defaults to 480.\n final ourBox =\n boxes.firstWhere((b) => b.constraints.maxWidth == 480.0);\n\n expect(ourBox.child, isA<Padding>());\n final padding = ourBox.child! as Padding;\n expect(padding.padding, customPadding);\n });\n\n testWidgets('wraps content in a Dialog', (tester) async {\n await _showDialog(\n tester,\n const VisorFormDialog(child: Text('Dialog content')),\n );\n expect(find.byType(Dialog), findsOneWidget);\n });\n });\n}\n",
4138
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_form_dialog.dart';\n\nWidget _wrap(Widget child, {TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(body: child),\n ),\n );\n}\n\n/// Pumps a [VisorFormDialog] via [showDialog] and settles the animation.\nFuture<void> _showDialog(WidgetTester tester, VisorFormDialog dialog) async {\n await tester.pumpWidget(_wrap(Builder(\n builder: (context) {\n return TextButton(\n onPressed: () => showDialog<void>(\n context: context,\n builder: (_) => dialog,\n ),\n child: const Text('open'),\n );\n },\n )));\n await tester.tap(find.text('open'));\n await tester.pumpAndSettle();\n}\n\n/// Finds all [ConstrainedBox] widgets that are direct children of [Dialog]\n/// by locating the Dialog in the tree and inspecting its child hierarchy.\nList<ConstrainedBox> _constrainedBoxesUnderDialog(WidgetTester tester) {\n return tester\n .widgetList<ConstrainedBox>(\n find.descendant(\n of: find.byType(Dialog),\n matching: find.byType(ConstrainedBox),\n ),\n )\n .toList();\n}\n\nvoid main() {\n group('VisorFormDialog', () {\n testWidgets('renders child widget', (tester) async {\n await _showDialog(\n tester,\n const VisorFormDialog(child: Text('Form content')),\n );\n expect(find.text('Form content'), findsOneWidget);\n });\n\n testWidgets('applies default maxWidth constraint', (tester) async {\n await _showDialog(\n tester,\n const VisorFormDialog(child: Text('Form content')),\n );\n\n // The ConstrainedBox we insert is the first direct child of Dialog;\n // find all ConstrainedBoxes under Dialog and filter for the one capped\n // at 480 (the Dialog internals use Infinity as maxWidth).\n final boxes = _constrainedBoxesUnderDialog(tester);\n final capped = boxes.where((b) => b.constraints.maxWidth == 480.0);\n expect(capped, isNotEmpty,\n reason: 'Expected a ConstrainedBox with maxWidth 480 inside Dialog');\n });\n\n testWidgets('respects custom maxWidth', (tester) async {\n await _showDialog(\n tester,\n const VisorFormDialog(maxWidth: 320, child: Text('Narrow form')),\n );\n\n final boxes = _constrainedBoxesUnderDialog(tester);\n final capped = boxes.where((b) => b.constraints.maxWidth == 320.0);\n expect(capped, isNotEmpty,\n reason: 'Expected a ConstrainedBox with maxWidth 320 inside Dialog');\n });\n\n testWidgets('applies default padding from spacing token', (tester) async {\n await _showDialog(\n tester,\n const VisorFormDialog(child: Text('Padded form')),\n );\n\n // Find the Padding widget that is a direct child of our ConstrainedBox.\n // We locate our ConstrainedBox (maxWidth 480) then traverse to its child.\n final boxes = _constrainedBoxesUnderDialog(tester);\n final ourBox =\n boxes.firstWhere((b) => b.constraints.maxWidth == 480.0);\n\n // The immediate child of our ConstrainedBox must be a Padding.\n expect(\n ourBox.child,\n isA<Padding>(),\n reason: 'ConstrainedBox child should be a Padding widget',\n );\n final padding = ourBox.child! as Padding;\n\n // Default padding is spacing.xl = 24.0.\n expect(padding.padding, const EdgeInsets.all(24.0));\n });\n\n testWidgets('respects custom padding override', (tester) async {\n const customPadding = EdgeInsets.symmetric(horizontal: 32, vertical: 16);\n await _showDialog(\n tester,\n const VisorFormDialog(\n padding: customPadding,\n child: Text('Custom padded form'),\n ),\n );\n\n final boxes = _constrainedBoxesUnderDialog(tester);\n // With custom maxWidth not set, it defaults to 480.\n final ourBox =\n boxes.firstWhere((b) => b.constraints.maxWidth == 480.0);\n\n expect(ourBox.child, isA<Padding>());\n final padding = ourBox.child! as Padding;\n expect(padding.padding, customPadding);\n });\n\n testWidgets('wraps content in a Dialog', (tester) async {\n await _showDialog(\n tester,\n const VisorFormDialog(child: Text('Dialog content')),\n );\n expect(find.byType(Dialog), findsOneWidget);\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n Builder(\n builder: (context) => TextButton(\n onPressed: () => showDialog<void>(\n context: context,\n builder: (_) =>\n const VisorFormDialog(child: Text('RTL content')),\n ),\n child: const Text('open'),\n ),\n ),\n textDirection: TextDirection.rtl,\n ),\n );\n await tester.tap(find.text('open'));\n await tester.pumpAndSettle();\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorFormDialog), findsOneWidget);\n });\n });\n}\n",
3911
4139
  "target": "flutter"
3912
4140
  }
3913
4141
  ]
@@ -3934,7 +4162,7 @@
3934
4162
  {
3935
4163
  "path": "components/flutter/visor_loading_dots/visor_loading_dots_test.dart",
3936
4164
  "type": "registry:ui",
3937
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_loading_dots.dart';\n\nWidget _wrap(Widget child, {bool disableAnimations = false}) {\n return MediaQuery(\n data: MediaQueryData(disableAnimations: disableAnimations),\n child: MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(body: Center(child: child)),\n ),\n );\n}\n\nvoid main() {\n group('VisorLoadingDots', () {\n // -----------------------------------------------------------------------\n // Rendering\n // -----------------------------------------------------------------------\n\n testWidgets('renders three dots', (tester) async {\n await tester.pumpWidget(_wrap(const VisorLoadingDots()));\n // Three _AnimatedDot widgets → three SizedBox + DecoratedBox pairs.\n expect(find.byType(DecoratedBox), findsNWidgets(3));\n });\n\n testWidgets('default dot size is 10 dp', (tester) async {\n await tester.pumpWidget(_wrap(const VisorLoadingDots()));\n final boxes = tester.widgetList<SizedBox>(find.byType(SizedBox)).where(\n (s) => s.width == 10.0 && s.height == 10.0,\n );\n expect(boxes.length, 3);\n });\n\n testWidgets('custom dot size is applied to all three dots', (tester) async {\n await tester.pumpWidget(_wrap(const VisorLoadingDots(dotSize: 20.0)));\n final boxes = tester.widgetList<SizedBox>(find.byType(SizedBox)).where(\n (s) => s.width == 20.0 && s.height == 20.0,\n );\n expect(boxes.length, 3);\n });\n\n testWidgets('renders a Row with three children', (tester) async {\n await tester.pumpWidget(_wrap(const VisorLoadingDots()));\n final row = tester.widget<Row>(find.byType(Row));\n expect(row.mainAxisSize, MainAxisSize.min);\n });\n\n // -----------------------------------------------------------------------\n // Animation — running by default\n // -----------------------------------------------------------------------\n\n testWidgets('animation controller is active when disableAnimations is false',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorLoadingDots()));\n // Advance by 750 ms (half the 1500 ms cycle) — if the controller is\n // repeating, the dots should still be present after frame advance.\n await tester.pump(const Duration(milliseconds: 750));\n // Widget still renders (no error, dots still present).\n expect(find.byType(DecoratedBox), findsNWidgets(3));\n });\n\n // -----------------------------------------------------------------------\n // Reduce-motion\n // -----------------------------------------------------------------------\n\n testWidgets('animation halts when disableAnimations is true', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingDots(), disableAnimations: true),\n );\n // Pump a full cycle — if the controller is stopped, state remains stable.\n await tester.pump(const Duration(seconds: 2));\n // Dots still render (no error).\n expect(find.byType(DecoratedBox), findsNWidgets(3));\n });\n\n testWidgets('dots render at resting color when disableAnimations is true',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingDots(), disableAnimations: true),\n );\n // All three decorated boxes should be circles; they will use colorStart\n // (surfaceAccentSubtle from testColors = 0xFFEFF6FF).\n final decorated =\n tester.widgetList<DecoratedBox>(find.byType(DecoratedBox)).toList();\n expect(decorated.length, 3);\n for (final box in decorated) {\n final decoration = box.decoration as BoxDecoration;\n expect(decoration.shape, BoxShape.circle);\n // Color must be non-null (controller value = 0 → colorStart).\n expect(decoration.color, isNotNull);\n }\n });\n\n testWidgets(\n 'toggling disableAnimations from true to false resumes animation',\n (tester) async {\n // Start with animations disabled.\n await tester.pumpWidget(\n _wrap(const VisorLoadingDots(), disableAnimations: true),\n );\n // Re-render with animations enabled.\n await tester.pumpWidget(\n _wrap(const VisorLoadingDots(), disableAnimations: false),\n );\n await tester.pump(const Duration(milliseconds: 750));\n // Widget still renders without error — animation resumed.\n expect(find.byType(DecoratedBox), findsNWidgets(3));\n });\n\n // -----------------------------------------------------------------------\n // Semantics\n // -----------------------------------------------------------------------\n\n testWidgets('no Semantics label by default', (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorLoadingDots()));\n expect(find.bySemanticsLabel('Loading'), findsNothing);\n handle.dispose();\n });\n\n testWidgets('renders Semantics label when semanticLabel is provided',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorLoadingDots(semanticLabel: 'Loading')),\n );\n expect(find.bySemanticsLabel('Loading'), findsOneWidget);\n handle.dispose();\n });\n\n // -----------------------------------------------------------------------\n // Custom colors\n // -----------------------------------------------------------------------\n\n testWidgets('custom colors are accepted without error', (tester) async {\n const customStart = Color(0xFFE0F2FE);\n const customMid = Color(0xFF38BDF8);\n const customEnd = Color(0xFF0369A1);\n await tester.pumpWidget(\n _wrap(\n const VisorLoadingDots(\n colorStart: customStart,\n colorMid: customMid,\n colorEnd: customEnd,\n ),\n ),\n );\n expect(find.byType(DecoratedBox), findsNWidgets(3));\n });\n\n // -----------------------------------------------------------------------\n // Dispose safety\n // -----------------------------------------------------------------------\n\n testWidgets('no error when widget is disposed while animating',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorLoadingDots()));\n // Pump a little to ensure controller is running.\n await tester.pump(const Duration(milliseconds: 100));\n // Swap widget out (triggers dispose).\n await tester.pumpWidget(_wrap(const SizedBox()));\n await tester.pump(const Duration(milliseconds: 500));\n // No error = test passes.\n });\n });\n}\n",
4165
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_loading_dots.dart';\n\nWidget _wrap(Widget child, {bool disableAnimations = false, TextDirection textDirection = TextDirection.ltr}) {\n return MediaQuery(\n data: MediaQueryData(disableAnimations: disableAnimations),\n child: MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(body: Center(child: child)),\n ),\n ),\n );\n}\n\nvoid main() {\n group('VisorLoadingDots', () {\n // -----------------------------------------------------------------------\n // Rendering\n // -----------------------------------------------------------------------\n\n testWidgets('renders three dots', (tester) async {\n await tester.pumpWidget(_wrap(const VisorLoadingDots()));\n // Three _AnimatedDot widgets → three SizedBox + DecoratedBox pairs.\n expect(find.byType(DecoratedBox), findsNWidgets(3));\n });\n\n testWidgets('default dot size is 10 dp', (tester) async {\n await tester.pumpWidget(_wrap(const VisorLoadingDots()));\n final boxes = tester.widgetList<SizedBox>(find.byType(SizedBox)).where(\n (s) => s.width == 10.0 && s.height == 10.0,\n );\n expect(boxes.length, 3);\n });\n\n testWidgets('custom dot size is applied to all three dots', (tester) async {\n await tester.pumpWidget(_wrap(const VisorLoadingDots(dotSize: 20.0)));\n final boxes = tester.widgetList<SizedBox>(find.byType(SizedBox)).where(\n (s) => s.width == 20.0 && s.height == 20.0,\n );\n expect(boxes.length, 3);\n });\n\n testWidgets('renders a Row with three children', (tester) async {\n await tester.pumpWidget(_wrap(const VisorLoadingDots()));\n final row = tester.widget<Row>(find.byType(Row));\n expect(row.mainAxisSize, MainAxisSize.min);\n });\n\n // -----------------------------------------------------------------------\n // Animation — running by default\n // -----------------------------------------------------------------------\n\n testWidgets('animation controller is active when disableAnimations is false',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorLoadingDots()));\n // Advance by 750 ms (half the 1500 ms cycle) — if the controller is\n // repeating, the dots should still be present after frame advance.\n await tester.pump(const Duration(milliseconds: 750));\n // Widget still renders (no error, dots still present).\n expect(find.byType(DecoratedBox), findsNWidgets(3));\n });\n\n // -----------------------------------------------------------------------\n // Reduce-motion\n // -----------------------------------------------------------------------\n\n testWidgets('animation halts when disableAnimations is true', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingDots(), disableAnimations: true),\n );\n // Pump a full cycle — if the controller is stopped, state remains stable.\n await tester.pump(const Duration(seconds: 2));\n // Dots still render (no error).\n expect(find.byType(DecoratedBox), findsNWidgets(3));\n });\n\n testWidgets('dots render at resting color when disableAnimations is true',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingDots(), disableAnimations: true),\n );\n // All three decorated boxes should be circles; they will use colorStart\n // (surfaceAccentSubtle from testColors = 0xFFEFF6FF).\n final decorated =\n tester.widgetList<DecoratedBox>(find.byType(DecoratedBox)).toList();\n expect(decorated.length, 3);\n for (final box in decorated) {\n final decoration = box.decoration as BoxDecoration;\n expect(decoration.shape, BoxShape.circle);\n // Color must be non-null (controller value = 0 → colorStart).\n expect(decoration.color, isNotNull);\n }\n });\n\n testWidgets(\n 'toggling disableAnimations from true to false resumes animation',\n (tester) async {\n // Start with animations disabled.\n await tester.pumpWidget(\n _wrap(const VisorLoadingDots(), disableAnimations: true),\n );\n // Re-render with animations enabled.\n await tester.pumpWidget(\n _wrap(const VisorLoadingDots(), disableAnimations: false),\n );\n await tester.pump(const Duration(milliseconds: 750));\n // Widget still renders without error — animation resumed.\n expect(find.byType(DecoratedBox), findsNWidgets(3));\n });\n\n // -----------------------------------------------------------------------\n // Semantics\n // -----------------------------------------------------------------------\n\n testWidgets('no Semantics label by default', (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorLoadingDots()));\n expect(find.bySemanticsLabel('Loading'), findsNothing);\n handle.dispose();\n });\n\n testWidgets('renders Semantics label when semanticLabel is provided',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorLoadingDots(semanticLabel: 'Loading')),\n );\n expect(find.bySemanticsLabel('Loading'), findsOneWidget);\n handle.dispose();\n });\n\n // -----------------------------------------------------------------------\n // Custom colors\n // -----------------------------------------------------------------------\n\n testWidgets('custom colors are accepted without error', (tester) async {\n const customStart = Color(0xFFE0F2FE);\n const customMid = Color(0xFF38BDF8);\n const customEnd = Color(0xFF0369A1);\n await tester.pumpWidget(\n _wrap(\n const VisorLoadingDots(\n colorStart: customStart,\n colorMid: customMid,\n colorEnd: customEnd,\n ),\n ),\n );\n expect(find.byType(DecoratedBox), findsNWidgets(3));\n });\n\n // -----------------------------------------------------------------------\n // Dispose safety\n // -----------------------------------------------------------------------\n\n testWidgets('no error when widget is disposed while animating',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorLoadingDots()));\n // Pump a little to ensure controller is running.\n await tester.pump(const Duration(milliseconds: 100));\n // Swap widget out (triggers dispose).\n await tester.pumpWidget(_wrap(const SizedBox()));\n await tester.pump(const Duration(milliseconds: 500));\n // No error = test passes.\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorLoadingDots(),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorLoadingDots), findsOneWidget);\n });\n });\n}\n",
3938
4166
  "target": "flutter"
3939
4167
  }
3940
4168
  ]
@@ -3961,7 +4189,7 @@
3961
4189
  {
3962
4190
  "path": "components/flutter/visor_loading_indicator/visor_loading_indicator_test.dart",
3963
4191
  "type": "registry:ui",
3964
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_loading_indicator.dart';\n\nWidget _wrap(Widget child, {bool disableAnimations = false}) {\n return MediaQuery(\n data: MediaQueryData(disableAnimations: disableAnimations),\n child: MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(body: Center(child: child)),\n ),\n );\n}\n\nvoid main() {\n group('VisorLoadingIndicator', () {\n // -----------------------------------------------------------------------\n // Immediate render (no delay)\n // -----------------------------------------------------------------------\n\n testWidgets('renders a CircularProgressIndicator immediately when no delay',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator()),\n );\n expect(find.byType(CircularProgressIndicator), findsOneWidget);\n });\n\n testWidgets('default size is 24 dp', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator()),\n );\n final sized = tester.widgetList<SizedBox>(find.byType(SizedBox)).firstWhere(\n (s) => s.width == 24.0 && s.height == 24.0,\n orElse: () => throw StateError('Expected 24x24 SizedBox'),\n );\n expect(sized.width, 24.0);\n expect(sized.height, 24.0);\n });\n\n testWidgets('custom size is applied', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator(size: 48.0)),\n );\n final sized = tester.widgetList<SizedBox>(find.byType(SizedBox)).firstWhere(\n (s) => s.width == 48.0 && s.height == 48.0,\n orElse: () => throw StateError('Expected 48x48 SizedBox'),\n );\n expect(sized.width, 48.0);\n });\n\n testWidgets('Duration.zero delay is treated as immediate (no stateful gate)',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator(delay: Duration.zero)),\n );\n // Should render immediately — no shrink box\n expect(find.byType(CircularProgressIndicator), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // Delay gate (stateful path)\n // -----------------------------------------------------------------------\n\n testWidgets('spinner is absent before delay elapses', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator(\n delay: Duration(milliseconds: 300),\n )),\n );\n // Immediately after pump — spinner not yet visible\n expect(find.byType(CircularProgressIndicator), findsNothing);\n\n // Drain the pending timer so the test framework doesn't complain about\n // a pending timer at teardown.\n await tester.pump(const Duration(milliseconds: 300));\n });\n\n testWidgets('spinner appears after delay elapses', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator(\n delay: Duration(milliseconds: 300),\n )),\n );\n expect(find.byType(CircularProgressIndicator), findsNothing);\n\n // Advance fake clock past the delay\n await tester.pump(const Duration(milliseconds: 300));\n expect(find.byType(CircularProgressIndicator), findsOneWidget);\n });\n\n testWidgets('no setState-after-dispose error when widget disposed during delay',\n (tester) async {\n // Mount the delayed indicator\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator(\n delay: Duration(milliseconds: 300),\n )),\n );\n\n // Dispose before the delay fires by swapping to a different widget\n await tester.pumpWidget(\n _wrap(const SizedBox()),\n );\n\n // Advance past the original delay — should produce no errors\n await tester.pump(const Duration(milliseconds: 300));\n // No assertion needed — absence of error IS the test\n });\n\n // -----------------------------------------------------------------------\n // Reduce-motion\n // -----------------------------------------------------------------------\n\n testWidgets('renders static box instead of spinner when disableAnimations',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorLoadingIndicator(),\n disableAnimations: true,\n ),\n );\n expect(find.byType(CircularProgressIndicator), findsNothing);\n expect(find.byType(DecoratedBox), findsOneWidget);\n });\n\n testWidgets('static reduce-motion box matches requested size', (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorLoadingIndicator(size: 32.0),\n disableAnimations: true,\n ),\n );\n final sized = tester.widgetList<SizedBox>(find.byType(SizedBox)).firstWhere(\n (s) => s.width == 32.0 && s.height == 32.0,\n orElse: () => throw StateError('Expected 32x32 SizedBox'),\n );\n expect(sized.width, 32.0);\n });\n\n // -----------------------------------------------------------------------\n // Color\n // -----------------------------------------------------------------------\n\n testWidgets('applies custom color to spinner', (tester) async {\n const customColor = Color(0xFFFF0000);\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator(color: customColor)),\n );\n final indicator = tester.widget<CircularProgressIndicator>(\n find.byType(CircularProgressIndicator),\n );\n final animated = indicator.valueColor as AlwaysStoppedAnimation<Color>;\n expect(animated.value, customColor);\n });\n\n testWidgets('uses interactivePrimaryBg token when no color supplied',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator()),\n );\n final indicator = tester.widget<CircularProgressIndicator>(\n find.byType(CircularProgressIndicator),\n );\n final animated = indicator.valueColor as AlwaysStoppedAnimation<Color>;\n // testColors() sets interactivePrimaryBg = 0xFF2563EB\n expect(animated.value, const Color(0xFF2563EB));\n });\n\n // -----------------------------------------------------------------------\n // Semantics\n // -----------------------------------------------------------------------\n\n testWidgets('no Semantics label by default', (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator()),\n );\n expect(find.bySemanticsLabel('Loading'), findsNothing);\n handle.dispose();\n });\n\n testWidgets('renders Semantics label when semanticLabel is provided',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator(semanticLabel: 'Loading')),\n );\n expect(find.bySemanticsLabel('Loading'), findsOneWidget);\n handle.dispose();\n });\n\n testWidgets(\n 'delayed path: no Semantics label before delay, label visible after',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator(\n delay: Duration(milliseconds: 300),\n semanticLabel: 'Loading',\n )),\n );\n // Before delay fires — no semantics node\n expect(find.bySemanticsLabel('Loading'), findsNothing);\n\n // Advance past the delay\n await tester.pump(const Duration(milliseconds: 300));\n expect(find.bySemanticsLabel('Loading'), findsOneWidget);\n handle.dispose();\n });\n });\n}\n",
4192
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_loading_indicator.dart';\n\nWidget _wrap(Widget child, {bool disableAnimations = false, TextDirection textDirection = TextDirection.ltr}) {\n return MediaQuery(\n data: MediaQueryData(disableAnimations: disableAnimations),\n child: MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(body: Center(child: child)),\n ),\n ),\n );\n}\n\nvoid main() {\n group('VisorLoadingIndicator', () {\n // -----------------------------------------------------------------------\n // Immediate render (no delay)\n // -----------------------------------------------------------------------\n\n testWidgets('renders a CircularProgressIndicator immediately when no delay',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator()),\n );\n expect(find.byType(CircularProgressIndicator), findsOneWidget);\n });\n\n testWidgets('default size is 24 dp', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator()),\n );\n final sized = tester.widgetList<SizedBox>(find.byType(SizedBox)).firstWhere(\n (s) => s.width == 24.0 && s.height == 24.0,\n orElse: () => throw StateError('Expected 24x24 SizedBox'),\n );\n expect(sized.width, 24.0);\n expect(sized.height, 24.0);\n });\n\n testWidgets('custom size is applied', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator(size: 48.0)),\n );\n final sized = tester.widgetList<SizedBox>(find.byType(SizedBox)).firstWhere(\n (s) => s.width == 48.0 && s.height == 48.0,\n orElse: () => throw StateError('Expected 48x48 SizedBox'),\n );\n expect(sized.width, 48.0);\n });\n\n testWidgets('Duration.zero delay is treated as immediate (no stateful gate)',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator(delay: Duration.zero)),\n );\n // Should render immediately — no shrink box\n expect(find.byType(CircularProgressIndicator), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // Delay gate (stateful path)\n // -----------------------------------------------------------------------\n\n testWidgets('spinner is absent before delay elapses', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator(\n delay: Duration(milliseconds: 300),\n )),\n );\n // Immediately after pump — spinner not yet visible\n expect(find.byType(CircularProgressIndicator), findsNothing);\n\n // Drain the pending timer so the test framework doesn't complain about\n // a pending timer at teardown.\n await tester.pump(const Duration(milliseconds: 300));\n });\n\n testWidgets('spinner appears after delay elapses', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator(\n delay: Duration(milliseconds: 300),\n )),\n );\n expect(find.byType(CircularProgressIndicator), findsNothing);\n\n // Advance fake clock past the delay\n await tester.pump(const Duration(milliseconds: 300));\n expect(find.byType(CircularProgressIndicator), findsOneWidget);\n });\n\n testWidgets('no setState-after-dispose error when widget disposed during delay',\n (tester) async {\n // Mount the delayed indicator\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator(\n delay: Duration(milliseconds: 300),\n )),\n );\n\n // Dispose before the delay fires by swapping to a different widget\n await tester.pumpWidget(\n _wrap(const SizedBox()),\n );\n\n // Advance past the original delay — should produce no errors\n await tester.pump(const Duration(milliseconds: 300));\n // No assertion needed — absence of error IS the test\n });\n\n // -----------------------------------------------------------------------\n // Reduce-motion\n // -----------------------------------------------------------------------\n\n testWidgets('renders static box instead of spinner when disableAnimations',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorLoadingIndicator(),\n disableAnimations: true,\n ),\n );\n expect(find.byType(CircularProgressIndicator), findsNothing);\n expect(find.byType(DecoratedBox), findsOneWidget);\n });\n\n testWidgets('static reduce-motion box matches requested size', (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorLoadingIndicator(size: 32.0),\n disableAnimations: true,\n ),\n );\n final sized = tester.widgetList<SizedBox>(find.byType(SizedBox)).firstWhere(\n (s) => s.width == 32.0 && s.height == 32.0,\n orElse: () => throw StateError('Expected 32x32 SizedBox'),\n );\n expect(sized.width, 32.0);\n });\n\n // -----------------------------------------------------------------------\n // Color\n // -----------------------------------------------------------------------\n\n testWidgets('applies custom color to spinner', (tester) async {\n const customColor = Color(0xFFFF0000);\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator(color: customColor)),\n );\n final indicator = tester.widget<CircularProgressIndicator>(\n find.byType(CircularProgressIndicator),\n );\n final animated = indicator.valueColor as AlwaysStoppedAnimation<Color>;\n expect(animated.value, customColor);\n });\n\n testWidgets('uses interactivePrimaryBg token when no color supplied',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator()),\n );\n final indicator = tester.widget<CircularProgressIndicator>(\n find.byType(CircularProgressIndicator),\n );\n final animated = indicator.valueColor as AlwaysStoppedAnimation<Color>;\n // testColors() sets interactivePrimaryBg = 0xFF2563EB\n expect(animated.value, const Color(0xFF2563EB));\n });\n\n // -----------------------------------------------------------------------\n // Semantics\n // -----------------------------------------------------------------------\n\n testWidgets('no Semantics label by default', (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator()),\n );\n expect(find.bySemanticsLabel('Loading'), findsNothing);\n handle.dispose();\n });\n\n testWidgets('renders Semantics label when semanticLabel is provided',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator(semanticLabel: 'Loading')),\n );\n expect(find.bySemanticsLabel('Loading'), findsOneWidget);\n handle.dispose();\n });\n\n testWidgets(\n 'delayed path: no Semantics label before delay, label visible after',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorLoadingIndicator(\n delay: Duration(milliseconds: 300),\n semanticLabel: 'Loading',\n )),\n );\n // Before delay fires — no semantics node\n expect(find.bySemanticsLabel('Loading'), findsNothing);\n\n // Advance past the delay\n await tester.pump(const Duration(milliseconds: 300));\n expect(find.bySemanticsLabel('Loading'), findsOneWidget);\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorLoadingIndicator(),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorLoadingIndicator), findsOneWidget);\n });\n });\n}\n",
3965
4193
  "target": "flutter"
3966
4194
  }
3967
4195
  ]
@@ -3988,7 +4216,7 @@
3988
4216
  {
3989
4217
  "path": "components/flutter/visor_otp_input/visor_otp_input_test.dart",
3990
4218
  "type": "registry:ui",
3991
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_otp_input.dart';\n\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: Center(\n child: SizedBox(width: 400, child: child),\n ),\n ),\n );\n}\n\nvoid main() {\n group('VisorOtpInput', () {\n testWidgets('renders default 6 digit boxes', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorOtpInput(onCodeComplete: (_) {})),\n );\n // 6 SizedBoxes at the digit size level — find by key count via\n // the visible TextField widgets (one per empty digit).\n expect(find.byType(TextField), findsNWidgets(6));\n });\n\n testWidgets('renders configurable digitCount boxes', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorOtpInput(digitCount: 4, onCodeComplete: (_) {})),\n );\n expect(find.byType(TextField), findsNWidgets(4));\n });\n\n testWidgets('onCodeChanged fires on digit entry', (tester) async {\n final codes = <String>[];\n await tester.pumpWidget(\n _wrap(VisorOtpInput(\n digitCount: 4,\n onCodeChanged: codes.add,\n onCodeComplete: (_) {},\n )),\n );\n\n await tester.tap(find.byType(TextField).first);\n await tester.pump();\n await tester.enterText(find.byType(TextField).first, '3');\n await tester.pump();\n\n expect(codes, isNotEmpty);\n });\n\n testWidgets('onCodeComplete fires when all digits are filled',\n (tester) async {\n final completions = <String>[];\n await tester.pumpWidget(\n _wrap(VisorOtpInput(\n digitCount: 4,\n onCodeComplete: completions.add,\n onCodeChanged: (_) {},\n )),\n );\n\n // Enter one digit at a time in each text field.\n final fields = find.byType(TextField);\n for (var i = 0; i < 4; i++) {\n await tester.tap(fields.at(i));\n await tester.pump();\n await tester.enterText(fields.at(i), '${i + 1}');\n await tester.pump();\n }\n\n expect(completions, hasLength(1));\n // Code should be '1234' — 4 digits entered sequentially.\n expect(completions.first, hasLength(4));\n });\n\n testWidgets('onCodeComplete does not re-fire on re-entry after completion',\n (tester) async {\n var fireCount = 0;\n await tester.pumpWidget(\n _wrap(VisorOtpInput(\n digitCount: 2,\n onCodeComplete: (_) => fireCount++,\n )),\n );\n\n final fields = find.byType(TextField);\n await tester.tap(fields.first);\n await tester.pump();\n await tester.enterText(fields.first, '1');\n await tester.pump();\n await tester.tap(fields.last);\n await tester.pump();\n await tester.enterText(fields.last, '2');\n await tester.pump();\n\n // Completing again by changing a digit should reset guard.\n // fireCount should be 1 after one full completion.\n expect(fireCount, equals(1));\n });\n\n testWidgets('clear() resets all digits and calls onCodeChanged',\n (tester) async {\n final key = GlobalKey<VisorOtpInputState>();\n final codes = <String>[];\n\n await tester.pumpWidget(\n _wrap(VisorOtpInput(\n key: key,\n digitCount: 2,\n onCodeChanged: codes.add,\n onCodeComplete: (_) {},\n )),\n );\n\n // Enter digits.\n final fields = find.byType(TextField);\n await tester.tap(fields.first);\n await tester.pump();\n await tester.enterText(fields.first, '5');\n await tester.pump();\n\n // Clear.\n key.currentState!.clear();\n await tester.pump();\n\n // After clear, onCodeChanged should have been called with ''.\n expect(codes.last, equals(''));\n });\n\n testWidgets('enabled: false disables all TextFields', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorOtpInput(\n digitCount: 4,\n enabled: false,\n onCodeComplete: (_) {},\n )),\n );\n\n // All text fields should be disabled.\n final textFields =\n tester.widgetList<TextField>(find.byType(TextField)).toList();\n for (final field in textFields) {\n expect(field.enabled, isFalse);\n }\n });\n\n testWidgets('renders VisorOtpInput widget without error', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorOtpInput(\n digitCount: 6,\n onCodeComplete: (_) {},\n onCodeChanged: (_) {},\n )),\n );\n expect(find.byType(VisorOtpInput), findsOneWidget);\n });\n\n // R6 — per-cell + container Semantics (VI-253)\n\n testWidgets('row Semantics container has default label including digit count',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorOtpInput(onCodeComplete: (_) {})),\n );\n expect(find.bySemanticsLabel('OTP code, 6 digits'), findsOneWidget);\n handle.dispose();\n });\n\n testWidgets('per-digit Semantics labels include position and value',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorOtpInput(onCodeComplete: (_) {})),\n );\n\n // All cells start empty with position-aware labels.\n expect(\n find.bySemanticsLabel('OTP digit 1 of 6, empty'),\n findsOneWidget,\n );\n expect(\n find.bySemanticsLabel('OTP digit 6 of 6, empty'),\n findsOneWidget,\n );\n\n // Fill digit at index 2 with '7'; label should reflect the value.\n final fields = find.byType(TextField);\n await tester.tap(fields.at(2));\n await tester.pump();\n await tester.enterText(fields.at(2), '7');\n await tester.pump();\n\n expect(\n find.bySemanticsLabel('OTP digit 3 of 6, 7'),\n findsOneWidget,\n );\n // Untouched cells still report empty with their position.\n expect(\n find.bySemanticsLabel('OTP digit 1 of 6, empty'),\n findsOneWidget,\n );\n handle.dispose();\n });\n\n testWidgets('semanticLabel param overrides container label',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorOtpInput(\n semanticLabel: 'Two-factor code',\n onCodeComplete: (_) {},\n )),\n );\n expect(find.bySemanticsLabel('Two-factor code'), findsOneWidget);\n expect(find.bySemanticsLabel('OTP code, 6 digits'), findsNothing);\n handle.dispose();\n });\n\n // R11 — meetsGuideline tap-target + labeled-tap-target (VI-253)\n\n testWidgets(\n 'default 6-digit input meets Android tap-target + labeled-tap-target guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorOtpInput(onCodeComplete: (_) {})),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n });\n}\n",
4219
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_otp_input.dart';\n\nWidget _wrap(Widget child, {TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(\n body: Center(\n child: SizedBox(width: 400, child: child),\n ),\n ),\n ),\n );\n}\n\nvoid main() {\n group('VisorOtpInput', () {\n testWidgets('renders default 6 digit boxes', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorOtpInput(onCodeComplete: (_) {})),\n );\n // 6 SizedBoxes at the digit size level — find by key count via\n // the visible TextField widgets (one per empty digit).\n expect(find.byType(TextField), findsNWidgets(6));\n });\n\n testWidgets('renders configurable digitCount boxes', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorOtpInput(digitCount: 4, onCodeComplete: (_) {})),\n );\n expect(find.byType(TextField), findsNWidgets(4));\n });\n\n testWidgets('onCodeChanged fires on digit entry', (tester) async {\n final codes = <String>[];\n await tester.pumpWidget(\n _wrap(VisorOtpInput(\n digitCount: 4,\n onCodeChanged: codes.add,\n onCodeComplete: (_) {},\n )),\n );\n\n await tester.tap(find.byType(TextField).first);\n await tester.pump();\n await tester.enterText(find.byType(TextField).first, '3');\n await tester.pump();\n\n expect(codes, isNotEmpty);\n });\n\n testWidgets('onCodeComplete fires when all digits are filled',\n (tester) async {\n final completions = <String>[];\n await tester.pumpWidget(\n _wrap(VisorOtpInput(\n digitCount: 4,\n onCodeComplete: completions.add,\n onCodeChanged: (_) {},\n )),\n );\n\n // Enter one digit at a time in each text field.\n final fields = find.byType(TextField);\n for (var i = 0; i < 4; i++) {\n await tester.tap(fields.at(i));\n await tester.pump();\n await tester.enterText(fields.at(i), '${i + 1}');\n await tester.pump();\n }\n\n expect(completions, hasLength(1));\n // Code should be '1234' — 4 digits entered sequentially.\n expect(completions.first, hasLength(4));\n });\n\n testWidgets('onCodeComplete does not re-fire on re-entry after completion',\n (tester) async {\n var fireCount = 0;\n await tester.pumpWidget(\n _wrap(VisorOtpInput(\n digitCount: 2,\n onCodeComplete: (_) => fireCount++,\n )),\n );\n\n final fields = find.byType(TextField);\n await tester.tap(fields.first);\n await tester.pump();\n await tester.enterText(fields.first, '1');\n await tester.pump();\n await tester.tap(fields.last);\n await tester.pump();\n await tester.enterText(fields.last, '2');\n await tester.pump();\n\n // Completing again by changing a digit should reset guard.\n // fireCount should be 1 after one full completion.\n expect(fireCount, equals(1));\n });\n\n testWidgets('clear() resets all digits and calls onCodeChanged',\n (tester) async {\n final key = GlobalKey<VisorOtpInputState>();\n final codes = <String>[];\n\n await tester.pumpWidget(\n _wrap(VisorOtpInput(\n key: key,\n digitCount: 2,\n onCodeChanged: codes.add,\n onCodeComplete: (_) {},\n )),\n );\n\n // Enter digits.\n final fields = find.byType(TextField);\n await tester.tap(fields.first);\n await tester.pump();\n await tester.enterText(fields.first, '5');\n await tester.pump();\n\n // Clear.\n key.currentState!.clear();\n await tester.pump();\n\n // After clear, onCodeChanged should have been called with ''.\n expect(codes.last, equals(''));\n });\n\n testWidgets('enabled: false disables all TextFields', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorOtpInput(\n digitCount: 4,\n enabled: false,\n onCodeComplete: (_) {},\n )),\n );\n\n // All text fields should be disabled.\n final textFields =\n tester.widgetList<TextField>(find.byType(TextField)).toList();\n for (final field in textFields) {\n expect(field.enabled, isFalse);\n }\n });\n\n testWidgets('renders VisorOtpInput widget without error', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorOtpInput(\n digitCount: 6,\n onCodeComplete: (_) {},\n onCodeChanged: (_) {},\n )),\n );\n expect(find.byType(VisorOtpInput), findsOneWidget);\n });\n\n // R6 — per-cell + container Semantics (VI-253)\n\n testWidgets('row Semantics container has default label including digit count',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorOtpInput(onCodeComplete: (_) {})),\n );\n expect(find.bySemanticsLabel('OTP code, 6 digits'), findsOneWidget);\n handle.dispose();\n });\n\n testWidgets('per-digit Semantics labels include position and value',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorOtpInput(onCodeComplete: (_) {})),\n );\n\n // All cells start empty with position-aware labels.\n expect(\n find.bySemanticsLabel('OTP digit 1 of 6, empty'),\n findsOneWidget,\n );\n expect(\n find.bySemanticsLabel('OTP digit 6 of 6, empty'),\n findsOneWidget,\n );\n\n // Fill digit at index 2 with '7'; label should reflect the value.\n final fields = find.byType(TextField);\n await tester.tap(fields.at(2));\n await tester.pump();\n await tester.enterText(fields.at(2), '7');\n await tester.pump();\n\n expect(\n find.bySemanticsLabel('OTP digit 3 of 6, 7'),\n findsOneWidget,\n );\n // Untouched cells still report empty with their position.\n expect(\n find.bySemanticsLabel('OTP digit 1 of 6, empty'),\n findsOneWidget,\n );\n handle.dispose();\n });\n\n testWidgets('semanticLabel param overrides container label',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorOtpInput(\n semanticLabel: 'Two-factor code',\n onCodeComplete: (_) {},\n )),\n );\n expect(find.bySemanticsLabel('Two-factor code'), findsOneWidget);\n expect(find.bySemanticsLabel('OTP code, 6 digits'), findsNothing);\n handle.dispose();\n });\n\n // R11 — meetsGuideline tap-target + labeled-tap-target (VI-253)\n\n testWidgets(\n 'default 6-digit input meets Android tap-target + labeled-tap-target guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorOtpInput(onCodeComplete: (_) {})),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorOtpInput(onCodeComplete: (_) {}),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorOtpInput), findsOneWidget);\n // OTP digit boxes are displayed in a Row. In RTL, the Row reverses\n // visual order so digit 1 appears at the right end. The digit boxes\n // themselves are index-keyed and symmetric, so visual reversal does\n // not break entry — the cursor still moves to the next logical field.\n // No semantic reordering follow-up required; OTP codes are locale-neutral.\n expect(find.byType(TextField), findsNWidgets(6));\n });\n });\n}\n",
3992
4220
  "target": "flutter"
3993
4221
  }
3994
4222
  ]
@@ -4021,7 +4249,7 @@
4021
4249
  {
4022
4250
  "path": "components/flutter/visor_password_input/visor_password_input_test.dart",
4023
4251
  "type": "registry:ui",
4024
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport '../visor_text_input/visor_text_input.dart';\nimport 'visor_password_input.dart';\n\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(body: Center(child: child)),\n );\n}\n\nvoid main() {\n group('VisorPasswordInput', () {\n // -----------------------------------------------------------------------\n // Rendering\n // -----------------------------------------------------------------------\n\n testWidgets('renders the label text', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n expect(find.text('Password'), findsOneWidget);\n });\n\n testWidgets('renders a VisorTextInput as the base', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n expect(find.byType(VisorTextInput), findsOneWidget);\n });\n\n testWidgets('renders the inner TextFormField', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n expect(find.byType(TextFormField), findsOneWidget);\n });\n\n testWidgets('is disabled when enabled is false', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password', enabled: false)),\n );\n final field = tester.widget<TextFormField>(find.byType(TextFormField));\n expect(field.enabled, isFalse);\n });\n\n // -----------------------------------------------------------------------\n // obscureText toggle\n // -----------------------------------------------------------------------\n\n testWidgets('text is obscured by default', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n // obscureText is surfaced on EditableText, which TextFormField creates\n // internally. The obscure state is reflected in the EditableText widget.\n final editableText = tester.widget<EditableText>(find.byType(EditableText));\n expect(editableText.obscureText, isTrue);\n });\n\n testWidgets('tapping the eye icon reveals text', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n\n // Initially obscured.\n final textBefore = tester.widget<EditableText>(find.byType(EditableText));\n expect(textBefore.obscureText, isTrue);\n\n // Tap the visibility-off icon to reveal.\n await tester.tap(find.byIcon(Icons.visibility_off_outlined));\n await tester.pump();\n\n // Should now be revealed.\n final textAfter = tester.widget<EditableText>(find.byType(EditableText));\n expect(textAfter.obscureText, isFalse);\n\n // Icon should have flipped to the visibility icon.\n expect(find.byIcon(Icons.visibility_outlined), findsOneWidget);\n });\n\n testWidgets('tapping the eye icon a second time re-obscures text',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n\n // Reveal.\n await tester.tap(find.byIcon(Icons.visibility_off_outlined));\n await tester.pump();\n\n // Re-obscure.\n await tester.tap(find.byIcon(Icons.visibility_outlined));\n await tester.pump();\n\n final editableText = tester.widget<EditableText>(find.byType(EditableText));\n expect(editableText.obscureText, isTrue);\n });\n\n testWidgets('eye icon is always visible regardless of content', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n expect(find.byIcon(Icons.visibility_off_outlined), findsOneWidget);\n });\n\n testWidgets('eye toggle is inert when field is disabled', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password', enabled: false)),\n );\n\n // Attempt tap — should not toggle (gesture is disabled).\n await tester.tap(find.byIcon(Icons.visibility_off_outlined), warnIfMissed: false);\n await tester.pump();\n\n // Still obscured — icon has not changed.\n expect(find.byIcon(Icons.visibility_off_outlined), findsOneWidget);\n expect(find.byIcon(Icons.visibility_outlined), findsNothing);\n });\n\n // -----------------------------------------------------------------------\n // Validation states\n // -----------------------------------------------------------------------\n\n testWidgets('shows checkmark icon when valid', (tester) async {\n final controller = TextEditingController(text: 'S3cur3P@ssw0rd');\n await tester.pumpWidget(\n _wrap(VisorPasswordInput(\n labelText: 'Password',\n controller: controller,\n validator: (v) =>\n v != null && v.length >= 8 ? null : 'Too short',\n )),\n );\n await tester.pump();\n expect(find.byIcon(Icons.check_circle_outline), findsOneWidget);\n });\n\n testWidgets('does not show checkmark when field is empty', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorPasswordInput(\n labelText: 'Password',\n validator: (v) => v?.isEmpty == true ? 'Required' : null,\n )),\n );\n expect(find.byIcon(Icons.check_circle_outline), findsNothing);\n });\n\n testWidgets('shows both checkmark and eye toggle when valid', (tester) async {\n final controller = TextEditingController(text: 'S3cur3P@ss');\n await tester.pumpWidget(\n _wrap(VisorPasswordInput(\n labelText: 'Password',\n controller: controller,\n validator: (v) =>\n v != null && v.length >= 8 ? null : 'Too short',\n )),\n );\n await tester.pump();\n expect(find.byIcon(Icons.check_circle_outline), findsOneWidget);\n expect(find.byIcon(Icons.visibility_off_outlined), findsOneWidget);\n });\n\n testWidgets('shows error text after user interaction', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorPasswordInput(\n labelText: 'Password',\n autovalidateMode: AutovalidateMode.onUserInteraction,\n validator: (v) => v?.isEmpty == true ? 'Required' : null,\n )),\n );\n await tester.tap(find.byType(TextFormField));\n await tester.enterText(find.byType(TextFormField), 'x');\n await tester.pump();\n await tester.enterText(find.byType(TextFormField), '');\n await tester.pump();\n expect(find.text('Required'), findsOneWidget);\n });\n\n testWidgets('shows explicit errorText override', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(\n labelText: 'Password',\n errorText: 'Incorrect password',\n )),\n );\n expect(find.text('Incorrect password'), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // isValid override\n // -----------------------------------------------------------------------\n\n testWidgets('isValid: true forces checkmark regardless of validator',\n (tester) async {\n await tester.pumpWidget(\n _wrap(VisorPasswordInput(\n labelText: 'Password',\n isValid: true,\n validator: (_) => 'Always invalid',\n )),\n );\n expect(find.byIcon(Icons.check_circle_outline), findsOneWidget);\n });\n\n testWidgets('isValid: false suppresses checkmark even when validator passes',\n (tester) async {\n final controller = TextEditingController(text: 'hunter2');\n await tester.pumpWidget(\n _wrap(VisorPasswordInput(\n labelText: 'Password',\n controller: controller,\n isValid: false,\n validator: (_) => null,\n )),\n );\n await tester.pump();\n expect(find.byIcon(Icons.check_circle_outline), findsNothing);\n });\n\n // -----------------------------------------------------------------------\n // Form integration\n // -----------------------------------------------------------------------\n\n testWidgets('Form.validate() returns true when validator passes',\n (tester) async {\n final formKey = GlobalKey<FormState>();\n final controller = TextEditingController(text: 'S3cur3P@ss');\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: Form(\n key: formKey,\n child: VisorPasswordInput(\n labelText: 'Password',\n controller: controller,\n validator: (v) =>\n v != null && v.length >= 8 ? null : 'Too short',\n ),\n ),\n ),\n ),\n );\n expect(formKey.currentState!.validate(), isTrue);\n });\n\n testWidgets('Form.validate() returns false when validator fails',\n (tester) async {\n final formKey = GlobalKey<FormState>();\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: Form(\n key: formKey,\n child: VisorPasswordInput(\n labelText: 'Password',\n validator: (_) => 'Required',\n ),\n ),\n ),\n ),\n );\n await tester.pump();\n expect(formKey.currentState!.validate(), isFalse);\n });\n\n // -----------------------------------------------------------------------\n // Callbacks\n // -----------------------------------------------------------------------\n\n testWidgets('onChanged fires when text is entered', (tester) async {\n String? lastValue;\n await tester.pumpWidget(\n _wrap(VisorPasswordInput(\n labelText: 'Password',\n onChanged: (v) => lastValue = v,\n )),\n );\n await tester.enterText(find.byType(TextFormField), 'hunter2');\n expect(lastValue, 'hunter2');\n });\n\n // -----------------------------------------------------------------------\n // Token usage — no hard-coded values\n // -----------------------------------------------------------------------\n\n testWidgets('widget builds without hard-coded color references',\n (tester) async {\n // Static analysis (flutter analyze) enforces the actual token rule.\n // This test verifies the widget renders without throwing.\n await tester.pumpWidget(\n _wrap(VisorPasswordInput(\n labelText: 'Password',\n validator: (v) => v?.isEmpty == true ? 'Required' : null,\n )),\n );\n expect(find.byType(VisorPasswordInput), findsOneWidget);\n });\n });\n\n // -------------------------------------------------------------------------\n // meetsGuideline (R11) — tap-target + labeled-tap coverage\n // -------------------------------------------------------------------------\n\n group('meetsGuideline (R11)', () {\n testWidgets(\n 'default password input meets Android tap target + label guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets(\n 'password input in error state meets Android tap target + label guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(\n labelText: 'Password',\n errorText: 'Incorrect password',\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets(\n 'password input with isValid override meets Android tap target + label guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(\n labelText: 'Password',\n isValid: true,\n )),\n );\n await tester.pump();\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n });\n}\n",
4252
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport '../visor_text_input/visor_text_input.dart';\nimport 'visor_password_input.dart';\n\nWidget _wrap(Widget child, {TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(body: Center(child: child)),\n ),\n );\n}\n\nvoid main() {\n group('VisorPasswordInput', () {\n // -----------------------------------------------------------------------\n // Rendering\n // -----------------------------------------------------------------------\n\n testWidgets('renders the label text', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n expect(find.text('Password'), findsOneWidget);\n });\n\n testWidgets('renders a VisorTextInput as the base', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n expect(find.byType(VisorTextInput), findsOneWidget);\n });\n\n testWidgets('renders the inner TextFormField', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n expect(find.byType(TextFormField), findsOneWidget);\n });\n\n testWidgets('is disabled when enabled is false', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password', enabled: false)),\n );\n final field = tester.widget<TextFormField>(find.byType(TextFormField));\n expect(field.enabled, isFalse);\n });\n\n // -----------------------------------------------------------------------\n // obscureText toggle\n // -----------------------------------------------------------------------\n\n testWidgets('text is obscured by default', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n // obscureText is surfaced on EditableText, which TextFormField creates\n // internally. The obscure state is reflected in the EditableText widget.\n final editableText = tester.widget<EditableText>(find.byType(EditableText));\n expect(editableText.obscureText, isTrue);\n });\n\n testWidgets('tapping the eye icon reveals text', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n\n // Initially obscured.\n final textBefore = tester.widget<EditableText>(find.byType(EditableText));\n expect(textBefore.obscureText, isTrue);\n\n // Tap the visibility-off icon to reveal.\n await tester.tap(find.byIcon(Icons.visibility_off_outlined));\n await tester.pump();\n\n // Should now be revealed.\n final textAfter = tester.widget<EditableText>(find.byType(EditableText));\n expect(textAfter.obscureText, isFalse);\n\n // Icon should have flipped to the visibility icon.\n expect(find.byIcon(Icons.visibility_outlined), findsOneWidget);\n });\n\n testWidgets('tapping the eye icon a second time re-obscures text',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n\n // Reveal.\n await tester.tap(find.byIcon(Icons.visibility_off_outlined));\n await tester.pump();\n\n // Re-obscure.\n await tester.tap(find.byIcon(Icons.visibility_outlined));\n await tester.pump();\n\n final editableText = tester.widget<EditableText>(find.byType(EditableText));\n expect(editableText.obscureText, isTrue);\n });\n\n testWidgets('eye icon is always visible regardless of content', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n expect(find.byIcon(Icons.visibility_off_outlined), findsOneWidget);\n });\n\n testWidgets('eye toggle is inert when field is disabled', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password', enabled: false)),\n );\n\n // Attempt tap — should not toggle (gesture is disabled).\n await tester.tap(find.byIcon(Icons.visibility_off_outlined), warnIfMissed: false);\n await tester.pump();\n\n // Still obscured — icon has not changed.\n expect(find.byIcon(Icons.visibility_off_outlined), findsOneWidget);\n expect(find.byIcon(Icons.visibility_outlined), findsNothing);\n });\n\n // -----------------------------------------------------------------------\n // Validation states\n // -----------------------------------------------------------------------\n\n testWidgets('shows checkmark icon when valid', (tester) async {\n final controller = TextEditingController(text: 'S3cur3P@ssw0rd');\n await tester.pumpWidget(\n _wrap(VisorPasswordInput(\n labelText: 'Password',\n controller: controller,\n validator: (v) =>\n v != null && v.length >= 8 ? null : 'Too short',\n )),\n );\n await tester.pump();\n expect(find.byIcon(Icons.check_circle_outline), findsOneWidget);\n });\n\n testWidgets('does not show checkmark when field is empty', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorPasswordInput(\n labelText: 'Password',\n validator: (v) => v?.isEmpty == true ? 'Required' : null,\n )),\n );\n expect(find.byIcon(Icons.check_circle_outline), findsNothing);\n });\n\n testWidgets('shows both checkmark and eye toggle when valid', (tester) async {\n final controller = TextEditingController(text: 'S3cur3P@ss');\n await tester.pumpWidget(\n _wrap(VisorPasswordInput(\n labelText: 'Password',\n controller: controller,\n validator: (v) =>\n v != null && v.length >= 8 ? null : 'Too short',\n )),\n );\n await tester.pump();\n expect(find.byIcon(Icons.check_circle_outline), findsOneWidget);\n expect(find.byIcon(Icons.visibility_off_outlined), findsOneWidget);\n });\n\n testWidgets('shows error text after user interaction', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorPasswordInput(\n labelText: 'Password',\n autovalidateMode: AutovalidateMode.onUserInteraction,\n validator: (v) => v?.isEmpty == true ? 'Required' : null,\n )),\n );\n await tester.tap(find.byType(TextFormField));\n await tester.enterText(find.byType(TextFormField), 'x');\n await tester.pump();\n await tester.enterText(find.byType(TextFormField), '');\n await tester.pump();\n expect(find.text('Required'), findsOneWidget);\n });\n\n testWidgets('shows explicit errorText override', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(\n labelText: 'Password',\n errorText: 'Incorrect password',\n )),\n );\n expect(find.text('Incorrect password'), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // isValid override\n // -----------------------------------------------------------------------\n\n testWidgets('isValid: true forces checkmark regardless of validator',\n (tester) async {\n await tester.pumpWidget(\n _wrap(VisorPasswordInput(\n labelText: 'Password',\n isValid: true,\n validator: (_) => 'Always invalid',\n )),\n );\n expect(find.byIcon(Icons.check_circle_outline), findsOneWidget);\n });\n\n testWidgets('isValid: false suppresses checkmark even when validator passes',\n (tester) async {\n final controller = TextEditingController(text: 'hunter2');\n await tester.pumpWidget(\n _wrap(VisorPasswordInput(\n labelText: 'Password',\n controller: controller,\n isValid: false,\n validator: (_) => null,\n )),\n );\n await tester.pump();\n expect(find.byIcon(Icons.check_circle_outline), findsNothing);\n });\n\n // -----------------------------------------------------------------------\n // Form integration\n // -----------------------------------------------------------------------\n\n testWidgets('Form.validate() returns true when validator passes',\n (tester) async {\n final formKey = GlobalKey<FormState>();\n final controller = TextEditingController(text: 'S3cur3P@ss');\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: Form(\n key: formKey,\n child: VisorPasswordInput(\n labelText: 'Password',\n controller: controller,\n validator: (v) =>\n v != null && v.length >= 8 ? null : 'Too short',\n ),\n ),\n ),\n ),\n );\n expect(formKey.currentState!.validate(), isTrue);\n });\n\n testWidgets('Form.validate() returns false when validator fails',\n (tester) async {\n final formKey = GlobalKey<FormState>();\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: Form(\n key: formKey,\n child: VisorPasswordInput(\n labelText: 'Password',\n validator: (_) => 'Required',\n ),\n ),\n ),\n ),\n );\n await tester.pump();\n expect(formKey.currentState!.validate(), isFalse);\n });\n\n // -----------------------------------------------------------------------\n // Callbacks\n // -----------------------------------------------------------------------\n\n testWidgets('onChanged fires when text is entered', (tester) async {\n String? lastValue;\n await tester.pumpWidget(\n _wrap(VisorPasswordInput(\n labelText: 'Password',\n onChanged: (v) => lastValue = v,\n )),\n );\n await tester.enterText(find.byType(TextFormField), 'hunter2');\n expect(lastValue, 'hunter2');\n });\n\n // -----------------------------------------------------------------------\n // Token usage — no hard-coded values\n // -----------------------------------------------------------------------\n\n testWidgets('widget builds without hard-coded color references',\n (tester) async {\n // Static analysis (flutter analyze) enforces the actual token rule.\n // This test verifies the widget renders without throwing.\n await tester.pumpWidget(\n _wrap(VisorPasswordInput(\n labelText: 'Password',\n validator: (v) => v?.isEmpty == true ? 'Required' : null,\n )),\n );\n expect(find.byType(VisorPasswordInput), findsOneWidget);\n });\n });\n\n // -------------------------------------------------------------------------\n // meetsGuideline (R11) — tap-target + labeled-tap coverage\n // -------------------------------------------------------------------------\n\n group('meetsGuideline (R11)', () {\n testWidgets(\n 'default password input meets Android tap target + label guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets(\n 'password input in error state meets Android tap target + label guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(\n labelText: 'Password',\n errorText: 'Incorrect password',\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets(\n 'password input with isValid override meets Android tap target + label guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(\n labelText: 'Password',\n isValid: true,\n )),\n );\n await tester.pump();\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n // Rec5 — textContrastGuideline (VI-257)\n\n testWidgets('default password input renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(labelText: 'Password')),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('password input in error state renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorPasswordInput(\n labelText: 'Password',\n errorText: 'Incorrect password',\n )),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorPasswordInput(labelText: 'Password'),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorPasswordInput), findsOneWidget);\n });\n });\n}\n",
4025
4253
  "target": "flutter"
4026
4254
  }
4027
4255
  ]
@@ -4056,7 +4284,7 @@
4056
4284
  {
4057
4285
  "path": "components/flutter/visor_phone_input/visor_phone_input_test.dart",
4058
4286
  "type": "registry:ui",
4059
- "content": "import 'package:country_code_picker/country_code_picker.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport '../visor_text_input/visor_text_input.dart';\nimport 'visor_phone_input.dart';\n\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(body: Center(child: child)),\n );\n}\n\nvoid main() {\n group('VisorPhoneInput', () {\n // -------------------------------------------------------------------------\n // Rendering\n // -------------------------------------------------------------------------\n\n testWidgets('renders the label text', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(labelText: 'Phone number')),\n );\n expect(find.text('Phone number'), findsOneWidget);\n });\n\n testWidgets('renders an underlying VisorTextInput', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(labelText: 'Phone')),\n );\n expect(find.byType(VisorTextInput), findsOneWidget);\n });\n\n testWidgets('renders the dial code for the default country (US)',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(labelText: 'Phone')),\n );\n // CountryCode.fromCountryCode('US') yields dialCode '+1'.\n expect(find.text('+1'), findsOneWidget);\n });\n\n testWidgets('renders the dial code for an explicit initialCountryCode',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(\n labelText: 'Phone',\n initialCountryCode: 'GB',\n )),\n );\n expect(find.text('+44'), findsOneWidget);\n });\n\n testWidgets('renders the country picker prefix', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(labelText: 'Phone')),\n );\n expect(find.byType(CountryCodePicker), findsOneWidget);\n });\n\n // -------------------------------------------------------------------------\n // Disabled state\n // -------------------------------------------------------------------------\n\n testWidgets('forwards enabled: false to the inner field', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(labelText: 'Phone', enabled: false)),\n );\n final field = tester.widget<TextFormField>(find.byType(TextFormField));\n expect(field.enabled, isFalse);\n });\n\n // -------------------------------------------------------------------------\n // Validation surface\n // -------------------------------------------------------------------------\n\n testWidgets('renders explicit errorText override', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(\n labelText: 'Phone',\n errorText: 'Network error',\n )),\n );\n expect(find.text('Network error'), findsOneWidget);\n });\n\n testWidgets('does not show checkmark when field is empty', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(labelText: 'Phone')),\n );\n expect(find.byIcon(Icons.check_circle_outline), findsNothing);\n });\n\n // -------------------------------------------------------------------------\n // Form integration\n // -------------------------------------------------------------------------\n\n testWidgets('Form.validate() returns false when validator fails',\n (tester) async {\n final formKey = GlobalKey<FormState>();\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: Form(\n key: formKey,\n child: VisorPhoneInput(\n labelText: 'Phone',\n validator: (v) =>\n v != null && v.isNotEmpty ? null : 'Required',\n ),\n ),\n ),\n ),\n );\n await tester.pump();\n expect(formKey.currentState!.validate(), isFalse);\n });\n\n testWidgets('Form.validate() returns true when validator passes',\n (tester) async {\n final formKey = GlobalKey<FormState>();\n final controller = TextEditingController(text: '5551234567');\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: Form(\n key: formKey,\n child: VisorPhoneInput(\n labelText: 'Phone',\n controller: controller,\n validator: (v) =>\n v != null && v.isNotEmpty ? null : 'Required',\n ),\n ),\n ),\n ),\n );\n expect(formKey.currentState!.validate(), isTrue);\n });\n\n // -------------------------------------------------------------------------\n // Callbacks\n // -------------------------------------------------------------------------\n\n testWidgets('onChanged fires when text is entered', (tester) async {\n String? lastValue;\n await tester.pumpWidget(\n _wrap(VisorPhoneInput(\n labelText: 'Phone',\n onChanged: (v) => lastValue = v,\n )),\n );\n await tester.enterText(find.byType(TextFormField), '5551234567');\n expect(lastValue, isNotNull);\n expect(lastValue!.replaceAll(RegExp(r'[^\\d]'), ''), '5551234567');\n });\n\n // -------------------------------------------------------------------------\n // Country change behavior\n // -------------------------------------------------------------------------\n\n testWidgets(\n 'country change clears the internal controller and fires onCountryChanged',\n (tester) async {\n CountryCode? lastCountry;\n await tester.pumpWidget(\n _wrap(VisorPhoneInput(\n labelText: 'Phone',\n onCountryChanged: (c) => lastCountry = c,\n )),\n );\n // Seed text in the inner field, then simulate a country change.\n await tester.enterText(find.byType(TextFormField), '5551234567');\n expect(\n tester.widget<TextFormField>(find.byType(TextFormField)).controller!.text,\n isNotEmpty,\n );\n\n // Drive the picker via the state's _onCountryChanged path by tapping\n // through CountryCodePicker is brittle in tests — instead, find the\n // picker and invoke its onChanged directly to verify wiring.\n final picker =\n tester.widget<CountryCodePicker>(find.byType(CountryCodePicker));\n picker.onChanged?.call(CountryCode.fromCountryCode('GB'));\n await tester.pump();\n\n // Field cleared after country change (regardless of internal vs external\n // controller) and callback fired with the new country.\n expect(\n tester.widget<TextFormField>(find.byType(TextFormField)).controller!.text,\n isEmpty,\n );\n expect(lastCountry?.code, 'GB');\n });\n\n testWidgets('country change clears an external controller', (tester) async {\n final controller = TextEditingController(text: '5551234567');\n await tester.pumpWidget(\n _wrap(VisorPhoneInput(\n labelText: 'Phone',\n controller: controller,\n )),\n );\n expect(controller.text, isNotEmpty);\n\n final picker =\n tester.widget<CountryCodePicker>(find.byType(CountryCodePicker));\n picker.onChanged?.call(CountryCode.fromCountryCode('GB'));\n await tester.pump();\n\n expect(controller.text, isEmpty);\n });\n\n // -------------------------------------------------------------------------\n // Token usage — smoke check\n // -------------------------------------------------------------------------\n\n testWidgets('widget builds without hard-coded color references',\n (tester) async {\n await tester.pumpWidget(\n _wrap(VisorPhoneInput(\n labelText: 'Phone',\n validator: (v) => v?.isEmpty == true ? 'Required' : null,\n )),\n );\n expect(find.byType(VisorPhoneInput), findsOneWidget);\n });\n });\n\n // ---------------------------------------------------------------------------\n // meetsGuideline (R11) — tap-target + labeled-tap coverage\n // ---------------------------------------------------------------------------\n\n group('meetsGuideline (R11)', () {\n testWidgets(\n 'default phone input meets Android tap target + label guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(labelText: 'Phone number')),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets(\n 'phone input in error state meets Android tap target + label guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(\n labelText: 'Phone',\n errorText: 'Invalid phone number',\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n });\n}\n",
4287
+ "content": "import 'package:country_code_picker/country_code_picker.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport '../visor_text_input/visor_text_input.dart';\nimport 'visor_phone_input.dart';\n\nWidget _wrap(Widget child, {TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(body: Center(child: child)),\n ),\n );\n}\n\nvoid main() {\n group('VisorPhoneInput', () {\n // -------------------------------------------------------------------------\n // Rendering\n // -------------------------------------------------------------------------\n\n testWidgets('renders the label text', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(labelText: 'Phone number')),\n );\n expect(find.text('Phone number'), findsOneWidget);\n });\n\n testWidgets('renders an underlying VisorTextInput', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(labelText: 'Phone')),\n );\n expect(find.byType(VisorTextInput), findsOneWidget);\n });\n\n testWidgets('renders the dial code for the default country (US)',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(labelText: 'Phone')),\n );\n // CountryCode.fromCountryCode('US') yields dialCode '+1'.\n expect(find.text('+1'), findsOneWidget);\n });\n\n testWidgets('renders the dial code for an explicit initialCountryCode',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(\n labelText: 'Phone',\n initialCountryCode: 'GB',\n )),\n );\n expect(find.text('+44'), findsOneWidget);\n });\n\n testWidgets('renders the country picker prefix', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(labelText: 'Phone')),\n );\n expect(find.byType(CountryCodePicker), findsOneWidget);\n });\n\n // -------------------------------------------------------------------------\n // Disabled state\n // -------------------------------------------------------------------------\n\n testWidgets('forwards enabled: false to the inner field', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(labelText: 'Phone', enabled: false)),\n );\n final field = tester.widget<TextFormField>(find.byType(TextFormField));\n expect(field.enabled, isFalse);\n });\n\n // -------------------------------------------------------------------------\n // Validation surface\n // -------------------------------------------------------------------------\n\n testWidgets('renders explicit errorText override', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(\n labelText: 'Phone',\n errorText: 'Network error',\n )),\n );\n expect(find.text('Network error'), findsOneWidget);\n });\n\n testWidgets('does not show checkmark when field is empty', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(labelText: 'Phone')),\n );\n expect(find.byIcon(Icons.check_circle_outline), findsNothing);\n });\n\n // -------------------------------------------------------------------------\n // Form integration\n // -------------------------------------------------------------------------\n\n testWidgets('Form.validate() returns false when validator fails',\n (tester) async {\n final formKey = GlobalKey<FormState>();\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: Form(\n key: formKey,\n child: VisorPhoneInput(\n labelText: 'Phone',\n validator: (v) =>\n v != null && v.isNotEmpty ? null : 'Required',\n ),\n ),\n ),\n ),\n );\n await tester.pump();\n expect(formKey.currentState!.validate(), isFalse);\n });\n\n testWidgets('Form.validate() returns true when validator passes',\n (tester) async {\n final formKey = GlobalKey<FormState>();\n final controller = TextEditingController(text: '5551234567');\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: Form(\n key: formKey,\n child: VisorPhoneInput(\n labelText: 'Phone',\n controller: controller,\n validator: (v) =>\n v != null && v.isNotEmpty ? null : 'Required',\n ),\n ),\n ),\n ),\n );\n expect(formKey.currentState!.validate(), isTrue);\n });\n\n // -------------------------------------------------------------------------\n // Callbacks\n // -------------------------------------------------------------------------\n\n testWidgets('onChanged fires when text is entered', (tester) async {\n String? lastValue;\n await tester.pumpWidget(\n _wrap(VisorPhoneInput(\n labelText: 'Phone',\n onChanged: (v) => lastValue = v,\n )),\n );\n await tester.enterText(find.byType(TextFormField), '5551234567');\n expect(lastValue, isNotNull);\n expect(lastValue!.replaceAll(RegExp(r'[^\\d]'), ''), '5551234567');\n });\n\n // -------------------------------------------------------------------------\n // Country change behavior\n // -------------------------------------------------------------------------\n\n testWidgets(\n 'country change clears the internal controller and fires onCountryChanged',\n (tester) async {\n CountryCode? lastCountry;\n await tester.pumpWidget(\n _wrap(VisorPhoneInput(\n labelText: 'Phone',\n onCountryChanged: (c) => lastCountry = c,\n )),\n );\n // Seed text in the inner field, then simulate a country change.\n await tester.enterText(find.byType(TextFormField), '5551234567');\n expect(\n tester.widget<TextFormField>(find.byType(TextFormField)).controller!.text,\n isNotEmpty,\n );\n\n // Drive the picker via the state's _onCountryChanged path by tapping\n // through CountryCodePicker is brittle in tests — instead, find the\n // picker and invoke its onChanged directly to verify wiring.\n final picker =\n tester.widget<CountryCodePicker>(find.byType(CountryCodePicker));\n picker.onChanged?.call(CountryCode.fromCountryCode('GB'));\n await tester.pump();\n\n // Field cleared after country change (regardless of internal vs external\n // controller) and callback fired with the new country.\n expect(\n tester.widget<TextFormField>(find.byType(TextFormField)).controller!.text,\n isEmpty,\n );\n expect(lastCountry?.code, 'GB');\n });\n\n testWidgets('country change clears an external controller', (tester) async {\n final controller = TextEditingController(text: '5551234567');\n await tester.pumpWidget(\n _wrap(VisorPhoneInput(\n labelText: 'Phone',\n controller: controller,\n )),\n );\n expect(controller.text, isNotEmpty);\n\n final picker =\n tester.widget<CountryCodePicker>(find.byType(CountryCodePicker));\n picker.onChanged?.call(CountryCode.fromCountryCode('GB'));\n await tester.pump();\n\n expect(controller.text, isEmpty);\n });\n\n // -------------------------------------------------------------------------\n // Token usage — smoke check\n // -------------------------------------------------------------------------\n\n testWidgets('widget builds without hard-coded color references',\n (tester) async {\n await tester.pumpWidget(\n _wrap(VisorPhoneInput(\n labelText: 'Phone',\n validator: (v) => v?.isEmpty == true ? 'Required' : null,\n )),\n );\n expect(find.byType(VisorPhoneInput), findsOneWidget);\n });\n });\n\n // ---------------------------------------------------------------------------\n // meetsGuideline (R11) — tap-target + labeled-tap coverage\n // ---------------------------------------------------------------------------\n\n group('meetsGuideline (R11)', () {\n testWidgets(\n 'default phone input meets Android tap target + label guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(labelText: 'Phone number')),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets(\n 'phone input in error state meets Android tap target + label guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(\n labelText: 'Phone',\n errorText: 'Invalid phone number',\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n // Rec5 — textContrastGuideline (VI-257)\n\n testWidgets('default phone input renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(labelText: 'Phone number')),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('phone input in error state renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorPhoneInput(\n labelText: 'Phone',\n errorText: 'Invalid phone number',\n )),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorPhoneInput(labelText: 'Phone number'),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorPhoneInput), findsOneWidget);\n // The country picker prefix (flag + dial code + keyboard_arrow_down\n // chevron) is laid out inside VisorTextInput's prefixIcon slot, which\n // respects Directionality. The chevron glyph (keyboard_arrow_down) is\n // a vertical arrow and is not direction-sensitive; no semantic flip\n // follow-up required. The Row inside the prefix widget does not reverse\n // because it is a horizontal layout that simply shifts to the opposing\n // side of the text field in RTL via the InputDecoration prefix slot.\n expect(find.byIcon(Icons.keyboard_arrow_down), findsOneWidget);\n });\n });\n}\n",
4060
4288
  "target": "flutter"
4061
4289
  }
4062
4290
  ]
@@ -4087,7 +4315,7 @@
4087
4315
  {
4088
4316
  "path": "components/flutter/visor_rich_text/visor_rich_text_test.dart",
4089
4317
  "type": "registry:ui",
4090
- "content": "import 'package:flutter/gestures.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_rich_text.dart';\n\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(body: Center(child: child)),\n );\n}\n\n/// Walks a [TextSpan] tree and returns every leaf span. `SelectableText.rich`\n/// and `RichText` both accept [TextSpan] children, so this works for either\n/// rendering path.\nList<TextSpan> _flatten(InlineSpan? span) {\n final out = <TextSpan>[];\n if (span is! TextSpan) return out;\n if (span.text != null && span.text!.isNotEmpty) {\n out.add(span);\n }\n for (final child in span.children ?? const <InlineSpan>[]) {\n out.addAll(_flatten(child));\n }\n return out;\n}\n\nTextSpan _topSpan(WidgetTester tester) {\n final selectable = find.byType(SelectableText);\n if (selectable.evaluate().isNotEmpty) {\n return tester.widget<SelectableText>(selectable).textSpan!;\n }\n final rich = tester.widget<RichText>(find.byType(RichText).first);\n return rich.text as TextSpan;\n}\n\nvoid main() {\n group('VisorRichText', () {\n // -----------------------------------------------------------------------\n // Rendering — pure text\n // -----------------------------------------------------------------------\n\n testWidgets('renders pure text when no URLs are present', (tester) async {\n await tester.pumpWidget(_wrap(const VisorRichText(text: 'Hello world')));\n final spans = _flatten(_topSpan(tester));\n expect(spans, hasLength(1));\n expect(spans.single.text, 'Hello world');\n expect(spans.single.recognizer, isNull);\n });\n\n // -----------------------------------------------------------------------\n // Rendering — URL detection\n // -----------------------------------------------------------------------\n\n testWidgets('splits a single URL into a link span', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorRichText(text: 'Visit https://example.com today')),\n );\n final spans = _flatten(_topSpan(tester));\n expect(spans.map((s) => s.text).toList(), [\n 'Visit ',\n 'https://example.com',\n ' today',\n ]);\n // Only the URL span has a tap recognizer.\n expect(spans[0].recognizer, isNull);\n expect(spans[1].recognizer, isA<TapGestureRecognizer>());\n expect(spans[2].recognizer, isNull);\n });\n\n testWidgets('handles multiple URLs in one string', (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorRichText(\n text: 'See https://a.example.com and https://b.example.com.',\n ),\n ),\n );\n final spans = _flatten(_topSpan(tester));\n final linkTexts =\n spans.where((s) => s.recognizer != null).map((s) => s.text).toList();\n expect(linkTexts, [\n 'https://a.example.com',\n 'https://b.example.com',\n ]);\n });\n\n testWidgets('renders the URL at the start of the string as a link',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorRichText(text: 'https://example.com is the link')),\n );\n final spans = _flatten(_topSpan(tester));\n expect(spans.first.text, 'https://example.com');\n expect(spans.first.recognizer, isA<TapGestureRecognizer>());\n });\n\n // -----------------------------------------------------------------------\n // Tap handling\n // -----------------------------------------------------------------------\n\n testWidgets('tapping a link span invokes onLinkTap with the URL',\n (tester) async {\n String? tappedUrl;\n await tester.pumpWidget(\n _wrap(\n VisorRichText(\n text: 'Open https://example.com please',\n onLinkTap: (url) => tappedUrl = url,\n ),\n ),\n );\n final span = _flatten(_topSpan(tester))\n .firstWhere((s) => s.recognizer != null);\n (span.recognizer! as TapGestureRecognizer).onTap!();\n expect(tappedUrl, 'https://example.com');\n });\n\n // -----------------------------------------------------------------------\n // Token-driven styling\n // -----------------------------------------------------------------------\n\n testWidgets('link span uses textLink color from VisorColors by default',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorRichText(text: 'Open https://example.com')),\n );\n final link = _flatten(_topSpan(tester))\n .firstWhere((s) => s.recognizer != null);\n // testColors() sets textLink to 0xFF2563EB.\n expect(link.style!.color, const Color(0xFF2563EB));\n });\n\n testWidgets('linkStyle override is applied to link spans', (tester) async {\n const overrideColor = Color(0xFF112233);\n await tester.pumpWidget(\n _wrap(\n const VisorRichText(\n text: 'Open https://example.com',\n linkStyle: TextStyle(color: overrideColor),\n ),\n ),\n );\n final link = _flatten(_topSpan(tester))\n .firstWhere((s) => s.recognizer != null);\n expect(link.style!.color, overrideColor);\n });\n\n testWidgets('style override is applied to base spans', (tester) async {\n const overrideColor = Color(0xFF445566);\n await tester.pumpWidget(\n _wrap(\n const VisorRichText(\n text: 'Plain text only',\n style: TextStyle(color: overrideColor),\n ),\n ),\n );\n final span = _flatten(_topSpan(tester)).single;\n expect(span.style!.color, overrideColor);\n });\n\n // -----------------------------------------------------------------------\n // Selectable toggle\n // -----------------------------------------------------------------------\n\n testWidgets('renders SelectableText.rich by default', (tester) async {\n await tester.pumpWidget(_wrap(const VisorRichText(text: 'Hello')));\n expect(find.byType(SelectableText), findsOneWidget);\n });\n\n testWidgets('renders RichText (no SelectableText) when selectable: false',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorRichText(text: 'Hello', selectable: false)),\n );\n expect(find.byType(SelectableText), findsNothing);\n expect(find.byType(RichText), findsAtLeastNWidgets(1));\n });\n\n // -----------------------------------------------------------------------\n // Semantics\n // -----------------------------------------------------------------------\n\n testWidgets('link span carries its URL as semanticsLabel', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorRichText(text: 'Visit https://example.com')),\n );\n final link = _flatten(_topSpan(tester))\n .firstWhere((s) => s.recognizer != null);\n expect(link.semanticsLabel, 'https://example.com');\n });\n\n testWidgets('semanticLabel wraps the widget in a Semantics node',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(\n const VisorRichText(\n text: 'Hello',\n semanticLabel: 'Greeting copy',\n ),\n ),\n );\n expect(find.bySemanticsLabel('Greeting copy'), findsOneWidget);\n handle.dispose();\n });\n\n // not applicable: inline-text — link tap surfaces are text-sized by\n // design; meetsGuideline tap-target matchers don't apply per quality\n // contract R7/R11 escape hatch.\n\n // -----------------------------------------------------------------------\n // URL detection — encoding + punctuation\n // -----------------------------------------------------------------------\n\n testWidgets('preserves percent-encoded characters inside URLs',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorRichText(\n text: 'Search https://example.com/q?term=hello%20world.',\n ),\n ),\n );\n final link = _flatten(_topSpan(tester))\n .firstWhere((s) => s.recognizer != null);\n expect(link.text, 'https://example.com/q?term=hello%20world');\n });\n\n testWidgets('strips trailing sentence punctuation but keeps balanced ()',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorRichText(\n text: 'See (https://en.wikipedia.org/wiki/URL_(rfc)) for more.',\n ),\n ),\n );\n final link = _flatten(_topSpan(tester))\n .firstWhere((s) => s.recognizer != null);\n expect(link.text, 'https://en.wikipedia.org/wiki/URL_(rfc)');\n });\n\n // -----------------------------------------------------------------------\n // Recognizer lifecycle — no leak across rebuilds\n // -----------------------------------------------------------------------\n\n testWidgets('updates link spans when text prop changes', (tester) async {\n String? tappedUrl;\n Widget tree(String text) => _wrap(\n VisorRichText(text: text, onLinkTap: (url) => tappedUrl = url),\n );\n\n await tester.pumpWidget(tree('First https://a.example.com'));\n var link = _flatten(_topSpan(tester))\n .firstWhere((s) => s.recognizer != null);\n expect(link.text, 'https://a.example.com');\n\n await tester.pumpWidget(tree('Second https://b.example.com'));\n link = _flatten(_topSpan(tester))\n .firstWhere((s) => s.recognizer != null);\n expect(link.text, 'https://b.example.com');\n // Tapping the new link still routes through onLinkTap with the new URL.\n (link.recognizer! as TapGestureRecognizer).onTap!();\n expect(tappedUrl, 'https://b.example.com');\n });\n\n testWidgets('disposing the widget tree does not throw',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorRichText(text: 'See https://example.com')),\n );\n // Swap in an empty tree — triggers State.dispose() on VisorRichText.\n await tester.pumpWidget(_wrap(const SizedBox()));\n // Reaching here without exception confirms recognizers disposed cleanly.\n });\n });\n}\n",
4318
+ "content": "import 'package:flutter/gestures.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_rich_text.dart';\n\nWidget _wrap(Widget child, {TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(body: Center(child: child)),\n ),\n );\n}\n\n/// Walks a [TextSpan] tree and returns every leaf span. `SelectableText.rich`\n/// and `RichText` both accept [TextSpan] children, so this works for either\n/// rendering path.\nList<TextSpan> _flatten(InlineSpan? span) {\n final out = <TextSpan>[];\n if (span is! TextSpan) return out;\n if (span.text != null && span.text!.isNotEmpty) {\n out.add(span);\n }\n for (final child in span.children ?? const <InlineSpan>[]) {\n out.addAll(_flatten(child));\n }\n return out;\n}\n\nTextSpan _topSpan(WidgetTester tester) {\n final selectable = find.byType(SelectableText);\n if (selectable.evaluate().isNotEmpty) {\n return tester.widget<SelectableText>(selectable).textSpan!;\n }\n final rich = tester.widget<RichText>(find.byType(RichText).first);\n return rich.text as TextSpan;\n}\n\nvoid main() {\n group('VisorRichText', () {\n // -----------------------------------------------------------------------\n // Rendering — pure text\n // -----------------------------------------------------------------------\n\n testWidgets('renders pure text when no URLs are present', (tester) async {\n await tester.pumpWidget(_wrap(const VisorRichText(text: 'Hello world')));\n final spans = _flatten(_topSpan(tester));\n expect(spans, hasLength(1));\n expect(spans.single.text, 'Hello world');\n expect(spans.single.recognizer, isNull);\n });\n\n // -----------------------------------------------------------------------\n // Rendering — URL detection\n // -----------------------------------------------------------------------\n\n testWidgets('splits a single URL into a link span', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorRichText(text: 'Visit https://example.com today')),\n );\n final spans = _flatten(_topSpan(tester));\n expect(spans.map((s) => s.text).toList(), [\n 'Visit ',\n 'https://example.com',\n ' today',\n ]);\n // Only the URL span has a tap recognizer.\n expect(spans[0].recognizer, isNull);\n expect(spans[1].recognizer, isA<TapGestureRecognizer>());\n expect(spans[2].recognizer, isNull);\n });\n\n testWidgets('handles multiple URLs in one string', (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorRichText(\n text: 'See https://a.example.com and https://b.example.com.',\n ),\n ),\n );\n final spans = _flatten(_topSpan(tester));\n final linkTexts =\n spans.where((s) => s.recognizer != null).map((s) => s.text).toList();\n expect(linkTexts, [\n 'https://a.example.com',\n 'https://b.example.com',\n ]);\n });\n\n testWidgets('renders the URL at the start of the string as a link',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorRichText(text: 'https://example.com is the link')),\n );\n final spans = _flatten(_topSpan(tester));\n expect(spans.first.text, 'https://example.com');\n expect(spans.first.recognizer, isA<TapGestureRecognizer>());\n });\n\n // -----------------------------------------------------------------------\n // Tap handling\n // -----------------------------------------------------------------------\n\n testWidgets('tapping a link span invokes onLinkTap with the URL',\n (tester) async {\n String? tappedUrl;\n await tester.pumpWidget(\n _wrap(\n VisorRichText(\n text: 'Open https://example.com please',\n onLinkTap: (url) => tappedUrl = url,\n ),\n ),\n );\n final span = _flatten(_topSpan(tester))\n .firstWhere((s) => s.recognizer != null);\n (span.recognizer! as TapGestureRecognizer).onTap!();\n expect(tappedUrl, 'https://example.com');\n });\n\n // -----------------------------------------------------------------------\n // Token-driven styling\n // -----------------------------------------------------------------------\n\n testWidgets('link span uses textLink color from VisorColors by default',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorRichText(text: 'Open https://example.com')),\n );\n final link = _flatten(_topSpan(tester))\n .firstWhere((s) => s.recognizer != null);\n // testColors() sets textLink to 0xFF2563EB.\n expect(link.style!.color, const Color(0xFF2563EB));\n });\n\n testWidgets('linkStyle override is applied to link spans', (tester) async {\n const overrideColor = Color(0xFF112233);\n await tester.pumpWidget(\n _wrap(\n const VisorRichText(\n text: 'Open https://example.com',\n linkStyle: TextStyle(color: overrideColor),\n ),\n ),\n );\n final link = _flatten(_topSpan(tester))\n .firstWhere((s) => s.recognizer != null);\n expect(link.style!.color, overrideColor);\n });\n\n testWidgets('style override is applied to base spans', (tester) async {\n const overrideColor = Color(0xFF445566);\n await tester.pumpWidget(\n _wrap(\n const VisorRichText(\n text: 'Plain text only',\n style: TextStyle(color: overrideColor),\n ),\n ),\n );\n final span = _flatten(_topSpan(tester)).single;\n expect(span.style!.color, overrideColor);\n });\n\n // -----------------------------------------------------------------------\n // Selectable toggle\n // -----------------------------------------------------------------------\n\n testWidgets('renders SelectableText.rich by default', (tester) async {\n await tester.pumpWidget(_wrap(const VisorRichText(text: 'Hello')));\n expect(find.byType(SelectableText), findsOneWidget);\n });\n\n testWidgets('renders RichText (no SelectableText) when selectable: false',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorRichText(text: 'Hello', selectable: false)),\n );\n expect(find.byType(SelectableText), findsNothing);\n expect(find.byType(RichText), findsAtLeastNWidgets(1));\n });\n\n // -----------------------------------------------------------------------\n // Semantics\n // -----------------------------------------------------------------------\n\n testWidgets('link span carries its URL as semanticsLabel', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorRichText(text: 'Visit https://example.com')),\n );\n final link = _flatten(_topSpan(tester))\n .firstWhere((s) => s.recognizer != null);\n expect(link.semanticsLabel, 'https://example.com');\n });\n\n testWidgets('semanticLabel wraps the widget in a Semantics node',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(\n const VisorRichText(\n text: 'Hello',\n semanticLabel: 'Greeting copy',\n ),\n ),\n );\n expect(find.bySemanticsLabel('Greeting copy'), findsOneWidget);\n handle.dispose();\n });\n\n // not applicable: inline-text — link tap surfaces are text-sized by\n // design; meetsGuideline tap-target matchers don't apply per quality\n // contract R7/R11 escape hatch.\n\n // -----------------------------------------------------------------------\n // URL detection — encoding + punctuation\n // -----------------------------------------------------------------------\n\n testWidgets('preserves percent-encoded characters inside URLs',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorRichText(\n text: 'Search https://example.com/q?term=hello%20world.',\n ),\n ),\n );\n final link = _flatten(_topSpan(tester))\n .firstWhere((s) => s.recognizer != null);\n expect(link.text, 'https://example.com/q?term=hello%20world');\n });\n\n testWidgets('strips trailing sentence punctuation but keeps balanced ()',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorRichText(\n text: 'See (https://en.wikipedia.org/wiki/URL_(rfc)) for more.',\n ),\n ),\n );\n final link = _flatten(_topSpan(tester))\n .firstWhere((s) => s.recognizer != null);\n expect(link.text, 'https://en.wikipedia.org/wiki/URL_(rfc)');\n });\n\n // -----------------------------------------------------------------------\n // Recognizer lifecycle — no leak across rebuilds\n // -----------------------------------------------------------------------\n\n testWidgets('updates link spans when text prop changes', (tester) async {\n String? tappedUrl;\n Widget tree(String text) => _wrap(\n VisorRichText(text: text, onLinkTap: (url) => tappedUrl = url),\n );\n\n await tester.pumpWidget(tree('First https://a.example.com'));\n var link = _flatten(_topSpan(tester))\n .firstWhere((s) => s.recognizer != null);\n expect(link.text, 'https://a.example.com');\n\n await tester.pumpWidget(tree('Second https://b.example.com'));\n link = _flatten(_topSpan(tester))\n .firstWhere((s) => s.recognizer != null);\n expect(link.text, 'https://b.example.com');\n // Tapping the new link still routes through onLinkTap with the new URL.\n (link.recognizer! as TapGestureRecognizer).onTap!();\n expect(tappedUrl, 'https://b.example.com');\n });\n\n testWidgets('disposing the widget tree does not throw',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorRichText(text: 'See https://example.com')),\n );\n // Swap in an empty tree — triggers State.dispose() on VisorRichText.\n await tester.pumpWidget(_wrap(const SizedBox()));\n // Reaching here without exception confirms recognizers disposed cleanly.\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorRichText(text: 'مرحبا بالعالم'),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorRichText), findsOneWidget);\n });\n });\n}\n",
4091
4319
  "target": "flutter"
4092
4320
  }
4093
4321
  ]
@@ -4114,7 +4342,7 @@
4114
4342
  {
4115
4343
  "path": "components/flutter/visor_section_header/visor_section_header_test.dart",
4116
4344
  "type": "registry:ui",
4117
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_section_header.dart';\n\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(body: child),\n );\n}\n\nvoid main() {\n group('VisorSectionHeader', () {\n testWidgets('renders title', (tester) async {\n await tester.pumpWidget(_wrap(\n const VisorSectionHeader(title: 'Recent activity'),\n ));\n expect(find.text('Recent activity'), findsOneWidget);\n });\n\n testWidgets('renders subtitle when provided', (tester) async {\n await tester.pumpWidget(_wrap(\n const VisorSectionHeader(\n title: 'Recent activity',\n subtitle: 'Last 30 days',\n ),\n ));\n expect(find.text('Last 30 days'), findsOneWidget);\n });\n\n testWidgets('omits subtitle when null', (tester) async {\n await tester.pumpWidget(_wrap(\n const VisorSectionHeader(title: 'Recent activity'),\n ));\n expect(find.byType(Text), findsOneWidget);\n });\n\n testWidgets('renders trailing widget when provided', (tester) async {\n await tester.pumpWidget(_wrap(\n VisorSectionHeader(\n title: 'Recent activity',\n trailing: TextButton(\n onPressed: () {},\n child: const Text('View all'),\n ),\n ),\n ));\n expect(find.byType(TextButton), findsOneWidget);\n expect(find.text('View all'), findsOneWidget);\n });\n });\n}\n",
4345
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_section_header.dart';\n\nWidget _wrap(Widget child, {TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(body: child),\n ),\n );\n}\n\nvoid main() {\n group('VisorSectionHeader', () {\n testWidgets('renders title', (tester) async {\n await tester.pumpWidget(_wrap(\n const VisorSectionHeader(title: 'Recent activity'),\n ));\n expect(find.text('Recent activity'), findsOneWidget);\n });\n\n testWidgets('renders subtitle when provided', (tester) async {\n await tester.pumpWidget(_wrap(\n const VisorSectionHeader(\n title: 'Recent activity',\n subtitle: 'Last 30 days',\n ),\n ));\n expect(find.text('Last 30 days'), findsOneWidget);\n });\n\n testWidgets('omits subtitle when null', (tester) async {\n await tester.pumpWidget(_wrap(\n const VisorSectionHeader(title: 'Recent activity'),\n ));\n expect(find.byType(Text), findsOneWidget);\n });\n\n testWidgets('renders trailing widget when provided', (tester) async {\n await tester.pumpWidget(_wrap(\n VisorSectionHeader(\n title: 'Recent activity',\n trailing: TextButton(\n onPressed: () {},\n child: const Text('View all'),\n ),\n ),\n ));\n expect(find.byType(TextButton), findsOneWidget);\n expect(find.text('View all'), findsOneWidget);\n });\n\n // Rec5 — textContrastGuideline (VI-257)\n\n testWidgets('title renders with sufficient text contrast', (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(\n const VisorSectionHeader(title: 'Recent activity'),\n ));\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('title + subtitle render with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(\n const VisorSectionHeader(\n title: 'Recent activity',\n subtitle: 'Last 30 days',\n ),\n ));\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorSectionHeader(title: 'Recent activity'),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorSectionHeader), findsOneWidget);\n // VisorSectionHeader is text-only by default; no trailing icons present.\n // The Row lays out title (and optional trailing widget) with Directionality\n // awareness — in RTL, trailing content moves to the left end. No icon\n // mirroring required since there are no directional glyphs in the default\n // configuration.\n });\n\n testWidgets('renders trailing widget without overflow under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n VisorSectionHeader(\n title: 'Recent activity',\n trailing: TextButton(\n onPressed: () {},\n child: const Text('View all'),\n ),\n ),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(TextButton), findsOneWidget);\n });\n });\n}\n",
4118
4346
  "target": "flutter"
4119
4347
  }
4120
4348
  ]
@@ -4135,13 +4363,13 @@
4135
4363
  {
4136
4364
  "path": "components/flutter/visor_settings_tile/visor_settings_tile.dart",
4137
4365
  "type": "registry:ui",
4138
- "content": "import 'package:flutter/material.dart';\nimport 'package:visor_core/visor_core.dart';\n\n/// A list-tile navigation primitive for settings screens and sidebar navigation.\n///\n/// Provides a leading icon, label, optional subtitle, flexible trailing widget\n/// (defaults to a chevron caret), destructive variant, and selected state.\n/// All styling reads from `Theme.of(context)` via the `visor_core`\n/// BuildContext extensions — no hard-coded colors or spacing.\n///\n/// ```dart\n/// VisorSettingsTile(\n/// icon: Icons.person_outline,\n/// label: 'Account',\n/// subtitle: 'Manage your profile',\n/// onTap: _openAccount,\n/// )\n///\n/// // Destructive action\n/// VisorSettingsTile(\n/// icon: Icons.logout,\n/// label: 'Sign out',\n/// destructive: true,\n/// onTap: _signOut,\n/// )\n///\n/// // With custom trailing\n/// VisorSettingsTile(\n/// icon: Icons.notifications_outlined,\n/// label: 'Push notifications',\n/// trailing: Switch(value: _enabled, onChanged: _toggle),\n/// onTap: null,\n/// )\n/// ```\nclass VisorSettingsTile extends StatelessWidget {\n const VisorSettingsTile({\n super.key,\n required this.icon,\n required this.label,\n this.subtitle,\n this.trailing,\n this.onTap,\n this.destructive = false,\n this.selected = false,\n this.semanticLabel,\n });\n\n /// The leading icon displayed to the left of the label.\n final IconData icon;\n\n /// The primary text label of the tile.\n final String label;\n\n /// Optional secondary text shown below the label.\n final String? subtitle;\n\n /// Optional trailing widget. Defaults to a chevron-right caret when null.\n /// Pass any widget (e.g. [Switch], [Text], [Icon]) to replace the default.\n final Widget? trailing;\n\n /// Called when the tile is tapped. Pass null to disable tap behaviour.\n final VoidCallback? onTap;\n\n /// When true, renders the icon and label in the error/destructive palette.\n final bool destructive;\n\n /// When true, highlights the tile background with `surfaceAccentSubtle`.\n final bool selected;\n\n /// Overrides the accessibility label. Defaults to [label] when null.\n final String? semanticLabel;\n\n @override\n Widget build(BuildContext context) {\n final colors = context.visorColors;\n final spacing = context.visorSpacing;\n final textStyles = context.visorTextStyles;\n\n final Color foreground =\n destructive ? colors.textError : colors.textPrimary;\n final Color subtitleColor = colors.textSecondary;\n final Color? background = selected ? colors.surfaceAccentSubtle : null;\n\n final Widget defaultTrailing = Icon(\n Icons.chevron_right,\n size: 20,\n color: colors.textTertiary,\n );\n\n return Semantics(\n button: true,\n label: semanticLabel ?? label,\n excludeSemantics: semanticLabel != null,\n child: InkWell(\n onTap: onTap,\n child: Container(\n color: background,\n padding: EdgeInsets.symmetric(\n vertical: spacing.lg,\n horizontal: spacing.lg,\n ),\n child: Row(\n crossAxisAlignment: CrossAxisAlignment.center,\n children: [\n Icon(icon, size: 20, color: foreground),\n SizedBox(width: spacing.lg),\n Expanded(\n child: Column(\n crossAxisAlignment: CrossAxisAlignment.start,\n mainAxisSize: MainAxisSize.min,\n children: [\n Text(\n label,\n style: textStyles.labelLarge.copyWith(\n fontSize: 15,\n fontWeight: FontWeight.w600,\n color: foreground,\n ),\n ),\n if (subtitle != null) ...[\n SizedBox(height: spacing.xs),\n Text(\n subtitle!,\n style: textStyles.bodySmall\n .copyWith(color: subtitleColor),\n ),\n ],\n ],\n ),\n ),\n SizedBox(width: spacing.sm),\n trailing ?? defaultTrailing,\n ],\n ),\n ),\n ),\n );\n }\n}\n",
4366
+ "content": "import 'package:flutter/material.dart';\nimport 'package:visor_core/visor_core.dart';\n\n/// A list-tile navigation primitive for settings screens and sidebar navigation.\n///\n/// Provides a leading icon, label, optional subtitle, flexible trailing widget\n/// (defaults to a chevron caret), destructive variant, and selected state.\n/// All styling reads from `Theme.of(context)` via the `visor_core`\n/// BuildContext extensions — no hard-coded colors or spacing.\n///\n/// ```dart\n/// VisorSettingsTile(\n/// icon: Icons.person_outline,\n/// label: 'Account',\n/// subtitle: 'Manage your profile',\n/// onTap: _openAccount,\n/// )\n///\n/// // Destructive action\n/// VisorSettingsTile(\n/// icon: Icons.logout,\n/// label: 'Sign out',\n/// destructive: true,\n/// onTap: _signOut,\n/// )\n///\n/// // With custom trailing\n/// VisorSettingsTile(\n/// icon: Icons.notifications_outlined,\n/// label: 'Push notifications',\n/// trailing: Switch(value: _enabled, onChanged: _toggle),\n/// onTap: null,\n/// )\n/// ```\nclass VisorSettingsTile extends StatelessWidget {\n const VisorSettingsTile({\n super.key,\n required this.icon,\n required this.label,\n this.subtitle,\n this.trailing,\n this.onTap,\n this.destructive = false,\n this.selected = false,\n this.semanticLabel,\n });\n\n /// The leading icon displayed to the left of the label.\n final IconData icon;\n\n /// The primary text label of the tile.\n final String label;\n\n /// Optional secondary text shown below the label.\n final String? subtitle;\n\n /// Optional trailing widget. Defaults to a chevron-right caret when null.\n /// Pass any widget (e.g. [Switch], [Text], [Icon]) to replace the default.\n final Widget? trailing;\n\n /// Called when the tile is tapped. Pass null to disable tap behaviour.\n final VoidCallback? onTap;\n\n /// When true, renders the icon and label in the error/destructive palette.\n final bool destructive;\n\n /// When true, highlights the tile background with `surfaceSelected`.\n final bool selected;\n\n /// Overrides the accessibility label. Defaults to [label] when null.\n final String? semanticLabel;\n\n @override\n Widget build(BuildContext context) {\n final colors = context.visorColors;\n final spacing = context.visorSpacing;\n final textStyles = context.visorTextStyles;\n\n final Color foreground =\n destructive ? colors.textError : colors.textPrimary;\n final Color subtitleColor = colors.textSecondary;\n final Color? background = selected ? colors.surfaceSelected : null;\n\n final Widget defaultTrailing = Icon(\n Icons.chevron_right,\n size: 20,\n color: colors.textTertiary,\n );\n\n return Semantics(\n button: true,\n label: semanticLabel ?? label,\n excludeSemantics: semanticLabel != null,\n child: InkWell(\n onTap: onTap,\n child: Container(\n color: background,\n padding: EdgeInsets.symmetric(\n vertical: spacing.lg,\n horizontal: spacing.lg,\n ),\n child: Row(\n crossAxisAlignment: CrossAxisAlignment.center,\n children: [\n Icon(icon, size: 20, color: foreground),\n SizedBox(width: spacing.lg),\n Expanded(\n child: Column(\n crossAxisAlignment: CrossAxisAlignment.start,\n mainAxisSize: MainAxisSize.min,\n children: [\n Text(\n label,\n style: textStyles.labelLarge.copyWith(\n fontSize: 15,\n fontWeight: FontWeight.w600,\n color: foreground,\n ),\n ),\n if (subtitle != null) ...[\n SizedBox(height: spacing.xs),\n Text(\n subtitle!,\n style: textStyles.bodySmall\n .copyWith(color: subtitleColor),\n ),\n ],\n ],\n ),\n ),\n SizedBox(width: spacing.sm),\n trailing ?? defaultTrailing,\n ],\n ),\n ),\n ),\n );\n }\n}\n",
4139
4367
  "target": "flutter"
4140
4368
  },
4141
4369
  {
4142
4370
  "path": "components/flutter/visor_settings_tile/visor_settings_tile_test.dart",
4143
4371
  "type": "registry:ui",
4144
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_settings_tile.dart';\n\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(body: child),\n );\n}\n\nvoid main() {\n group('VisorSettingsTile', () {\n testWidgets('renders the provided label', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n )),\n );\n expect(find.text('Account'), findsOneWidget);\n });\n\n testWidgets('renders the leading icon', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n )),\n );\n expect(find.byIcon(Icons.person_outline), findsOneWidget);\n });\n\n testWidgets('shows default chevron caret when trailing is null',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n )),\n );\n expect(find.byIcon(Icons.chevron_right), findsOneWidget);\n });\n\n testWidgets('renders subtitle when provided', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n subtitle: 'Manage your profile',\n )),\n );\n expect(find.text('Manage your profile'), findsOneWidget);\n });\n\n testWidgets('omits subtitle when null', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n )),\n );\n // Only the label Text widget should be present (no subtitle Text).\n expect(find.byType(Text), findsOneWidget);\n });\n\n testWidgets('renders custom trailing widget when provided', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.notifications_outlined,\n label: 'Notifications',\n trailing: Switch(value: true, onChanged: (_) {}),\n )),\n );\n expect(find.byType(Switch), findsOneWidget);\n // Default caret should not appear.\n expect(find.byIcon(Icons.chevron_right), findsNothing);\n });\n\n testWidgets('fires onTap callback when tapped', (tester) async {\n var tapped = false;\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n onTap: () => tapped = true,\n )),\n );\n await tester.tap(find.byType(InkWell));\n await tester.pump();\n expect(tapped, isTrue);\n });\n\n testWidgets('does not fire when onTap is null', (tester) async {\n var tapped = false;\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n onTap: null,\n )),\n );\n // Tapping a null InkWell should be a no-op.\n await tester.tap(find.byType(InkWell), warnIfMissed: false);\n await tester.pump();\n expect(tapped, isFalse);\n });\n\n testWidgets('destructive variant renders without error', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.logout,\n label: 'Sign out',\n destructive: true,\n onTap: () {},\n )),\n );\n expect(find.text('Sign out'), findsOneWidget);\n });\n\n testWidgets('selected variant renders without error', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n selected: true,\n onTap: () {},\n )),\n );\n expect(find.text('Account'), findsOneWidget);\n // A Container with a non-null color should be present for the highlight.\n final container = tester.widgetList<Container>(find.byType(Container))\n .firstWhere((c) => c.color != null, orElse: () => throw StateError(\n 'Expected a Container with a background color for selected state',\n ));\n expect(container.color, isNotNull);\n });\n\n testWidgets('semanticLabel overrides accessibility label', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorSettingsTile(\n icon: Icons.logout,\n label: 'Sign out',\n semanticLabel: 'Sign out of your account',\n )),\n );\n expect(\n find.bySemanticsLabel('Sign out of your account'),\n findsOneWidget,\n );\n });\n\n // R11 — tap-target size (meetsGuideline)\n testWidgets('default tile meets Android tap target guideline',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n onTap: () {},\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets('tile with subtitle meets Android tap target guideline',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n subtitle: 'Manage your profile',\n onTap: () {},\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets(\n 'tile with custom Switch trailing meets Android tap target guideline',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.notifications_outlined,\n label: 'Push notifications',\n trailing: Switch(value: true, onChanged: (_) {}),\n onTap: null,\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n });\n}\n",
4372
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_settings_tile.dart';\n\nWidget _wrap(Widget child, {TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(body: child),\n ),\n );\n}\n\nvoid main() {\n group('VisorSettingsTile', () {\n testWidgets('renders the provided label', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n )),\n );\n expect(find.text('Account'), findsOneWidget);\n });\n\n testWidgets('renders the leading icon', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n )),\n );\n expect(find.byIcon(Icons.person_outline), findsOneWidget);\n });\n\n testWidgets('shows default chevron caret when trailing is null',\n (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n )),\n );\n expect(find.byIcon(Icons.chevron_right), findsOneWidget);\n });\n\n testWidgets('renders subtitle when provided', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n subtitle: 'Manage your profile',\n )),\n );\n expect(find.text('Manage your profile'), findsOneWidget);\n });\n\n testWidgets('omits subtitle when null', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n )),\n );\n // Only the label Text widget should be present (no subtitle Text).\n expect(find.byType(Text), findsOneWidget);\n });\n\n testWidgets('renders custom trailing widget when provided', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.notifications_outlined,\n label: 'Notifications',\n trailing: Switch(value: true, onChanged: (_) {}),\n )),\n );\n expect(find.byType(Switch), findsOneWidget);\n // Default caret should not appear.\n expect(find.byIcon(Icons.chevron_right), findsNothing);\n });\n\n testWidgets('fires onTap callback when tapped', (tester) async {\n var tapped = false;\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n onTap: () => tapped = true,\n )),\n );\n await tester.tap(find.byType(InkWell));\n await tester.pump();\n expect(tapped, isTrue);\n });\n\n testWidgets('does not fire when onTap is null', (tester) async {\n var tapped = false;\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n onTap: null,\n )),\n );\n // Tapping a null InkWell should be a no-op.\n await tester.tap(find.byType(InkWell), warnIfMissed: false);\n await tester.pump();\n expect(tapped, isFalse);\n });\n\n testWidgets('destructive variant renders without error', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.logout,\n label: 'Sign out',\n destructive: true,\n onTap: () {},\n )),\n );\n expect(find.text('Sign out'), findsOneWidget);\n });\n\n testWidgets('selected variant renders without error', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n selected: true,\n onTap: () {},\n )),\n );\n expect(find.text('Account'), findsOneWidget);\n // A Container with a non-null color should be present for the highlight.\n final container = tester.widgetList<Container>(find.byType(Container))\n .firstWhere((c) => c.color != null, orElse: () => throw StateError(\n 'Expected a Container with a background color for selected state',\n ));\n expect(container.color, isNotNull);\n });\n\n testWidgets('semanticLabel overrides accessibility label', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorSettingsTile(\n icon: Icons.logout,\n label: 'Sign out',\n semanticLabel: 'Sign out of your account',\n )),\n );\n expect(\n find.bySemanticsLabel('Sign out of your account'),\n findsOneWidget,\n );\n });\n\n // R11 — tap-target size (meetsGuideline)\n testWidgets('default tile meets Android tap target guideline',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n onTap: () {},\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets('tile with subtitle meets Android tap target guideline',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n subtitle: 'Manage your profile',\n onTap: () {},\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets(\n 'tile with custom Switch trailing meets Android tap target guideline',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.notifications_outlined,\n label: 'Push notifications',\n trailing: Switch(value: true, onChanged: (_) {}),\n onTap: null,\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n // Rec5 — textContrastGuideline (VI-257)\n\n testWidgets('default tile renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n onTap: () {},\n )),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('tile with subtitle renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n subtitle: 'Manage your profile',\n onTap: () {},\n )),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('destructive tile renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(VisorSettingsTile(\n icon: Icons.logout,\n label: 'Sign out',\n destructive: true,\n onTap: () {},\n )),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorSettingsTile(\n icon: Icons.person_outline,\n label: 'Account',\n ),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorSettingsTile), findsOneWidget);\n // The default trailing chevron (Icons.chevron_right) is laid out by the\n // ambient Directionality and the Row's textDirection, so leading icon and\n // label swap sides correctly. Flutter's Icon widget does NOT auto-mirror\n // non-directional icons — the chevron_right glyph itself does not flip.\n // Semantic directional mirroring (chevron_left in RTL) is a follow-up:\n // see VI-259 (RTL chevron semantic flip for VisorSettingsTile).\n expect(find.byIcon(Icons.chevron_right), findsOneWidget);\n });\n });\n}\n",
4145
4373
  "target": "flutter"
4146
4374
  }
4147
4375
  ]
@@ -4168,7 +4396,7 @@
4168
4396
  {
4169
4397
  "path": "components/flutter/visor_snack_bar/visor_snack_bar_test.dart",
4170
4398
  "type": "registry:ui",
4171
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_snack_bar.dart';\n\n/// Pumps a full [MaterialApp] + [Scaffold] shell so that\n/// [ScaffoldMessenger.maybeOf] resolves correctly and snack bars\n/// appear in the widget tree when triggered.\nWidget _shell(Widget body) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(body: Center(child: body)),\n );\n}\n\n/// A helper button that fires the provided callback when tapped.\nclass _TriggerButton extends StatelessWidget {\n const _TriggerButton({required this.onTap});\n\n final VoidCallback onTap;\n\n @override\n Widget build(BuildContext context) {\n return ElevatedButton(\n onPressed: onTap,\n child: const Text('trigger'),\n );\n }\n}\n\nvoid main() {\n group('VisorSnackBar', () {\n // -----------------------------------------------------------------------\n // Variant render checks\n // -----------------------------------------------------------------------\n\n testWidgets('success variant renders message text', (tester) async {\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () => VisorSnackBar.success(ctx, 'Saved successfully'),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('trigger'));\n await tester.pump();\n\n expect(find.text('Saved successfully'), findsOneWidget);\n });\n\n testWidgets('error variant renders message text', (tester) async {\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () => VisorSnackBar.error(ctx, 'Upload failed'),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('trigger'));\n await tester.pump();\n\n expect(find.text('Upload failed'), findsOneWidget);\n });\n\n testWidgets('standard variant renders message text', (tester) async {\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () => VisorSnackBar.standard(ctx, 'Syncing…'),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('trigger'));\n await tester.pump();\n\n expect(find.text('Syncing…'), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // Helper-method API resolution\n // -----------------------------------------------------------------------\n\n testWidgets('helper methods resolve when ScaffoldMessenger is present',\n (tester) async {\n // Verifies all three static helpers do not throw and each produce a\n // SnackBar in the widget tree.\n\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => Column(\n mainAxisSize: MainAxisSize.min,\n children: [\n ElevatedButton(\n onPressed: () => VisorSnackBar.success(ctx, 'success msg'),\n child: const Text('success'),\n ),\n ElevatedButton(\n onPressed: () => VisorSnackBar.error(ctx, 'error msg'),\n child: const Text('error'),\n ),\n ElevatedButton(\n onPressed: () => VisorSnackBar.standard(ctx, 'standard msg'),\n child: const Text('standard'),\n ),\n ],\n ),\n ),\n ),\n );\n\n // Each button fires a distinct helper — no assertion needed beyond\n // \"no exception is thrown and the message appears\".\n await tester.tap(find.text('success'));\n await tester.pump();\n expect(find.text('success msg'), findsOneWidget);\n\n // Dismiss before triggering the next so messages don't overlap.\n final messenger = tester.firstState<ScaffoldMessengerState>(\n find.byType(ScaffoldMessenger),\n );\n messenger.hideCurrentSnackBar();\n await tester.pump();\n\n await tester.tap(find.text('error'));\n await tester.pump();\n expect(find.text('error msg'), findsOneWidget);\n\n messenger.hideCurrentSnackBar();\n await tester.pump();\n\n await tester.tap(find.text('standard'));\n await tester.pump();\n expect(find.text('standard msg'), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // Action label\n // -----------------------------------------------------------------------\n\n testWidgets('renders action label when provided', (tester) async {\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () => VisorSnackBar.success(\n ctx,\n 'File deleted',\n actionLabel: 'Undo',\n ),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('trigger'));\n await tester.pump();\n\n expect(find.text('File deleted'), findsOneWidget);\n expect(find.text('Undo'), findsOneWidget);\n });\n\n testWidgets('action callback is wired to onAction', (tester) async {\n var callbackCalled = false;\n\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () => VisorSnackBar.error(\n ctx,\n 'Upload failed',\n actionLabel: 'Retry',\n onAction: () => callbackCalled = true,\n ),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('trigger'));\n await tester.pump();\n\n // Retrieve the SnackBarAction widget and invoke its onPressed callback\n // directly. The floating snack bar may be positioned off-screen in the\n // test environment, making gesture-based tap unreliable — invoking via\n // the widget API is the idiomatic workaround.\n final action = tester.widget<SnackBarAction>(find.byType(SnackBarAction));\n action.onPressed();\n await tester.pump();\n\n expect(callbackCalled, isTrue);\n });\n\n // -----------------------------------------------------------------------\n // Silent fallback when ScaffoldMessenger is absent\n // -----------------------------------------------------------------------\n\n test('maybeOf guard: returns without throwing when messenger is null', () {\n // Unit-level verification: the guard at the top of _show() short-circuits\n // when ScaffoldMessenger.maybeOf returns null. This is verified by\n // constructing the scenario inline rather than through the widget tree,\n // since MaterialApp always injects a ScaffoldMessenger making it\n // impossible to produce a truly null context via pumpWidget.\n //\n // The operative contract is that `ScaffoldMessenger.maybeOf(context)`\n // returns null gracefully — no exception from VisorSnackBar itself.\n // The static helpers are thin wrappers over that guard, so a passing\n // flutter_analyze + the remaining integration tests are sufficient\n // confidence here.\n });\n\n // -----------------------------------------------------------------------\n // Semantics — liveRegion on message text\n // -----------------------------------------------------------------------\n\n testWidgets('message content has liveRegion semantics', (tester) async {\n final handle = tester.ensureSemantics();\n\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () =>\n VisorSnackBar.success(ctx, 'Changes saved'),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('trigger'));\n await tester.pump();\n\n // The Semantics widget wrapping the message text should have\n // liveRegion set so assistive technology announces it.\n final liveRegionFinder = find.byWidgetPredicate(\n (w) => w is Semantics && (w.properties.liveRegion ?? false),\n );\n // At least one Semantics widget with liveRegion exists.\n expect(liveRegionFinder, findsAtLeastNWidgets(1));\n\n handle.dispose();\n });\n\n // -----------------------------------------------------------------------\n // Custom duration\n // -----------------------------------------------------------------------\n\n testWidgets('respects custom duration', (tester) async {\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () => VisorSnackBar.standard(\n ctx,\n 'Quick toast',\n duration: const Duration(milliseconds: 200),\n ),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('trigger'));\n await tester.pump();\n expect(find.text('Quick toast'), findsOneWidget);\n\n // Pump past the custom duration + enough frames for the exit animation.\n await tester.pump(const Duration(milliseconds: 200));\n await tester.pumpAndSettle(\n const Duration(milliseconds: 100),\n EnginePhase.sendSemanticsUpdate,\n const Duration(seconds: 3),\n );\n expect(find.text('Quick toast'), findsNothing);\n });\n });\n}\n",
4399
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_snack_bar.dart';\n\n/// Pumps a full [MaterialApp] + [Scaffold] shell so that\n/// [ScaffoldMessenger.maybeOf] resolves correctly and snack bars\n/// appear in the widget tree when triggered.\nWidget _shell(Widget body, {TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(body: Center(child: body)),\n ),\n );\n}\n\n/// A helper button that fires the provided callback when tapped.\nclass _TriggerButton extends StatelessWidget {\n const _TriggerButton({required this.onTap});\n\n final VoidCallback onTap;\n\n @override\n Widget build(BuildContext context) {\n return ElevatedButton(\n onPressed: onTap,\n child: const Text('trigger'),\n );\n }\n}\n\nvoid main() {\n group('VisorSnackBar', () {\n // -----------------------------------------------------------------------\n // Variant render checks\n // -----------------------------------------------------------------------\n\n testWidgets('success variant renders message text', (tester) async {\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () => VisorSnackBar.success(ctx, 'Saved successfully'),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('trigger'));\n await tester.pump();\n\n expect(find.text('Saved successfully'), findsOneWidget);\n });\n\n testWidgets('error variant renders message text', (tester) async {\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () => VisorSnackBar.error(ctx, 'Upload failed'),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('trigger'));\n await tester.pump();\n\n expect(find.text('Upload failed'), findsOneWidget);\n });\n\n testWidgets('standard variant renders message text', (tester) async {\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () => VisorSnackBar.standard(ctx, 'Syncing…'),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('trigger'));\n await tester.pump();\n\n expect(find.text('Syncing…'), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // Helper-method API resolution\n // -----------------------------------------------------------------------\n\n testWidgets('helper methods resolve when ScaffoldMessenger is present',\n (tester) async {\n // Verifies all three static helpers do not throw and each produce a\n // SnackBar in the widget tree.\n\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => Column(\n mainAxisSize: MainAxisSize.min,\n children: [\n ElevatedButton(\n onPressed: () => VisorSnackBar.success(ctx, 'success msg'),\n child: const Text('success'),\n ),\n ElevatedButton(\n onPressed: () => VisorSnackBar.error(ctx, 'error msg'),\n child: const Text('error'),\n ),\n ElevatedButton(\n onPressed: () => VisorSnackBar.standard(ctx, 'standard msg'),\n child: const Text('standard'),\n ),\n ],\n ),\n ),\n ),\n );\n\n // Each button fires a distinct helper — no assertion needed beyond\n // \"no exception is thrown and the message appears\".\n await tester.tap(find.text('success'));\n await tester.pump();\n expect(find.text('success msg'), findsOneWidget);\n\n // Dismiss before triggering the next so messages don't overlap.\n final messenger = tester.firstState<ScaffoldMessengerState>(\n find.byType(ScaffoldMessenger),\n );\n messenger.hideCurrentSnackBar();\n await tester.pump();\n\n await tester.tap(find.text('error'));\n await tester.pump();\n expect(find.text('error msg'), findsOneWidget);\n\n messenger.hideCurrentSnackBar();\n await tester.pump();\n\n await tester.tap(find.text('standard'));\n await tester.pump();\n expect(find.text('standard msg'), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // Action label\n // -----------------------------------------------------------------------\n\n testWidgets('renders action label when provided', (tester) async {\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () => VisorSnackBar.success(\n ctx,\n 'File deleted',\n actionLabel: 'Undo',\n ),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('trigger'));\n await tester.pump();\n\n expect(find.text('File deleted'), findsOneWidget);\n expect(find.text('Undo'), findsOneWidget);\n });\n\n testWidgets('action callback is wired to onAction', (tester) async {\n var callbackCalled = false;\n\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () => VisorSnackBar.error(\n ctx,\n 'Upload failed',\n actionLabel: 'Retry',\n onAction: () => callbackCalled = true,\n ),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('trigger'));\n await tester.pump();\n\n // Retrieve the SnackBarAction widget and invoke its onPressed callback\n // directly. The floating snack bar may be positioned off-screen in the\n // test environment, making gesture-based tap unreliable — invoking via\n // the widget API is the idiomatic workaround.\n final action = tester.widget<SnackBarAction>(find.byType(SnackBarAction));\n action.onPressed();\n await tester.pump();\n\n expect(callbackCalled, isTrue);\n });\n\n // -----------------------------------------------------------------------\n // Silent fallback when ScaffoldMessenger is absent\n // -----------------------------------------------------------------------\n\n test('maybeOf guard: returns without throwing when messenger is null', () {\n // Unit-level verification: the guard at the top of _show() short-circuits\n // when ScaffoldMessenger.maybeOf returns null. This is verified by\n // constructing the scenario inline rather than through the widget tree,\n // since MaterialApp always injects a ScaffoldMessenger making it\n // impossible to produce a truly null context via pumpWidget.\n //\n // The operative contract is that `ScaffoldMessenger.maybeOf(context)`\n // returns null gracefully — no exception from VisorSnackBar itself.\n // The static helpers are thin wrappers over that guard, so a passing\n // flutter_analyze + the remaining integration tests are sufficient\n // confidence here.\n });\n\n // -----------------------------------------------------------------------\n // Semantics — liveRegion on message text\n // -----------------------------------------------------------------------\n\n testWidgets('message content has liveRegion semantics', (tester) async {\n final handle = tester.ensureSemantics();\n\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () =>\n VisorSnackBar.success(ctx, 'Changes saved'),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('trigger'));\n await tester.pump();\n\n // The Semantics widget wrapping the message text should have\n // liveRegion set so assistive technology announces it.\n final liveRegionFinder = find.byWidgetPredicate(\n (w) => w is Semantics && (w.properties.liveRegion ?? false),\n );\n // At least one Semantics widget with liveRegion exists.\n expect(liveRegionFinder, findsAtLeastNWidgets(1));\n\n handle.dispose();\n });\n\n // -----------------------------------------------------------------------\n // Custom duration\n // -----------------------------------------------------------------------\n\n testWidgets('respects custom duration', (tester) async {\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () => VisorSnackBar.standard(\n ctx,\n 'Quick toast',\n duration: const Duration(milliseconds: 200),\n ),\n ),\n ),\n ),\n );\n\n await tester.tap(find.text('trigger'));\n await tester.pump();\n expect(find.text('Quick toast'), findsOneWidget);\n\n // Pump past the custom duration + enough frames for the exit animation.\n await tester.pump(const Duration(milliseconds: 200));\n await tester.pumpAndSettle(\n const Duration(milliseconds: 100),\n EnginePhase.sendSemanticsUpdate,\n const Duration(seconds: 3),\n );\n expect(find.text('Quick toast'), findsNothing);\n });\n\n // Rec5 — textContrastGuideline (VI-257)\n\n testWidgets('success variant renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () => VisorSnackBar.success(ctx, 'Saved successfully'),\n ),\n ),\n ),\n );\n await tester.tap(find.text('trigger'));\n await tester.pump();\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('error variant renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () => VisorSnackBar.error(ctx, 'Upload failed'),\n ),\n ),\n ),\n );\n await tester.tap(find.text('trigger'));\n await tester.pump();\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('standard variant renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () => VisorSnackBar.standard(ctx, 'Syncing…'),\n ),\n ),\n ),\n );\n await tester.tap(find.text('trigger'));\n await tester.pump();\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _shell(\n Builder(\n builder: (ctx) => _TriggerButton(\n onTap: () => VisorSnackBar.success(ctx, 'Saved'),\n ),\n ),\n textDirection: TextDirection.rtl,\n ),\n );\n await tester.tap(find.text('trigger'));\n await tester.pump();\n expect(tester.takeException(), isNull);\n expect(find.text('Saved'), findsOneWidget);\n });\n });\n}\n",
4172
4400
  "target": "flutter"
4173
4401
  }
4174
4402
  ]
@@ -4195,7 +4423,7 @@
4195
4423
  {
4196
4424
  "path": "components/flutter/visor_stat_card/visor_stat_card_test.dart",
4197
4425
  "type": "registry:ui",
4198
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_stat_card.dart';\n\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(body: Center(child: child)),\n );\n}\n\nvoid main() {\n group('VisorStatCard', () {\n testWidgets('renders title and value', (tester) async {\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Revenue',\n value: r'$12,430',\n )));\n expect(find.text('Revenue'), findsOneWidget);\n expect(find.text(r'$12,430'), findsOneWidget);\n });\n\n testWidgets('omits delta row when delta is null', (tester) async {\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Users',\n value: '1,204',\n )));\n expect(find.byIcon(Icons.arrow_upward), findsNothing);\n expect(find.byIcon(Icons.arrow_downward), findsNothing);\n expect(find.byIcon(Icons.horizontal_rule), findsNothing);\n });\n\n testWidgets('shows up arrow for VisorDeltaDirection.up', (tester) async {\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Revenue',\n value: r'$12,430',\n delta: '+8.2%',\n deltaDirection: VisorDeltaDirection.up,\n )));\n expect(find.text('+8.2%'), findsOneWidget);\n expect(find.byIcon(Icons.arrow_upward), findsOneWidget);\n });\n\n testWidgets('shows down arrow for VisorDeltaDirection.down',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Churn',\n value: '2.4%',\n delta: '-0.3pp',\n deltaDirection: VisorDeltaDirection.down,\n )));\n expect(find.byIcon(Icons.arrow_downward), findsOneWidget);\n });\n\n testWidgets('renders leading icon when provided', (tester) async {\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Revenue',\n value: r'$12,430',\n icon: Icons.trending_up,\n )));\n expect(find.byIcon(Icons.trending_up), findsOneWidget);\n });\n\n testWidgets(\"default Semantics label is '<title>: <value>' when no delta\",\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Revenue',\n value: r'$12,430',\n )));\n expect(find.bySemanticsLabel(r'Revenue: $12,430'), findsOneWidget);\n handle.dispose();\n });\n\n testWidgets('default Semantics label includes delta when present',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Revenue',\n value: r'$12,430',\n delta: '+8.2%',\n deltaDirection: VisorDeltaDirection.up,\n )));\n expect(\n find.bySemanticsLabel(r'Revenue: $12,430, +8.2%'),\n findsOneWidget,\n );\n handle.dispose();\n });\n\n testWidgets('semanticLabel param overrides default composition',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Revenue',\n value: r'$12,430',\n semanticLabel: 'Custom override',\n )));\n expect(find.bySemanticsLabel('Custom override'), findsOneWidget);\n expect(find.bySemanticsLabel(r'Revenue: $12,430'), findsNothing);\n handle.dispose();\n });\n });\n}\n",
4426
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_stat_card.dart';\n\nWidget _wrap(Widget child, {TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(body: Center(child: child)),\n ),\n );\n}\n\nvoid main() {\n group('VisorStatCard', () {\n testWidgets('renders title and value', (tester) async {\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Revenue',\n value: r'$12,430',\n )));\n expect(find.text('Revenue'), findsOneWidget);\n expect(find.text(r'$12,430'), findsOneWidget);\n });\n\n testWidgets('omits delta row when delta is null', (tester) async {\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Users',\n value: '1,204',\n )));\n expect(find.byIcon(Icons.arrow_upward), findsNothing);\n expect(find.byIcon(Icons.arrow_downward), findsNothing);\n expect(find.byIcon(Icons.horizontal_rule), findsNothing);\n });\n\n testWidgets('shows up arrow for VisorDeltaDirection.up', (tester) async {\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Revenue',\n value: r'$12,430',\n delta: '+8.2%',\n deltaDirection: VisorDeltaDirection.up,\n )));\n expect(find.text('+8.2%'), findsOneWidget);\n expect(find.byIcon(Icons.arrow_upward), findsOneWidget);\n });\n\n testWidgets('shows down arrow for VisorDeltaDirection.down',\n (tester) async {\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Churn',\n value: '2.4%',\n delta: '-0.3pp',\n deltaDirection: VisorDeltaDirection.down,\n )));\n expect(find.byIcon(Icons.arrow_downward), findsOneWidget);\n });\n\n testWidgets('renders leading icon when provided', (tester) async {\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Revenue',\n value: r'$12,430',\n icon: Icons.trending_up,\n )));\n expect(find.byIcon(Icons.trending_up), findsOneWidget);\n });\n\n testWidgets(\"default Semantics label is '<title>: <value>' when no delta\",\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Revenue',\n value: r'$12,430',\n )));\n expect(find.bySemanticsLabel(r'Revenue: $12,430'), findsOneWidget);\n handle.dispose();\n });\n\n testWidgets('default Semantics label includes delta when present',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Revenue',\n value: r'$12,430',\n delta: '+8.2%',\n deltaDirection: VisorDeltaDirection.up,\n )));\n expect(\n find.bySemanticsLabel(r'Revenue: $12,430, +8.2%'),\n findsOneWidget,\n );\n handle.dispose();\n });\n\n testWidgets('semanticLabel param overrides default composition',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Revenue',\n value: r'$12,430',\n semanticLabel: 'Custom override',\n )));\n expect(find.bySemanticsLabel('Custom override'), findsOneWidget);\n expect(find.bySemanticsLabel(r'Revenue: $12,430'), findsNothing);\n handle.dispose();\n });\n\n // Rec5 — textContrastGuideline (VI-257)\n\n testWidgets('renders with sufficient text contrast (no delta)',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Revenue',\n value: r'$12,430',\n )));\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('renders with sufficient text contrast (with delta up)',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Revenue',\n value: r'$12,430',\n delta: '+8.2%',\n deltaDirection: VisorDeltaDirection.up,\n )));\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('renders with sufficient text contrast (with delta down)',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(_wrap(const VisorStatCard(\n title: 'Churn',\n value: '2.4%',\n delta: '-0.3pp',\n deltaDirection: VisorDeltaDirection.down,\n )));\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorStatCard(\n title: 'Revenue',\n value: r'$12,430',\n delta: '+8.2%',\n deltaDirection: VisorDeltaDirection.up,\n ),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorStatCard), findsOneWidget);\n // Delta arrows (arrow_upward / arrow_downward) are semantic direction\n // indicators, not layout-directional icons — they convey trend, not\n // pointing direction. They remain unchanged in RTL (up arrow still means\n // \"went up\"). No semantic mirroring follow-up required.\n expect(find.byIcon(Icons.arrow_upward), findsOneWidget);\n });\n });\n}\n",
4199
4427
  "target": "flutter"
4200
4428
  }
4201
4429
  ]
@@ -4222,7 +4450,7 @@
4222
4450
  {
4223
4451
  "path": "components/flutter/visor_text_input/visor_text_input_test.dart",
4224
4452
  "type": "registry:ui",
4225
- "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_text_input.dart';\n\nWidget _wrap(Widget child) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(body: Center(child: child)),\n );\n}\n\nvoid main() {\n group('VisorTextInput', () {\n // -----------------------------------------------------------------------\n // Rendering\n // -----------------------------------------------------------------------\n\n testWidgets('renders the label text', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorTextInput(labelText: 'Email')),\n );\n expect(find.text('Email'), findsOneWidget);\n });\n\n testWidgets('renders the inner TextFormField', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorTextInput(labelText: 'Email')),\n );\n expect(find.byType(TextFormField), findsOneWidget);\n });\n\n testWidgets('is disabled when enabled is false', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorTextInput(labelText: 'Email', enabled: false)),\n );\n final field = tester.widget<TextFormField>(find.byType(TextFormField));\n expect(field.enabled, isFalse);\n });\n\n testWidgets('renders prefix icon when provided', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorTextInput(\n labelText: 'Email',\n prefixIcon: Icon(Icons.email),\n )),\n );\n expect(find.byIcon(Icons.email), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // Label float animation\n // -----------------------------------------------------------------------\n\n testWidgets('label is present before interaction', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorTextInput(labelText: 'Password')),\n );\n expect(find.text('Password'), findsOneWidget);\n });\n\n testWidgets('label remains visible after receiving focus', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorTextInput(labelText: 'Password')),\n );\n await tester.tap(find.byType(TextFormField));\n await tester.pump();\n // Label still rendered (floated to top).\n expect(find.text('Password'), findsOneWidget);\n });\n\n testWidgets('label remains visible when field has content', (tester) async {\n final controller = TextEditingController(text: 'hello');\n await tester.pumpWidget(\n _wrap(VisorTextInput(labelText: 'Name', controller: controller)),\n );\n expect(find.text('Name'), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // Validation states\n // -----------------------------------------------------------------------\n\n testWidgets('shows checkmark icon when valid', (tester) async {\n final controller = TextEditingController(text: 'valid@example.com');\n await tester.pumpWidget(\n _wrap(VisorTextInput(\n labelText: 'Email',\n controller: controller,\n validator: (v) =>\n v?.contains('@') == true ? null : 'Invalid email',\n )),\n );\n // Pump to reflect state.\n await tester.pump();\n expect(find.byIcon(Icons.check_circle_outline), findsOneWidget);\n });\n\n testWidgets('does not show checkmark when field is empty', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorTextInput(\n labelText: 'Email',\n validator: (v) => v?.isEmpty == true ? 'Required' : null,\n )),\n );\n expect(find.byIcon(Icons.check_circle_outline), findsNothing);\n });\n\n testWidgets('shows error text after user interaction', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorTextInput(\n labelText: 'Email',\n autovalidateMode: AutovalidateMode.onUserInteraction,\n validator: (v) => v?.isEmpty == true ? 'Required' : null,\n )),\n );\n await tester.tap(find.byType(TextFormField));\n await tester.enterText(find.byType(TextFormField), 'x');\n await tester.pump();\n await tester.enterText(find.byType(TextFormField), '');\n await tester.pump();\n expect(find.text('Required'), findsOneWidget);\n });\n\n testWidgets('does not show error text before user interaction', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorTextInput(\n labelText: 'Email',\n autovalidateMode: AutovalidateMode.onUserInteraction,\n validator: (_) => 'Always error',\n )),\n );\n expect(find.text('Always error'), findsNothing);\n });\n\n testWidgets('shows explicit errorText override', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorTextInput(\n labelText: 'Email',\n errorText: 'Server error',\n )),\n );\n expect(find.text('Server error'), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // isValid override (D3)\n // -----------------------------------------------------------------------\n\n testWidgets('isValid: true forces checkmark regardless of validator',\n (tester) async {\n await tester.pumpWidget(\n _wrap(VisorTextInput(\n labelText: 'Username',\n isValid: true,\n validator: (_) => 'Always invalid',\n )),\n );\n expect(find.byIcon(Icons.check_circle_outline), findsOneWidget);\n });\n\n testWidgets('isValid: false suppresses checkmark even when validator passes',\n (tester) async {\n final controller = TextEditingController(text: 'taken_user');\n await tester.pumpWidget(\n _wrap(VisorTextInput(\n labelText: 'Username',\n controller: controller,\n isValid: false,\n validator: (_) => null, // synchronous pass\n )),\n );\n await tester.pump();\n expect(find.byIcon(Icons.check_circle_outline), findsNothing);\n });\n\n // -----------------------------------------------------------------------\n // Form integration (D2)\n // -----------------------------------------------------------------------\n\n testWidgets('Form.validate() returns true when validator passes',\n (tester) async {\n final formKey = GlobalKey<FormState>();\n final controller = TextEditingController(text: 'valid@example.com');\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: Form(\n key: formKey,\n child: VisorTextInput(\n labelText: 'Email',\n controller: controller,\n validator: (v) =>\n v?.contains('@') == true ? null : 'Invalid',\n ),\n ),\n ),\n ),\n );\n expect(formKey.currentState!.validate(), isTrue);\n });\n\n testWidgets('Form.validate() returns false when validator fails',\n (tester) async {\n final formKey = GlobalKey<FormState>();\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: Form(\n key: formKey,\n child: VisorTextInput(\n labelText: 'Email',\n validator: (_) => 'Required',\n ),\n ),\n ),\n ),\n );\n await tester.pump();\n expect(formKey.currentState!.validate(), isFalse);\n });\n\n // -----------------------------------------------------------------------\n // Callbacks\n // -----------------------------------------------------------------------\n\n testWidgets('onChanged fires when text is entered', (tester) async {\n String? lastValue;\n await tester.pumpWidget(\n _wrap(VisorTextInput(\n labelText: 'Search',\n onChanged: (v) => lastValue = v,\n )),\n );\n await tester.enterText(find.byType(TextFormField), 'hello');\n expect(lastValue, 'hello');\n });\n\n // -----------------------------------------------------------------------\n // Token usage — no UIColors / UISpacing / UIPrimaryColors\n // -----------------------------------------------------------------------\n\n testWidgets('widget builds without hard-coded color references',\n (tester) async {\n // This test simply verifies the widget renders without throwing;\n // static analysis (flutter analyze) enforces the actual token rule.\n await tester.pumpWidget(\n _wrap(VisorTextInput(\n labelText: 'Amount',\n prefixIcon: const Icon(Icons.attach_money),\n validator: (v) => v?.isEmpty == true ? 'Required' : null,\n )),\n );\n expect(find.byType(VisorTextInput), findsOneWidget);\n });\n });\n\n // -------------------------------------------------------------------------\n // meetsGuideline (R11) — tap-target + labeled-tap coverage\n // -------------------------------------------------------------------------\n\n group('meetsGuideline (R11)', () {\n testWidgets(\n 'default text input meets Android tap target + label guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorTextInput(labelText: 'Email')),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets(\n 'input with prefixIcon meets Android tap target + label guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorTextInput(\n labelText: 'Email',\n prefixIcon: Icon(Icons.email),\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets(\n 'input in error state meets Android tap target + label guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorTextInput(\n labelText: 'Email',\n errorText: 'Invalid email address',\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n });\n}\n",
4453
+ "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../_fixtures.dart';\nimport 'visor_text_input.dart';\n\nWidget _wrap(Widget child, {TextDirection textDirection = TextDirection.ltr}) {\n return MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Directionality(\n textDirection: textDirection,\n child: Scaffold(body: Center(child: child)),\n ),\n );\n}\n\nvoid main() {\n group('VisorTextInput', () {\n // -----------------------------------------------------------------------\n // Rendering\n // -----------------------------------------------------------------------\n\n testWidgets('renders the label text', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorTextInput(labelText: 'Email')),\n );\n expect(find.text('Email'), findsOneWidget);\n });\n\n testWidgets('renders the inner TextFormField', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorTextInput(labelText: 'Email')),\n );\n expect(find.byType(TextFormField), findsOneWidget);\n });\n\n testWidgets('is disabled when enabled is false', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorTextInput(labelText: 'Email', enabled: false)),\n );\n final field = tester.widget<TextFormField>(find.byType(TextFormField));\n expect(field.enabled, isFalse);\n });\n\n testWidgets('renders prefix icon when provided', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorTextInput(\n labelText: 'Email',\n prefixIcon: Icon(Icons.email),\n )),\n );\n expect(find.byIcon(Icons.email), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // Label float animation\n // -----------------------------------------------------------------------\n\n testWidgets('label is present before interaction', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorTextInput(labelText: 'Password')),\n );\n expect(find.text('Password'), findsOneWidget);\n });\n\n testWidgets('label remains visible after receiving focus', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorTextInput(labelText: 'Password')),\n );\n await tester.tap(find.byType(TextFormField));\n await tester.pump();\n // Label still rendered (floated to top).\n expect(find.text('Password'), findsOneWidget);\n });\n\n testWidgets('label remains visible when field has content', (tester) async {\n final controller = TextEditingController(text: 'hello');\n await tester.pumpWidget(\n _wrap(VisorTextInput(labelText: 'Name', controller: controller)),\n );\n expect(find.text('Name'), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // Validation states\n // -----------------------------------------------------------------------\n\n testWidgets('shows checkmark icon when valid', (tester) async {\n final controller = TextEditingController(text: 'valid@example.com');\n await tester.pumpWidget(\n _wrap(VisorTextInput(\n labelText: 'Email',\n controller: controller,\n validator: (v) =>\n v?.contains('@') == true ? null : 'Invalid email',\n )),\n );\n // Pump to reflect state.\n await tester.pump();\n expect(find.byIcon(Icons.check_circle_outline), findsOneWidget);\n });\n\n testWidgets('does not show checkmark when field is empty', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorTextInput(\n labelText: 'Email',\n validator: (v) => v?.isEmpty == true ? 'Required' : null,\n )),\n );\n expect(find.byIcon(Icons.check_circle_outline), findsNothing);\n });\n\n testWidgets('shows error text after user interaction', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorTextInput(\n labelText: 'Email',\n autovalidateMode: AutovalidateMode.onUserInteraction,\n validator: (v) => v?.isEmpty == true ? 'Required' : null,\n )),\n );\n await tester.tap(find.byType(TextFormField));\n await tester.enterText(find.byType(TextFormField), 'x');\n await tester.pump();\n await tester.enterText(find.byType(TextFormField), '');\n await tester.pump();\n expect(find.text('Required'), findsOneWidget);\n });\n\n testWidgets('does not show error text before user interaction', (tester) async {\n await tester.pumpWidget(\n _wrap(VisorTextInput(\n labelText: 'Email',\n autovalidateMode: AutovalidateMode.onUserInteraction,\n validator: (_) => 'Always error',\n )),\n );\n expect(find.text('Always error'), findsNothing);\n });\n\n testWidgets('shows explicit errorText override', (tester) async {\n await tester.pumpWidget(\n _wrap(const VisorTextInput(\n labelText: 'Email',\n errorText: 'Server error',\n )),\n );\n expect(find.text('Server error'), findsOneWidget);\n });\n\n // -----------------------------------------------------------------------\n // isValid override (D3)\n // -----------------------------------------------------------------------\n\n testWidgets('isValid: true forces checkmark regardless of validator',\n (tester) async {\n await tester.pumpWidget(\n _wrap(VisorTextInput(\n labelText: 'Username',\n isValid: true,\n validator: (_) => 'Always invalid',\n )),\n );\n expect(find.byIcon(Icons.check_circle_outline), findsOneWidget);\n });\n\n testWidgets('isValid: false suppresses checkmark even when validator passes',\n (tester) async {\n final controller = TextEditingController(text: 'taken_user');\n await tester.pumpWidget(\n _wrap(VisorTextInput(\n labelText: 'Username',\n controller: controller,\n isValid: false,\n validator: (_) => null, // synchronous pass\n )),\n );\n await tester.pump();\n expect(find.byIcon(Icons.check_circle_outline), findsNothing);\n });\n\n // -----------------------------------------------------------------------\n // Form integration (D2)\n // -----------------------------------------------------------------------\n\n testWidgets('Form.validate() returns true when validator passes',\n (tester) async {\n final formKey = GlobalKey<FormState>();\n final controller = TextEditingController(text: 'valid@example.com');\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: Form(\n key: formKey,\n child: VisorTextInput(\n labelText: 'Email',\n controller: controller,\n validator: (v) =>\n v?.contains('@') == true ? null : 'Invalid',\n ),\n ),\n ),\n ),\n );\n expect(formKey.currentState!.validate(), isTrue);\n });\n\n testWidgets('Form.validate() returns false when validator fails',\n (tester) async {\n final formKey = GlobalKey<FormState>();\n await tester.pumpWidget(\n MaterialApp(\n theme: VisorTheme.build(\n colors: testColors(),\n brightness: Brightness.light,\n ),\n home: Scaffold(\n body: Form(\n key: formKey,\n child: VisorTextInput(\n labelText: 'Email',\n validator: (_) => 'Required',\n ),\n ),\n ),\n ),\n );\n await tester.pump();\n expect(formKey.currentState!.validate(), isFalse);\n });\n\n // -----------------------------------------------------------------------\n // Callbacks\n // -----------------------------------------------------------------------\n\n testWidgets('onChanged fires when text is entered', (tester) async {\n String? lastValue;\n await tester.pumpWidget(\n _wrap(VisorTextInput(\n labelText: 'Search',\n onChanged: (v) => lastValue = v,\n )),\n );\n await tester.enterText(find.byType(TextFormField), 'hello');\n expect(lastValue, 'hello');\n });\n\n // -----------------------------------------------------------------------\n // Token usage — no UIColors / UISpacing / UIPrimaryColors\n // -----------------------------------------------------------------------\n\n testWidgets('widget builds without hard-coded color references',\n (tester) async {\n // This test simply verifies the widget renders without throwing;\n // static analysis (flutter analyze) enforces the actual token rule.\n await tester.pumpWidget(\n _wrap(VisorTextInput(\n labelText: 'Amount',\n prefixIcon: const Icon(Icons.attach_money),\n validator: (v) => v?.isEmpty == true ? 'Required' : null,\n )),\n );\n expect(find.byType(VisorTextInput), findsOneWidget);\n });\n });\n\n // -------------------------------------------------------------------------\n // meetsGuideline (R11) — tap-target + labeled-tap coverage\n // -------------------------------------------------------------------------\n\n group('meetsGuideline (R11)', () {\n testWidgets(\n 'default text input meets Android tap target + label guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorTextInput(labelText: 'Email')),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets(\n 'input with prefixIcon meets Android tap target + label guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorTextInput(\n labelText: 'Email',\n prefixIcon: Icon(Icons.email),\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n testWidgets(\n 'input in error state meets Android tap target + label guidelines',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorTextInput(\n labelText: 'Email',\n errorText: 'Invalid email address',\n )),\n );\n await expectLater(tester, meetsGuideline(androidTapTargetGuideline));\n await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));\n handle.dispose();\n });\n\n // Rec5 — textContrastGuideline (VI-257)\n\n testWidgets('default input renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorTextInput(labelText: 'Email')),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n testWidgets('input in error state renders with sufficient text contrast',\n (tester) async {\n final handle = tester.ensureSemantics();\n await tester.pumpWidget(\n _wrap(const VisorTextInput(\n labelText: 'Email',\n errorText: 'Invalid email address',\n )),\n );\n await expectLater(tester, meetsGuideline(textContrastGuideline));\n handle.dispose();\n });\n\n // -------------------------------------------------------------------------\n // R9 — Directionality respect\n // -------------------------------------------------------------------------\n\n testWidgets('renders without overflow or exception under RTL',\n (tester) async {\n await tester.pumpWidget(\n _wrap(\n const VisorTextInput(labelText: 'Email'),\n textDirection: TextDirection.rtl,\n ),\n );\n expect(tester.takeException(), isNull);\n expect(find.byType(VisorTextInput), findsOneWidget);\n });\n });\n}\n",
4226
4454
  "target": "flutter"
4227
4455
  }
4228
4456
  ]