@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.
- package/dist/NumberInput-Bnh2u7pp.js +381 -0
- package/dist/charts/BarList.d.ts +33 -0
- package/dist/charts/CategoryBar.d.ts +23 -0
- package/dist/charts/GaugeChart.d.ts +43 -0
- package/dist/charts/LineChart.d.ts +39 -0
- package/dist/charts/RadarChart.d.ts +34 -0
- package/dist/charts/StatusTracker.d.ts +27 -0
- package/dist/charts/index.d.ts +6 -0
- package/dist/charts.js +693 -100
- package/dist/commerce.js +2 -2
- package/dist/elements.css +206 -0
- package/dist/full.css +206 -0
- package/dist/index.d.ts +18 -1
- package/dist/index.js +5086 -3915
- package/dist/motion-jYMWmZqB.js +119 -0
- package/dist/ui/AnimatedBeam.d.ts +34 -0
- package/dist/ui/BentoGrid.d.ts +39 -0
- package/dist/ui/BorderBeam.d.ts +27 -0
- package/dist/ui/Callout.d.ts +27 -0
- package/dist/ui/Confetti.d.ts +46 -0
- package/dist/ui/CursorTrail.d.ts +25 -0
- package/dist/ui/DiffViewer.d.ts +33 -0
- package/dist/ui/Dock.d.ts +31 -0
- package/dist/ui/Globe.d.ts +44 -0
- package/dist/ui/Meteors.d.ts +23 -0
- package/dist/ui/ModelPicker.d.ts +38 -0
- package/dist/ui/ReasoningTrace.d.ts +28 -0
- package/dist/ui/Snippet.d.ts +19 -0
- package/dist/ui/SuggestionChips.d.ts +25 -0
- package/dist/ui/ToolCallTimeline.d.ts +34 -0
- package/dist/ui/UsageMeter.d.ts +26 -0
- package/dist/ui/WorldMap.d.ts +36 -0
- package/package.json +1 -1
- package/src/charts/BarList.tsx +83 -0
- package/src/charts/CategoryBar.tsx +95 -0
- package/src/charts/GaugeChart.tsx +175 -0
- package/src/charts/LineChart.tsx +264 -0
- package/src/charts/RadarChart.tsx +215 -0
- package/src/charts/StatusTracker.tsx +81 -0
- package/src/charts/index.ts +6 -0
- package/src/index.ts +31 -1
- package/src/ui/AnimatedBeam.tsx +187 -0
- package/src/ui/BentoGrid.tsx +89 -0
- package/src/ui/BorderBeam.tsx +96 -0
- package/src/ui/Callout.tsx +66 -0
- package/src/ui/Confetti.tsx +131 -0
- package/src/ui/CursorTrail.tsx +88 -0
- package/src/ui/DiffViewer.tsx +166 -0
- package/src/ui/Dock.tsx +124 -0
- package/src/ui/Globe.tsx +317 -0
- package/src/ui/Meteors.tsx +109 -0
- package/src/ui/ModelPicker.tsx +119 -0
- package/src/ui/ReasoningTrace.tsx +83 -0
- package/src/ui/Snippet.tsx +64 -0
- package/src/ui/SuggestionChips.tsx +65 -0
- package/src/ui/ToolCallTimeline.tsx +137 -0
- package/src/ui/UsageMeter.tsx +71 -0
- package/src/ui/WorldMap.tsx +191 -0
- package/dist/NumberInput-BQhVucw-.js +0 -491
- 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
|
+
}
|
package/src/ui/Dock.tsx
ADDED
|
@@ -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
|
+
}
|