@loworbitstudio/visor 0.5.0 → 0.6.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "0.4.0",
3
- "generated_at": "2026-04-28T22:06:10.258Z",
3
+ "generated_at": "2026-04-29T19:23:27.559Z",
4
4
  "components": {
5
5
  "accessibility-specimen": {
6
6
  "changeType": "current",
@@ -476,6 +476,12 @@
476
476
  "breakingChange": false,
477
477
  "migrationNote": null
478
478
  },
479
+ "theme-switcher": {
480
+ "changeType": "current",
481
+ "files": [],
482
+ "breakingChange": false,
483
+ "migrationNote": null
484
+ },
479
485
  "timeline": {
480
486
  "changeType": "current",
481
487
  "files": [],
@@ -2156,6 +2156,27 @@
2156
2156
  }
2157
2157
  ]
2158
2158
  },
2159
+ {
2160
+ "name": "theme-switcher",
2161
+ "type": "registry:ui",
2162
+ "description": "A fixed-position pill switcher for theme and dark/light mode. Persists selections to localStorage; exposes an extras slot for hosting devtools chrome (e.g., SourceInspectorToggle). Generalized from the admin-v7-r1 reference; apps pass their own ThemeOption[] list.",
2163
+ "category": "general",
2164
+ "dependencies": [
2165
+ "@loworbitstudio/visor-core"
2166
+ ],
2167
+ "files": [
2168
+ {
2169
+ "path": "components/ui/theme-switcher/theme-switcher.tsx",
2170
+ "type": "registry:ui",
2171
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport styles from \"./theme-switcher.module.css\"\n\nexport interface ThemeOption {\n id: string\n label: string\n bodyClass: string\n}\n\nexport type ColorMode = \"dark\" | \"light\"\n\nexport interface ThemeSwitcherProps {\n themes?: ThemeOption[]\n defaultThemeId?: string\n defaultMode?: ColorMode\n themeStorageKey?: string\n modeStorageKey?: string\n extras?: React.ReactNode\n className?: string\n style?: React.CSSProperties\n}\n\nconst DEFAULT_THEME_KEY = \"visor-theme\"\nconst DEFAULT_MODE_KEY = \"visor-color-mode\"\n\nfunction applyTheme(themeId: string | null, themes: ThemeOption[], storageKey: string) {\n const body = document.body\n for (const t of themes) {\n if (t.bodyClass) body.classList.remove(t.bodyClass)\n }\n const theme = themes.find((t) => t.id === themeId)\n if (theme?.bodyClass) body.classList.add(theme.bodyClass)\n try {\n if (themeId) localStorage.setItem(storageKey, themeId)\n } catch {\n // localStorage unavailable; class application still succeeds\n }\n}\n\nfunction applyMode(mode: ColorMode, storageKey: string) {\n const html = document.documentElement\n html.classList.remove(\"dark\", \"light\")\n html.classList.add(mode)\n html.style.colorScheme = mode\n try {\n localStorage.setItem(storageKey, mode)\n } catch {\n // localStorage unavailable; class application still succeeds\n }\n}\n\nexport function ThemeSwitcher({\n themes = [],\n defaultThemeId,\n defaultMode = \"dark\",\n themeStorageKey = DEFAULT_THEME_KEY,\n modeStorageKey = DEFAULT_MODE_KEY,\n extras,\n className,\n style,\n}: ThemeSwitcherProps) {\n const initialThemeId = defaultThemeId ?? themes[0]?.id ?? null\n const [themeId, setThemeId] = React.useState<string | null>(initialThemeId)\n const [mode, setMode] = React.useState<ColorMode>(defaultMode)\n const [mounted, setMounted] = React.useState(false)\n\n React.useEffect(() => {\n try {\n const storedTheme = localStorage.getItem(themeStorageKey)\n const storedMode = localStorage.getItem(modeStorageKey) as ColorMode | null\n const validTheme =\n storedTheme && themes.some((t) => t.id === storedTheme)\n ? storedTheme\n : initialThemeId\n const validMode: ColorMode = storedMode === \"light\" || storedMode === \"dark\" ? storedMode : defaultMode\n setThemeId(validTheme)\n setMode(validMode)\n } catch {\n // localStorage unavailable; fall back to defaults\n }\n setMounted(true)\n // initialThemeId, defaultMode, themes, themeStorageKey, modeStorageKey are\n // configuration; we read them once on mount and intentionally do not\n // re-sync if the host re-renders with different values.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n if (!mounted) return null\n\n const wrapperClass = className ? `${styles.root} ${className}` : styles.root\n\n return (\n <div className={wrapperClass} style={style} role=\"group\" aria-label=\"Theme and mode switcher\">\n {themes.length > 0 ? (\n <>\n <span className={styles.label}>Theme</span>\n <div className={styles.segment} role=\"radiogroup\" aria-label=\"Theme\">\n {themes.map((theme) => (\n <button\n key={theme.id}\n type=\"button\"\n role=\"radio\"\n aria-checked={themeId === theme.id}\n data-active={themeId === theme.id}\n onClick={() => {\n setThemeId(theme.id)\n applyTheme(theme.id, themes, themeStorageKey)\n }}\n className={styles.option}\n >\n {theme.label}\n </button>\n ))}\n </div>\n <span className={styles.divider} aria-hidden=\"true\" />\n </>\n ) : null}\n <span className={styles.label}>Mode</span>\n <div className={styles.segment} role=\"radiogroup\" aria-label=\"Mode\">\n <button\n type=\"button\"\n role=\"radio\"\n aria-checked={mode === \"dark\"}\n data-active={mode === \"dark\"}\n onClick={() => {\n setMode(\"dark\")\n applyMode(\"dark\", modeStorageKey)\n }}\n className={styles.option}\n >\n Dark\n </button>\n <button\n type=\"button\"\n role=\"radio\"\n aria-checked={mode === \"light\"}\n data-active={mode === \"light\"}\n onClick={() => {\n setMode(\"light\")\n applyMode(\"light\", modeStorageKey)\n }}\n className={styles.option}\n >\n Light\n </button>\n </div>\n {extras}\n </div>\n )\n}\n"
2172
+ },
2173
+ {
2174
+ "path": "components/ui/theme-switcher/theme-switcher.module.css",
2175
+ "type": "registry:ui",
2176
+ "content": ".root {\n position: fixed;\n bottom: var(--spacing-3, 0.75rem);\n right: var(--spacing-3, 0.75rem);\n z-index: 1000;\n\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n\n padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);\n border-radius: var(--radius-full, 9999px);\n background: var(--surface-popover, var(--surface-card, #111827));\n color: var(--text-secondary, #4b5563);\n backdrop-filter: blur(8px);\n -webkit-backdrop-filter: blur(8px);\n\n font-family: var(--font-body, system-ui, sans-serif);\n font-size: var(--font-size-xs, 0.75rem);\n letter-spacing: 0.04em;\n\n outline: var(--stroke-width-thin, 1px) solid color-mix(in srgb, var(--text-tertiary, #9ca3af) 30%, transparent);\n outline-offset: 0;\n box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1));\n}\n\n.label {\n font-size: var(--font-size-xs, 0.75rem);\n text-transform: uppercase;\n letter-spacing: 0.12em;\n color: var(--text-tertiary, #9ca3af);\n font-weight: var(--font-weight-medium, 500);\n}\n\n.divider {\n display: inline-block;\n width: var(--stroke-width-thin, 1px);\n height: 14px;\n background: color-mix(in srgb, var(--text-tertiary, #9ca3af) 35%, transparent);\n}\n\n.segment {\n display: inline-flex;\n align-items: center;\n gap: calc(var(--spacing-1, 0.25rem) / 2);\n padding: calc(var(--spacing-1, 0.25rem) / 2);\n background: color-mix(in srgb, var(--text-tertiary, #9ca3af) 14%, transparent);\n border-radius: var(--radius-full, 9999px);\n}\n\n.option {\n appearance: none;\n background: transparent;\n border: 0;\n padding: calc(var(--spacing-1, 0.25rem) * 0.75) var(--spacing-2, 0.5rem);\n border-radius: var(--radius-full, 9999px);\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-medium, 500);\n color: var(--text-secondary, #4b5563);\n cursor: pointer;\n letter-spacing: 0.02em;\n transition: background-color var(--motion-duration-100, 100ms) var(--motion-easing-default, ease-in-out),\n color var(--motion-duration-100, 100ms) var(--motion-easing-default, ease-in-out);\n}\n\n.option:hover {\n color: var(--text-primary, #111827);\n}\n\n.option[data-active=\"true\"] {\n background: var(--surface-card, #ffffff);\n color: var(--text-primary, #111827);\n box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-tertiary, #9ca3af) 25%, transparent);\n}\n\n.option:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n"
2177
+ }
2178
+ ]
2179
+ },
2159
2180
  {
2160
2181
  "name": "use-media-query",
2161
2182
  "type": "registry:hook",
@@ -3556,6 +3577,53 @@
3556
3577
  }
