@a4ui/core 0.31.1 → 0.33.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 (60) hide show
  1. package/dist/NumberInput-Bnh2u7pp.js +381 -0
  2. package/dist/charts/BarList.d.ts +33 -0
  3. package/dist/charts/CategoryBar.d.ts +23 -0
  4. package/dist/charts/GaugeChart.d.ts +43 -0
  5. package/dist/charts/LineChart.d.ts +39 -0
  6. package/dist/charts/RadarChart.d.ts +34 -0
  7. package/dist/charts/StatusTracker.d.ts +27 -0
  8. package/dist/charts/index.d.ts +6 -0
  9. package/dist/charts.js +693 -100
  10. package/dist/commerce.js +2 -2
  11. package/dist/elements.css +206 -0
  12. package/dist/full.css +206 -0
  13. package/dist/index.d.ts +18 -1
  14. package/dist/index.js +5086 -3915
  15. package/dist/motion-jYMWmZqB.js +119 -0
  16. package/dist/ui/AnimatedBeam.d.ts +34 -0
  17. package/dist/ui/BentoGrid.d.ts +39 -0
  18. package/dist/ui/BorderBeam.d.ts +27 -0
  19. package/dist/ui/Callout.d.ts +27 -0
  20. package/dist/ui/Confetti.d.ts +46 -0
  21. package/dist/ui/CursorTrail.d.ts +25 -0
  22. package/dist/ui/DiffViewer.d.ts +33 -0
  23. package/dist/ui/Dock.d.ts +31 -0
  24. package/dist/ui/Globe.d.ts +44 -0
  25. package/dist/ui/Meteors.d.ts +23 -0
  26. package/dist/ui/ModelPicker.d.ts +38 -0
  27. package/dist/ui/ReasoningTrace.d.ts +28 -0
  28. package/dist/ui/Snippet.d.ts +19 -0
  29. package/dist/ui/SuggestionChips.d.ts +25 -0
  30. package/dist/ui/ToolCallTimeline.d.ts +34 -0
  31. package/dist/ui/UsageMeter.d.ts +26 -0
  32. package/dist/ui/WorldMap.d.ts +36 -0
  33. package/package.json +1 -1
  34. package/src/charts/BarList.tsx +83 -0
  35. package/src/charts/CategoryBar.tsx +95 -0
  36. package/src/charts/GaugeChart.tsx +175 -0
  37. package/src/charts/LineChart.tsx +264 -0
  38. package/src/charts/RadarChart.tsx +215 -0
  39. package/src/charts/StatusTracker.tsx +81 -0
  40. package/src/charts/index.ts +6 -0
  41. package/src/index.ts +31 -1
  42. package/src/ui/AnimatedBeam.tsx +187 -0
  43. package/src/ui/BentoGrid.tsx +89 -0
  44. package/src/ui/BorderBeam.tsx +96 -0
  45. package/src/ui/Callout.tsx +66 -0
  46. package/src/ui/Confetti.tsx +131 -0
  47. package/src/ui/CursorTrail.tsx +88 -0
  48. package/src/ui/DiffViewer.tsx +166 -0
  49. package/src/ui/Dock.tsx +124 -0
  50. package/src/ui/Globe.tsx +317 -0
  51. package/src/ui/Meteors.tsx +109 -0
  52. package/src/ui/ModelPicker.tsx +119 -0
  53. package/src/ui/ReasoningTrace.tsx +83 -0
  54. package/src/ui/Snippet.tsx +64 -0
  55. package/src/ui/SuggestionChips.tsx +65 -0
  56. package/src/ui/ToolCallTimeline.tsx +137 -0
  57. package/src/ui/UsageMeter.tsx +71 -0
  58. package/src/ui/WorldMap.tsx +191 -0
  59. package/dist/NumberInput-BQhVucw-.js +0 -491
  60. package/dist/cn-B6yFEsav.js +0 -8
