@loworbitstudio/visor 1.25.0 → 1.26.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.
- package/dist/CHANGELOG.json +1 -1
- package/dist/registry.json +5 -4
- package/dist/visor-manifest.json +26 -2
- package/package.json +1 -1
package/dist/CHANGELOG.json
CHANGED
package/dist/registry.json
CHANGED
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
{
|
|
128
128
|
"name": "button",
|
|
129
129
|
"type": "registry:ui",
|
|
130
|
-
"description": "A button component with multiple variants and sizes using CVA.",
|
|
130
|
+
"description": "A button component with multiple variants and sizes using CVA. Includes a gated permission state and a pending state that holds the control's geometry while its action is in flight.",
|
|
131
131
|
"category": "form",
|
|
132
132
|
"dependencies": [
|
|
133
133
|
"@radix-ui/react-slot",
|
|
@@ -135,18 +135,19 @@
|
|
|
135
135
|
"@loworbitstudio/visor-core"
|
|
136
136
|
],
|
|
137
137
|
"registryDependencies": [
|
|
138
|
-
"utils"
|
|
138
|
+
"utils",
|
|
139
|
+
"spinner"
|
|
139
140
|
],
|
|
140
141
|
"files": [
|
|
141
142
|
{
|
|
142
143
|
"path": "components/ui/button/button.tsx",
|
|
143
144
|
"type": "registry:ui",
|
|
144
|
-
"content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./button.module.css\"\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"../tooltip/tooltip\"\n\nconst buttonVariants = cva(styles.base, {\n variants: {\n variant: {\n default: styles.variantDefault,\n secondary: styles.variantSecondary,\n outline: styles.variantOutline,\n ghost: styles.variantGhost,\n destructive: styles.variantDestructive,\n },\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n lg: styles.sizeLg,\n dlg: styles.sizeDlg,\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"md\",\n },\n})\n\nexport interface ButtonProps\n extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n VariantProps<typeof buttonVariants> {\n asChild?: boolean\n /**\n * When true, the button renders in an inert gated state — visually dimmed,\n * cursor not-allowed, and click handlers suppressed. Uses `aria-disabled`\n * instead of the native `disabled` attribute so the button remains\n * keyboard-focusable (required for the tooltip to be accessible).\n *\n * Note: a `<TooltipProvider>` ancestor is required when `gatedReason` is set.\n */\n gated?: boolean\n /**\n * Explanation surfaced in an anchored tooltip when the button is gated.\n * Only rendered when `gated` is also true.\n * Example: \"You can't delete this — you're not an owner\"\n */\n gatedReason?: string\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n ({ className, variant, size, asChild, gated, gatedReason, onClick, ...props }, ref) => {\n const Comp = asChild ? Slot : \"button\"\n\n const handleClick = React.useCallback(\n (e: React.MouseEvent<HTMLButtonElement>) => {\n if (gated) return\n onClick?.(e)\n },\n [gated, onClick]\n )\n\n const button = (\n <Comp\n className={cn(buttonVariants({ variant, size }), className)}\n ref={ref}\n aria-disabled={gated ? true : undefined}\n data-gated={gated ? \"true\" : undefined}\n data-gated-reason={gated && gatedReason ? gatedReason : undefined}\n onClick={handleClick}\n {...props}\n />\n )\n\n if (gated && gatedReason) {\n return (\n <Tooltip>\n <TooltipTrigger asChild>{button}</TooltipTrigger>\n <TooltipContent>{gatedReason}</TooltipContent>\n </Tooltip>\n )\n }\n\n return button\n }\n)\nButton.displayName = \"Button\"\n\nexport { Button, buttonVariants }\n"
|
|
145
|
+
"content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./button.module.css\"\nimport { Spinner } from \"../spinner/spinner\"\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"../tooltip/tooltip\"\n\nconst buttonVariants = cva(styles.base, {\n variants: {\n variant: {\n default: styles.variantDefault,\n secondary: styles.variantSecondary,\n outline: styles.variantOutline,\n ghost: styles.variantGhost,\n destructive: styles.variantDestructive,\n },\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n lg: styles.sizeLg,\n dlg: styles.sizeDlg,\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"md\",\n },\n})\n\n/**\n * Fallback thresholds for the `pending` affordance, in milliseconds.\n *\n * The delay suppresses the busy glyph for actions that resolve fast enough\n * that showing one would read as a flicker; the minimum duration keeps it on\n * screen long enough to be legible once it has appeared. Both are overridable\n * per call site (`pendingDelay` / `pendingMinDuration`) and per theme\n * (`--button-pending-delay` / `--button-pending-min-duration`).\n */\nexport const BUTTON_PENDING_DELAY_MS = 200\nexport const BUTTON_PENDING_MIN_DURATION_MS = 300\n\n/** Parses a CSS time value (`200ms`, `0.2s`, bare `200`) into milliseconds. */\nfunction parseCssDuration(raw: string | null | undefined): number | undefined {\n if (!raw) return undefined\n const match = /^(\\d+(?:\\.\\d+)?)\\s*(ms|s)?$/.exec(raw.trim())\n if (!match) return undefined\n const value = Number.parseFloat(match[1])\n if (!Number.isFinite(value)) return undefined\n return match[2] === \"s\" ? value * 1000 : value\n}\n\n/**\n * Resolution order for a threshold: explicit prop, then the themed custom\n * property read off the live element, then the module fallback.\n *\n * The custom properties are deliberately NOT declared on `.base` — an\n * element-level declaration would shadow anything a theme emits on `:root`,\n * which is the defect VI-625 fixed on Spinner.\n */\nfunction resolveThreshold(\n element: HTMLElement | null,\n customProperty: string,\n propValue: number | undefined,\n fallback: number\n): number {\n if (typeof propValue === \"number\" && Number.isFinite(propValue)) {\n return Math.max(0, propValue)\n }\n if (element && typeof window !== \"undefined\" && typeof window.getComputedStyle === \"function\") {\n const themed = parseCssDuration(\n window.getComputedStyle(element).getPropertyValue(customProperty)\n )\n if (themed !== undefined) return themed\n }\n return fallback\n}\n\n/**\n * Turns a raw `pending` boolean into a *paintable* one: false for the first\n * `delay` ms, then true for at least `minDuration` ms once shown.\n *\n * Without the delay a 150ms action strobes; without the minimum a 250ms action\n * shows a one-frame flash, which reads as a glitch rather than as progress.\n */\nfunction usePendingVisibility(\n pending: boolean,\n elementRef: React.RefObject<HTMLElement | null>,\n delay: number | undefined,\n minDuration: number | undefined\n): boolean {\n const [visible, setVisible] = React.useState(false)\n const visibleRef = React.useRef(false)\n const shownAtRef = React.useRef(0)\n\n const show = React.useCallback((next: boolean) => {\n visibleRef.current = next\n setVisible(next)\n }, [])\n\n React.useEffect(() => {\n if (pending) {\n const delayMs = resolveThreshold(\n elementRef.current,\n \"--button-pending-delay\",\n delay,\n BUTTON_PENDING_DELAY_MS\n )\n if (delayMs <= 0) {\n shownAtRef.current = Date.now()\n show(true)\n return\n }\n const armed = setTimeout(() => {\n shownAtRef.current = Date.now()\n show(true)\n }, delayMs)\n return () => clearTimeout(armed)\n }\n\n // Never painted — nothing to unwind, and no minimum to honour.\n if (!visibleRef.current) return\n\n const minMs = resolveThreshold(\n elementRef.current,\n \"--button-pending-min-duration\",\n minDuration,\n BUTTON_PENDING_MIN_DURATION_MS\n )\n const remaining = minMs - (Date.now() - shownAtRef.current)\n if (remaining <= 0) {\n show(false)\n return\n }\n const held = setTimeout(() => show(false), remaining)\n return () => clearTimeout(held)\n }, [pending, delay, minDuration, elementRef, show])\n\n return visible\n}\n\n/** Spinner ring size that reads correctly inside each button size. */\nconst PENDING_SPINNER_SIZE: Record<string, \"xs\" | \"sm\"> = {\n sm: \"xs\",\n dlg: \"xs\",\n md: \"sm\",\n lg: \"sm\",\n}\n\nexport interface ButtonProps\n extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n VariantProps<typeof buttonVariants> {\n asChild?: boolean\n /**\n * When true, the button renders in an inert gated state — visually dimmed,\n * cursor not-allowed, and click handlers suppressed. Uses `aria-disabled`\n * instead of the native `disabled` attribute so the button remains\n * keyboard-focusable (required for the tooltip to be accessible).\n *\n * Note: a `<TooltipProvider>` ancestor is required when `gatedReason` is set.\n */\n gated?: boolean\n /**\n * Explanation surfaced in an anchored tooltip when the button is gated.\n * Only rendered when `gated` is also true.\n * Example: \"You can't delete this — you're not an owner\"\n */\n gatedReason?: string\n /**\n * When true, the button reports that its action is in flight: a busy glyph\n * appears, `aria-busy` is set, and activation is suppressed so the action\n * cannot be double-submitted.\n *\n * **The geometry is held.** The idle children stay in the box at zero\n * opacity and the glyph paints over them, so the control never resizes and\n * never shoves its siblings. That is the whole point — a pending state that\n * reflows has not fixed the visible defect.\n *\n * Like `gated`, this does NOT set the native `disabled` attribute: the\n * button stays focusable, which matters because it is the user's focus\n * anchor while they wait.\n */\n pending?: boolean\n /**\n * Optional text shown beside the busy glyph while pending. Purely visual —\n * it is `aria-hidden`, and the accessible name stays the idle label so the\n * control is not renamed mid-action. Pair `pending` with your own\n * `role=\"status\"` region when the message needs to be spoken.\n */\n pendingLabel?: React.ReactNode\n /**\n * Milliseconds to wait before painting the busy glyph. Overrides the themed\n * `--button-pending-delay`. @default 200\n */\n pendingDelay?: number\n /**\n * Milliseconds to keep the busy glyph on screen once painted. Overrides the\n * themed `--button-pending-min-duration`. @default 300\n */\n pendingMinDuration?: number\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n (\n {\n className,\n variant,\n size,\n asChild,\n gated,\n gatedReason,\n pending,\n pendingLabel,\n pendingDelay,\n pendingMinDuration,\n onClick,\n children,\n ...props\n },\n ref\n ) => {\n const Comp = asChild ? Slot : \"button\"\n\n const elementRef = React.useRef<HTMLButtonElement | null>(null)\n const mergeRef = React.useCallback(\n (node: HTMLButtonElement | null) => {\n elementRef.current = node\n if (typeof ref === \"function\") ref(node)\n else if (ref) ref.current = node\n },\n [ref]\n )\n\n const pendingVisible = usePendingVisibility(\n pending === true,\n elementRef,\n pendingDelay,\n pendingMinDuration\n )\n\n // Activation follows what the user can see: suppressed while the action is\n // in flight AND for as long as the glyph is still held on screen, so a\n // control that looks busy never silently accepts a click.\n const inert = gated === true || pending === true || pendingVisible\n\n const handleClick = React.useCallback(\n (e: React.MouseEvent<HTMLButtonElement>) => {\n if (inert) {\n /* `preventDefault` as well as swallowing the handler, and this is\n * load-bearing: most buttons `pending` and `gated` exist for are a\n * `type=\"submit\"` inside a form, and suppressing `onClick` alone\n * leaves the browser's own ACTIVATION BEHAVIOUR intact — the form\n * submits anyway, through `onSubmit`, which this never sees. Measured\n * in a consumer: a second click on a pending submit mailed a second\n * one-time code. A keyboard Enter on a focused submit button\n * dispatches a click too, so this covers that path as well. */\n e.preventDefault()\n return\n }\n onClick?.(e)\n },\n [inert, onClick]\n )\n\n // `asChild` hands a single child straight to Slot, so the pending chrome\n // (which needs its own wrapper elements) cannot be injected. The state\n // attributes and activation suppression still apply.\n const showPendingChrome = !asChild && pendingVisible\n const spinnerSize = PENDING_SPINNER_SIZE[size ?? \"md\"] ?? \"sm\"\n\n const button = (\n <Comp\n className={cn(buttonVariants({ variant, size }), className)}\n ref={mergeRef}\n aria-disabled={gated || pendingVisible ? true : undefined}\n aria-busy={pending === true || pendingVisible ? true : undefined}\n data-gated={gated ? \"true\" : undefined}\n data-gated-reason={gated && gatedReason ? gatedReason : undefined}\n data-pending={pendingVisible ? \"true\" : undefined}\n onClick={handleClick}\n {...props}\n >\n {showPendingChrome ? (\n <>\n <span className={styles.pendingLabel} data-slot=\"button-label\">\n {children}\n </span>\n <span\n className={styles.pendingOverlay}\n data-slot=\"button-pending\"\n aria-hidden=\"true\"\n >\n <Spinner size={spinnerSize} />\n {pendingLabel ? (\n <span className={styles.pendingText}>{pendingLabel}</span>\n ) : null}\n </span>\n </>\n ) : (\n children\n )}\n </Comp>\n )\n\n if (gated && gatedReason) {\n return (\n <Tooltip>\n <TooltipTrigger asChild>{button}</TooltipTrigger>\n <TooltipContent>{gatedReason}</TooltipContent>\n </Tooltip>\n )\n }\n\n return button\n }\n)\nButton.displayName = \"Button\"\n\nexport { Button, buttonVariants }\n"
|
|
145
146
|
},
|
|
146
147
|
{
|
|
147
148
|
"path": "components/ui/button/button.module.css",
|
|
148
149
|
"type": "registry:ui",
|
|
149
|
-
"content": "/* Button base styles */\n.base {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: var(--spacing-2, 0.5rem);\n white-space: nowrap;\n border-radius: var(--radius-md, 0.375rem);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n line-height: 1;\n cursor: pointer;\n border: 1px solid transparent;\n transition: background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), opacity 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 text-decoration: none;\n outline: none;\n}\n\n.base:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.base:active:not(:disabled) {\n transform: translateY(1px);\n transition: none;\n}\n\n.base:disabled {\n pointer-events: none;\n opacity: 0.5;\n}\n\n/* Variants */\n.variantDefault {\n background-color: var(--interactive-primary-bg, var(--primary, #111827));\n color: var(--interactive-primary-text, #f9fafb);\n}\n\n.variantSecondary {\n background-color: var(--interactive-secondary-bg, #f3f4f6);\n color: var(--interactive-secondary-text, #111827);\n}\n\n/* Editorial density: secondary on the card tier, ghost truly transparent.\n * Direct values — the editorial design language owns control surfaces, so the\n * theme's --interactive-secondary-bg / --interactive-ghost-bg are deliberately\n * bypassed (matches the blessed overlay, which out-cascaded the palette). */\n:global([data-density=\"editorial\"]) .variantSecondary {\n background-color: var(--surface-card, #ffffff);\n}\n\n:global([data-density=\"editorial\"]) .variantGhost {\n background-color: transparent;\n}\n\n.variantOutline {\n background-color: transparent;\n color: var(--text-primary, #111827);\n border-color: var(--border-strong, #e5e7eb);\n}\n\n.variantGhost {\n background-color: var(--interactive-ghost-bg, transparent);\n color: var(--text-primary, #111827);\n}\n\n.variantDestructive {\n background-color: var(--interactive-destructive-bg, #ef4444);\n color: var(--interactive-destructive-text, #fef2f2);\n}\n\n@media (hover: hover) {\n .variantDefault:hover {\n background-color: var(--interactive-primary-bg-hover, #1f2937);\n }\n\n .variantSecondary:hover {\n background-color: var(--interactive-secondary-bg-hover, #e5e7eb);\n }\n\n .variantOutline:hover {\n background-color: var(--surface-muted, #f3f4f6);\n }\n\n .variantGhost:hover {\n background-color: var(--interactive-ghost-bg-hover, var(--surface-muted, #f3f4f6));\n }\n\n .variantDestructive:hover {\n background-color: var(--interactive-destructive-bg-hover, #dc2626);\n }\n}\n\n/* Open / active state — ghost variant only. OPT-IN, strictly additive.\n A ghost Button reads as \"held\" while its popover/menu is open so the\n trigger no longer needs an inline style. Three opt-in triggers, all\n resolving to the same held look:\n - data-state=\"open\" — forwarded automatically by Radix when the\n Button is the trigger via <Trigger asChild> (Popover/DropdownMenu).\n - data-active=\"true\" — manual non-Radix triggers (mirrors the\n sidebar `data-active` convention).\n - .isActive — className opt-in (mirrors sidebar/section-nav).\n Default ghost rendering is untouched: none of these match unless a\n consumer explicitly sets them. Matches the prototype-overlay held look:\n background var(--surface-subtle), color var(--text-primary). */\n.variantGhost[data-state=\"open\"],\n.variantGhost[data-active=\"true\"],\n.variantGhost.isActive {\n background-color: var(--surface-subtle, var(--surface-muted, #f3f4f6));\n color: var(--text-primary, #111827);\n}\n\n/* Gated state — orthogonal to variant, works with all variants */\n.base[data-gated=\"true\"] {\n opacity: var(--opacity-40, 0.4);\n cursor: not-allowed;\n border-color: var(--border-default, #e5e7eb);\n}\n\n/* Editorial density: gated reads like plain disabled (the blessed admin\n builds render gated actions at the 0.5 disabled opacity). */\n:global([data-density=\"editorial\"]) .base[data-gated=\"true\"] {\n opacity: 0.5;\n}\n\n/* Suppress active transform when gated */\n.base[data-gated=\"true\"]:active {\n transform: none;\n transition: opacity var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n/* Sizes\n Default (no data-density attr) rendering is byte-identical to the prior\n canonical values. Under data-density=\"editorial\" (set on any ancestor,\n typically the app root), the editorial density axis re-drives specific\n metrics to the blessed admin values. */\n.sizeSm {\n height: 2rem;\n padding: 0 var(--spacing-3, 0.75rem);\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n.sizeMd {\n height: 2.5rem;\n padding: 0 var(--spacing-4, 1rem);\n line-height: 1;\n}\n\n:global([data-density=\"editorial\"]) .sizeSm {\n font-size: 0.8125rem;\n}\n\n:global([data-density=\"editorial\"]) .sizeMd {\n height: 2.125rem;\n line-height: normal;\n}\n\n.sizeLg {\n height: 3rem;\n padding: 0 var(--spacing-6, 1.5rem);\n font-size: var(--font-size-base, 1rem);\n}\n\n/* Dialog-footer button — dlg-btn (Animal dialog substrate, VI-620).\n Compact modal-action metrics measured from the AN prototype (11.5px / 600,\n ~7px 13px padding, 7px radius), normalized onto Visor tokens: font-size-2xs\n (11px), spacing-2/spacing-3 padding (8/12px), radius-lg (8px). Height is\n auto so the compact padding drives the box. Color faces reuse the existing\n variant axis — primary via variant=\"default\", ghost via variant=\"ghost\"\n (refined below to a hairline-outlined, secondary-ink face), danger via\n variant=\"destructive\" (refined below to a subtle-danger face). */\n.sizeDlg {\n height: auto;\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n font-size: var(--font-size-2xs, 0.6875rem);\n font-weight: var(--font-weight-semibold, 600);\n line-height: 1;\n border-radius: var(--radius-lg, 0.5rem);\n}\n\n/* dlg ghost — transparent well with a hairline outline + secondary ink\n (AN dlg-btn ghost face: transparent / --ink-60 / 1px --hair). Two-class\n specificity keeps this scoped to the dlg size only. */\n.sizeDlg.variantGhost {\n color: var(--text-secondary, #6b7280);\n border-color: var(--hairline, var(--border-default, #e5e7eb));\n}\n\n/* dlg danger — subtle-danger face (AN dlg-btn danger face: --red on a faint\n red fill), distinct from the solid `destructive` variant. */\n.sizeDlg.variantDestructive {\n background-color: var(--surface-error-subtle, color-mix(in srgb, var(--destructive, #ef4444) 8%, transparent));\n color: var(--text-error, var(--destructive, #b91c1c));\n}\n\n@media (hover: hover) {\n .sizeDlg.variantDestructive:hover {\n background-color: color-mix(in srgb, var(--destructive, #ef4444) 14%, transparent);\n }\n}\n"
|
|
150
|
+
"content": "/* Button base styles */\n.base {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: var(--spacing-2, 0.5rem);\n white-space: nowrap;\n border-radius: var(--radius-md, 0.375rem);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n line-height: 1;\n cursor: pointer;\n border: 1px solid transparent;\n transition: background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), opacity 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 text-decoration: none;\n outline: none;\n}\n\n.base:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.base:active:not(:disabled) {\n transform: translateY(1px);\n transition: none;\n}\n\n.base:disabled {\n pointer-events: none;\n opacity: 0.5;\n}\n\n/* Variants */\n.variantDefault {\n background-color: var(--interactive-primary-bg, var(--primary, #111827));\n color: var(--interactive-primary-text, #f9fafb);\n}\n\n.variantSecondary {\n background-color: var(--interactive-secondary-bg, #f3f4f6);\n color: var(--interactive-secondary-text, #111827);\n}\n\n/* Editorial density: secondary on the card tier, ghost truly transparent.\n * Direct values — the editorial design language owns control surfaces, so the\n * theme's --interactive-secondary-bg / --interactive-ghost-bg are deliberately\n * bypassed (matches the blessed overlay, which out-cascaded the palette). */\n:global([data-density=\"editorial\"]) .variantSecondary {\n background-color: var(--surface-card, #ffffff);\n}\n\n:global([data-density=\"editorial\"]) .variantGhost {\n background-color: transparent;\n}\n\n.variantOutline {\n background-color: transparent;\n color: var(--text-primary, #111827);\n border-color: var(--border-strong, #e5e7eb);\n}\n\n.variantGhost {\n background-color: var(--interactive-ghost-bg, transparent);\n color: var(--text-primary, #111827);\n}\n\n.variantDestructive {\n background-color: var(--interactive-destructive-bg, #ef4444);\n color: var(--interactive-destructive-text, #fef2f2);\n}\n\n@media (hover: hover) {\n .variantDefault:hover {\n background-color: var(--interactive-primary-bg-hover, #1f2937);\n }\n\n .variantSecondary:hover {\n background-color: var(--interactive-secondary-bg-hover, #e5e7eb);\n }\n\n .variantOutline:hover {\n background-color: var(--surface-muted, #f3f4f6);\n }\n\n .variantGhost:hover {\n background-color: var(--interactive-ghost-bg-hover, var(--surface-muted, #f3f4f6));\n }\n\n .variantDestructive:hover {\n background-color: var(--interactive-destructive-bg-hover, #dc2626);\n }\n}\n\n/* Open / active state — ghost variant only. OPT-IN, strictly additive.\n A ghost Button reads as \"held\" while its popover/menu is open so the\n trigger no longer needs an inline style. Three opt-in triggers, all\n resolving to the same held look:\n - data-state=\"open\" — forwarded automatically by Radix when the\n Button is the trigger via <Trigger asChild> (Popover/DropdownMenu).\n - data-active=\"true\" — manual non-Radix triggers (mirrors the\n sidebar `data-active` convention).\n - .isActive — className opt-in (mirrors sidebar/section-nav).\n Default ghost rendering is untouched: none of these match unless a\n consumer explicitly sets them. Matches the prototype-overlay held look:\n background var(--surface-subtle), color var(--text-primary). */\n.variantGhost[data-state=\"open\"],\n.variantGhost[data-active=\"true\"],\n.variantGhost.isActive {\n background-color: var(--surface-subtle, var(--surface-muted, #f3f4f6));\n color: var(--text-primary, #111827);\n}\n\n/* Gated state — orthogonal to variant, works with all variants */\n.base[data-gated=\"true\"] {\n opacity: var(--opacity-40, 0.4);\n cursor: not-allowed;\n border-color: var(--border-default, #e5e7eb);\n}\n\n/* Editorial density: gated reads like plain disabled (the blessed admin\n builds render gated actions at the 0.5 disabled opacity). */\n:global([data-density=\"editorial\"]) .base[data-gated=\"true\"] {\n opacity: 0.5;\n}\n\n/* Suppress active transform when gated */\n.base[data-gated=\"true\"]:active {\n transform: none;\n transition: opacity var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n/* Sizes\n Default (no data-density attr) rendering is byte-identical to the prior\n canonical values. Under data-density=\"editorial\" (set on any ancestor,\n typically the app root), the editorial density axis re-drives specific\n metrics to the blessed admin values. */\n.sizeSm {\n height: 2rem;\n padding: 0 var(--spacing-3, 0.75rem);\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n.sizeMd {\n height: 2.5rem;\n padding: 0 var(--spacing-4, 1rem);\n line-height: 1;\n}\n\n:global([data-density=\"editorial\"]) .sizeSm {\n font-size: 0.8125rem;\n}\n\n:global([data-density=\"editorial\"]) .sizeMd {\n height: 2.125rem;\n line-height: normal;\n}\n\n.sizeLg {\n height: 3rem;\n padding: 0 var(--spacing-6, 1.5rem);\n font-size: var(--font-size-base, 1rem);\n}\n\n/* Dialog-footer button — dlg-btn (Animal dialog substrate, VI-620).\n Compact modal-action metrics measured from the AN prototype (11.5px / 600,\n ~7px 13px padding, 7px radius), normalized onto Visor tokens: font-size-2xs\n (11px), spacing-2/spacing-3 padding (8/12px), radius-lg (8px). Height is\n auto so the compact padding drives the box. Color faces reuse the existing\n variant axis — primary via variant=\"default\", ghost via variant=\"ghost\"\n (refined below to a hairline-outlined, secondary-ink face), danger via\n variant=\"destructive\" (refined below to a subtle-danger face). */\n.sizeDlg {\n height: auto;\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n font-size: var(--font-size-2xs, 0.6875rem);\n font-weight: var(--font-weight-semibold, 600);\n line-height: 1;\n border-radius: var(--radius-lg, 0.5rem);\n}\n\n/* dlg ghost — transparent well with a hairline outline + secondary ink\n (AN dlg-btn ghost face: transparent / --ink-60 / 1px --hair). Two-class\n specificity keeps this scoped to the dlg size only. */\n.sizeDlg.variantGhost {\n color: var(--text-secondary, #6b7280);\n border-color: var(--hairline, var(--border-default, #e5e7eb));\n}\n\n/* dlg danger — subtle-danger face (AN dlg-btn danger face: --red on a faint\n red fill), distinct from the solid `destructive` variant. */\n.sizeDlg.variantDestructive {\n background-color: var(--surface-error-subtle, color-mix(in srgb, var(--destructive, #ef4444) 8%, transparent));\n color: var(--text-error, var(--destructive, #b91c1c));\n}\n\n@media (hover: hover) {\n .sizeDlg.variantDestructive:hover {\n background-color: color-mix(in srgb, var(--destructive, #ef4444) 14%, transparent);\n }\n}\n\n/* Pending state — orthogonal to variant and size (VI-643).\n The idle children stay in the box at zero opacity so the control keeps its\n width, its height and its accessible name; the busy glyph paints over them\n in an absolutely-positioned overlay. A pending state that reflows has not\n fixed the defect it exists for, which is why nothing here is a leading slot\n that adds width.\n\n Themes tune the two timings via --button-pending-delay and\n --button-pending-min-duration. Neither is declared here on purpose: an\n element-level declaration shadows anything a theme emits on :root (VI-625). */\n.base[data-pending=\"true\"] {\n position: relative;\n cursor: progress;\n}\n\n/* No press dip while the action is in flight — the button is not accepting\n the click, so it must not pretend to. */\n.base[data-pending=\"true\"]:active {\n transform: none;\n transition: none;\n}\n\n/* Geometry holder. Mirrors .base's own flex metrics so wrapping the children\n does not change how they lay out. */\n.pendingLabel {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: var(--spacing-2, 0.5rem);\n opacity: 0;\n transition: opacity var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n.pendingOverlay {\n position: absolute;\n inset: 0;\n box-sizing: border-box;\n padding: inherit;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: var(--spacing-2, 0.5rem);\n pointer-events: none;\n}\n\n/* Clamped, so a pending label longer than the idle one can never widen the\n box it is being held inside. */\n.pendingText {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n"
|
|
150
151
|
}
|
|
151
152
|
]
|
|
152
153
|
},
|
package/dist/visor-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "0.4.0",
|
|
3
|
-
"generated_at": "2026-09-
|
|
3
|
+
"generated_at": "2026-09-18T16:07:02.796Z",
|
|
4
4
|
"components": {
|
|
5
5
|
"accessibility-specimen": {
|
|
6
6
|
"category": "specimen",
|
|
@@ -1071,6 +1071,30 @@
|
|
|
1071
1071
|
"type": "string",
|
|
1072
1072
|
"default": "undefined",
|
|
1073
1073
|
"description": "Explanation surfaced in an anchored Radix tooltip when the button is gated. Only rendered when gated is also true. A TooltipProvider ancestor is required whenever gatedReason is set — add one at the page or layout level. Example: \"You can't delete this — you're not an owner\".\n"
|
|
1074
|
+
},
|
|
1075
|
+
{
|
|
1076
|
+
"name": "pending",
|
|
1077
|
+
"type": "boolean",
|
|
1078
|
+
"default": false,
|
|
1079
|
+
"description": "Reports that the button's action is in flight — a busy glyph paints over the label, aria-busy is set, and activation is suppressed so the action cannot be double-submitted. The control's geometry is held: the idle children stay in the box at zero opacity and the glyph overlays them, so the button never resizes and never shoves its siblings. Like gated, it does NOT set the native disabled attribute, so the button stays focusable while the user waits. Under asChild the state attributes and the activation suppression still apply, but no chrome is injected (Slot takes a single child).\n"
|
|
1080
|
+
},
|
|
1081
|
+
{
|
|
1082
|
+
"name": "pendingLabel",
|
|
1083
|
+
"type": "ReactNode",
|
|
1084
|
+
"default": "undefined",
|
|
1085
|
+
"description": "Optional text shown beside the busy glyph while pending. Decorative only — it is aria-hidden, and the accessible name stays the idle label so the control is not renamed mid-action. Pair pending with your own role=\"status\" region when the message needs to be spoken; Button deliberately does not invent one.\n"
|
|
1086
|
+
},
|
|
1087
|
+
{
|
|
1088
|
+
"name": "pendingDelay",
|
|
1089
|
+
"type": "number",
|
|
1090
|
+
"default": 200,
|
|
1091
|
+
"description": "Milliseconds to wait before painting the busy glyph, so an action that resolves faster than this shows nothing at all. Overrides the themed --button-pending-delay.\n"
|
|
1092
|
+
},
|
|
1093
|
+
{
|
|
1094
|
+
"name": "pendingMinDuration",
|
|
1095
|
+
"type": "number",
|
|
1096
|
+
"default": 300,
|
|
1097
|
+
"description": "Milliseconds to keep the busy glyph on screen once painted, so a barely- slow action does not strobe. Overrides the themed --button-pending-min-duration.\n"
|
|
1074
1098
|
}
|
|
1075
1099
|
],
|
|
1076
1100
|
"dependencies": [
|
|
@@ -1120,7 +1144,7 @@
|
|
|
1120
1144
|
"--text-primary",
|
|
1121
1145
|
"--text-secondary"
|
|
1122
1146
|
],
|
|
1123
|
-
"example": "<Button variant=\"default\" size=\"md\">Save changes</Button>\n\n<Button variant=\"destructive\">Delete account</Button>\n\n<Button variant=\"ghost\" size=\"sm\">Cancel</Button>\n\n{/* Gated — no reason (tooltip suppressed) */}\n<Button gated>Delete organization</Button>\n\n{/* Gated with reason — tooltip shown on hover/focus */}\n<TooltipProvider>\n <Button gated gatedReason=\"You can't delete this — you're not an owner\">\n Delete organization\n </Button>\n</TooltipProvider>\n"
|
|
1147
|
+
"example": "<Button variant=\"default\" size=\"md\">Save changes</Button>\n\n<Button variant=\"destructive\">Delete account</Button>\n\n<Button variant=\"ghost\" size=\"sm\">Cancel</Button>\n\n{/* Gated — no reason (tooltip suppressed) */}\n<Button gated>Delete organization</Button>\n\n{/* Pending — geometry held, activation suppressed, aria-busy set */}\n<Button pending={saving}>Save changes</Button>\n\n{/* Pending with a decorative busy label */}\n<Button pending={saving} pendingLabel=\"Saving…\">Save changes</Button>\n\n {/* Gated with reason — tooltip shown on hover/focus */}\n<TooltipProvider>\n <Button gated gatedReason=\"You can't delete this — you're not an owner\">\n Delete organization\n </Button>\n</TooltipProvider>\n"
|
|
1124
1148
|
},
|
|
1125
1149
|
"calendar": {
|
|
1126
1150
|
"category": "form",
|