@loworbitstudio/visor 0.6.0 → 0.7.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
  },
@@ -650,7 +652,7 @@
650
652
  {
651
653
  "path": "components/ui/tabs/tabs.module.css",
652
654
  "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"
655
+ "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
656
  }
655
657
  ]
656
658
  },
@@ -1155,7 +1157,7 @@
1155
1157
  {
1156
1158
  "path": "components/ui/command/command.tsx",
1157
1159
  "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"
1160
+ "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
1161
  },
1160
1162
  {
1161
1163
  "path": "components/ui/command/command.module.css",
@@ -1415,13 +1417,14 @@
1415
1417
  "utils",
1416
1418
  "field",
1417
1419
  "input",
1418
- "button"
1420
+ "button",
1421
+ "password-managers-context"
1419
1422
  ],
1420
1423
  "files": [
1421
1424
  {
1422
1425
  "path": "components/ui/form/form.tsx",
1423
1426
  "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"
1427
+ "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
1428
  },
1426
1429
  {
1427
1430
  "path": "components/ui/form/form-field.tsx",
@@ -1993,12 +1996,12 @@
1993
1996
  {
1994
1997
  "path": "components/ui/data-table/data-table.tsx",
1995
1998
  "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"
1999
+ "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
2000
  },
1998
2001
  {
1999
2002
  "path": "components/ui/data-table/data-table.module.css",
2000
2003
  "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"
2004
+ "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
2005
  }
2003
2006
  ]
2004
2007
  },
@@ -2121,12 +2124,12 @@
2121
2124
  {
2122
2125
  "path": "components/ui/stat-card/stat-card.tsx",
2123
2126
  "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"
2127
+ "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
2128
  },
2126
2129
  {
2127
2130
  "path": "components/ui/stat-card/stat-card.module.css",
2128
2131
  "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"
2132
+ "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"
2130
2133
  }
2131
2134
  ]
2132
2135
  },
@@ -2387,6 +2390,18 @@
2387
2390
  }
2388
2391
  ]
2389
2392
  },
2393
+ {
2394
+ "name": "password-managers-context",
2395
+ "type": "registry:lib",
2396
+ "description": "React context that lets Form set a passwordManagers default for descendant Input and Textarea fields.",
2397
+ "files": [
2398
+ {
2399
+ "path": "lib/password-managers-context.tsx",
2400
+ "type": "registry:lib",
2401
+ "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"
2402
+ }
2403
+ ]
2404
+ },
2390
2405
  {
2391
2406
  "name": "deck-context",
2392
2407
  "type": "registry:ui",
@@ -2847,7 +2862,7 @@
2847
2862
  {
2848
2863
  "path": "blocks/admin-dashboard/admin-dashboard.tsx",
2849
2864
  "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"
2865
+ "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
2866
  },
2852
2867
  {
2853
2868
  "path": "blocks/admin-dashboard/admin-dashboard.module.css",
@@ -2905,12 +2920,12 @@
2905
2920
  {
2906
2921
  "path": "blocks/admin-detail-drawer/admin-detail-drawer.tsx",
2907
2922
  "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"
2923
+ "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
2924
  },
2910
2925
  {
2911
2926
  "path": "blocks/admin-detail-drawer/admin-detail-drawer.module.css",
2912
2927
  "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"
2928
+ "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
2929
  }
2915
2930
  ]
2916
2931
  },
@@ -2964,12 +2979,12 @@
2964
2979
  {
2965
2980
  "path": "blocks/admin-settings-page/admin-settings-page.tsx",
2966
2981
  "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"
2982
+ "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
2983
  },
2969
2984
  {
2970
2985
  "path": "blocks/admin-settings-page/admin-settings-page.module.css",
2971
2986
  "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"
2987
+ "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
2988
  }
2974
2989
  ]
2975
2990
  },
@@ -3001,6 +3016,33 @@
3001
3016
  }
3002
3017
  ]
3003
3018
  },
