@a4ui/core 0.32.0 → 0.34.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 (63) hide show
  1. package/dist/charts/BarList.d.ts +33 -0
  2. package/dist/charts/CategoryBar.d.ts +23 -0
  3. package/dist/charts/StatusTracker.d.ts +27 -0
  4. package/dist/charts/index.d.ts +3 -0
  5. package/dist/charts.js +446 -316
  6. package/dist/elements.css +278 -0
  7. package/dist/full.css +278 -0
  8. package/dist/index.d.ts +25 -1
  9. package/dist/index.js +6423 -4195
  10. package/dist/ui/AudioWaveform.d.ts +22 -0
  11. package/dist/ui/Callout.d.ts +27 -0
  12. package/dist/ui/Confetti.d.ts +46 -0
  13. package/dist/ui/CouponField.d.ts +33 -0
  14. package/dist/ui/CursorTrail.d.ts +25 -0
  15. package/dist/ui/DiffViewer.d.ts +33 -0
  16. package/dist/ui/FollowingPointer.d.ts +24 -0
  17. package/dist/ui/GanttChart.d.ts +37 -0
  18. package/dist/ui/Globe.d.ts +44 -0
  19. package/dist/ui/Kanban.d.ts +49 -0
  20. package/dist/ui/Lamp.d.ts +22 -0
  21. package/dist/ui/Lightbox.d.ts +41 -0
  22. package/dist/ui/ModelPicker.d.ts +38 -0
  23. package/dist/ui/OnboardingChecklist.d.ts +51 -0
  24. package/dist/ui/PivotTable.d.ts +37 -0
  25. package/dist/ui/ReasoningTrace.d.ts +28 -0
  26. package/dist/ui/SheetSnap.d.ts +28 -0
  27. package/dist/ui/Snippet.d.ts +19 -0
  28. package/dist/ui/SuggestionChips.d.ts +25 -0
  29. package/dist/ui/ToolCallTimeline.d.ts +34 -0
  30. package/dist/ui/TreeTable.d.ts +52 -0
  31. package/dist/ui/UsageMeter.d.ts +26 -0
  32. package/dist/ui/VideoPlayerShell.d.ts +19 -0
  33. package/dist/ui/WorldMap.d.ts +36 -0
  34. package/package.json +1 -1
  35. package/src/charts/BarList.tsx +83 -0
  36. package/src/charts/CategoryBar.tsx +95 -0
  37. package/src/charts/StatusTracker.tsx +81 -0
  38. package/src/charts/index.ts +3 -0
  39. package/src/index.ts +48 -1
  40. package/src/ui/AudioWaveform.tsx +190 -0
  41. package/src/ui/Callout.tsx +66 -0
  42. package/src/ui/Confetti.tsx +131 -0
  43. package/src/ui/CouponField.tsx +120 -0
  44. package/src/ui/CursorTrail.tsx +88 -0
  45. package/src/ui/DiffViewer.tsx +166 -0
  46. package/src/ui/FollowingPointer.tsx +112 -0
  47. package/src/ui/GanttChart.tsx +283 -0
  48. package/src/ui/Globe.tsx +317 -0
  49. package/src/ui/Kanban.tsx +250 -0
  50. package/src/ui/Lamp.tsx +88 -0
  51. package/src/ui/Lightbox.tsx +261 -0
  52. package/src/ui/ModelPicker.tsx +119 -0
  53. package/src/ui/OnboardingChecklist.tsx +163 -0
  54. package/src/ui/PivotTable.tsx +0 -0
  55. package/src/ui/ReasoningTrace.tsx +83 -0
  56. package/src/ui/SheetSnap.tsx +264 -0
  57. package/src/ui/Snippet.tsx +64 -0
  58. package/src/ui/SuggestionChips.tsx +65 -0
  59. package/src/ui/ToolCallTimeline.tsx +137 -0
  60. package/src/ui/TreeTable.tsx +172 -0
  61. package/src/ui/UsageMeter.tsx +71 -0
  62. package/src/ui/VideoPlayerShell.tsx +282 -0
  63. package/src/ui/WorldMap.tsx +191 -0
