@a4ui/core 0.31.1 → 0.32.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.
@@ -0,0 +1,89 @@
1
+ // BentoGrid + BentoCard — responsive grid of glass tiles with variable spans.
2
+ import type { JSX } from 'solid-js'
3
+ import { splitProps } from 'solid-js'
4
+
5
+ import { cn } from '../lib/cn'
6
+
7
+ export interface BentoGridProps {
8
+ children: JSX.Element
9
+ class?: string
10
+ }
11
+
12
+ export interface BentoCardProps {
13
+ children: JSX.Element
14
+ /** Number of grid columns to span at `sm`+ (2) and `lg`+ (3). Defaults to 1. */
15
+ colSpan?: 1 | 2 | 3
16
+ /** Number of grid rows to span. Defaults to 1. */
17
+ rowSpan?: 1 | 2
18
+ class?: string
19
+ }
20
+
21
+ // Static lookup so Tailwind's content scanner can see the full class names
22
+ // (dynamic `col-span-${n}` strings are invisible to it and get purged).
23
+ const COL_SPAN_CLASSES: Record<NonNullable<BentoCardProps['colSpan']>, string> = {
24
+ 1: '',
25
+ 2: 'sm:col-span-2',
26
+ 3: 'sm:col-span-2 lg:col-span-3',
27
+ }
28
+
29
+ const ROW_SPAN_CLASSES: Record<NonNullable<BentoCardProps['rowSpan']>, string> = {
30
+ 1: '',
31
+ 2: 'row-span-2',
32
+ }
33
+
34
+ /**
35
+ * Responsive CSS grid for bento-style layouts — 1 column on mobile, 2 at
36
+ * `sm`, 3 at `lg`. Compose with {@link BentoCard} tiles, using `colSpan`/
37
+ * `rowSpan` on each tile to create the varied-size bento look.
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * <BentoGrid>
42
+ * <BentoCard colSpan={2} rowSpan={2}>
43
+ * <h3 class="text-lg font-semibold">Overview</h3>
44
+ * </BentoCard>
45
+ * <BentoCard>
46
+ * <h3 class="text-lg font-semibold">Uptime</h3>
47
+ * </BentoCard>
48
+ * <BentoCard>
49
+ * <h3 class="text-lg font-semibold">Latency</h3>
50
+ * </BentoCard>
51
+ * <BentoCard colSpan={3}>
52
+ * <h3 class="text-lg font-semibold">Recent activity</h3>
53
+ * </BentoCard>
54
+ * </BentoGrid>
55
+ * ```
56
+ */
57
+ export function BentoGrid(props: BentoGridProps): JSX.Element {
58
+ const [local, rest] = splitProps(props, ['class', 'children'])
59
+ return (
60
+ <div
61
+ class={cn(
62
+ 'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 auto-rows-[minmax(11rem,auto)]',
63
+ local.class,
64
+ )}
65
+ {...rest}
66
+ >
67
+ {local.children}
68
+ </div>
69
+ )
70
+ }
71
+
72
+ /** Glass tile for a {@link BentoGrid}, spanning columns/rows via `colSpan`/`rowSpan`. */
73
+ export function BentoCard(props: BentoCardProps): JSX.Element {
74
+ const [local, rest] = splitProps(props, ['class', 'children', 'colSpan', 'rowSpan'])
75
+ return (
76
+ <div
77
+ class={cn(
78
+ 'rounded-2xl border border-border bg-card p-5 text-card-foreground overflow-hidden',
79
+ 'transition-transform hover:-translate-y-0.5',
80
+ COL_SPAN_CLASSES[local.colSpan ?? 1],
81
+ ROW_SPAN_CLASSES[local.rowSpan ?? 1],
82
+ local.class,
83
+ )}
84
+ {...rest}
85
+ >
86
+ {local.children}
87
+ </div>
88
+ )
89
+ }
@@ -0,0 +1,96 @@
1
+ // BorderBeam — a small gradient light segment that travels continuously
2
+ // around the border of its `position: relative` parent, tracing the box's
3
+ // own edge via CSS `offset-path: rect(...)` with `offset-distance` animated
4
+ // 0% → 100%. Pure CSS: no JS animation engine, no ResizeObserver — the
5
+ // browser resolves the rect()'s `auto` edges against the element's own box,
6
+ // so the path always matches the parent's current size however it resizes.
7
+ // The traveling element is a thin bar (not a full square) so it hugs the
8
+ // edge by itself, without needing a border-only mask trick. Reduced motion
9
+ // swaps the moving beam for a static, non-animated edge glow.
10
+ import { type JSX, Show } from 'solid-js'
11
+
12
+ import { cn } from '../lib/cn'
13
+ import { motionReduced } from '../lib/motion'
14
+
15
+ export interface BorderBeamProps {
16
+ /** Length of the traveling gradient segment, in px. @default 60 */
17
+ size?: number
18
+ /** Full trip duration around the border, in seconds. @default 6 */
19
+ duration?: number
20
+ /** Animation start offset, in seconds. @default 0 */
21
+ delay?: number
22
+ class?: string
23
+ }
24
+
25
+ const KEYFRAMES_ID = 'a4ui-border-beam-keyframes'
26
+ const KEYFRAMES = '@keyframes border-beam-travel { to { offset-distance: 100%; } }'
27
+
28
+ // Injected once, lazily, the first time a <BorderBeam> renders. The repo's
29
+ // CSS doctrine keeps shared @keyframes in src/styles/tokens.css, but this
30
+ // component ships standalone (tree-shakeable, no required stylesheet import)
31
+ // so it carries its own single small <style> tag instead — de-duped by id,
32
+ // so multiple instances on a page still only inject it once.
33
+ function ensureKeyframes(): void {
34
+ if (typeof document === 'undefined' || document.getElementById(KEYFRAMES_ID)) return
35
+ const style = document.createElement('style')
36
+ style.id = KEYFRAMES_ID
37
+ style.textContent = KEYFRAMES
38
+ document.head.appendChild(style)
39
+ }
40
+
41
+ /**
42
+ * Decorative light segment that travels continuously around the border of
43
+ * its parent. The parent must be `position: relative` (or similar) — this
44
+ * renders an absolutely-positioned, `pointer-events-none` layer (`inset-0`,
45
+ * `rounded-[inherit]`) tracking the parent's own box. Purely cosmetic
46
+ * (`aria-hidden`); under reduced motion the moving beam is replaced with a
47
+ * static edge glow instead of animating.
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * <div class="relative overflow-hidden rounded-2xl border border-border p-6">
52
+ * <BorderBeam />
53
+ * <p>Card content</p>
54
+ * </div>
55
+ * ```
56
+ */
57
+ export function BorderBeam(props: BorderBeamProps): JSX.Element {
58
+ const size = () => props.size ?? 60
59
+ const duration = () => props.duration ?? 6
60
+ const delay = () => props.delay ?? 0
61
+
62
+ if (!motionReduced()) ensureKeyframes()
63
+
64
+ return (
65
+ <Show
66
+ when={!motionReduced()}
67
+ fallback={
68
+ <span
69
+ aria-hidden="true"
70
+ class={cn(
71
+ 'pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_0_0_0_1px_hsl(var(--primary)/0.35)]',
72
+ props.class,
73
+ )}
74
+ />
75
+ }
76
+ >
77
+ <span
78
+ aria-hidden="true"
79
+ class={cn('pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]', props.class)}
80
+ >
81
+ <span
82
+ class="absolute top-0 left-0 h-[3px] rounded-full"
83
+ style={{
84
+ width: `${size()}px`,
85
+ 'offset-path': `rect(0 auto auto 0 round ${size()}px)`,
86
+ 'offset-distance': '0%',
87
+ background:
88
+ 'linear-gradient(to right, transparent, hsl(var(--primary)), hsl(var(--accent)), transparent)',
89
+ animation: `border-beam-travel ${duration()}s linear infinite`,
90
+ 'animation-delay': `${delay()}s`,
91
+ }}
92
+ />
93
+ </span>
94
+ </Show>
95
+ )
96
+ }
@@ -0,0 +1,124 @@
1
+ // Dock — macOS-style app dock. A horizontal glass pill of icon buttons that
2
+ // magnify toward the cursor (closest item biggest, tapering over a fixed
3
+ // radius), driven by direct transform + CSS transition (scale only, so it
4
+ // stays on the compositor). No-op magnify under reduced motion — icons stay
5
+ // static but remain clickable.
6
+ import { For, Show, type JSX } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+ import { motionReduced } from '../lib/motion'
10
+
11
+ /** Px radius from the cursor within which items magnify; beyond it, scale is 1×. */
12
+ const FALLOFF_PX = 120
13
+ /** Peak scale applied to the item directly under the cursor. */
14
+ const PEAK_SCALE = 1.6
15
+
16
+ export interface DockItem {
17
+ icon: JSX.Element
18
+ label?: string
19
+ onClick?: (e: MouseEvent) => void
20
+ href?: string
21
+ }
22
+
23
+ export interface DockProps {
24
+ items: DockItem[]
25
+ class?: string
26
+ }
27
+
28
+ /**
29
+ * macOS-style dock: a glass pill of icon buttons that magnify based on
30
+ * horizontal distance to the cursor. Scale-only transform (`transform-origin:
31
+ * bottom`), reset to 1× on pointerleave. Respects `prefers-reduced-motion`
32
+ * (renders static, still fully clickable/keyboard-focusable).
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * import { Home, Search, Settings } from 'lucide-solid'
37
+ *
38
+ * <Dock
39
+ * items={[
40
+ * { icon: <Home size={22} />, label: 'Home', onClick: () => {} },
41
+ * { icon: <Search size={22} />, label: 'Search', onClick: () => {} },
42
+ * { icon: <Settings size={22} />, label: 'Settings', href: '/settings' },
43
+ * ]}
44
+ * />
45
+ * ```
46
+ */
47
+ export function Dock(props: DockProps): JSX.Element {
48
+ const itemEls: (HTMLElement | undefined)[] = []
49
+
50
+ const handlePointerMove = (event: PointerEvent): void => {
51
+ if (motionReduced()) return
52
+
53
+ for (const el of itemEls) {
54
+ if (!el) continue
55
+ const rect = el.getBoundingClientRect()
56
+ const center = rect.left + rect.width / 2
57
+ const distance = Math.abs(event.clientX - center)
58
+ const scale = 1 + (PEAK_SCALE - 1) * Math.max(0, 1 - distance / FALLOFF_PX)
59
+ el.style.transform = `scale(${scale})`
60
+ }
61
+ }
62
+
63
+ const handlePointerLeave = (): void => {
64
+ for (const el of itemEls) {
65
+ if (el) el.style.transform = 'scale(1)'
66
+ }
67
+ }
68
+
69
+ return (
70
+ <div
71
+ class={cn(
72
+ 'inline-flex items-end gap-2 rounded-2xl border border-border bg-card/70 px-3 py-2 backdrop-blur',
73
+ props.class,
74
+ )}
75
+ onPointerMove={handlePointerMove}
76
+ onPointerLeave={handlePointerLeave}
77
+ >
78
+ <For each={props.items}>
79
+ {(item, i) => {
80
+ const shared = {
81
+ ref: (el: HTMLElement) => {
82
+ itemEls[i()] = el
83
+ },
84
+ class:
85
+ 'group relative inline-flex h-11 w-11 shrink-0 origin-bottom items-center justify-center rounded-xl text-foreground transition-transform duration-150 ease-out will-change-transform hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
86
+ 'aria-label': item.label,
87
+ onClick: item.onClick,
88
+ }
89
+
90
+ return (
91
+ <Show
92
+ when={item.href}
93
+ fallback={
94
+ <button type="button" {...shared}>
95
+ {item.icon}
96
+ <Show when={item.label}>
97
+ <span
98
+ aria-hidden="true"
99
+ class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 rounded-md border border-border bg-card px-2 py-1 text-xs whitespace-nowrap text-card-foreground opacity-0 shadow-md transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
100
+ >
101
+ {item.label}
102
+ </span>
103
+ </Show>
104
+ </button>
105
+ }
106
+ >
107
+ <a href={item.href} {...shared}>
108
+ {item.icon}
109
+ <Show when={item.label}>
110
+ <span
111
+ aria-hidden="true"
112
+ class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 rounded-md border border-border bg-card px-2 py-1 text-xs whitespace-nowrap text-card-foreground opacity-0 shadow-md transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
113
+ >
114
+ {item.label}
115
+ </span>
116
+ </Show>
117
+ </a>
118
+ </Show>
119
+ )
120
+ }}
121
+ </For>
122
+ </div>
123
+ )
124
+ }
@@ -0,0 +1,109 @@
1
+ // Meteors — Aceternity-style decorative meteor shower: thin diagonal streaks
2
+ // that fall down-left and fade, looping with per-meteor stagger. Pure CSS
3
+ // keyframes (injected once into the document head, like LoadingDots) so the
4
+ // motion costs nothing in JS; each meteor's position/delay/duration is derived
5
+ // deterministically from its index (no Math.random, so the layout is
6
+ // reproducible across renders and safe under SSR).
7
+ import { For, type JSX, Show } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { motionReduced } from '../lib/motion'
11
+
12
+ export interface MeteorsProps {
13
+ /** Number of meteor streaks to render. @default 20 */
14
+ count?: number
15
+ class?: string
16
+ }
17
+
18
+ const STYLE_ID = 'a4ui-meteors-style'
19
+
20
+ function ensureStyleInjected(): void {
21
+ if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return
22
+ const style = document.createElement('style')
23
+ style.id = STYLE_ID
24
+ style.textContent = `
25
+ @keyframes a4-meteor-fall {
26
+ 0% { transform: rotate(215deg) translateX(0); opacity: 1; }
27
+ 70% { opacity: 1; }
28
+ 100% { transform: rotate(215deg) translateX(-500px); opacity: 0; }
29
+ }
30
+ `
31
+ document.head.appendChild(style)
32
+ }
33
+
34
+ /**
35
+ * Deterministic pseudo-random value in [0, 1) derived from an index via a
36
+ * sine-based hash. Used to vary each meteor's position/timing without
37
+ * `Math.random`, so the layout is identical across renders (and SSR-safe).
38
+ */
39
+ function pseudoRandom(seed: number): number {
40
+ const x = Math.sin(seed * 12.9898) * 43758.5453
41
+ return x - Math.floor(x)
42
+ }
43
+
44
+ /**
45
+ * Decorative full-cover meteor shower: thin diagonal streaks that fall
46
+ * down-left and fade, looping with staggered delays and varied start
47
+ * positions/durations. Purely visual — absolute, `pointer-events-none`,
48
+ * `aria-hidden` — so it layers behind/inside a `relative overflow-hidden`
49
+ * container without affecting layout or a11y. Under {@link motionReduced} it
50
+ * swaps the falling streaks for a handful of static, faint dots.
51
+ *
52
+ * @example
53
+ * ```tsx
54
+ * <div class="relative overflow-hidden rounded-2xl border border-border p-8">
55
+ * <Meteors count={20} />
56
+ * <p class="relative">Content sits above the meteors.</p>
57
+ * </div>
58
+ * ```
59
+ */
60
+ export function Meteors(props: MeteorsProps): JSX.Element {
61
+ ensureStyleInjected()
62
+
63
+ const count = () => props.count ?? 20
64
+ const meteors = () => Array.from({ length: count() })
65
+ const staticDots = () => Array.from({ length: Math.min(6, count()) })
66
+
67
+ return (
68
+ <div class={cn('pointer-events-none absolute inset-0 overflow-hidden', props.class)} aria-hidden="true">
69
+ <Show
70
+ when={!motionReduced()}
71
+ fallback={
72
+ <For each={staticDots()}>
73
+ {(_, i) => (
74
+ <span
75
+ class="absolute h-0.5 w-0.5 rounded-full bg-foreground/30"
76
+ style={{
77
+ top: `${pseudoRandom(i() * 2 + 1) * 100}%`,
78
+ left: `${pseudoRandom(i() * 2 + 2) * 100}%`,
79
+ }}
80
+ />
81
+ )}
82
+ </For>
83
+ }
84
+ >
85
+ <For each={meteors()}>
86
+ {(_, i) => {
87
+ const left = pseudoRandom(i() * 2 + 1) * 100
88
+ const delay = pseudoRandom(i() * 2 + 2) * 8
89
+ const duration = 2 + pseudoRandom(i() * 3 + 5) * 5
90
+
91
+ return (
92
+ <span
93
+ class={cn(
94
+ 'absolute top-0 h-0.5 w-0.5 rotate-[215deg] rounded-full bg-foreground',
95
+ "before:absolute before:top-1/2 before:h-px before:w-12 before:-translate-y-1/2 before:bg-gradient-to-r before:from-foreground/60 before:to-transparent before:content-['']",
96
+ )}
97
+ style={{
98
+ left: `${left}%`,
99
+ animation: `a4-meteor-fall ${duration}s linear infinite`,
100
+ 'animation-delay': `${delay}s`,
101
+ }}
102
+ />
103
+ )
104
+ }}
105
+ </For>
106
+ </Show>
107
+ </div>
108
+ )
109
+ }