3019
+ {
3020
+ "name": "workspace-switcher",
3021
+ "type": "registry:block",
3022
+ "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.",
3023
+ "category": "admin",
3024
+ "dependencies": [
3025
+ "@loworbitstudio/visor-core",
3026
+ "@phosphor-icons/react"
3027
+ ],
3028
+ "registryDependencies": [
3029
+ "utils",
3030
+ "avatar",
3031
+ "dropdown-menu"
3032
+ ],
3033
+ "files": [
3034
+ {
3035
+ "path": "blocks/workspace-switcher/workspace-switcher.tsx",
3036
+ "type": "registry:block",
3037
+ "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"
3038
+ },
3039
+ {
3040
+ "path": "blocks/workspace-switcher/workspace-switcher.module.css",
3041
+ "type": "registry:block",
3042
+ "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"
3043
+ }
3044
+ ]
3045
+ },
3004
3046
  {
3005
3047
  "name": "configuration-panel",
3006
3048
  "type": "registry:block",
@@ -3586,7 +3628,7 @@
3586
3628
  {
3587
3629
  "path": "components/devtools/source-inspector/source-inspector.tsx",
3588
3630
  "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"
3631
+ "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
3632
  },
3591
3633
  {
3592
3634
  "path": "components/devtools/source-inspector/source-inspector.module.css",
@@ -3596,7 +3638,12 @@
3596
3638
  {
3597
3639
  "path": "components/devtools/source-inspector/classify.ts",
3598
3640
  "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"
3641
+ "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"
3642
+ },
3643
+ {
3644
+ "path": "components/devtools/source-inspector/visor-component-names.generated.ts",
3645
+ "type": "registry:devtool",
3646
+ "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 \"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 \"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 \"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 \"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 \"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 \"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])\n"
3600
3647
  }
3601
3648
  ]
3602
3649
  },
@@ -3615,7 +3662,7 @@
3615
3662
  {
3616
3663
  "path": "components/devtools/source-inspector/source-inspector-toggle.tsx",
3617
3664
  "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"
3665
+ "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
3666
  },
3620
3667
  {
3621
3668
  "path": "components/devtools/source-inspector/source-inspector-toggle.module.css",
@@ -3654,7 +3701,7 @@
3654
3701
  {
3655
3702
  "path": "components/flutter/visor_avatar/visor_avatar_test.dart",
3656
3703
  "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",
3704
+ "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
3705
  "target": "flutter"
3659
3706
  }
3660
3707
  ]
@@ -3702,13 +3749,13 @@
3702
3749
  {
3703
3750
  "path": "components/flutter/visor_button/visor_button.dart",
3704
3751
  "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",
3752
+ "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
3753
  "target": "flutter"
3707
3754
  },
3708
3755
  {
3709
3756
  "path": "components/flutter/visor_button/visor_button_test.dart",
3710
3757
  "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",
3758
+ "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
3759
  "target": "flutter"
3713
3760
  },
3714
3761
  {
@@ -3741,7 +3788,7 @@
3741
3788
  {
3742
3789
  "path": "components/flutter/visor_chip/visor_chip_test.dart",
3743
3790
  "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",
3791
+ "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
3792
  "target": "flutter"
3746
3793
  }
3747
3794
  ]
@@ -3768,7 +3815,7 @@
3768
3815
  {
3769
3816
  "path": "components/flutter/visor_chip_search_input/visor_chip_search_input_test.dart",
3770
3817
  "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",
3818
+ "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
3819
  "target": "flutter"
3773
3820
  }
3774
3821
  ]
@@ -3799,7 +3846,7 @@
3799
3846
  {
3800
3847
  "path": "components/flutter/visor_confirm_sheet/visor_confirm_sheet_test.dart",
3801
3848
  "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",
3849
+ "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
3850
  "target": "flutter"
3804
3851
  }
3805
3852
  ]
@@ -3826,7 +3873,7 @@
3826
3873
  {
3827
3874
  "path": "components/flutter/visor_empty_state/visor_empty_state_test.dart",
3828
3875
  "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",
3876
+ "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
3877
  "target": "flutter"
3831
3878
  }
3832
3879
  ]
@@ -3853,7 +3900,7 @@
3853
3900
  {
3854
3901
  "path": "components/flutter/visor_empty_state_card/visor_empty_state_card_test.dart",
3855
3902
  "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",
3903
+ "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
3904
  "target": "flutter"
3858
3905
  }
