@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,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,112 @@
1
+ // FollowingPointer — hides the native cursor over `children` and renders a
2
+ // custom arrow + label chip that follows the mouse instead. Position is
3
+ // written straight to the DOM node's `transform` on every pointermove (same
4
+ // "skip the signal, touch the ref directly" idiom as Magnetic.tsx) rather
5
+ // than through a reactive signal, so the follow is jank-free. No-op (native
6
+ // cursor kept, nothing custom rendered) under motionReduced().
7
+ import { onCleanup, onMount, Show, type JSX } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { motionReduced } from '../lib/motion'
11
+
12
+ export interface FollowingPointerProps {
13
+ label?: JSX.Element
14
+ /** Tone of the custom cursor + label chip. @default 'primary' */
15
+ color?: 'primary' | 'accent'
16
+ children: JSX.Element
17
+ class?: string
18
+ }
19
+
20
+ /**
21
+ * Wraps `children` in a `relative` container that hides the native cursor
22
+ * and renders a custom pointer — a small arrow glyph plus a rounded label
23
+ * chip in the given tone — that follows the mouse within the container.
24
+ * Hidden until the pointer enters, hidden again on leave. Under
25
+ * {@link motionReduced}, the native cursor is kept and nothing custom is
26
+ * rendered. Listeners are cleaned up on unmount.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * <FollowingPointer label="Alex" color="accent">
31
+ * <div class="grid h-48 place-items-center rounded-lg border">Hover me</div>
32
+ * </FollowingPointer>
33
+ * ```
34
+ */
35
+ export function FollowingPointer(props: FollowingPointerProps): JSX.Element {
36
+ let root: HTMLDivElement | undefined
37
+ let pointerEl: HTMLDivElement | undefined
38
+
39
+ onMount(() => {
40
+ if (motionReduced() || !root || !pointerEl) return
41
+
42
+ const handlePointerMove = (event: PointerEvent): void => {
43
+ const rect = root!.getBoundingClientRect()
44
+ const x = event.clientX - rect.left
45
+ const y = event.clientY - rect.top
46
+ pointerEl!.style.transform = `translate(${x}px, ${y}px)`
47
+ }
48
+
49
+ const handlePointerEnter = (event: PointerEvent): void => {
50
+ pointerEl!.style.opacity = '1'
51
+ handlePointerMove(event)
52
+ }
53
+
54
+ const handlePointerLeave = (): void => {
55
+ pointerEl!.style.opacity = '0'
56
+ }
57
+
58
+ root.addEventListener('pointermove', handlePointerMove)
59
+ root.addEventListener('pointerenter', handlePointerEnter)
60
+ root.addEventListener('pointerleave', handlePointerLeave)
61
+
62
+ onCleanup(() => {
63
+ root!.removeEventListener('pointermove', handlePointerMove)
64
+ root!.removeEventListener('pointerenter', handlePointerEnter)
65
+ root!.removeEventListener('pointerleave', handlePointerLeave)
66
+ })
67
+ })
68
+
69
+ const tone = (): 'primary' | 'accent' => (props.color === 'accent' ? 'accent' : 'primary')
70
+
71
+ return (
72
+ <div ref={root} class={cn('relative', !motionReduced() && 'cursor-none', props.class)}>
73
+ {props.children}
74
+ <Show when={!motionReduced()}>
75
+ <div
76
+ ref={pointerEl}
77
+ aria-hidden="true"
78
+ class="pointer-events-none absolute left-0 top-0 z-50 flex items-center opacity-0 transition-opacity duration-150 will-change-transform"
79
+ >
80
+ <svg
81
+ width="20"
82
+ height="20"
83
+ viewBox="0 0 20 20"
84
+ class={cn(
85
+ '-translate-x-0.5 -translate-y-0.5 drop-shadow',
86
+ tone() === 'accent' ? 'text-accent' : 'text-primary',
87
+ )}
88
+ >
89
+ <path
90
+ d="M2 2 L18 9 L10 11 L8 18 Z"
91
+ fill="currentColor"
92
+ stroke="hsl(var(--background))"
93
+ stroke-width="1"
94
+ />
95
+ </svg>
96
+ <Show when={props.label}>
97
+ <div
98
+ class={cn(
99
+ 'ml-2 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium shadow-lg',
100
+ tone() === 'accent'
101
+ ? 'bg-accent text-accent-foreground'
102
+ : 'bg-primary text-primary-foreground',
103
+ )}
104
+ >
105
+ {props.label}
106
+ </div>
107
+ </Show>
108
+ </div>
109
+ </Show>
110
+ </div>
111
+ )
112
+ }
@@ -0,0 +1,283 @@
1
+ // GanttChart — a read-only project schedule chart built from plain flex/grid
2
+ // `div`s (no SVG, no date library). Each task is a row with its name in a
3
+ // fixed-width left column and a bar positioned on a proportional time axis
4
+ // (left%/width% derived from the task's start/end within the overall date
5
+ // range), mirroring BarChart's percentage-of-max layout idiom. Dependency
6
+ // arrows are drawn as right-angle elbow connectors using bordered divs so
7
+ // the whole chart stays in the same box-model coordinate space as the bars.
8
+ import { createMemo, For, Show, type JSX } from 'solid-js'
9
+
10
+ import { cn } from '../lib/cn'
11
+
12
+ const DAY_MS = 24 * 60 * 60 * 1000
13
+ /** Header height (`h-8`) in px — the coordinate origin for row overlays. */
14
+ const HEADER_HEIGHT = 32
15
+ /** Per-task row height (`h-10`) in px. */
16
+ const ROW_HEIGHT = 40
17
+ /** Candidate tick spacings (days); the smallest that keeps ticks readable wins. */
18
+ const TICK_STEP_DAYS = [1, 2, 3, 7, 14, 30, 60, 90, 180]
19
+
20
+ /** A single bar rendered by {@link GanttChart}. */
21
+ export interface GanttTask {
22
+ id: string
23
+ name: string
24
+ /** ISO date, `'YYYY-MM-DD'`. */
25
+ start: string
26
+ /** ISO date, `'YYYY-MM-DD'`. */
27
+ end: string
28
+ /** Ids of tasks that must finish before this one starts; drawn as elbow connectors. */
29
+ dependencies?: string[]
30
+ /** Bar color. Defaults to `'primary'`. */
31
+ tone?: 'primary' | 'accent'
32
+ }
33
+
34
+ export interface GanttChartProps {
35
+ tasks: GanttTask[]
36
+ class?: string
37
+ }
38
+
39
+ interface Tick {
40
+ label: string
41
+ percent: number
42
+ }
43
+
44
+ interface Connector {
45
+ id: string
46
+ /** Elbow's vertical run: x of the dependency's end, from its row to the dependent's row. */
47
+ xFrom: number
48
+ yFrom: number
49
+ xTo: number
50
+ yTo: number
51
+ arrowRight: boolean
52
+ }
53
+
54
+ function pickStepDays(totalDays: number): number {
55
+ const target = totalDays / 8
56
+ return TICK_STEP_DAYS.find((step) => step >= target) ?? TICK_STEP_DAYS[TICK_STEP_DAYS.length - 1]
57
+ }
58
+
59
+ function formatTick(date: Date): string {
60
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
61
+ }
62
+
63
+ /**
64
+ * Read-only Gantt chart: a header row of date ticks over a proportional time
65
+ * axis, one row per task with a colored bar spanning `start`–`end`, elbow
66
+ * connectors for `dependencies`, and a "today" marker when today falls
67
+ * within the overall range. Horizontal scroll kicks in when the range is
68
+ * wide relative to the container.
69
+ *
70
+ * @example
71
+ * ```tsx
72
+ * <GanttChart
73
+ * tasks={[
74
+ * { id: 'design', name: 'Design', start: '2026-01-05', end: '2026-01-16', tone: 'accent' },
75
+ * { id: 'build', name: 'Build', start: '2026-01-19', end: '2026-02-06', dependencies: ['design'] },
76
+ * { id: 'launch', name: 'Launch', start: '2026-02-09', end: '2026-02-10', dependencies: ['build'] },
77
+ * ]}
78
+ * />
79
+ * ```
80
+ */
81
+ export function GanttChart(props: GanttChartProps): JSX.Element {
82
+ const range = createMemo(() => {
83
+ if (props.tasks.length === 0) return null
84
+ let min = Number.POSITIVE_INFINITY
85
+ let max = Number.NEGATIVE_INFINITY
86
+ for (const task of props.tasks) {
87
+ const start = new Date(task.start).getTime()
88
+ const end = new Date(task.end).getTime()
89
+ if (start < min) min = start
90
+ if (end > max) max = end
91
+ }
92
+ // Guard a zero-width range (single task, start === end) so percentages stay finite.
93
+ const totalMs = Math.max(max - min, DAY_MS)
94
+ return { min, max: min + totalMs, totalMs }
95
+ })
96
+
97
+ const percentFor = (isoOrTime: string | number): number => {
98
+ const r = range()
99
+ if (!r) return 0
100
+ const time = typeof isoOrTime === 'string' ? new Date(isoOrTime).getTime() : isoOrTime
101
+ return Math.min(100, Math.max(0, ((time - r.min) / r.totalMs) * 100))
102
+ }
103
+
104
+ const ticks = createMemo<Tick[]>(() => {
105
+ const r = range()
106
+ if (!r) return []
107
+ const totalDays = Math.max(1, Math.round(r.totalMs / DAY_MS))
108
+ const step = pickStepDays(totalDays)
109
+ const out: Tick[] = []
110
+ for (let d = 0; d <= totalDays; d += step) {
111
+ out.push({ label: formatTick(new Date(r.min + d * DAY_MS)), percent: (d / totalDays) * 100 })
112
+ }
113
+ return out
114
+ })
115
+
116
+ const todayPercent = createMemo<number | null>(() => {
117
+ const r = range()
118
+ if (!r) return null
119
+ const now = new Date()
120
+ const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())
121
+ if (today < r.min || today > r.max) return null
122
+ return percentFor(today)
123
+ })
124
+
125
+ const rowIndex = createMemo(() => {
126
+ const map = new Map<string, number>()
127
+ props.tasks.forEach((task, i) => map.set(task.id, i))
128
+ return map
129
+ })
130
+
131
+ const connectors = createMemo<Connector[]>(() => {
132
+ const byId = new Map(props.tasks.map((t) => [t.id, t]))
133
+ const index = rowIndex()
134
+ const out: Connector[] = []
135
+ for (const task of props.tasks) {
136
+ for (const depId of task.dependencies ?? []) {
137
+ const dep = byId.get(depId)
138
+ if (!dep || dep.id === task.id) continue
139
+ const iDep = index.get(dep.id)
140
+ const iTask = index.get(task.id)
141
+ if (iDep === undefined || iTask === undefined) continue
142
+ const xFrom = percentFor(dep.end)
143
+ const xTo = percentFor(task.start)
144
+ out.push({
145
+ id: `${dep.id}->${task.id}`,
146
+ xFrom,
147
+ yFrom: iDep * ROW_HEIGHT + ROW_HEIGHT / 2,
148
+ xTo,
149
+ yTo: iTask * ROW_HEIGHT + ROW_HEIGHT / 2,
150
+ arrowRight: xTo >= xFrom,
151
+ })
152
+ }
153
+ }
154
+ return out
155
+ })
156
+
157
+ return (
158
+ <div class={cn('overflow-x-auto rounded-lg border border-border bg-card', props.class)}>
159
+ <Show
160
+ when={props.tasks.length > 0}
161
+ fallback={<p class="p-4 text-sm text-muted-foreground">No tasks to display.</p>}
162
+ >
163
+ <div class="flex min-w-[640px]">
164
+ {/* Names column */}
165
+ <div class="w-40 shrink-0 border-r border-border">
166
+ <div class="flex h-8 items-center px-3 text-xs font-medium text-muted-foreground">Task</div>
167
+ <For each={props.tasks}>
168
+ {(task, i) => (
169
+ <div
170
+ class={cn(
171
+ 'flex h-10 items-center border-t border-border px-3',
172
+ i() % 2 === 1 && 'bg-muted',
173
+ )}
174
+ >
175
+ <span class="truncate text-sm text-foreground" title={task.name}>
176
+ {task.name}
177
+ </span>
178
+ </div>
179
+ )}
180
+ </For>
181
+ </div>
182
+
183
+ {/* Chart column: header ticks, rows, connectors and the today marker all
184
+ share this box as their coordinate space (percent for x, px for y). */}
185
+ <div class="relative flex-1">
186
+ {/* Vertical gridlines, one per tick, spanning the whole chart column. */}
187
+ <div class="pointer-events-none absolute inset-0">
188
+ <For each={ticks()}>
189
+ {(tick) => (
190
+ <div
191
+ class="absolute inset-y-0 border-l border-border/40"
192
+ style={{ left: `${tick.percent}%` }}
193
+ />
194
+ )}
195
+ </For>
196
+ </div>
197
+
198
+ <div class="relative h-8">
199
+ <For each={ticks()}>
200
+ {(tick) => (
201
+ <span
202
+ class="absolute top-1/2 -translate-y-1/2 whitespace-nowrap pl-1 text-[11px] text-muted-foreground"
203
+ style={{ left: `${tick.percent}%` }}
204
+ >
205
+ {tick.label}
206
+ </span>
207
+ )}
208
+ </For>
209
+ </div>
210
+
211
+ <For each={props.tasks}>
212
+ {(task, i) => {
213
+ const left = () => percentFor(task.start)
214
+ const width = () => Math.max(percentFor(task.end) - left(), 1)
215
+ return (
216
+ <div class={cn('relative h-10 border-t border-border', i() % 2 === 1 && 'bg-muted')}>
217
+ <div
218
+ title={`${task.name}: ${task.start} → ${task.end}`}
219
+ class={cn(
220
+ 'absolute inset-y-2 rounded',
221
+ task.tone === 'accent' ? 'bg-accent' : 'bg-primary',
222
+ )}
223
+ style={{ left: `${left()}%`, width: `${width()}%` }}
224
+ />
225
+ </div>
226
+ )
227
+ }}
228
+ </For>
229
+
230
+ {/* Dependency connectors: elbow (vertical run at the predecessor's end,
231
+ horizontal run into the dependent's start) as bordered divs. */}
232
+ <div
233
+ class="pointer-events-none absolute inset-x-0 bottom-0"
234
+ style={{ top: `${HEADER_HEIGHT}px` }}
235
+ >
236
+ <For each={connectors()}>
237
+ {(c) => (
238
+ <>
239
+ <div
240
+ class="absolute border-l border-border"
241
+ style={{
242
+ left: `${c.xFrom}%`,
243
+ top: `${Math.min(c.yFrom, c.yTo)}px`,
244
+ height: `${Math.abs(c.yTo - c.yFrom)}px`,
245
+ }}
246
+ />
247
+ <div
248
+ class="absolute border-t border-border"
249
+ style={{
250
+ top: `${c.yTo}px`,
251
+ left: `${Math.min(c.xFrom, c.xTo)}%`,
252
+ width: `${Math.abs(c.xTo - c.xFrom)}%`,
253
+ }}
254
+ />
255
+ <div
256
+ class={cn(
257
+ 'absolute h-0 w-0 border-y-4 border-y-transparent',
258
+ c.arrowRight ? 'border-l-8 border-l-border' : 'border-r-8 border-r-border',
259
+ )}
260
+ style={{
261
+ top: `${c.yTo - 4}px`,
262
+ left: c.arrowRight ? `calc(${c.xTo}% - 8px)` : `${c.xTo}%`,
263
+ }}
264
+ />
265
+ </>
266
+ )}
267
+ </For>
268
+ </div>
269
+
270
+ {/* "Today" marker, spanning the header + every row. */}
271
+ <Show when={todayPercent() !== null}>
272
+ <div
273
+ class="pointer-events-none absolute inset-y-0 border-l border-primary"
274
+ style={{ left: `${todayPercent()}%` }}
275
+ title="Today"
276
+ />
277
+ </Show>
278
+ </div>
279
+ </div>
280
+ </Show>
281
+ </div>
282
+ )
283
+ }