@@ -0,0 +1,317 @@
1
+ // A dotted 3D globe rendered on a single <canvas> with plain Canvas 2D — no
2
+ // three.js, no WebGL. The sphere's dots come from a Fibonacci sphere
3
+ // (deterministic, evenly distributed — no Math.random so the dot field is
4
+ // stable across renders/SSR hydration). Each frame: rotate every point around
5
+ // the Y axis, project orthographically (drop z), keep only front-facing
6
+ // points (z > 0), and scale alpha/size by depth for a cheap "3D" read.
7
+ // Markers/arcs project through the same pipeline so they stay glued to the
8
+ // sphere as it spins. Colors are read from the live theme tokens via
9
+ // getComputedStyle (canvas can't consume `hsl(var(--x))` directly). Follows
10
+ // SpaceBackground's onMount/onCleanup idiom: bind listeners into a cleanups
11
+ // array, cancel the rAF, tear everything down on unmount.
12
+ import { createEffect, onCleanup, onMount, type JSX } from 'solid-js'
13
+
14
+ import { cn } from '../lib/cn'
15
+ import { motionReduced } from '../lib/motion'
16
+
17
+ export interface GlobeMarker {
18
+ lat: number
19
+ lng: number
20
+ label?: string
21
+ }
22
+
23
+ export interface GlobeArc {
24
+ /** [lat, lng] */
25
+ from: [number, number]
26
+ /** [lat, lng] */
27
+ to: [number, number]
28
+ }
29
+
30
+ export interface GlobeProps {
31
+ markers?: GlobeMarker[]
32
+ arcs?: GlobeArc[]
33
+ /** Canvas size in CSS px (square). @default 400 */
34
+ size?: number
35
+ /** Spin automatically. @default true (ignored under reduced motion). */
36
+ autoRotate?: boolean
37
+ class?: string
38
+ }
39
+
40
+ interface Vec3 {
41
+ x: number
42
+ y: number
43
+ z: number
44
+ }
45
+
46
+ interface Projected {
47
+ x: number
48
+ y: number
49
+ z: number
50
+ front: boolean
51
+ }
52
+
53
+ const DOT_COUNT = 900
54
+ const ROTATE_SPEED = 0.12 // rad/s
55
+ const DRAG_SPEED = 0.012 // rad per px of pointer movement
56
+
57
+ // Fibonacci sphere: evenly-spaced points on a unit sphere, fully deterministic
58
+ // (no RNG) so the dot field is stable across renders and reproducible in tests.
59
+ function fibonacciSphere(count: number): Vec3[] {
60
+ const points: Vec3[] = []
61
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5))
62
+ for (let i = 0; i < count; i++) {
63
+ const y = 1 - (i / (count - 1)) * 2 // 1..-1
64
+ const radius = Math.sqrt(Math.max(0, 1 - y * y))
65
+ const theta = goldenAngle * i
66
+ points.push({ x: Math.cos(theta) * radius, y, z: Math.sin(theta) * radius })
67
+ }
68
+ return points
69
+ }
70
+
71
+ const SPHERE_POINTS = fibonacciSphere(DOT_COUNT)
72
+
73
+ function latLngToVec3(lat: number, lng: number): Vec3 {
74
+ const latRad = (lat * Math.PI) / 180
75
+ const lngRad = (lng * Math.PI) / 180
76
+ return {
77
+ x: Math.cos(latRad) * Math.sin(lngRad),
78
+ y: Math.sin(latRad),
79
+ z: Math.cos(latRad) * Math.cos(lngRad),
80
+ }
81
+ }
82
+
83
+ // Rotate around the Y axis and project orthographically to a `radius`-sized
84
+ // disc centered at (cx, cy). z stays in [-1, 1] (front-facing when > 0).
85
+ function project(p: Vec3, angle: number, radius: number, cx: number, cy: number): Projected {
86
+ const cos = Math.cos(angle)
87
+ const sin = Math.sin(angle)
88
+ const x = p.x * cos + p.z * sin
89
+ const z = -p.x * sin + p.z * cos
90
+ const y = p.y
91
+ return { x: cx + x * radius, y: cy - y * radius, z, front: z > 0 }
92
+ }
93
+
94
+ function resolveColor(canvas: HTMLCanvasElement, token: string, fallback: string): string {
95
+ const raw = getComputedStyle(document.documentElement).getPropertyValue(token).trim()
96
+ return raw ? `hsl(${raw})` : fallback
97
+ }
98
+
99
+ /**
100
+ * A dotted 3D globe drawn on `<canvas>` with plain Canvas 2D (no three.js,
101
+ * no WebGL): dots come from a deterministic Fibonacci-sphere distribution,
102
+ * rotate around the Y axis, and project orthographically with depth-scaled
103
+ * alpha/size. `markers` render as brighter glowing dots (title = label);
104
+ * `arcs` draw as outward-bulging curves between two front-facing markers,
105
+ * with a small traveling highlight. Auto-rotates unless `autoRotate={false}`;
106
+ * drag with the pointer to spin manually. Renders a single static frame
107
+ * under `motionReduced()` (no auto-rotation; drag still works).
108
+ *
109
+ * @example
110
+ * ```tsx
111
+ * <Globe
112
+ * size={360}
113
+ * markers={[
114
+ * { lat: 40.7, lng: -74.0, label: 'New York' },
115
+ * { lat: 35.7, lng: 139.7, label: 'Tokyo' },
116
+ * ]}
117
+ * arcs={[{ from: [40.7, -74.0], to: [35.7, 139.7] }]}
118
+ * />
119
+ * ```
120
+ */
121
+ export function Globe(props: GlobeProps): JSX.Element {
122
+ let canvas!: HTMLCanvasElement
123
+ const size = () => props.size ?? 400
124
+
125
+ onMount(() => {
126
+ const ctx = canvas.getContext('2d')
127
+ if (!ctx) return
128
+
129
+ const cleanups: Array<() => void> = []
130
+ const bind = (
131
+ target: EventTarget,
132
+ type: string,
133
+ handler: EventListener,
134
+ opts?: AddEventListenerOptions,
135
+ ) => {
136
+ target.addEventListener(type, handler, opts)
137
+ cleanups.push(() => target.removeEventListener(type, handler, opts))
138
+ }
139
+
140
+ let angle = 0
141
+ let dragging = false
142
+ let lastX = 0
143
+ let rafId = 0
144
+ let lastTs = 0
145
+ // Screen-space marker positions from the last draw, for hover hit-testing
146
+ // (cheap: only a handful of markers, refreshed every frame anyway).
147
+ let markerHits: Array<{ x: number; y: number; label: string }> = []
148
+
149
+ const draw = (): void => {
150
+ const dpr = window.devicePixelRatio || 1
151
+ const cssSize = size()
152
+ const radius = cssSize * 0.42
153
+ const cx = cssSize / 2
154
+ const cy = cssSize / 2
155
+
156
+ if (canvas.width !== cssSize * dpr || canvas.height !== cssSize * dpr) {
157
+ canvas.width = cssSize * dpr
158
+ canvas.height = cssSize * dpr
159
+ canvas.style.width = `${cssSize}px`
160
+ canvas.style.height = `${cssSize}px`
161
+ }
162
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
163
+ ctx.clearRect(0, 0, cssSize, cssSize)
164
+
165
+ const dotColor = resolveColor(canvas, '--foreground', '210 20% 90%')
166
+ const markerColor = resolveColor(canvas, '--accent', '200 90% 60%')
167
+ const arcColor = resolveColor(canvas, '--primary', '250 80% 65%')
168
+
169
+ // Base sphere dots, back-to-front isn't needed (no overlap testing) —
170
+ // just skip anything facing away from the viewer.
171
+ for (const p of SPHERE_POINTS) {
172
+ const proj = project(p, angle, radius, cx, cy)
173
+ if (!proj.front) continue
174
+ const depth = proj.z // 0..1
175
+ ctx.beginPath()
176
+ ctx.fillStyle = dotColor
177
+ ctx.globalAlpha = 0.15 + depth * 0.55
178
+ ctx.arc(proj.x, proj.y, 0.6 + depth * 1.2, 0, Math.PI * 2)
179
+ ctx.fill()
180
+ }
181
+ ctx.globalAlpha = 1
182
+
183
+ // Arcs: only drawn when both endpoints are currently front-facing.
184
+ const arcs = props.arcs ?? []
185
+ for (const arc of arcs) {
186
+ const from = project(latLngToVec3(arc.from[0], arc.from[1]), angle, radius, cx, cy)
187
+ const to = project(latLngToVec3(arc.to[0], arc.to[1]), angle, radius, cx, cy)
188
+ if (!from.front || !to.front) continue
189
+
190
+ const mx = (from.x + to.x) / 2
191
+ const my = (from.y + to.y) / 2
192
+ // Bulge outward from the sphere center, proportional to the chord length.
193
+ const dx = mx - cx
194
+ const dy = my - cy
195
+ const dist = Math.hypot(dx, dy) || 1
196
+ const chord = Math.hypot(to.x - from.x, to.y - from.y)
197
+ const bulge = chord * 0.35
198
+ const ctrlX = mx + (dx / dist) * bulge
199
+ const ctrlY = my + (dy / dist) * bulge
200
+
201
+ ctx.beginPath()
202
+ ctx.strokeStyle = arcColor
203
+ ctx.globalAlpha = 0.55
204
+ ctx.lineWidth = 1.4
205
+ ctx.moveTo(from.x, from.y)
206
+ ctx.quadraticCurveTo(ctrlX, ctrlY, to.x, to.y)
207
+ ctx.stroke()
208
+ ctx.globalAlpha = 1
209
+
210
+ // Traveling highlight: a bright dot eased along the same quadratic,
211
+ // looping with the wall clock so it doesn't need its own rAF state.
212
+ if (!motionReduced()) {
213
+ const t = (performance.now() / 1600) % 1
214
+ const it = 1 - t
215
+ const hx = it * it * from.x + 2 * it * t * ctrlX + t * t * to.x
216
+ const hy = it * it * from.y + 2 * it * t * ctrlY + t * t * to.y
217
+ ctx.beginPath()
218
+ ctx.fillStyle = markerColor
219
+ ctx.arc(hx, hy, 2.4, 0, Math.PI * 2)
220
+ ctx.fill()
221
+ }
222
+ }
223
+
224
+ // Markers: brighter glowing dots, only when front-facing.
225
+ const markers = props.markers ?? []
226
+ const hits: typeof markerHits = []
227
+ for (const marker of markers) {
228
+ const proj = project(latLngToVec3(marker.lat, marker.lng), angle, radius, cx, cy)
229
+ if (!proj.front) continue
230
+ ctx.beginPath()
231
+ ctx.fillStyle = markerColor
232
+ ctx.shadowColor = markerColor
233
+ ctx.shadowBlur = 8
234
+ ctx.arc(proj.x, proj.y, 3, 0, Math.PI * 2)
235
+ ctx.fill()
236
+ ctx.shadowBlur = 0
237
+ if (marker.label) hits.push({ x: proj.x, y: proj.y, label: marker.label })
238
+ }
239
+ markerHits = hits
240
+ }
241
+
242
+ const tick = (ts: number): void => {
243
+ const dt = lastTs ? (ts - lastTs) / 1000 : 0
244
+ lastTs = ts
245
+ if (!dragging && !motionReduced() && props.autoRotate !== false) {
246
+ angle += ROTATE_SPEED * dt
247
+ }
248
+ draw()
249
+ // Keep animating while auto-rotating, or to refresh the traveling arc
250
+ // highlight — both are no-ops under reduced motion (frozen frame).
251
+ if (!motionReduced()) rafId = requestAnimationFrame(tick)
252
+ }
253
+
254
+ if (motionReduced()) {
255
+ draw() // single static frame
256
+ } else {
257
+ rafId = requestAnimationFrame(tick)
258
+ }
259
+
260
+ // Pointer drag to spin manually — works even under reduced motion.
261
+ const onPointerDown = (e: PointerEvent): void => {
262
+ dragging = true
263
+ lastX = e.clientX
264
+ canvas.setPointerCapture(e.pointerId)
265
+ }
266
+ const onPointerMove = (e: PointerEvent): void => {
267
+ if (!dragging) {
268
+ // Hover hit-test against the last drawn marker positions → native tooltip.
269
+ const rect = canvas.getBoundingClientRect()
270
+ const px = e.clientX - rect.left
271
+ const py = e.clientY - rect.top
272
+ const hit = markerHits.find((m) => Math.hypot(m.x - px, m.y - py) <= 6)
273
+ canvas.title = hit?.label ?? ''
274
+ return
275
+ }
276
+ const dx = e.clientX - lastX
277
+ lastX = e.clientX
278
+ angle += dx * DRAG_SPEED
279
+ if (motionReduced()) draw() // rAF loop is off; redraw explicitly per move
280
+ }
281
+ const onPointerUp = (e: PointerEvent): void => {
282
+ dragging = false
283
+ if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId)
284
+ }
285
+ bind(canvas, 'pointerdown', onPointerDown as EventListener)
286
+ bind(canvas, 'pointermove', onPointerMove as EventListener)
287
+ bind(canvas, 'pointerup', onPointerUp as EventListener)
288
+ bind(canvas, 'pointercancel', onPointerUp as EventListener)
289
+
290
+ // Re-render (static frame) if props change while reduced motion holds the
291
+ // loop frozen — otherwise the rAF loop already picks up prop changes.
292
+ createEffect(() => {
293
+ // Track reactive deps: size + reference identity of markers/arcs.
294
+ void size()
295
+ void props.markers
296
+ void props.arcs
297
+ void props.autoRotate
298
+ if (motionReduced()) draw()
299
+ })
300
+
301
+ onCleanup(() => {
302
+ if (rafId) cancelAnimationFrame(rafId)
303
+ cleanups.forEach((fn) => fn())
304
+ })
305
+ })
306
+
307
+ return (
308
+ <canvas
309
+ ref={canvas}
310
+ class={cn('touch-none rounded-full select-none', props.class)}
311
+ width={size()}
312
+ height={size()}
313
+ role="img"
314
+ aria-label="Interactive globe"
315
+ />
316
+ )
317
+ }
@@ -0,0 +1,250 @@
1
+ // Kanban board: columns of draggable cards, reorderable within a column and
2
+ // movable across columns. Pointer-events based drag (no HTML5 drag/drop),
3
+ // the same grip-handle + floating-clone idiom as Sortable.tsx, extended to
4
+ // track which column the pointer is currently over so cards can cross
5
+ // container boundaries. No transition/animation is applied to the drag
6
+ // itself (same as Sortable), so there's nothing to gate on reduced motion —
7
+ // drops are instant either way.
8
+ import { createEffect, createSignal, For, Show, type JSX } from 'solid-js'
9
+ import { Portal } from 'solid-js/web'
10
+ import { GripVertical } from 'lucide-solid'
11
+
12
+ import { cn } from '../lib/cn'
13
+ import { Badge } from './Badge'
14
+ import { Card } from './Card'
15
+
16
+ /** A single card on a {@link Kanban} board. */
17
+ export interface KanbanCard {
18
+ /** Stable unique id (used for drag tracking + list keying). */
19
+ id: string
20
+ /** Card body — usually a title/label. */
21
+ title: JSX.Element
22
+ /** Optional trailing badge (e.g. priority, assignee initials). */
23
+ badge?: JSX.Element
24
+ }
25
+
26
+ /** A column of a {@link Kanban} board. */
27
+ export interface KanbanColumn {
28
+ /** Stable unique id. */
29
+ id: string
30
+ /** Column heading. */
31
+ title: string
32
+ /** Cards in the column, top to bottom. */
33
+ cards: KanbanCard[]
34
+ /** Optional WIP limit — the count badge switches to a warning tone past it. */
35
+ limit?: number
36
+ }
37
+
38
+ export interface KanbanProps {
39
+ /** Columns, in display order left to right. */
40
+ columns: KanbanColumn[]
41
+ /** Called with the full new columns array after any drag (reorder or move). */
42
+ onChange?: (columns: KanbanColumn[]) => void
43
+ class?: string
44
+ }
45
+
46
+ interface Drag {
47
+ cardId: string
48
+ card: KanbanCard
49
+ fromColumnId: string
50
+ currentColumnId: string
51
+ y: number
52
+ x: number
53
+ offsetY: number
54
+ offsetX: number
55
+ height: number
56
+ width: number
57
+ }
58
+
59
+ /**
60
+ * Horizontal Kanban board: glass column panels holding vertical lists of
61
+ * draggable cards. Drag a card by its grip handle to reorder it within a
62
+ * column or drop it into another column — a dashed placeholder marks the
63
+ * drop slot and a floating clone follows the pointer, the same pointer-events
64
+ * idiom as {@link Sortable} extended to track which column's list the
65
+ * pointer is currently over. Works controlled (pass `columns` sourced from
66
+ * your own state + `onChange` to write it back) or uncontrolled (pass
67
+ * `columns` once and just read the final layout from `onChange`).
68
+ *
69
+ * @example
70
+ * ```tsx
71
+ * const [columns, setColumns] = createSignal<KanbanColumn[]>([
72
+ * { id: 'todo', title: 'To do', cards: [{ id: '1', title: 'Write spec' }] },
73
+ * { id: 'doing', title: 'Doing', cards: [], limit: 2 },
74
+ * { id: 'done', title: 'Done', cards: [] },
75
+ * ])
76
+ * <Kanban columns={columns()} onChange={setColumns} />
77
+ * ```
78
+ */
79
+ export function Kanban(props: KanbanProps): JSX.Element {
80
+ // eslint-disable-next-line solid/reactivity -- seed once; a createEffect below keeps it in sync
81
+ const [local, setLocal] = createSignal<KanbanColumn[]>(props.columns)
82
+ const [drag, setDrag] = createSignal<Drag | null>(null)
83
+ const listRefs: Record<string, HTMLDivElement | undefined> = {}
84
+
85
+ createEffect(() => {
86
+ const next = props.columns
87
+ if (!drag()) setLocal(next)
88
+ })
89
+
90
+ const moveCard = (cardId: string, targetColumnId: string, targetIndex: number) => {
91
+ setLocal((cur) => {
92
+ const next = cur.map((col) => ({ ...col, cards: col.cards.slice() }))
93
+ const fromCol = next.find((col) => col.cards.some((card) => card.id === cardId))
94
+ const toCol = next.find((col) => col.id === targetColumnId)
95
+ if (!fromCol || !toCol) return cur
96
+ const fromIndex = fromCol.cards.findIndex((card) => card.id === cardId)
97
+ const [moved] = fromCol.cards.splice(fromIndex, 1)
98
+ toCol.cards.splice(Math.min(targetIndex, toCol.cards.length), 0, moved)
99
+ return next
100
+ })
101
+ }
102
+
103
+ const onDragMove = (e: PointerEvent) => {
104
+ const d = drag()
105
+ if (!d) return
106
+
107
+ // Which column's list is the pointer over horizontally?
108
+ let targetColumnId = d.currentColumnId
109
+ for (const [colId, el] of Object.entries(listRefs)) {
110
+ if (!el) continue
111
+ const r = el.getBoundingClientRect()
112
+ if (e.clientX >= r.left && e.clientX <= r.right) {
113
+ targetColumnId = colId
114
+ break
115
+ }
116
+ }
117
+
118
+ // Where in that column's (other) cards does the pointer sit vertically?
119
+ const listEl = listRefs[targetColumnId]
120
+ const rows = listEl
121
+ ? Array.from(listEl.querySelectorAll<HTMLElement>('[data-kanban-card]')).filter(
122
+ (row) => row.dataset.kanbanCard !== d.cardId,
123
+ )
124
+ : []
125
+ let targetIndex = rows.length
126
+ for (let i = 0; i < rows.length; i++) {
127
+ const r = rows[i].getBoundingClientRect()
128
+ if (e.clientY < r.top + r.height / 2) {
129
+ targetIndex = i
130
+ break
131
+ }
132
+ }
133
+
134
+ setDrag({ ...d, y: e.clientY, x: e.clientX, currentColumnId: targetColumnId })
135
+ moveCard(d.cardId, targetColumnId, targetIndex)
136
+ }
137
+
138
+ const onDragStart = (e: PointerEvent, card: KanbanCard, columnId: string) => {
139
+ e.preventDefault()
140
+ const row = (e.currentTarget as HTMLElement).closest<HTMLElement>('[data-kanban-card]')
141
+ if (!row) return
142
+ const r = row.getBoundingClientRect()
143
+ setDrag({
144
+ cardId: card.id,
145
+ card,
146
+ fromColumnId: columnId,
147
+ currentColumnId: columnId,
148
+ y: e.clientY,
149
+ x: e.clientX,
150
+ offsetY: e.clientY - r.top,
151
+ offsetX: e.clientX - r.left,
152
+ height: r.height,
153
+ width: r.width,
154
+ })
155
+
156
+ const move = (ev: PointerEvent) => onDragMove(ev)
157
+ const up = () => {
158
+ window.removeEventListener('pointermove', move)
159
+ window.removeEventListener('pointerup', up)
160
+ const had = drag() !== null
161
+ setDrag(null)
162
+ if (had) props.onChange?.(local())
163
+ }
164
+ window.addEventListener('pointermove', move)
165
+ window.addEventListener('pointerup', up)
166
+ }
167
+
168
+ return (
169
+ <div class={cn('flex gap-4 overflow-x-auto pb-2', drag() && 'select-none', props.class)}>
170
+ <For each={local()}>
171
+ {(column) => {
172
+ const count = () => column.cards.length
173
+ const overLimit = () => column.limit !== undefined && count() > column.limit
174
+ return (
175
+ <Card glass class="flex w-72 shrink-0 flex-col">
176
+ <div class="flex items-center justify-between gap-2 border-b border-border p-3">
177
+ <h3 class="truncate font-semibold text-foreground">{column.title}</h3>
178
+ <Badge tone={overLimit() ? 'warning' : 'neutral'}>
179
+ {column.limit !== undefined ? `${count()}/${column.limit}` : `${count()}`}
180
+ </Badge>
181
+ </div>
182
+ <div
183
+ ref={(el) => (listRefs[column.id] = el)}
184
+ class="flex min-h-[60px] flex-1 flex-col gap-2 overflow-y-auto p-3"
185
+ >
186
+ <For each={column.cards}>
187
+ {(card) => {
188
+ const isDragged = () => drag()?.cardId === card.id
189
+ return (
190
+ <Show
191
+ when={!isDragged()}
192
+ fallback={
193
+ <div
194
+ data-kanban-card={card.id}
195
+ class="rounded-lg border-2 border-dashed border-primary/40 bg-primary/5"
196
+ style={{ height: `${drag()?.height ?? 0}px` }}
197
+ />
198
+ }
199
+ >
200
+ <div
201
+ data-kanban-card={card.id}
202
+ tabIndex={0}
203
+ class="flex items-center gap-2 rounded-lg border border-border bg-card p-2 text-card-foreground"
204
+ >
205
+ <button
206
+ type="button"
207
+ onPointerDown={(e) => onDragStart(e, card, column.id)}
208
+ style={{ 'touch-action': 'none' }}
209
+ class="shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing"
210
+ aria-label="Drag to move card"
211
+ >
212
+ <GripVertical class="h-4 w-4" />
213
+ </button>
214
+ <div class="min-w-0 flex-1">{card.title}</div>
215
+ <Show when={card.badge}>{card.badge}</Show>
216
+ </div>
217
+ </Show>
218
+ )
219
+ }}
220
+ </For>
221
+ </div>
222
+ </Card>
223
+ )
224
+ }}
225
+ </For>
226
+ <Show when={drag()}>
227
+ {(d) => (
228
+ <Portal>
229
+ <div
230
+ class="fixed flex items-center gap-2 rounded-lg border border-border bg-card p-2 text-card-foreground shadow-2xl"
231
+ style={{
232
+ left: `${d().x - d().offsetX}px`,
233
+ top: `${d().y - d().offsetY}px`,
234
+ width: `${d().width}px`,
235
+ 'pointer-events': 'none',
236
+ 'z-index': 70,
237
+ }}
238
+ >
239
+ <span class="shrink-0 text-muted-foreground">
240
+ <GripVertical class="h-4 w-4" />
241
+ </span>
242
+ <div class="min-w-0 flex-1">{d().card.title}</div>
243
+ <Show when={d().card.badge}>{d().card.badge}</Show>
244
+ </div>
245
+ </Portal>
246
+ )}
247
+ </Show>
248
+ </div>
249
+ )
250
+ }
@@ -0,0 +1,88 @@
1
+ // Lamp — a hero/section backdrop: a spotlight beam fanning down from the top
2
+ // center (two symmetric blurred gradient cones + a bright thin line), with
3
+ // `children` sitting in the illuminated area below. Same "self-contained
4
+ // backdrop" idiom as Aurora/SpaceBackground (dark base, token-tinted glow,
5
+ // motionReduced()-aware), just scoped to one hero/section instead of the
6
+ // whole page.
7
+ import { onMount, type JSX } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { animate, motionReduced } from '../lib/motion'
11
+
12
+ export interface LampProps {
13
+ children?: JSX.Element
14
+ class?: string
15
+ }
16
+
17
+ /**
18
+ * Hero/section backdrop: a spotlight beam fanning down from the top center —
19
+ * two symmetric blurred gradient cones (`--primary` / `--accent`) plus a
20
+ * bright thin source line — over a dark base, with `children` centered in
21
+ * the illuminated area below. Grows/brightens in on mount via Motion
22
+ * (opacity + scale); static under {@link motionReduced}. Self-contained
23
+ * (`relative overflow-hidden`); size it with `class` (e.g. `min-h-[28rem]`).
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * <Lamp class="min-h-[28rem]">
28
+ * <h1 class="text-4xl font-semibold text-foreground">Build in the light</h1>
29
+ * <p class="mt-4 text-muted-foreground">A hero backdrop with a spotlight glow.</p>
30
+ * </Lamp>
31
+ * ```
32
+ */
33
+ export function Lamp(props: LampProps): JSX.Element {
34
+ let beamEl: HTMLDivElement | undefined
35
+
36
+ onMount(() => {
37
+ if (motionReduced() || !beamEl) return
38
+ animate(beamEl, { opacity: [0, 1], scale: [0.85, 1] }, { duration: 0.9, ease: 'easeOut' })
39
+ })
40
+
41
+ return (
42
+ <div
43
+ class={cn(
44
+ 'relative flex min-h-[24rem] w-full flex-col items-center overflow-hidden bg-background',
45
+ props.class,
46
+ )}
47
+ >
48
+ <div
49
+ ref={beamEl}
50
+ aria-hidden="true"
51
+ class="pointer-events-none absolute inset-0"
52
+ style={motionReduced() ? undefined : { opacity: 0, 'transform-origin': 'top center' }}
53
+ >
54
+ {/* Left cone: apex at top-center, fanning down-left. */}
55
+ <div
56
+ class="absolute left-1/2 top-0 h-72 w-72 -translate-x-full blur-2xl"
57
+ style={{
58
+ background: 'linear-gradient(115deg, hsl(var(--primary) / 0.75), transparent 70%)',
59
+ 'clip-path': 'polygon(100% 0%, 0% 100%, 100% 100%)',
60
+ }}
61
+ />
62
+ {/* Right cone: mirror of the left, same apex. */}
63
+ <div
64
+ class="absolute left-1/2 top-0 h-72 w-72 blur-2xl"
65
+ style={{
66
+ background: 'linear-gradient(245deg, hsl(var(--accent) / 0.75), transparent 70%)',
67
+ 'clip-path': 'polygon(0% 0%, 100% 100%, 0% 100%)',
68
+ }}
69
+ />
70
+ {/* Bright thin line at the beam's source. */}
71
+ <div
72
+ class="absolute left-1/2 top-0 h-px w-64 -translate-x-1/2 blur-[1px]"
73
+ style={{ background: 'hsl(var(--primary))' }}
74
+ />
75
+ {/* Soft bloom under the line, grounding the beam. */}
76
+ <div
77
+ class="absolute left-1/2 top-0 h-40 w-80 -translate-x-1/2 blur-2xl"
78
+ style={{
79
+ background: 'radial-gradient(ellipse at top, hsl(var(--primary) / 0.5), transparent 70%)',
80
+ }}
81
+ />
82
+ </div>
83
+ <div class="relative z-10 flex flex-1 flex-col items-center justify-center px-4 py-16 text-center">
84
+ {props.children}
85
+ </div>
86
+ </div>
87
+ )
88
+ }