3859
3906
  ]
@@ -3880,7 +3927,7 @@
3880
3927
  {
3881
3928
  "path": "components/flutter/visor_error_view/visor_error_view_test.dart",
3882
3929
  "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",
3930
+ "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
3931
  "target": "flutter"
3885
3932
  }
3886
3933
  ]
@@ -3907,7 +3954,7 @@
3907
3954
  {
3908
3955
  "path": "components/flutter/visor_form_dialog/visor_form_dialog_test.dart",
3909
3956
  "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",
3957
+ "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
3958
  "target": "flutter"
3912
3959
  }
3913
3960
  ]
@@ -3934,7 +3981,7 @@
3934
3981
  {
3935
3982
  "path": "components/flutter/visor_loading_dots/visor_loading_dots_test.dart",
3936
3983
  "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",
3984
+ "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
3985
  "target": "flutter"
3939
3986
  }
3940
3987
  ]
@@ -3961,7 +4008,7 @@
3961
4008
  {
3962
4009
  "path": "components/flutter/visor_loading_indicator/visor_loading_indicator_test.dart",
3963
4010
  "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",
4011
+ "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
4012
  "target": "flutter"
3966
4013
  }
3967
4014
  ]
@@ -3988,7 +4035,7 @@
3988
4035
  {
3989
4036
  "path": "components/flutter/visor_otp_input/visor_otp_input_test.dart",
3990
4037
  "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",
4038
+ "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
4039
  "target": "flutter"
3993
4040
  }
3994
4041
  ]
@@ -4021,7 +4068,7 @@
4021
4068
  {
4022
4069
  "path": "components/flutter/visor_password_input/visor_password_input_test.dart",
4023
4070
  "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",
4071
+ "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
4072
  "target": "flutter"
4026
4073
  }
4027
4074
  ]
@@ -4056,7 +4103,7 @@
4056
4103
  {
4057
4104
  "path": "components/flutter/visor_phone_input/visor_phone_input_test.dart",
4058
4105
  "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",
4106
+ "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
4107
  "target": "flutter"
4061
4108
  }
4062
4109
  ]
@@ -4087,7 +4134,7 @@
4087
4134
  {
4088
4135
  "path": "components/flutter/visor_rich_text/visor_rich_text_test.dart",
4089
4136
  "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",
4137
+ "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
4138
  "target": "flutter"
4092
4139
  }
4093
4140
  ]
@@ -4114,7 +4161,7 @@
4114
4161
  {
4115
4162
  "path": "components/flutter/visor_section_header/visor_section_header_test.dart",
4116
4163
  "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",
4164
+ "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
4165
  "target": "flutter"
4119
4166
  }
4120
4167
  ]
@@ -4135,13 +4182,13 @@
4135
4182
  {
4136
4183
  "path": "components/flutter/visor_settings_tile/visor_settings_tile.dart",
4137
4184
  "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",
4185
+ "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
4186
  "target": "flutter"
4140
4187
  },
4141
4188
  {
4142
4189
  "path": "components/flutter/visor_settings_tile/visor_settings_tile_test.dart",
4143
4190
  "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",
4191
+ "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
4192
  "target": "flutter"
4146
4193
  }
4147
4194
  ]
@@ -4168,7 +4215,7 @@
4168
4215
  {
4169
4216
  "path": "components/flutter/visor_snack_bar/visor_snack_bar_test.dart",
4170
4217
  "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",
4218
+ "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
4219
  "target": "flutter"
4173
4220
  }
4174
4221
  ]
@@ -4195,7 +4242,7 @@
4195
4242
  {
4196
4243
  "path": "components/flutter/visor_stat_card/visor_stat_card_test.dart",
4197
4244
  "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",
4245
+ "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
4246
  "target": "flutter"
4200
4247
  }
4201
4248
  ]
@@ -4222,7 +4269,7 @@
4222
4269
  {
4223
4270
  "path": "components/flutter/visor_text_input/visor_text_input_test.dart",
4224
4271
  "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",
4272
+ "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
4273
  "target": "flutter"
4227
4274
  }
4228
4275
  ]