@a4ui/core 0.11.1 → 0.13.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.
Files changed (151) hide show
  1. package/README.md +80 -37
  2. package/dist/elements.css +79 -9
  3. package/dist/full.css +3112 -0
  4. package/dist/index.d.ts +13 -1
  5. package/dist/index.js +3392 -2537
  6. package/dist/lib/flyToCart.d.ts +25 -0
  7. package/dist/ui/Curtain.d.ts +44 -0
  8. package/dist/ui/Expandable.d.ts +34 -0
  9. package/dist/ui/FillText.d.ts +18 -0
  10. package/dist/ui/HoldToConfirm.d.ts +24 -0
  11. package/dist/ui/LoadingDots.d.ts +20 -0
  12. package/dist/ui/MultiStateBadge.d.ts +23 -0
  13. package/dist/ui/NotificationStack.d.ts +32 -0
  14. package/dist/ui/NowPlaying.d.ts +21 -0
  15. package/dist/ui/Parallax.d.ts +21 -0
  16. package/dist/ui/ScrambleText.d.ts +22 -0
  17. package/dist/ui/TextReveal.d.ts +23 -0
  18. package/package.json +8 -1
  19. package/src/charts/BarChart.tsx +55 -0
  20. package/src/charts/DonutChart.tsx +127 -0
  21. package/src/charts/Sparkline.tsx +101 -0
  22. package/src/charts/index.ts +5 -0
  23. package/src/commerce/CartLine.tsx +84 -0
  24. package/src/commerce/CartSummary.tsx +70 -0
  25. package/src/commerce/FilterGroup.tsx +70 -0
  26. package/src/commerce/PriceTag.tsx +62 -0
  27. package/src/commerce/ProductCard.tsx +121 -0
  28. package/src/commerce/ProductGrid.tsx +27 -0
  29. package/src/commerce/QuantityStepper.tsx +59 -0
  30. package/src/commerce/index.ts +10 -0
  31. package/src/elements.tsx +167 -0
  32. package/src/index.ts +186 -0
  33. package/src/layout/AppShell.tsx +102 -0
  34. package/src/layout/ChristmasBackground.tsx +177 -0
  35. package/src/layout/EffectsToggle.tsx +41 -0
  36. package/src/layout/NavGroup.tsx +45 -0
  37. package/src/layout/SnowScenery.tsx +76 -0
  38. package/src/layout/SpaceBackground.tsx +459 -0
  39. package/src/layout/ThemeToggle.tsx +40 -0
  40. package/src/layout/ThemedScenery.tsx +179 -0
  41. package/src/layout/sceneEffects.ts +49 -0
  42. package/src/lib/cn.ts +17 -0
  43. package/src/lib/effects.ts +71 -0
  44. package/src/lib/flyToCart.ts +103 -0
  45. package/src/lib/media.ts +27 -0
  46. package/src/lib/motion.ts +191 -0
  47. package/src/lib/theme.ts +113 -0
  48. package/src/lib/virtual.ts +33 -0
  49. package/src/styles/space.css +510 -0
  50. package/src/styles/tokens.css +213 -0
  51. package/src/themes/index.ts +102 -0
  52. package/src/themes/palettes.ts +368 -0
  53. package/src/ui/Accordion.tsx +65 -0
  54. package/src/ui/Affix.tsx +72 -0
  55. package/src/ui/Alert.tsx +55 -0
  56. package/src/ui/AlertDialog.tsx +58 -0
  57. package/src/ui/Anchor.tsx +100 -0
  58. package/src/ui/Avatar.tsx +34 -0
  59. package/src/ui/AvatarGroup.tsx +55 -0
  60. package/src/ui/BackToTop.tsx +51 -0
  61. package/src/ui/Badge.tsx +46 -0
  62. package/src/ui/BottomNavigation.tsx +65 -0
  63. package/src/ui/Breadcrumb.tsx +64 -0
  64. package/src/ui/Button.tsx +49 -0
  65. package/src/ui/Calendar.tsx +40 -0
  66. package/src/ui/CalendarHeatmap.tsx +180 -0
  67. package/src/ui/Card.tsx +84 -0
  68. package/src/ui/Carousel.tsx +121 -0
  69. package/src/ui/Cascader.tsx +158 -0
  70. package/src/ui/Checkbox.tsx +35 -0
  71. package/src/ui/Clock.tsx +221 -0
  72. package/src/ui/Collapse.tsx +48 -0
  73. package/src/ui/ColorPicker.tsx +89 -0
  74. package/src/ui/Combobox.tsx +67 -0
  75. package/src/ui/Command.tsx +158 -0
  76. package/src/ui/Comment.tsx +91 -0
  77. package/src/ui/ContextMenu.tsx +58 -0
  78. package/src/ui/Countdown.tsx +123 -0
  79. package/src/ui/Curtain.tsx +348 -0
  80. package/src/ui/DataGrid.tsx +177 -0
  81. package/src/ui/DateField.tsx +177 -0
  82. package/src/ui/DateRangePicker.tsx +204 -0
  83. package/src/ui/DateTimeField.tsx +68 -0
  84. package/src/ui/Descriptions.tsx +56 -0
  85. package/src/ui/Drawer.tsx +72 -0
  86. package/src/ui/Dropdown.tsx +71 -0
  87. package/src/ui/Dropzone.tsx +88 -0
  88. package/src/ui/Empty.tsx +53 -0
  89. package/src/ui/Expandable.tsx +229 -0
  90. package/src/ui/FileUpload.tsx +198 -0
  91. package/src/ui/FillText.tsx +63 -0
  92. package/src/ui/FloatingActionButton.tsx +48 -0
  93. package/src/ui/Form.tsx +108 -0
  94. package/src/ui/Highlight.tsx +53 -0
  95. package/src/ui/HoldToConfirm.tsx +141 -0
  96. package/src/ui/HoverCard.tsx +42 -0
  97. package/src/ui/Image.tsx +66 -0
  98. package/src/ui/Input.tsx +38 -0
  99. package/src/ui/Kbd.tsx +25 -0
  100. package/src/ui/List.tsx +71 -0
  101. package/src/ui/LoadingDots.tsx +75 -0
  102. package/src/ui/Marquee.tsx +48 -0
  103. package/src/ui/Mentions.tsx +170 -0
  104. package/src/ui/Meter.tsx +57 -0
  105. package/src/ui/Modal.tsx +106 -0
  106. package/src/ui/MultiSelect.tsx +236 -0
  107. package/src/ui/MultiStateBadge.tsx +93 -0
  108. package/src/ui/NotificationCenter.tsx +103 -0
  109. package/src/ui/NotificationStack.tsx +104 -0
  110. package/src/ui/NowPlaying.tsx +100 -0
  111. package/src/ui/NumberInput.tsx +48 -0
  112. package/src/ui/PageHeader.tsx +60 -0
  113. package/src/ui/Pagination.tsx +62 -0
  114. package/src/ui/Parallax.tsx +51 -0
  115. package/src/ui/Popover.tsx +42 -0
  116. package/src/ui/Portal.tsx +34 -0
  117. package/src/ui/Progress.tsx +55 -0
  118. package/src/ui/RadioGroup.tsx +65 -0
  119. package/src/ui/Rating.tsx +98 -0
  120. package/src/ui/Result.tsx +75 -0
  121. package/src/ui/RingProgress.tsx +75 -0
  122. package/src/ui/ScrambleText.tsx +95 -0
  123. package/src/ui/SegmentedControl.tsx +61 -0
  124. package/src/ui/Select.tsx +55 -0
  125. package/src/ui/Separator.tsx +39 -0
  126. package/src/ui/Skeleton.tsx +23 -0
  127. package/src/ui/Slider.tsx +57 -0
  128. package/src/ui/Sortable.tsx +174 -0
  129. package/src/ui/SpeedDial.tsx +85 -0
  130. package/src/ui/Spinner.tsx +33 -0
  131. package/src/ui/Splitter.tsx +115 -0
  132. package/src/ui/Stat.tsx +74 -0
  133. package/src/ui/Stepper.tsx +134 -0
  134. package/src/ui/Switch.tsx +44 -0
  135. package/src/ui/Table.tsx +128 -0
  136. package/src/ui/Tabs.tsx +72 -0
  137. package/src/ui/TagInput.tsx +85 -0
  138. package/src/ui/TextReveal.tsx +93 -0
  139. package/src/ui/Textarea.tsx +38 -0
  140. package/src/ui/TimeField.tsx +298 -0
  141. package/src/ui/Timeline.tsx +89 -0
  142. package/src/ui/Toast.tsx +68 -0
  143. package/src/ui/Toggle.tsx +41 -0
  144. package/src/ui/ToggleGroup.tsx +59 -0
  145. package/src/ui/Tooltip.tsx +42 -0
  146. package/src/ui/Tour.tsx +197 -0
  147. package/src/ui/Transfer.tsx +112 -0
  148. package/src/ui/Tree.tsx +100 -0
  149. package/src/ui/TreeSelect.tsx +151 -0
  150. package/src/ui/VirtualList.tsx +81 -0
  151. package/src/ui/internal/CalendarCore.tsx +276 -0