@@ -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,66 @@
1
+ // Prose-embed highlight block for docs/guidance content. Distinct from Alert
2
+ // (a dismissible notification banner): Callout sits inline in a body of text —
3
+ // left accent border, tinted background, comfortable padding — and is never
4
+ // dismissed. Tone tokens are shared with Alert so info/success/warning/danger
5
+ // read identically across both components.
6
+ import { CircleCheck, CircleX, Info, TriangleAlert } from 'lucide-solid'
7
+ import type { Component, JSX } from 'solid-js'
8
+ import { Show } from 'solid-js'
9
+ import { Dynamic } from 'solid-js/web'
10
+
11
+ import { cn } from '../lib/cn'
12
+
13
+ /** Semantic tone of a {@link Callout}; drives the accent border, tint, and default icon. */
14
+ export type CalloutTone = 'info' | 'success' | 'warning' | 'danger' | 'neutral'
15
+
16
+ const TONE: Record<CalloutTone, { wrap: string; icon: string; Icon: Component<{ class?: string }> }> = {
17
+ info: { wrap: 'border-sky-500 bg-sky-500/10', icon: 'text-sky-500', Icon: Info },
18
+ success: { wrap: 'border-emerald-500 bg-emerald-500/10', icon: 'text-emerald-500', Icon: CircleCheck },
19
+ warning: { wrap: 'border-amber-500 bg-amber-500/10', icon: 'text-amber-500', Icon: TriangleAlert },
20
+ danger: { wrap: 'border-rose-500 bg-rose-500/10', icon: 'text-rose-500', Icon: CircleX },
21
+ neutral: { wrap: 'border-border bg-muted', icon: 'text-muted-foreground', Icon: Info },
22
+ }
23
+
24
+ export interface CalloutProps {
25
+ /** Visual/semantic tone. Defaults to `'info'`. */
26
+ tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral'
27
+ /** Optional bold heading shown above the body. */
28
+ title?: JSX.Element
29
+ /** Overrides the tone's default icon. */
30
+ icon?: JSX.Element
31
+ children: JSX.Element
32
+ class?: string
33
+ }
34
+
35
+ /**
36
+ * Inline highlighted block for embedding guidance/notes inside prose (docs,
37
+ * READMEs, long-form content) — a left accent border, a faint tone-tinted
38
+ * background, and an icon. Unlike {@link Alert} it isn't a dismissible
39
+ * notification; it's part of the content flow.
40
+ *
41
+ * @example
42
+ * ```tsx
43
+ * <Callout tone="warning" title="Before you continue">
44
+ * This action can't be undone once the migration starts.
45
+ * </Callout>
46
+ * ```
47
+ */
48
+ export function Callout(props: CalloutProps): JSX.Element {
49
+ const tone = () => TONE[props.tone ?? 'info']
50
+ return (
51
+ <div class={cn('flex gap-3 rounded-lg border-l-4 p-4 text-sm text-foreground', tone().wrap, props.class)}>
52
+ <Show
53
+ when={props.icon}
54
+ fallback={<Dynamic component={tone().Icon} class={cn('mt-0.5 h-4 w-4 shrink-0', tone().icon)} />}
55
+ >
56
+ {props.icon}
57
+ </Show>
58
+ <div class="flex-1">
59
+ <Show when={props.title}>
60
+ <p class="font-semibold">{props.title}</p>
61
+ </Show>
62
+ <div class="text-muted-foreground">{props.children}</div>
63
+ </div>
64
+ </div>
65
+ )
66
+ }
@@ -0,0 +1,131 @@
1
+ // Decorative confetti burst: a spawn-then-remove particle idiom (same as
2
+ // Ripple.tsx) driven by the native Web Animations API — each piece is a
3
+ // one-shot element with nothing for a JS animation engine to add. Angle and
4
+ // velocity per piece are derived deterministically from the piece's index
5
+ // (via Math.sin), not Math.random, so a burst is reproducible and doesn't
6
+ // depend on an RNG.
7
+ import { createEffect, on, type JSX } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { motionReduced } from '../lib/motion'
11
+
12
+ export interface ConfettiProps {
13
+ /** Bump this (e.g. a counter) to fire a burst. Mount does not count. */
14
+ trigger?: number
15
+ /** Number of pieces per burst. @default 80 */
16
+ count?: number
17
+ class?: string
18
+ }
19
+
20
+ const CONFETTI_COLORS = ['hsl(var(--primary))', 'hsl(var(--accent))', 'hsl(var(--foreground))']
21
+
22
+ /**
23
+ * Fires one burst of confetti pieces from the bottom-center of `container`,
24
+ * flying up and outward in a fan, rotating, then falling with gravity and
25
+ * fading out — each piece removes itself once its animation finishes. Angle,
26
+ * speed, fall distance and spin are derived deterministically from each
27
+ * piece's index (Math.sin), so the same `count` always produces the same
28
+ * fan. `container` should be `position: relative` (or already positioned)
29
+ * with `overflow: hidden` so pieces don't affect layout. No-op under
30
+ * {@link motionReduced}. This is the primitive behind {@link Confetti}.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * fireConfetti(cardEl, { count: 120 })
35
+ * ```
36
+ */
37
+ export function fireConfetti(container: HTMLElement, opts: { count?: number } = {}): void {
38
+ if (motionReduced()) return
39
+ const count = opts.count ?? 80
40
+ const rect = container.getBoundingClientRect()
41
+ const originX = rect.width / 2
42
+ const originY = rect.height
43
+
44
+ for (let i = 0; i < count; i++) {
45
+ // Fan the burst across a ~126° arc centered straight up; wobble is a
46
+ // deterministic pseudo-random 0..1 derived from the index.
47
+ const spread = Math.PI * 0.7
48
+ const wobble = Math.sin(i * 12.9898) * 0.5 + 0.5
49
+ const angle = -Math.PI / 2 + (wobble - 0.5) * spread
50
+ const speed = 180 + (Math.sin(i * 4.567) * 0.5 + 0.5) * 160
51
+ const dx = Math.cos(angle) * speed
52
+ const dy = Math.sin(angle) * speed
53
+ const fall = 200 + (Math.sin(i * 5.113) * 0.5 + 0.5) * 160
54
+ const rotation = 240 + (Math.sin(i * 3.377) * 0.5 + 0.5) * 640
55
+ const duration = 900 + (Math.sin(i * 2.1) * 0.5 + 0.5) * 500
56
+
57
+ const el = document.createElement('span')
58
+ el.setAttribute('aria-hidden', 'true')
59
+ Object.assign(el.style, {
60
+ position: 'absolute',
61
+ left: `${originX}px`,
62
+ top: `${originY}px`,
63
+ width: `${5 + (i % 3) * 2}px`,
64
+ height: `${10 + (i % 4) * 2}px`,
65
+ borderRadius: '1px',
66
+ background: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
67
+ pointerEvents: 'none',
68
+ })
69
+ container.appendChild(el)
70
+
71
+ const animation = el.animate(
72
+ [
73
+ { transform: 'translate(-50%,-50%) translate(0px,0px) rotate(0deg)', opacity: '1' },
74
+ {
75
+ transform: `translate(-50%,-50%) translate(${dx * 0.7}px,${dy}px) rotate(${rotation * 0.5}deg)`,
76
+ opacity: '1',
77
+ offset: 0.45,
78
+ },
79
+ {
80
+ transform: `translate(-50%,-50%) translate(${dx}px,${dy + fall}px) rotate(${rotation}deg)`,
81
+ opacity: '0',
82
+ },
83
+ ],
84
+ { duration, easing: 'cubic-bezier(0.2, 0.6, 0.4, 1)' },
85
+ )
86
+ const done = (): void => el.remove()
87
+ animation.finished.then(done).catch(done)
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Absolutely-positioned confetti layer: fires a burst of colored pieces from
93
+ * the bottom-center whenever `trigger` changes (the initial mount does not
94
+ * fire one). Purely decorative — `pointer-events-none` and `aria-hidden`.
95
+ * Place it inside a `position: relative` container; pair with a `count`
96
+ * prop, or reach for {@link fireConfetti} directly to fire into an
97
+ * arbitrary element outside Solid's render tree.
98
+ *
99
+ * @example
100
+ * ```tsx
101
+ * const [bursts, setBursts] = createSignal(0)
102
+ * return (
103
+ * <div class="relative h-64">
104
+ * <Confetti trigger={bursts()} count={100} />
105
+ * <Button onClick={() => setBursts((n) => n + 1)}>Celebrate</Button>
106
+ * </div>
107
+ * )
108
+ * ```
109
+ */
110
+ export function Confetti(props: ConfettiProps): JSX.Element {
111
+ let containerEl: HTMLDivElement | undefined
112
+
113
+ createEffect(
114
+ on(
115
+ () => props.trigger,
116
+ () => {
117
+ if (!containerEl) return
118
+ fireConfetti(containerEl, { count: props.count })
119
+ },
120
+ { defer: true },
121
+ ),
122
+ )
123
+
124
+ return (
125
+ <div
126
+ ref={containerEl}
127
+ class={cn('absolute inset-0 overflow-hidden pointer-events-none', props.class)}
128
+ aria-hidden="true"
129
+ />
130
+ )
131
+ }
@@ -0,0 +1,88 @@
1
+ // Decorative cursor trail: same spawn-then-remove particle idiom as
2
+ // Ripple.tsx / Confetti.tsx, driven by the native Web Animations API. The
3
+ // layer itself is pointer-events-none, so the listener lives on the
4
+ // `relative` parent that hosts it.
5
+ import { onCleanup, onMount, type JSX } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+ import { motionReduced } from '../lib/motion'
9
+
10
+ export interface CursorTrailProps {
11
+ /** Blob tone. @default 'primary' */
12
+ color?: 'primary' | 'accent'
13
+ /** Blob diameter in px. @default 24 */
14
+ size?: number
15
+ class?: string
16
+ }
17
+
18
+ const SPAWN_THROTTLE_MS = 20
19
+
20
+ /**
21
+ * Layer that spawns a soft, blurred blob at the cursor position on every
22
+ * throttled `pointermove` over its parent, scaling down and fading out as
23
+ * it removes itself — leaving a trail. Must sit inside a `position:
24
+ * relative` parent (the layer listens on `parentElement`, since it is
25
+ * itself `pointer-events-none`). Purely decorative — `pointer-events-none`
26
+ * and `aria-hidden`. No-op under {@link motionReduced}; listeners are
27
+ * cleaned up on unmount.
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * <div class="relative h-48 rounded-lg border">
32
+ * <CursorTrail color="accent" size={32} />
33
+ * </div>
34
+ * ```
35
+ */
36
+ export function CursorTrail(props: CursorTrailProps): JSX.Element {
37
+ let layerEl: HTMLDivElement | undefined
38
+ let lastSpawn = 0
39
+
40
+ const handlePointerMove = (event: PointerEvent): void => {
41
+ if (motionReduced() || !layerEl) return
42
+ const now = performance.now()
43
+ if (now - lastSpawn < SPAWN_THROTTLE_MS) return
44
+ lastSpawn = now
45
+
46
+ const rect = layerEl.getBoundingClientRect()
47
+ const x = event.clientX - rect.left
48
+ const y = event.clientY - rect.top
49
+ const size = props.size ?? 24
50
+ const tone = props.color === 'accent' ? 'accent' : 'primary'
51
+
52
+ const el = document.createElement('span')
53
+ el.setAttribute('aria-hidden', 'true')
54
+ Object.assign(el.style, {
55
+ position: 'absolute',
56
+ left: `${x}px`,
57
+ top: `${y}px`,
58
+ width: `${size}px`,
59
+ height: `${size}px`,
60
+ borderRadius: '9999px',
61
+ background: `hsl(var(--${tone}) / 0.35)`,
62
+ filter: 'blur(6px)',
63
+ pointerEvents: 'none',
64
+ transform: 'translate(-50%,-50%) scale(1)',
65
+ })
66
+ layerEl.appendChild(el)
67
+
68
+ const animation = el.animate(
69
+ [
70
+ { transform: 'translate(-50%,-50%) scale(1)', opacity: '1' },
71
+ { transform: 'translate(-50%,-50%) scale(0.2)', opacity: '0' },
72
+ ],
73
+ { duration: 500, easing: 'ease-out' },
74
+ )
75
+ const done = (): void => el.remove()
76
+ animation.finished.then(done).catch(done)
77
+ }
78
+
79
+ onMount(() => {
80
+ const parent = layerEl?.parentElement
81
+ parent?.addEventListener('pointermove', handlePointerMove)
82
+ onCleanup(() => parent?.removeEventListener('pointermove', handlePointerMove))
83
+ })
84
+
85
+ return (
86
+ <div ref={layerEl} class={cn('absolute inset-0 pointer-events-none', props.class)} aria-hidden="true" />
87
+ )
88
+ }
@@ -0,0 +1,166 @@
1
+ // Read-only unified-diff / line-diff renderer. Reuses the CodeTabs monospace
2
+ // idiom (rounded border, bg-card, font-mono text-sm) and the Alert/Badge tone
3
+ // tokens: `emerald` for added (their `success` tone) and the `destructive`
4
+ // CSS-var token for removed. No syntax highlighting.
5
+ import { type JSX, Show } from 'solid-js'
6
+ import { createMemo, For } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+
10
+ /** A single rendered row of a diff. */
11
+ export interface DiffLine {
12
+ type: 'add' | 'remove' | 'context'
13
+ content: string
14
+ /** Line number in the old file. Present for `'remove'` and `'context'` rows. */
15
+ oldNum?: number
16
+ /** Line number in the new file. Present for `'add'` and `'context'` rows. */
17
+ newNum?: number
18
+ }
19
+
20
+ export interface DiffViewerProps {
21
+ /** Unified diff text (as produced by `git diff`/`diff -u`). Ignored if `lines` is set. */
22
+ diff?: string
23
+ /** Pre-parsed diff rows. Takes precedence over `diff`. */
24
+ lines?: DiffLine[]
25
+ /** Header label. If omitted, inferred from the `+++`/`---` headers of `diff`, when present. */
26
+ filename?: string
27
+ class?: string
28
+ }
29
+
30
+ const MARKER: Record<DiffLine['type'], string> = { add: '+', remove: '-', context: ' ' }
31
+
32
+ const ROW_BG: Record<DiffLine['type'], string> = {
33
+ add: 'bg-emerald-500/10',
34
+ remove: 'bg-destructive/10',
35
+ context: '',
36
+ }
37
+
38
+ const MARKER_TONE: Record<DiffLine['type'], string> = {
39
+ add: 'text-emerald-500',
40
+ remove: 'text-destructive',
41
+ context: 'text-muted-foreground',
42
+ }
43
+
44
+ const CONTENT_TONE: Record<DiffLine['type'], string> = {
45
+ add: 'text-foreground',
46
+ remove: 'text-foreground',
47
+ context: 'text-muted-foreground',
48
+ }
49
+
50
+ const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/
51
+
52
+ /** Parses a unified diff string into rows plus an optional inferred filename. */
53
+ function parseUnifiedDiff(diff: string): { lines: DiffLine[]; filename?: string } {
54
+ const rawLines = diff.split('\n')
55
+ // Drop the trailing empty element produced when `diff` ends with a newline.
56
+ if (rawLines.length > 0 && rawLines[rawLines.length - 1] === '') rawLines.pop()
57
+
58
+ const lines: DiffLine[] = []
59
+ let filename: string | undefined
60
+ let oldNum = 0
61
+ let newNum = 0
62
+
63
+ for (const raw of rawLines) {
64
+ if (raw.startsWith('+++ ') || raw.startsWith('--- ')) {
65
+ const path = raw.slice(4).trim()
66
+ if (raw.startsWith('+++ ') && path !== '/dev/null') filename = path.replace(/^[ab]\//, '')
67
+ continue
68
+ }
69
+ const hunkMatch = HUNK_HEADER.exec(raw)
70
+ if (hunkMatch) {
71
+ oldNum = Number(hunkMatch[1])
72
+ newNum = Number(hunkMatch[2])
73
+ continue
74
+ }
75
+ if (raw.startsWith('\\')) continue // ""
76
+
77
+ if (raw.startsWith('+')) {
78
+ lines.push({ type: 'add', content: raw.slice(1), newNum: newNum++ })
79
+ } else if (raw.startsWith('-')) {
80
+ lines.push({ type: 'remove', content: raw.slice(1), oldNum: oldNum++ })
81
+ } else {
82
+ lines.push({
83
+ type: 'context',
84
+ content: raw.startsWith(' ') ? raw.slice(1) : raw,
85
+ oldNum: oldNum++,
86
+ newNum: newNum++,
87
+ })
88
+ }
89
+ }
90
+
91
+ return { lines, filename }
92
+ }
93
+
94
+ /**
95
+ * Read-only diff block: renders added/removed/context lines with a line-number
96
+ * gutter and a +/-/space marker. Accepts either a raw unified-diff string
97
+ * (parsed internally) or pre-parsed {@link DiffLine} rows.
98
+ *
99
+ * @example
100
+ * ```tsx
101
+ * <DiffViewer
102
+ * filename="src/lib/cn.ts"
103
+ * diff={`@@ -1,3 +1,3 @@\n-export const cn = (a) => a\n+export const cn = (a, b) => a + b\n context line`}
104
+ * />
105
+ * ```
106
+ */
107
+ export function DiffViewer(props: DiffViewerProps): JSX.Element {
108
+ const parsed = createMemo(() =>
109
+ props.lines ? { lines: props.lines, filename: undefined } : parseUnifiedDiff(props.diff ?? ''),
110
+ )
111
+
112
+ const rows = () => parsed().lines
113
+ const displayFilename = () => props.filename ?? parsed().filename
114
+
115
+ const counts = createMemo(() => {
116
+ let added = 0
117
+ let removed = 0
118
+ for (const line of rows()) {
119
+ if (line.type === 'add') added++
120
+ else if (line.type === 'remove') removed++
121
+ }
122
+ return { added, removed }
123
+ })
124
+
125
+ const ariaLabel = () => {
126
+ const { added, removed } = counts()
127
+ const name = displayFilename()
128
+ return `Diff${name ? ` for ${name}` : ''}: ${added} added, ${removed} removed`
129
+ }
130
+
131
+ return (
132
+ <div
133
+ role="figure"
134
+ aria-label={ariaLabel()}
135
+ class={cn('overflow-hidden rounded-lg border border-border bg-card', props.class)}
136
+ >
137
+ <Show when={displayFilename()}>
138
+ <div class="border-b border-border px-4 py-2 text-sm font-medium text-foreground">
139
+ {displayFilename()}
140
+ </div>
141
+ </Show>
142
+ <div class="overflow-x-auto">
143
+ <div class="min-w-max font-mono text-sm">
144
+ <For each={rows()}>
145
+ {(line) => (
146
+ <div class={cn('flex', ROW_BG[line.type])}>
147
+ <span class="w-12 shrink-0 select-none py-0.5 pr-2 text-right tabular-nums text-xs text-muted-foreground">
148
+ {line.oldNum ?? ''}
149
+ </span>
150
+ <span class="w-12 shrink-0 select-none py-0.5 pr-2 text-right tabular-nums text-xs text-muted-foreground">
151
+ {line.newNum ?? ''}
152
+ </span>
153
+ <span class={cn('w-5 shrink-0 select-none py-0.5 text-center', MARKER_TONE[line.type])}>
154
+ {MARKER[line.type]}
155
+ </span>
156
+ <span class={cn('flex-1 whitespace-pre py-0.5 pr-4', CONTENT_TONE[line.type])}>
157
+ {line.content}
158
+ </span>
159
+ </div>
160
+ )}
161
+ </For>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ )
166
+ }
@@ -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
+ }