3557
3578
  ]
3558
3579
  },
3580
+ {
3581
+ "name": "source-inspector",
3582
+ "type": "registry:devtool",
3583
+ "description": "Borealis pre-flight x-ray overlay. Walks the React Fiber tree, classifies each rendered DOM node by source file, and tints regions to surface Visor coverage and gaps. No-op in production.",
3584
+ "category": "devtools",
3585
+ "files": [
3586
+ {
3587
+ "path": "components/devtools/source-inspector/source-inspector.tsx",
3588
+ "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"
3590
+ },
3591
+ {
3592
+ "path": "components/devtools/source-inspector/source-inspector.module.css",
3593
+ "type": "registry:devtool",
3594
+ "content": "/*\n * Body-scoped overlay rules. The runner stamps every rendered element with\n * data-source=\"visor|local|third-party|dom\" and toggles a body class to\n * activate one of the highlight modes.\n *\n * Outline + 30% tinted background per ticket. Tokens reference Visor stock\n * semantic colors (success=mint, warning=coral) so the overlay paints\n * predictably across themes.\n */\n\n.modeHighlightVisor :where([data-source=\"visor\"]) {\n outline: var(--stroke-width-medium, 2px) solid var(--text-success, #16a34a) !important;\n outline-offset: -1px !important;\n background-color: color-mix(in srgb, var(--surface-success-default, #22c55e) 30%, transparent) !important;\n}\n\n.modeHighlightNonVisor :where([data-source=\"local\"]) {\n outline: var(--stroke-width-medium, 2px) solid var(--text-warning, #b45309) !important;\n outline-offset: -1px !important;\n background-color: color-mix(in srgb, var(--surface-warning-default, #f59e0b) 30%, transparent) !important;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .modeHighlightVisor :where([data-source=\"visor\"]),\n .modeHighlightNonVisor :where([data-source=\"local\"]) {\n transition: none !important;\n animation: none !important;\n }\n}\n"
3595
+ },
3596
+ {
3597
+ "path": "components/devtools/source-inspector/classify.ts",
3598
+ "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"
3600
+ }
3601
+ ]
3602
+ },
3603
+ {
3604
+ "name": "source-inspector-toggle",
3605
+ "type": "registry:devtool",
3606
+ "description": "Phosphor `Scan` icon button that cycles the SourceInspector overlay through off → highlight-visor → highlight-non-visor → off. Mounts a default SourceInspectorProvider lazily if no provider is in scope.",
3607
+ "category": "devtools",
3608
+ "dependencies": [
3609
+ "@phosphor-icons/react"
3610
+ ],
3611
+ "registryDependencies": [
3612
+ "source-inspector"
3613
+ ],
3614
+ "files": [
3615
+ {
3616
+ "path": "components/devtools/source-inspector/source-inspector-toggle.tsx",
3617
+ "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"
3619
+ },
3620
+ {
3621
+ "path": "components/devtools/source-inspector/source-inspector-toggle.module.css",
3622
+ "type": "registry:devtool",
3623
+ "content": ".button {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: var(--spacing-7, 1.75rem);\n height: var(--spacing-7, 1.75rem);\n padding: 0;\n border: 0;\n border-radius: var(--radius-full, 9999px);\n background: transparent;\n color: var(--text-secondary, #4b5563);\n cursor: pointer;\n transition: background-color var(--motion-duration-100, 100ms) var(--motion-easing-default, ease-in-out),\n color var(--motion-duration-100, 100ms) var(--motion-easing-default, ease-in-out);\n}\n\n.button:hover {\n background-color: color-mix(in srgb, var(--text-tertiary, #9ca3af) 14%, transparent);\n color: var(--text-primary, #111827);\n}\n\n.button: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.button[data-mode=\"highlight-visor\"],\n.button[data-mode=\"highlight-non-visor\"] {\n color: var(--text-primary, #111827);\n}\n\n.dot {\n position: absolute;\n bottom: 2px;\n left: 50%;\n transform: translateX(-50%);\n width: 6px;\n height: 6px;\n border-radius: var(--radius-full, 9999px);\n}\n\n.dotVisor {\n background-color: var(--surface-success-default, #22c55e);\n}\n\n.dotNonVisor {\n background-color: var(--surface-warning-default, #f59e0b);\n}\n"
3624
+ }
3625
+ ]
3626
+ },
3559
3627
  {
3560
3628
  "name": "Avatar",
3561
3629
  "type": "registry:ui",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "0.4.0",
3
- "generated_at": "2026-04-28T22:06:10.255Z",
3
+ "generated_at": "2026-04-29T19:23:27.556Z",
4
4
  "components": {
5
5
  "accessibility-specimen": {
6
6
  "category": "specimen",
@@ -5651,6 +5651,81 @@
5651
5651
  ],
5652
5652
  "example": "<Textarea placeholder=\"Write your message...\" rows={4} />\n\n<Textarea aria-invalid=\"true\" />\n"
5653
5653
  },
5654
+ "theme-switcher": {
5655
+ "category": "general",
5656
+ "description": "Fixed-position pill switcher for theme and dark/light mode, with an extras slot for hosting devtools chrome.",
5657
+ "when_to_use": [
5658
+ "Hosts apps that ship multiple Visor themes and need a runtime switcher",
5659
+ "Dark/light mode toggle persisted across sessions",
5660
+ "Mounting devtools chrome (e.g., SourceInspectorToggle) into a stable host"
5661
+ ],
5662
+ "when_not_to_use": [
5663
+ "Production-only chrome with no dev affordances (use a tighter custom switcher)",
5664
+ "Single-theme apps that only need a mode toggle (build a leaner control)"
5665
+ ],
5666
+ "props": [
5667
+ {
5668
+ "name": "themes",
5669
+ "type": "ThemeOption[]",
5670
+ "default": "[]",
5671
+ "description": "List of themes available in the Theme segment. Empty list hides the Theme segment and only renders Mode."
5672
+ },
5673
+ {
5674
+ "name": "defaultThemeId",
5675
+ "type": "string",
5676
+ "default": "themes[0]?.id",
5677
+ "description": "Initial theme id when nothing is stored."
5678
+ },
5679
+ {
5680
+ "name": "defaultMode",
5681
+ "type": "'dark' | 'light'",
5682
+ "default": "dark"
5683
+ },
5684
+ {
5685
+ "name": "themeStorageKey",
5686
+ "type": "string",
5687
+ "default": "visor-theme"
5688
+ },
5689
+ {
5690
+ "name": "modeStorageKey",
5691
+ "type": "string",
5692
+ "default": "visor-color-mode"
5693
+ },
5694
+ {
5695
+ "name": "extras",
5696
+ "type": "React.ReactNode",
5697
+ "description": "Rendered after the Mode segment, inside the wrapper. For dev chrome (e.g., SourceInspectorToggle)."
5698
+ },
5699
+ {
5700
+ "name": "className",
5701
+ "type": "string",
5702
+ "description": "Additional className merged onto the root wrapper."
5703
+ }
5704
+ ],
5705
+ "dependencies": [],
5706
+ "tokens_used": [
5707
+ "--border-focus",
5708
+ "--focus-ring-offset",
5709
+ "--focus-ring-width",
5710
+ "--font-body",
5711
+ "--font-size-xs",
5712
+ "--font-weight-medium",
5713
+ "--motion-duration-100",
5714
+ "--motion-easing-default",
5715
+ "--radius-full",
5716
+ "--shadow-md",
5717
+ "--spacing-1",
5718
+ "--spacing-2",
5719
+ "--spacing-3",
5720
+ "--stroke-width-thin",
5721
+ "--surface-card",
5722
+ "--surface-popover",
5723
+ "--text-primary",
5724
+ "--text-secondary",
5725
+ "--text-tertiary"
5726
+ ],
5727
+ "example": "import { ThemeSwitcher } from \"@/components/ui/theme-switcher/theme-switcher\"\nimport { SourceInspectorToggle } from \"@/components/devtools/source-inspector/source-inspector-toggle\"\n\n<ThemeSwitcher\n themes={[\n { id: \"default\", label: \"Default\", bodyClass: \"\" },\n { id: \"blackout\", label: \"Blackout\", bodyClass: \"blackout-theme\" },\n ]}\n extras={<SourceInspectorToggle />}\n/>\n"
5728
+ },
5654
5729
  "timeline": {
5655
5730
  "category": "data-display",
5656
5731
  "description": "A vertical timeline with status-based styling, optional icons, timestamps, and connector lines.",
@@ -7871,6 +7946,9 @@
7871
7946
  "menubar",
7872
7947
  "sheet"
7873
7948
  ],
7949
+ "general": [
7950
+ "theme-switcher"
7951
+ ],
7874
7952
  "deck": [
7875
7953
  "deck-card-grid",
7876
7954
  "deck-carousel-gallery",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loworbitstudio/visor",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "CLI for the Visor design system — add components, hooks, and utilities to your project.",
5
5
  "type": "module",
6
6
  "bin": {