@@ -0,0 +1,49 @@
1
+ // Shared, theme-agnostic pointer effects used by every backdrop (SpaceBackground
2
+ // and ThemedScenery) so the whole UI feels alive on every theme, not just space:
3
+ // • cursor glow — the `#cursorGlow` element inside `root` follows the pointer
4
+ // • magnetic buttons — `.magnetic` elements lean toward the cursor
5
+ // • card edge-glow — `.glow-edge` cards track the pointer via `--mx`/`--my`
6
+ // Returns a cleanup that unbinds every listener. Call only when motion is allowed.
7
+
8
+ export function bindPointerFx(root: HTMLElement): () => void {
9
+ const glow = root.querySelector<HTMLElement>('#cursorGlow')
10
+
11
+ const onMove = (e: PointerEvent): void => {
12
+ if (glow) {
13
+ glow.style.opacity = '1'
14
+ glow.style.left = `${e.clientX}px`
15
+ glow.style.top = `${e.clientY}px`
16
+ }
17
+ const el = e.target as Element | null
18
+ const magnetic = el?.closest?.('.magnetic') as HTMLElement | null
19
+ if (magnetic) {
20
+ const r = magnetic.getBoundingClientRect()
21
+ const mx = (e.clientX - (r.left + r.width / 2)) / r.width
22
+ const my = (e.clientY - (r.top + r.height / 2)) / r.height
23
+ magnetic.style.transform = `translate(${(mx * 7).toFixed(1)}px, ${(my * 7).toFixed(1)}px)`
24
+ }
25
+ const card = el?.closest?.('.glow-edge') as HTMLElement | null
26
+ if (card) {
27
+ const r = card.getBoundingClientRect()
28
+ card.style.setProperty('--mx', `${e.clientX - r.left}px`)
29
+ card.style.setProperty('--my', `${e.clientY - r.top}px`)
30
+ }
31
+ }
32
+ const onLeave = (): void => {
33
+ if (glow) glow.style.opacity = '0'
34
+ }
35
+ const onOut = (e: PointerEvent): void => {
36
+ const magnetic = (e.target as Element | null)?.closest?.('.magnetic') as HTMLElement | null
37
+ if (magnetic) magnetic.style.transform = ''
38
+ }
39
+
40
+ document.addEventListener('pointermove', onMove as EventListener)
41
+ window.addEventListener('pointerleave', onLeave)
42
+ document.addEventListener('pointerout', onOut as EventListener)
43
+
44
+ return () => {
45
+ document.removeEventListener('pointermove', onMove as EventListener)
46
+ window.removeEventListener('pointerleave', onLeave)
47
+ document.removeEventListener('pointerout', onOut as EventListener)
48
+ }
49
+ }
package/src/lib/cn.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { type ClassValue, clsx } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ /**
5
+ * Merge Tailwind class lists, resolving conflicting utilities left-to-right
6
+ * (via `tailwind-merge`) after flattening conditionals/arrays/objects (via `clsx`).
7
+ * Use this instead of template-string concatenation whenever a component
8
+ * accepts a `class` override that should win over its own defaults.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * cn('px-2 py-1 text-sm', isActive && 'bg-primary text-primary-foreground', props.class)
13
+ * ```
14
+ */
15
+ export function cn(...inputs: ClassValue[]): string {
16
+ return twMerge(clsx(inputs))
17
+ }
@@ -0,0 +1,71 @@
1
+ // Single "visual effects" switch (merges the old motion + axe-preview toggles).
2
+ // ON by default = the full space-glass experience (translucent glass, starfield,
3
+ // animations). Turning it OFF adds `html.calm`, which makes surfaces opaque,
4
+ // hides the starfield and disables motion — a calm / high-contrast / low-power
5
+ // mode. `?calm=1` forces it off (used by contrast checks).
6
+ import { createSignal } from 'solid-js'
7
+
8
+ const STORAGE_KEY = 'a4ui-effects'
9
+
10
+ function lsSafe(): Storage | null {
11
+ try {
12
+ return window.localStorage
13
+ } catch {
14
+ return null
15
+ }
16
+ }
17
+
18
+ function initial(): boolean {
19
+ try {
20
+ if (new URLSearchParams(window.location.search).get('calm') === '1') return false
21
+ } catch {
22
+ /* ignore */
23
+ }
24
+ const raw = lsSafe()?.getItem(STORAGE_KEY)
25
+ return raw === null ? true : raw === '1' // default ON
26
+ }
27
+
28
+ function apply(on: boolean): void {
29
+ if (typeof document === 'undefined') return // SSR-safe: no-op on the server
30
+ document.documentElement.classList.toggle('calm', !on)
31
+ }
32
+
33
+ const start = initial()
34
+ apply(start)
35
+ const [effectsOn, setSignal] = createSignal(start)
36
+
37
+ /**
38
+ * Reactive accessor for the "visual effects" switch: `true` = full
39
+ * space-glass experience (glass, starfield, animation), `false` = calm mode.
40
+ * Backing signal shared module-wide, so every consumer (e.g.
41
+ * {@link EffectsToggle}) reads the same source of truth.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * const effectsOn = useEffects()
46
+ * console.log(effectsOn()) // true | false
47
+ * ```
48
+ */
49
+ export function useEffects() {
50
+ return effectsOn
51
+ }
52
+
53
+ /** True when visual effects are OFF (calm mode). Read by motion + charts. */
54
+ export function isCalm(): boolean {
55
+ return !effectsOn()
56
+ }
57
+
58
+ /**
59
+ * Persist and apply the visual-effects switch: toggles `html.calm` (opaque
60
+ * surfaces, no starfield, no motion when off) and updates {@link useEffects}.
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * setEffects(false) // enter calm mode
65
+ * ```
66
+ */
67
+ export function setEffects(on: boolean): void {
68
+ lsSafe()?.setItem(STORAGE_KEY, on ? '1' : '0')
69
+ apply(on)
70
+ setSignal(on)
71
+ }
@@ -0,0 +1,103 @@
1
+ // The classic "add to basket" fly animation: a ghost of the product image
2
+ // arcs from a source element (button/card) to a target element (the cart
3
+ // icon), then the target bumps to acknowledge the drop. Pure JS/DOM (a
4
+ // throwaway ghost node driven by Motion's `animate`), so it works from any
5
+ // click handler without touching component state. Reduced motion skips the
6
+ // flight entirely but still fires `onArrive` so cart counts stay correct.
7
+ import { animate, motionReduced } from './motion'
8
+
9
+ export interface FlyToCartOptions {
10
+ /** Image URL to fly; if omitted, the source element is cloned. */
11
+ image?: string
12
+ /** Flight duration in seconds. @default 0.6 */
13
+ duration?: number
14
+ /** Called when the flight finishes (e.g. to increment the cart count). */
15
+ onArrive?: () => void
16
+ }
17
+
18
+ /**
19
+ * Animate a small ghost from `source` to `target` (e.g. product → cart icon),
20
+ * then a bump on the target. No-op under reduced motion (still calls onArrive).
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * function onAddToCart(e: MouseEvent) {
25
+ * const button = e.currentTarget as HTMLElement
26
+ * const cartIcon = document.getElementById('cart-icon')!
27
+ * flyToCart(button, cartIcon, {
28
+ * image: product.thumbnailUrl,
29
+ * onArrive: () => setCartCount((n) => n + 1),
30
+ * })
31
+ * }
32
+ * ```
33
+ */
34
+ export function flyToCart(source: Element, target: Element, opts?: FlyToCartOptions): void {
35
+ if (typeof document === 'undefined') {
36
+ opts?.onArrive?.()
37
+ return
38
+ }
39
+
40
+ if (motionReduced()) {
41
+ opts?.onArrive?.()
42
+ return
43
+ }
44
+
45
+ try {
46
+ const sourceRect = source.getBoundingClientRect()
47
+ const targetRect = target.getBoundingClientRect()
48
+
49
+ const size = 48
50
+ const sourceCenterX = sourceRect.left + sourceRect.width / 2
51
+ const sourceCenterY = sourceRect.top + sourceRect.height / 2
52
+ const targetCenterX = targetRect.left + targetRect.width / 2
53
+ const targetCenterY = targetRect.top + targetRect.height / 2
54
+
55
+ const dx = targetCenterX - sourceCenterX
56
+ const dy = targetCenterY - sourceCenterY
57
+
58
+ const ghost: HTMLElement = opts?.image ? document.createElement('img') : document.createElement('div')
59
+ if (opts?.image) (ghost as HTMLImageElement).src = opts.image
60
+
61
+ ghost.style.position = 'fixed'
62
+ ghost.style.left = `${sourceCenterX - size / 2}px`
63
+ ghost.style.top = `${sourceCenterY - size / 2}px`
64
+ ghost.style.width = `${size}px`
65
+ ghost.style.height = `${size}px`
66
+ ghost.style.borderRadius = '9999px'
67
+ ghost.style.objectFit = 'cover'
68
+ ghost.style.pointerEvents = 'none'
69
+ ghost.style.zIndex = '9999'
70
+ if (!opts?.image) ghost.style.background = 'var(--a4-primary, #6366f1)'
71
+
72
+ document.body.appendChild(ghost)
73
+
74
+ const duration = opts?.duration ?? 0.6
75
+
76
+ const controls = animate(
77
+ ghost,
78
+ { x: [0, dx], y: [0, dy], scale: [1, 0.3], opacity: [1, 0.5] },
79
+ { duration, ease: 'easeInOut' },
80
+ )
81
+
82
+ controls.finished
83
+ .catch(() => {
84
+ /* stopped early — still clean up and settle below */
85
+ })
86
+ .then(() => {
87
+ ghost.remove()
88
+ opts?.onArrive?.()
89
+ try {
90
+ animate(target, { scale: [1, 1.25, 1] }, { duration: 0.3, ease: 'easeOut' })
91
+ } catch {
92
+ /* target no longer animatable — ignore */
93
+ }
94
+ })
95
+ .catch(() => {
96
+ /* onArrive threw — nothing more we can do here */
97
+ })
98
+ } catch {
99
+ // Rect reads / DOM writes can fail (detached nodes, hostile environments) —
100
+ // bail gracefully rather than throwing out of an event handler.
101
+ opts?.onArrive?.()
102
+ }
103
+ }
@@ -0,0 +1,27 @@
1
+ // Reactive CSS media-query matcher. Used to render EITHER the desktop table or
2
+ // the mobile cards (not both) so a virtualized long list keeps only one
3
+ // structure in the DOM.
4
+ import { createSignal, onCleanup } from 'solid-js'
5
+
6
+ /**
7
+ * Reactive `matchMedia` matcher: returns a signal accessor that updates when
8
+ * the query's match state changes, cleaning up its listener on unmount.
9
+ * Handy for rendering either a desktop or mobile layout instead of hiding one
10
+ * with CSS.
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * const isDesktop = useMediaQuery('(min-width: 768px)')
15
+ * return isDesktop() ? <DesktopTable /> : <MobileCards />
16
+ * ```
17
+ */
18
+ export function useMediaQuery(query: string): () => boolean {
19
+ const mql = typeof window !== 'undefined' ? window.matchMedia(query) : null
20
+ const [matches, setMatches] = createSignal(mql ? mql.matches : false)
21
+ if (mql) {
22
+ const on = (e: MediaQueryListEvent) => setMatches(e.matches)
23
+ mql.addEventListener('change', on)
24
+ onCleanup(() => mql.removeEventListener('change', on))
25
+ }
26
+ return matches
27
+ }
@@ -0,0 +1,191 @@
1
+ // Shared motion helpers. CSS-driven transitions (Tailwind classes, Kobalte's
2
+ // data-expanded/data-closed keyframes) get their reduced-motion guard for free
3
+ // from the global `@media (prefers-reduced-motion: reduce)` block in styles.css —
4
+ // this module is for the JS-driven paths (Motion's `animate`, the KPI count-up)
5
+ // that can't rely on a CSS media query alone. JS animation uses **Motion**
6
+ // (motion.dev, the `motion` package) — `animate`/`scroll`/`inView`/`stagger` are
7
+ // re-exported below so components (and consumers) share one motion engine.
8
+ import { animate, inView, scroll, stagger, spring } from 'motion'
9
+ import { createEffect, createSignal, onCleanup, untrack, type Accessor } from 'solid-js'
10
+
11
+ import { isCalm } from './effects'
12
+
13
+ // Re-export Motion's imperative API so the whole library shares one engine and
14
+ // consumers can reach it without a second dependency.
15
+ export { animate, inView, scroll, stagger, spring }
16
+
17
+ const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
18
+
19
+ /**
20
+ * Snapshot of the user's reduced-motion preference at call time. Not
21
+ * reactive to preference changes mid-session (matches theme.ts's one-shot
22
+ * environment read) — reload picks up changes.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * if (prefersReducedMotion()) skipEntranceAnimation()
27
+ * ```
28
+ */
29
+ export function prefersReducedMotion(): boolean {
30
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
31
+ return window.matchMedia(REDUCED_MOTION_QUERY).matches
32
+ }
33
+
34
+ // --- "force full motion" override (the ⚡ MotionToggle) --------------------
35
+ // Persisted opt-out from the OS "reduce motion" preference; toggling it also
36
+ // adds/removes `.force-motion` on <html>, which the reduced-motion @media block
37
+ // in styles.css keys off to re-enable CSS animation.
38
+ const FORCE_MOTION_KEY = 'a4ui-motion-forced'
39
+
40
+ function motionStore(): Storage | null {
41
+ try {
42
+ return window.localStorage
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ const [motionForced, setMotionForcedSignal] = createSignal(motionStore()?.getItem(FORCE_MOTION_KEY) === '1')
49
+
50
+ if (typeof document !== 'undefined') {
51
+ document.documentElement.classList.toggle('force-motion', untrack(motionForced))
52
+ }
53
+
54
+ /**
55
+ * Reactive accessor for the persisted "force full motion" opt-out (the ⚡
56
+ * MotionToggle) — `true` re-enables animation even when the OS requests
57
+ * reduced motion.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * const forced = useMotionForced()
62
+ * console.log(forced()) // true | false
63
+ * ```
64
+ */
65
+ export function useMotionForced(): Accessor<boolean> {
66
+ return motionForced
67
+ }
68
+
69
+ /**
70
+ * Persist the "force full motion" choice, toggle `html.force-motion`
71
+ * (which the reduced-motion `@media` block in styles.css keys off), and
72
+ * update {@link useMotionForced}.
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * setMotionForced(true) // opt back into full motion despite OS setting
77
+ * ```
78
+ */
79
+ export function setMotionForced(on: boolean): void {
80
+ motionStore()?.setItem(FORCE_MOTION_KEY, on ? '1' : '0')
81
+ if (typeof document !== 'undefined') document.documentElement.classList.toggle('force-motion', on)
82
+ setMotionForcedSignal(on)
83
+ }
84
+
85
+ /**
86
+ * Effective reduced-motion: reduced when calm mode (visual effects OFF) is on,
87
+ * or when the OS asks for reduced motion and the user hasn't forced it. JS-driven
88
+ * motion (SpaceBackground, count-up) gates on this rather than
89
+ * prefersReducedMotion() directly.
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * if (!motionReduced()) startParallaxLoop()
94
+ * ```
95
+ */
96
+ export function motionReduced(): boolean {
97
+ return isCalm() || (prefersReducedMotion() && !motionForced())
98
+ }
99
+
100
+ /**
101
+ * Tweens a numeric signal toward `target()` with Motion's `animate` (ease-out),
102
+ * for KPI count-ups. Jumps straight to the target under reduced motion.
103
+ *
104
+ * @example
105
+ * ```tsx
106
+ * const animated = createCountUp(() => props.value, 800)
107
+ * return <span>{Math.round(animated())}</span>
108
+ * ```
109
+ */
110
+ export function createCountUp(target: Accessor<number>, durationMs = 600): Accessor<number> {
111
+ const [value, setValue] = createSignal(0)
112
+ let controls: { stop: () => void } | undefined
113
+
114
+ createEffect(() => {
115
+ const to = target()
116
+ controls?.stop()
117
+
118
+ if (motionReduced()) {
119
+ setValue(to)
120
+ return
121
+ }
122
+
123
+ const from = untrack(value)
124
+ if (from === to) return
125
+
126
+ controls = animate(from, to, {
127
+ duration: durationMs / 1000,
128
+ ease: 'easeOut',
129
+ onUpdate: (latest: number) => setValue(latest),
130
+ })
131
+ })
132
+
133
+ onCleanup(() => controls?.stop())
134
+
135
+ return value
136
+ }
137
+
138
+ /**
139
+ * Fade + slide an element in on mount with Motion (ease-out), respecting
140
+ * reduced motion (a no-op then). Call it from a ref/onMount; use `delay` to
141
+ * stagger a row.
142
+ *
143
+ * @example
144
+ * ```tsx
145
+ * let el!: HTMLDivElement
146
+ * onMount(() => animateIn(el, { delay: 0.1 }))
147
+ * return <div ref={el}>…</div>
148
+ * ```
149
+ */
150
+ export function animateIn(el: Element, opts: { y?: number; duration?: number; delay?: number } = {}): void {
151
+ if (motionReduced()) return
152
+ animate(
153
+ el,
154
+ { opacity: [0, 1], y: [opts.y ?? 8, 0] },
155
+ { duration: opts.duration ?? 0.32, delay: opts.delay ?? 0, ease: 'easeOut' },
156
+ )
157
+ }
158
+
159
+ /**
160
+ * Reveal an element the first time it scrolls into view (fade + slide up), using
161
+ * Motion's `inView`. Reveals once (won't re-flash on re-entry) and is a no-op
162
+ * under reduced motion (the element stays visible). Returns a stop function.
163
+ *
164
+ * @example
165
+ * ```tsx
166
+ * let el!: HTMLDivElement
167
+ * onMount(() => revealOnScroll(el, { amount: 0.3 }))
168
+ * return <section ref={el}>…</section>
169
+ * ```
170
+ */
171
+ export function revealOnScroll(
172
+ el: Element,
173
+ opts: { y?: number; duration?: number; amount?: number } = {},
174
+ ): () => void {
175
+ if (motionReduced()) return () => {}
176
+ ;(el as HTMLElement).style.opacity = '0'
177
+ let done = false
178
+ return inView(
179
+ el,
180
+ () => {
181
+ if (done) return
182
+ done = true
183
+ animate(
184
+ el,
185
+ { opacity: [0, 1], y: [opts.y ?? 16, 0] },
186
+ { duration: opts.duration ?? 0.5, ease: 'easeOut' },
187
+ )
188
+ },
189
+ { amount: opts.amount ?? 0.2 },
190
+ )
191
+ }
@@ -0,0 +1,113 @@
1
+ // Light/dark theme handling. The dark "night" palette is the default (see
2
+ // styles.css / tokens.css); choosing light sets `data-theme="light"` on the
3
+ // root <html> element. The choice is persisted in localStorage so state
4
+ // survives reloads.
5
+ import { createSignal } from 'solid-js'
6
+
7
+ const STORAGE_KEY = 'a4ui-theme'
8
+
9
+ /** The two supported color schemes. `'dark'` (the "night" palette) is the default. */
10
+ export type Theme = 'dark' | 'light'
11
+
12
+ function isTheme(value: string | null): value is Theme {
13
+ return value === 'dark' || value === 'light'
14
+ }
15
+
16
+ function localStorageSafe(): Storage | null {
17
+ try {
18
+ return window.localStorage
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Read the persisted theme choice from `localStorage`, defaulting to `'dark'`
26
+ * when nothing is stored (or storage is unavailable).
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * const initial = storedTheme() // 'dark' | 'light'
31
+ * ```
32
+ */
33
+ export function storedTheme(): Theme {
34
+ const raw = localStorageSafe()?.getItem(STORAGE_KEY) ?? null
35
+ return isTheme(raw) ? raw : 'dark'
36
+ }
37
+
38
+ /**
39
+ * Reflect a theme onto `<html data-theme>` without touching storage or the
40
+ * shared signal. Dark is the default (no attribute); light sets
41
+ * `data-theme="light"`. Prefer {@link setTheme} unless you specifically need
42
+ * to apply without persisting.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * applyTheme('light')
47
+ * ```
48
+ */
49
+ export function applyTheme(theme: Theme): void {
50
+ if (typeof document === 'undefined') return // SSR-safe: no-op on the server
51
+ const root = document.documentElement
52
+ if (theme === 'light') {
53
+ root.setAttribute('data-theme', 'light')
54
+ } else {
55
+ root.removeAttribute('data-theme')
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Persist a theme choice to `localStorage` and apply it to `<html>`. Does not
61
+ * update the shared `useTheme()` signal directly — {@link toggleTheme} does
62
+ * that after calling this.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * setTheme('light')
67
+ * ```
68
+ */
69
+ export function setTheme(theme: Theme): void {
70
+ localStorageSafe()?.setItem(STORAGE_KEY, theme)
71
+ applyTheme(theme)
72
+ }
73
+
74
+ /**
75
+ * The opposite of the given theme. Pure helper (no signal/storage side effects)
76
+ * used by {@link toggleTheme}.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * toggled('dark') // 'light'
81
+ * ```
82
+ */
83
+ export function toggled(theme: Theme): Theme {
84
+ return theme === 'dark' ? 'light' : 'dark'
85
+ }
86
+
87
+ // Module-level signal so every consumer (ThemeToggle, anything reading the
88
+ // current theme) shares one source of truth, applied once at import time.
89
+ const initial = storedTheme()
90
+ applyTheme(initial)
91
+ const [theme, setThemeSignal] = createSignal<Theme>(initial)
92
+
93
+ /**
94
+ * Reactive accessor for the current theme, shared module-wide so every
95
+ * consumer (e.g. {@link ThemeToggle}, `SpaceBackground`) reads the same
96
+ * source of truth.
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * const theme = useTheme()
101
+ * console.log(theme()) // 'dark' | 'light'
102
+ * ```
103
+ */
104
+ export function useTheme() {
105
+ return theme
106
+ }
107
+
108
+ /** Flip the current theme (dark↔light), persisting and applying the new value. */
109
+ export function toggleTheme(): void {
110
+ const next = toggled(theme())
111
+ setTheme(next)
112
+ setThemeSignal(next)
113
+ }
@@ -0,0 +1,33 @@
1
+ // Helpers for @tanstack/solid-virtual lists.
2
+ import { onMount, type Accessor } from 'solid-js'
3
+
4
+ /**
5
+ * Works around a `@tanstack/solid-virtual` bug where the virtualizer measures
6
+ * a 0×0 viewport: it attaches its `ResizeObserver` during the render pass
7
+ * (when `getScrollElement` first returns the scroll container), before the
8
+ * container has been laid out, so it caches a 0×0 size and never renders any
9
+ * rows. Once layout settles, this detaches+reattaches the scroll element so
10
+ * the observer re-measures the real height. Call it right after wiring up
11
+ * the virtualizer, passing the scroll-element signal's getter and setter.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * const [scrollEl, setScrollEl] = createSignal<HTMLElement>()
16
+ * const virtualizer = createVirtualizer({ getScrollElement: scrollEl, ... })
17
+ * remeasureAfterLayout(scrollEl, setScrollEl)
18
+ * ```
19
+ */
20
+ export function remeasureAfterLayout(
21
+ scrollEl: Accessor<HTMLElement | undefined>,
22
+ setScrollEl: (v: HTMLElement | undefined) => void,
23
+ ): void {
24
+ onMount(() => {
25
+ requestAnimationFrame(() => {
26
+ const el = scrollEl()
27
+ if (el) {
28
+ setScrollEl(undefined)
29
+ setScrollEl(el)
30
+ }
31
+ })
32
+ })
33
+ }