@voltro/ui-shadcn 0.66.1 → 0.68.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.
@@ -16,7 +16,7 @@
16
16
  // (callouts, code blocks, steps) are siblings of this file and
17
17
  // composable in any combination.
18
18
 
19
- import { useEffect, useState, type ComponentType, type ReactNode } from 'react'
19
+ import { useEffect, useRef, useState, type ComponentType, type ReactNode } from 'react'
20
20
  import { cn } from '../cn'
21
21
  import type { ShellLinkProps } from './docShell'
22
22
  import { ChevronLeftIcon, ChevronRightIcon } from '../primitives/docIcons'
@@ -121,6 +121,12 @@ interface DocsLayoutProps {
121
121
  readonly brand: ReactNode
122
122
  readonly nav: ReadonlyArray<DocsNavGroup>
123
123
  readonly children: ReactNode
124
+ /** Compose the top bar while retaining the layout's mobile navigation control. */
125
+ readonly header?: (controls: { readonly menuButton: ReactNode }) => ReactNode
126
+ /** Full-width introduction above the reading columns. */
127
+ readonly intro?: ReactNode
128
+ /** Show the desktop section rail. Mobile navigation remains available. */
129
+ readonly sidebar?: boolean
124
130
  /** Current pathname — used to highlight the active nav entry +
125
131
  * decide which group opens by default. */
126
132
  readonly currentPath?: string
@@ -200,7 +206,7 @@ const NavLeaf = ({
200
206
  }): ReactNode => {
201
207
  const active = isActiveHref(entry.href, currentPath)
202
208
  return (
203
- <li>
209
+ <li data-docs-active={active}>
204
210
  <Link to={entry.href} prefetch className={cn(NAV_ROW, 'block', navRowColor(active))}>
205
211
  <span className="inline-flex items-center gap-2">
206
212
  {entry.label}
@@ -454,13 +460,32 @@ const Breadcrumbs = ({
454
460
  export const DocsLayout = ({
455
461
  brand, nav, children, currentPath, topNav, topRight, search,
456
462
  page, LinkComponent = PlainLink, className, labels, footer, sidebarHeader,
463
+ header, intro, sidebar = true,
457
464
  }: DocsLayoutProps): ReactNode => {
458
465
  const Link = LinkComponent
459
466
  const t = { ...DEFAULT_DOCS_LABELS, ...labels }
460
- // Mobile drawer state — only the inline state, no portal/escape
461
- // handling here; the search-trigger button can call setMobileOpen
462
- // via a parent island when full cmd-k lands.
463
467
  const [mobileOpen, setMobileOpen] = useState(false)
468
+ const drawer = useRef<HTMLDialogElement>(null)
469
+ useEffect(() => {
470
+ const element = drawer.current
471
+ if (!element) return
472
+ if (mobileOpen && !element.open) element.showModal()
473
+ if (!mobileOpen && element.open) element.close()
474
+ if (!mobileOpen) return
475
+ const previousOverflow = document.body.style.overflow
476
+ document.body.style.overflow = 'hidden'
477
+ return () => { document.body.style.overflow = previousOverflow }
478
+ }, [mobileOpen])
479
+ useEffect(() => { setMobileOpen(false) }, [currentPath])
480
+ const menuButton = (
481
+ <button type="button" onClick={() => setMobileOpen(true)}
482
+ className="docs-menu-toggle md:hidden inline-flex items-center justify-center w-11 h-11 rounded-md hover:bg-card transition-colors"
483
+ aria-label={t.toggleMenu} aria-expanded={mobileOpen} aria-haspopup="dialog">
484
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
485
+ <path d="M3 5 H15 M3 9 H15 M3 13 H15" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
486
+ </svg>
487
+ </button>
488
+ )
464
489
 
465
490
  return (
466
491
  <div className={cn('min-h-screen bg-background text-foreground', className)}>
@@ -468,19 +493,9 @@ export const DocsLayout = ({
468
493
  * sticky / max-width / h-16 / glass-on-scroll fade. The
469
494
  * children below define the docs-specific row: mobile menu,
470
495
  * brand, optional topNav, search, topRight slots. */}
471
- <PageHeader>
496
+ {header ? header({ menuButton }) : <PageHeader>
472
497
  <div className="flex items-center gap-4 min-w-0 flex-1">
473
- <button
474
- type="button"
475
- onClick={() => setMobileOpen(!mobileOpen)}
476
- className="md:hidden inline-flex items-center justify-center w-8 h-8 rounded-md hover:bg-card transition-colors"
477
- aria-label={t.toggleMenu}
478
- aria-expanded={mobileOpen}
479
- >
480
- <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
481
- <path d="M3 5 H15 M3 9 H15 M3 13 H15" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
482
- </svg>
483
- </button>
498
+ {menuButton}
484
499
  <div className="flex items-center gap-2 font-semibold">{brand}</div>
485
500
  {topNav ? <div className="hidden md:flex items-center gap-1 ml-4">{topNav}</div> : null}
486
501
  </div>
@@ -488,36 +503,43 @@ export const DocsLayout = ({
488
503
  {search ? <div className="hidden md:block">{search}</div> : null}
489
504
  {topRight ? <div className="flex items-center gap-1">{topRight}</div> : null}
490
505
  </div>
491
- </PageHeader>
506
+ </PageHeader>}
492
507
 
493
- <div className="mx-auto max-w-7xl flex">
508
+ {intro}
509
+
510
+ <div className="docs-columns mx-auto max-w-7xl flex">
494
511
  {/* Sidebar (desktop) */}
495
- <aside className="hidden md:block w-64 shrink-0 border-r border-border">
512
+ {sidebar ? <aside data-docs-sidebar className="hidden md:block w-64 shrink-0 border-r border-border">
496
513
  <div className="sticky top-16 max-h-[calc(100vh-4rem)] overflow-y-auto px-6 py-8">
497
514
  {sidebarHeader ? <div className="mb-6">{sidebarHeader}</div> : null}
498
515
  {nav.map((group) => (
499
516
  <SidebarGroup key={group.section} group={group} {...(currentPath ? { currentPath } : {})} Link={Link} />
500
517
  ))}
501
518
  </div>
502
- </aside>
519
+ </aside> : null}
503
520
 
504
521
  {/* Sidebar (mobile drawer) */}
505
- {mobileOpen ? (
506
- <div className="md:hidden fixed inset-0 z-40 flex">
507
- {/* Backdrop */}
508
- <div
509
- role="button"
510
- aria-label={t.closeMenu}
511
- className="flex-1 bg-background/70 backdrop-blur-sm"
512
- onClick={() => setMobileOpen(false)}
513
- />
514
- <aside className="w-72 max-w-[80vw] bg-card border-l border-border overflow-y-auto px-6 py-6">
522
+ <dialog ref={drawer} aria-label={t.menu}
523
+ onCancel={() => setMobileOpen(false)} onClose={() => setMobileOpen(false)}
524
+ onKeyDown={event => {
525
+ if (event.key !== 'Tab') return
526
+ const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not([disabled]), a[href], select:not([disabled]), [tabindex="0"]')]
527
+ const first = controls[0]
528
+ const last = controls.at(-1)
529
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
530
+ if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
531
+ }}
532
+ onClick={event => { if (event.target === event.currentTarget) setMobileOpen(false) }}
533
+ className="docs-drawer m-0 ml-auto h-dvh max-h-dvh w-80 max-w-[90vw] border-0 border-l border-border bg-card text-foreground p-0 backdrop:bg-black/50">
534
+ {mobileOpen ? <div className="min-h-full px-6 py-6" onClick={event => {
535
+ if (event.target instanceof Element && event.target.closest('a[href]')) setMobileOpen(false)
536
+ }}>
515
537
  <div className="mb-4 flex items-center justify-between">
516
538
  <span className="font-semibold">{t.menu}</span>
517
539
  <button
518
540
  type="button"
519
541
  onClick={() => setMobileOpen(false)}
520
- className="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-background transition-colors"
542
+ className="inline-flex items-center justify-center w-11 h-11 rounded-md hover:bg-background transition-colors"
521
543
  aria-label={t.close}
522
544
  >
523
545
  <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
@@ -529,9 +551,8 @@ export const DocsLayout = ({
529
551
  {nav.map((group) => (
530
552
  <SidebarGroup key={group.section} group={group} {...(currentPath ? { currentPath } : {})} Link={Link} />
531
553
  ))}
532
- </aside>
533
- </div>
534
- ) : null}
554
+ </div> : null}
555
+ </dialog>
535
556
 
536
557
  {/* Content + TOC */}
537
558
  <div className="flex-1 min-w-0 flex">
@@ -32,7 +32,7 @@ import {
32
32
  } from '../primitives/dropdownMenu'
33
33
  import { ToggleGroup, ToggleGroupItem } from '../primitives/toggleGroup'
34
34
  import {
35
- THEME_COOKIE, LOCALE_COOKIE, getCookie, setCookie, deleteCookie, applyTheme,
35
+ THEME_COOKIE, THEME_CHANGE_EVENT, LOCALE_COOKIE, getCookie, setCookie, applyTheme,
36
36
  type ThemePreference,
37
37
  } from '../cookies'
38
38
 
@@ -43,10 +43,8 @@ const readStoredTheme = (): ThemePreference => {
43
43
  }
44
44
 
45
45
  const persistTheme = (choice: ThemePreference): void => {
46
- // 'system' deletes the cookie so the next request falls back to
47
- // server default / matchMedia (matches themeBootScript semantics).
48
- if (choice === 'system') deleteCookie(THEME_COOKIE)
49
- else setCookie(THEME_COOKIE, choice)
46
+ // Persist System explicitly so every appearance control reads the same choice.
47
+ setCookie(THEME_COOKIE, choice)
50
48
  applyTheme(choice)
51
49
  }
52
50
 
@@ -66,6 +64,8 @@ export interface ProfileMenuLanguage {
66
64
  }
67
65
 
68
66
  export interface ProfileMenuProps {
67
+ /** Custom accessible trigger; Radix supplies menu state and focus handling. */
68
+ readonly trigger?: ReactNode
69
69
  readonly identity?: {
70
70
  readonly name: string
71
71
  readonly sublabel?: string
@@ -146,7 +146,7 @@ const navigateTo = (href: string, external?: boolean): void => {
146
146
 
147
147
  export const ProfileMenu = ({
148
148
  identity, links, languages, currentLanguage, onLanguageChange,
149
- logoutUrl, onLogout, signInUrl, labels,
149
+ logoutUrl, onLogout, signInUrl, labels, trigger,
150
150
  }: ProfileMenuProps): ReactNode => {
151
151
  const [theme, setTheme] = useState<ThemePreference | undefined>(undefined)
152
152
  // Hydrate language from cookie on mount; the prop overrides if set.
@@ -154,8 +154,11 @@ export const ProfileMenu = ({
154
154
  const locale = currentLanguage ?? cookieLocale
155
155
 
156
156
  useEffect(() => {
157
- setTheme(readStoredTheme())
157
+ const refreshTheme = (): void => setTheme(readStoredTheme())
158
+ refreshTheme()
158
159
  setCookieLocale(getCookie(LOCALE_COOKIE) ?? undefined)
160
+ window.addEventListener(THEME_CHANGE_EVENT, refreshTheme)
161
+ return () => window.removeEventListener(THEME_CHANGE_EVENT, refreshTheme)
159
162
  }, [])
160
163
 
161
164
  const onThemeChange = useCallback((next: string): void => {
@@ -203,7 +206,7 @@ export const ProfileMenu = ({
203
206
  return (
204
207
  <DropdownMenu>
205
208
  <DropdownMenuTrigger asChild>
206
- <Button
209
+ {trigger ?? <Button
207
210
  type="button"
208
211
  variant="outline"
209
212
  size="icon"
@@ -211,7 +214,7 @@ export const ProfileMenu = ({
211
214
  className="rounded-full size-8 text-muted-foreground hover:text-foreground"
212
215
  >
213
216
  <AvatarGlyph {...(initial ? { initial } : {})} />
214
- </Button>
217
+ </Button>}
215
218
  </DropdownMenuTrigger>
216
219
 
217
220
  <DropdownMenuContent align="end" className="w-64">
@@ -0,0 +1,43 @@
1
+ import type { ComponentProps, ReactNode } from 'react'
2
+ import { ProfileMenu, type ProfileMenuProps } from './profileMenu'
3
+ import { SearchIcon } from '../primitives/docIcons'
4
+ import { cn } from '../cn'
5
+
6
+ export interface SignalPreferencesProps extends Omit<ProfileMenuProps, 'trigger' | 'labels' | 'languages' | 'currentLanguage'> {
7
+ readonly locale: 'en' | 'de'
8
+ }
9
+
10
+ /** One appearance/language entry point for public and operating Signal surfaces. */
11
+ export function SignalPreferences({ locale, ...props }: SignalPreferencesProps): ReactNode {
12
+ const de = locale === 'de'
13
+ const label = de ? 'Darstellung und Sprache' : 'Appearance and language'
14
+ return <ProfileMenu {...props} currentLanguage={locale}
15
+ languages={[{ code: 'en', label: 'EN' }, { code: 'de', label: 'DE' }]}
16
+ labels={{ openMenu: label, theme: de ? 'Design' : 'Theme', language: de ? 'Sprache' : 'Language',
17
+ signIn: de ? 'Anmelden' : 'Sign in', signOut: de ? 'Abmelden' : 'Sign out',
18
+ themes: { system: 'System', light: de ? 'Hell' : 'Light', dark: de ? 'Dunkel' : 'Dark' } }}
19
+ trigger={<button type="button" className="signal-control signal-preferences" aria-label={label}>
20
+ <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5" /><path d="M3.5 12h17M12 3.5c4.5 4.5 4.5 12.5 0 17-4.5-4.5-4.5-12.5 0-17Z" /></svg>
21
+ <span>{props.identity?.name.trim().charAt(0).toUpperCase() ?? locale.toUpperCase()}</span>
22
+ </button>} />
23
+ }
24
+
25
+ export interface SignalSearchProps {
26
+ readonly label: string
27
+ readonly onOpen: () => void
28
+ readonly className?: string
29
+ }
30
+
31
+ /** Search is a command trigger, sized to the navigation rail with a compact mobile state. */
32
+ export function SignalSearch({ label, onOpen, className }: SignalSearchProps): ReactNode {
33
+ return <button type="button" className={cn('signal-control signal-search', className)} onClick={onOpen} aria-label={label}>
34
+ <SearchIcon /><span>{label}</span><kbd aria-hidden="true">⌘ K</kbd>
35
+ </button>
36
+ }
37
+
38
+ /** Secondary header action; the filled Signal CTA remains the page's primary action. */
39
+ export function SignalHeaderAction({ children, className, ...props }: ComponentProps<'a'>): ReactNode {
40
+ return <a {...props} className={cn('signal-control signal-header-action', className)}><span>{children}</span>
41
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15m-6-6 6 6-6 6" /></svg>
42
+ </a>
43
+ }
@@ -0,0 +1,75 @@
1
+ import { useEffect, useId, useRef, type ReactNode } from 'react'
2
+ import { cn } from '../cn'
3
+ import { createSignalMotion } from './signalMotion'
4
+
5
+ export interface SignalFieldProps {
6
+ /** Enable the ambient wave. Supply a visible pause control when enabling it. */
7
+ readonly animated?: boolean
8
+ readonly paused?: boolean
9
+ readonly className?: string
10
+ }
11
+
12
+ const ribbons = [
13
+ "M1080-160C870 90 1570 60 1530 400S1100 670 1320 990",
14
+ "M1055-160C845 98 1545 72 1505 408S1075 678 1295 998",
15
+ "M1030-160C820 106 1520 84 1480 416S1050 686 1270 1006",
16
+ "M1005-160C795 114 1495 96 1455 424S1025 694 1245 1014",
17
+ "M980-160C770 122 1470 108 1430 432S1000 702 1220 1022",
18
+ "M955-160C745 130 1445 120 1405 440S975 710 1195 1030",
19
+ "M930-160C720 138 1420 132 1380 448S950 718 1170 1038",
20
+ "M905-160C695 146 1395 144 1355 456S925 726 1145 1046",
21
+ "M880-160C670 154 1370 156 1330 464S900 734 1120 1054",
22
+ "M855-160C645 162 1345 168 1305 472S875 742 1095 1062",
23
+ "M830-160C620 170 1320 180 1280 480S850 750 1070 1070",
24
+ "M805-160C595 178 1295 192 1255 488S825 758 1045 1078"
25
+ ]
26
+ const returns = [
27
+ "M-150 600C170 550-70 930 360 960S490 1100 780 1200",
28
+ "M-150 621C165 571-75 951 355 981S485 1121 775 1221",
29
+ "M-150 642C160 592-80 972 350 1002S480 1142 770 1242",
30
+ "M-150 663C155 613-85 993 345 1023S475 1163 765 1263",
31
+ "M-150 684C150 634-90 1014 340 1044S470 1184 760 1284"
32
+ ]
33
+ const traces = [ribbons[1], ribbons[4], ribbons[7], ribbons[10], returns[2]] as const
34
+
35
+ /** Decorative Voltro contour field. Import `@voltro/ui-shadcn/signal.css`.
36
+ * Static at SSR, with reduced-motion and visibility-aware animation on hydration. */
37
+ export function SignalField({ animated = false, paused = false, className }: SignalFieldProps): ReactNode {
38
+ const ref = useRef<HTMLDivElement>(null)
39
+ const control = useRef<ReturnType<typeof createSignalMotion> | null>(null)
40
+ const id = useId()
41
+ useEffect(() => {
42
+ const element = ref.current
43
+ if (!element) return
44
+ const motion = createSignalMotion(element)
45
+ control.current = motion
46
+ return () => { motion.dispose(); control.current = null }
47
+ }, [])
48
+ useEffect(() => { control.current?.setEnabled(animated && !paused) }, [animated, paused])
49
+ return (
50
+ <div ref={ref} className={cn('signal-atmosphere', className)} aria-hidden="true" data-motion="paused">
51
+ <div className="field-light" />
52
+ <svg className="field-contours" viewBox="0 0 1600 1120" fill="none">
53
+ <defs>
54
+ <linearGradient id={`${id}-ink`} x1="750" y1="60" x2="1390" y2="1040" gradientUnits="userSpaceOnUse">
55
+ <stop stopColor="#A874FF" stopOpacity=".05" /><stop offset=".34" stopColor="#A874FF" stopOpacity=".36" />
56
+ <stop offset=".7" stopColor="#7C3AED" stopOpacity=".12" /><stop offset="1" stopColor="#A874FF" stopOpacity="0" />
57
+ </linearGradient>
58
+ <linearGradient id={`${id}-edge`} x1="0" y1="670" x2="740" y2="1120" gradientUnits="userSpaceOnUse">
59
+ <stop stopColor="#A874FF" stopOpacity="0" /><stop offset=".5" stopColor="#A874FF" stopOpacity=".22" />
60
+ <stop offset="1" stopColor="#A874FF" stopOpacity="0" />
61
+ </linearGradient>
62
+ </defs>
63
+ <g className="field-ribbons" stroke={`url(#${id}-ink)`} strokeWidth=".8">
64
+ {ribbons.map((d, i) => <path key={d} d={d} data-wave={`main-${i}`} />)}
65
+ </g>
66
+ <g className="field-return" stroke={`url(#${id}-edge)`} strokeWidth=".7">
67
+ {returns.map((d, i) => <path key={d} d={d} data-wave={`return-${i}`} />)}
68
+ </g>
69
+ {traces.map((d, i) => <path key={d} d={d} className={`field-ambient-trace trace-${i}`}
70
+ data-wave={['main-1', 'main-4', 'main-7', 'main-10', 'return-2'][i]}
71
+ stroke="#B995FF" strokeWidth="1" pathLength="100" />)}
72
+ </svg>
73
+ </div>
74
+ )
75
+ }
@@ -0,0 +1,96 @@
1
+ import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
2
+ import { cn } from '../cn'
3
+
4
+ export interface SignalNavigationLink {
5
+ readonly label: string
6
+ readonly href: string
7
+ readonly description?: string
8
+ readonly current?: boolean
9
+ }
10
+
11
+ export interface SignalHeaderProps {
12
+ readonly brand: ReactNode
13
+ readonly links: readonly SignalNavigationLink[]
14
+ readonly navigationLabel: string
15
+ readonly menuLabel: string
16
+ readonly menuTitle: ReactNode
17
+ readonly menuLinks: readonly SignalNavigationLink[]
18
+ readonly utilities?: ReactNode
19
+ /** Context navigation, such as the Docs section drawer, stays beside the rail. */
20
+ readonly navigationAction?: ReactNode
21
+ readonly className?: string
22
+ /** Keep navigation available while reading; the surface appears after scrolling. */
23
+ readonly sticky?: boolean
24
+ }
25
+
26
+ /** Compact brand navigation with a keyboard-accessible disclosure and moving marker. */
27
+ export function SignalHeader({ brand, links, navigationLabel, menuLabel, menuTitle, menuLinks, utilities, navigationAction, className, sticky = false }: SignalHeaderProps): ReactNode {
28
+ const [open, setOpen] = useState(false)
29
+ const [scrolled, setScrolled] = useState(false)
30
+ const ref = useRef<HTMLElement>(null)
31
+ const trigger = useRef<HTMLButtonElement>(null)
32
+ const marker = useRef<HTMLSpanElement>(null)
33
+ const id = useId()
34
+ useEffect(() => {
35
+ if (!sticky) return
36
+ const update = (): void => setScrolled(window.scrollY > 24)
37
+ update()
38
+ window.addEventListener('scroll', update, { passive: true })
39
+ return () => window.removeEventListener('scroll', update)
40
+ }, [sticky])
41
+ const move = (target: HTMLElement | null): void => {
42
+ if (!target || !marker.current) return
43
+ marker.current.style.transform = `translateX(${target.offsetLeft - 5}px) scaleX(${target.offsetWidth / 100})`
44
+ }
45
+ useEffect(() => {
46
+ const header = ref.current
47
+ if (!header) return
48
+ const reset = (): void => move(trigger.current)
49
+ const resize = new ResizeObserver(reset)
50
+ resize.observe(header)
51
+ reset()
52
+ return () => resize.disconnect()
53
+ }, [])
54
+ useEffect(() => {
55
+ if (!open) return
56
+ const close = (event: PointerEvent): void => {
57
+ if (event.target instanceof Node && !ref.current?.contains(event.target)) setOpen(false)
58
+ }
59
+ document.addEventListener('pointerdown', close)
60
+ return () => document.removeEventListener('pointerdown', close)
61
+ }, [open])
62
+ return (
63
+ <header ref={ref} className={cn('signal-masthead', sticky && 'signal-masthead-sticky', className)} data-scrolled={sticky && scrolled}
64
+ onKeyDown={event => { if (event.key === 'Escape' && open) { setOpen(false); trigger.current?.focus() } }}
65
+ onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false) }}>
66
+ <div className="signal-header signal-shell">
67
+ <div className="signal-brand">{brand}</div>
68
+ <div className="signal-navigation-area">
69
+ <nav className="signal-navigation" aria-label={navigationLabel} onPointerLeave={() => move(trigger.current)}>
70
+ <span ref={marker} className="signal-nav-marker" aria-hidden="true" />
71
+ <button ref={trigger} className="signal-nav-item" type="button" aria-expanded={open} aria-controls={id}
72
+ onClick={() => setOpen(!open)} onPointerEnter={event => move(event.currentTarget)} onFocus={event => move(event.currentTarget)}>
73
+ <span className="signal-nav-dot" aria-hidden="true" />{menuLabel}
74
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 6 4 4 4-4" /></svg>
75
+ </button>
76
+ {links.map(link => <a key={link.href} href={link.href} className="signal-nav-item" aria-current={link.current ? 'page' : undefined}
77
+ onPointerEnter={event => move(event.currentTarget)} onFocus={event => move(event.currentTarget)}>
78
+ {link.label}<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 18 18 6M6 6h12v12" /></svg>
79
+ </a>)}
80
+ {navigationAction}
81
+ </nav>
82
+ {utilities && <div className="signal-header-utilities">{utilities}</div>}
83
+ </div>
84
+ <nav id={id} className="signal-menu" aria-label={menuLabel} hidden={!open}>
85
+ <div className="signal-menu-intro">{menuTitle}</div>
86
+ <div className="signal-menu-links">
87
+ {menuLinks.map(link => <a key={link.href} href={link.href} onClick={() => setOpen(false)}>
88
+ <span>{link.label}{link.description && <small>{link.description}</small>}</span>
89
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 18 18 6M6 6h12v12" /></svg>
90
+ </a>)}
91
+ </div>
92
+ </nav>
93
+ </div>
94
+ </header>
95
+ )
96
+ }
@@ -0,0 +1,68 @@
1
+ /** Own all browser resources for one decorative field, including SPA teardown. */
2
+ export function createSignalMotion(element: HTMLElement): {
3
+ setEnabled: (enabled: boolean) => void
4
+ dispose: () => void
5
+ } {
6
+ const media = window.matchMedia('(prefers-reduced-motion: reduce)')
7
+ const animations: Animation[] = []
8
+ let enabled = false
9
+ let visible = false
10
+ let disposed = false
11
+ const supportsPath = typeof CSS !== 'undefined' && CSS.supports('d', 'path("M 0 0 L 1 1")')
12
+
13
+ if (supportsPath) {
14
+ for (const path of element.querySelectorAll<SVGPathElement>('path[data-wave]')) {
15
+ const coordinates = path.getAttribute('d')?.match(/-?\d+(?:\.\d+)?/g)?.map(Number)
16
+ if (!coordinates || coordinates.length !== 12) continue
17
+ const n = coordinates
18
+ const [group, index = '0'] = (path.dataset.wave ?? '').split('-')
19
+ // Neighboring curves share a wave with a small phase gap so the field
20
+ // visibly bends without collapsing its parallel contour spacing.
21
+ const main = group === 'main'
22
+ const deltas = main
23
+ ? [0, 0, -60, 34, 86, 54, -44, 70, -70, -54, 0, 0]
24
+ : [0, 0, 42, -35, -56, 45, 54, -51, -48, 32, 0, 0]
25
+ const shape = (direction: number): string => {
26
+ const values = n.map((value, i) => value + (deltas[i] ?? 0) * direction)
27
+ return `path("M ${values[0]} ${values[1]} C ${values[2]} ${values[3]} ${values[4]} ${values[5]} ${values[6]} ${values[7]} S ${values[8]} ${values[9]} ${values[10]} ${values[11]}")`
28
+ }
29
+ const outward = 'cubic-bezier(.333,.524,.667,1)'
30
+ const inward = 'cubic-bezier(.333,0,.667,.476)'
31
+ const animation = path.animate([
32
+ { d: shape(0), easing: outward }, { d: shape(1), easing: inward },
33
+ { d: shape(0), easing: outward }, { d: shape(-1), easing: inward }, { d: shape(0) },
34
+ ], { duration: main ? 12000 : 15000, delay: -Number(index) * (main ? 320 : 450), iterations: Infinity })
35
+ animation.pause()
36
+ animations.push(animation)
37
+ }
38
+ }
39
+
40
+ const sync = (): void => {
41
+ if (disposed) return
42
+ const running = enabled && visible && !document.hidden && !media.matches
43
+ element.dataset.motion = running ? 'running' : 'paused'
44
+ for (const animation of animations) {
45
+ if (media.matches) animation.cancel()
46
+ else if (running && animation.playState !== 'running') animation.play()
47
+ else if (!running && animation.playState === 'running') animation.pause()
48
+ }
49
+ }
50
+ const observer = new IntersectionObserver(([entry]) => {
51
+ visible = entry?.isIntersecting ?? false
52
+ sync()
53
+ }, { threshold: .01 })
54
+ observer.observe(element)
55
+ document.addEventListener('visibilitychange', sync)
56
+ media.addEventListener('change', sync)
57
+ return {
58
+ setEnabled(value) { enabled = value; sync() },
59
+ dispose() {
60
+ disposed = true
61
+ observer.disconnect()
62
+ document.removeEventListener('visibilitychange', sync)
63
+ media.removeEventListener('change', sync)
64
+ animations.forEach(animation => animation.cancel())
65
+ element.dataset.motion = 'paused'
66
+ },
67
+ }
68
+ }
@@ -9,7 +9,7 @@
9
9
  import { useEffect, useState, type HTMLAttributes, type ReactNode } from 'react'
10
10
  import { Button } from '../primitives/button'
11
11
  import { cn } from '../cn'
12
- import { THEME_COOKIE, setCookie } from '../cookies'
12
+ import { THEME_COOKIE, THEME_CHANGE_EVENT, setCookie, applyTheme } from '../cookies'
13
13
 
14
14
  /**
15
15
  * Inline script to drop into <head>. Reads the `voltro:theme` cookie
@@ -56,13 +56,16 @@ export const ThemeToggle = ({
56
56
  const [isDark, setIsDark] = useState<boolean | undefined>(undefined)
57
57
 
58
58
  useEffect(() => {
59
- setIsDark(document.documentElement.classList.contains('dark'))
59
+ const refreshTheme = (): void => setIsDark(document.documentElement.classList.contains('dark'))
60
+ refreshTheme()
61
+ window.addEventListener(THEME_CHANGE_EVENT, refreshTheme)
62
+ return () => window.removeEventListener(THEME_CHANGE_EVENT, refreshTheme)
60
63
  }, [])
61
64
 
62
65
  const toggle = (): void => {
63
66
  const next = !document.documentElement.classList.contains('dark')
64
- document.documentElement.classList.toggle('dark', next)
65
67
  setCookie(THEME_COOKIE, next ? 'dark' : 'light')
68
+ applyTheme(next ? 'dark' : 'light')
66
69
  setIsDark(next)
67
70
  }
68
71
 
package/src/cookies.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  // omit it in the browser (falls back to `document.cookie`).
12
12
 
13
13
  export const THEME_COOKIE = 'voltro:theme'
14
+ /** Fired after applyTheme so preference controls in the same document stay in sync. */
15
+ export const THEME_CHANGE_EVENT = 'voltro:theme-change'
14
16
  export const LOCALE_COOKIE = 'voltro:locale'
15
17
 
16
18
  export type ThemePreference = 'system' | 'light' | 'dark'
@@ -108,4 +110,5 @@ export const applyTheme = (theme: ThemePreference): void => {
108
110
  typeof window !== 'undefined' &&
109
111
  window.matchMedia?.('(prefers-color-scheme: dark)').matches === true)
110
112
  document.documentElement.classList.toggle('dark', dark)
113
+ if (typeof window !== 'undefined') window.dispatchEvent(new Event(THEME_CHANGE_EVENT))
111
114
  }
package/src/index.ts CHANGED
@@ -33,7 +33,7 @@ export { shadcnWidgets } from './widgets'
33
33
 
34
34
  // ---- User-preference cookies (theme + language) ----
35
35
  export {
36
- THEME_COOKIE, LOCALE_COOKIE,
36
+ THEME_COOKIE, THEME_CHANGE_EVENT, LOCALE_COOKIE,
37
37
  getCookie, setCookie, deleteCookie,
38
38
  parsePreferenceCookies, applyTheme,
39
39
  } from './cookies'
@@ -158,3 +158,7 @@ export {
158
158
  } from './primitives/docIcons'
159
159
  export { SearchModal, SearchTrigger } from './primitives/searchModal'
160
160
  export type { SearchModalProps, SearchModalLabels } from './primitives/searchModal'
161
+
162
+ export { SignalField, type SignalFieldProps } from './compositions/signalField'
163
+ export { SignalPreferences, SignalSearch, SignalHeaderAction, type SignalPreferencesProps, type SignalSearchProps } from './compositions/signalControls'
164
+ export { SignalHeader, type SignalHeaderProps, type SignalNavigationLink } from './compositions/signalHeader'
@@ -77,14 +77,14 @@ export const CodeBlock = ({
77
77
  }
78
78
 
79
79
  return (
80
- <figure className={cn('not-prose my-5 rounded-lg overflow-hidden border border-border bg-[oklch(0.18_0.005_280)]', className)}>
80
+ <figure className={cn('not-prose my-5 rounded-lg overflow-hidden border border-border bg-card text-card-foreground', className)}>
81
81
  {(filename || language) ? (
82
- <header className="flex items-center justify-between border-b border-border/60 px-4 py-2 bg-card/40">
82
+ <header className="flex items-center justify-between border-b border-border/60 px-4 py-2 bg-muted">
83
83
  {filename ? (
84
84
  <span className="text-xs text-muted-foreground font-mono">{filename}</span>
85
85
  ) : <span />}
86
86
  {language ? (
87
- <span className="text-[0.65rem] uppercase tracking-wider text-muted-foreground/80 font-semibold">
87
+ <span className="text-[0.65rem] uppercase tracking-wider text-muted-foreground font-semibold">
88
88
  {language}
89
89
  </span>
90
90
  ) : null}
@@ -95,7 +95,7 @@ export const CodeBlock = ({
95
95
  type="button"
96
96
  onClick={doCopy}
97
97
  aria-label={copied ? copiedAriaLabel : copyAriaLabel}
98
- className="absolute top-3 right-3 inline-flex items-center gap-1 rounded-md border border-border/60 bg-background/70 backdrop-blur px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
98
+ className="absolute top-3 right-3 inline-flex items-center gap-1 rounded-md border border-border/60 bg-background px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
99
99
  >
100
100
  {copied ? (
101
101
  <>
@@ -7,10 +7,10 @@
7
7
  // imperative API (`dialogRef.current?.showModal()`) is the native
8
8
  // path; we surface it as a uncontrolled wrapper for now.
9
9
 
10
- import type { DialogHTMLAttributes, HTMLAttributes, ReactNode } from 'react'
10
+ import type { ComponentPropsWithRef, HTMLAttributes, ReactNode } from 'react'
11
11
  import { cn } from '../cn'
12
12
 
13
- export const Dialog = ({ className, children, ...props }: DialogHTMLAttributes<HTMLDialogElement>): ReactNode => (
13
+ export const Dialog = ({ className, children, ...props }: ComponentPropsWithRef<'dialog'>): ReactNode => (
14
14
  <dialog
15
15
  data-slot="dialog"
16
16
  className={cn(
@@ -112,6 +112,8 @@ export const HighlightedCode = ({
112
112
  const out = hl.codeToHtml(code, {
113
113
  lang,
114
114
  themes: { light: 'github-light', dark: 'github-dark-dimmed' },
115
+ // GitHub Light's parameter orange falls below 4.5:1 on white.
116
+ colorReplacements: { 'github-light': { '#e36209': '#a44100' } },
115
117
  defaultColor: false,
116
118
  })
117
119
  setHtml(out)