@signal9/era-ui 23.1.1 → 23.3.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 (32) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/ai/index.d.ts +2 -0
  3. package/dist/ai/index.js +2 -0
  4. package/dist/ai/timeline/context.svelte.d.ts +67 -0
  5. package/dist/ai/timeline/context.svelte.js +99 -0
  6. package/dist/ai/timeline/index.d.ts +3 -0
  7. package/dist/ai/timeline/index.js +3 -0
  8. package/dist/ai/timeline/timeline-step.svelte +98 -0
  9. package/dist/ai/timeline/timeline-step.svelte.d.ts +9 -0
  10. package/dist/ai/timeline/timeline.svelte +84 -0
  11. package/dist/ai/timeline/timeline.svelte.d.ts +13 -0
  12. package/dist/ai/todo/context.svelte.d.ts +43 -0
  13. package/dist/ai/todo/context.svelte.js +53 -0
  14. package/dist/ai/todo/index.d.ts +5 -0
  15. package/dist/ai/todo/index.js +5 -0
  16. package/dist/ai/todo/todo-header.svelte +56 -0
  17. package/dist/ai/todo/todo-header.svelte.d.ts +9 -0
  18. package/dist/ai/todo/todo-item.svelte +97 -0
  19. package/dist/ai/todo/todo-item.svelte.d.ts +10 -0
  20. package/dist/ai/todo/todo-list.svelte +40 -0
  21. package/dist/ai/todo/todo-list.svelte.d.ts +10 -0
  22. package/dist/ai/todo/todo.svelte +49 -0
  23. package/dist/ai/todo/todo.svelte.d.ts +12 -0
  24. package/dist/era-ui.css +1 -1
  25. package/dist/ui/toggle-group/context.svelte.d.ts +28 -0
  26. package/dist/ui/toggle-group/context.svelte.js +9 -0
  27. package/dist/ui/toggle-group/index.d.ts +1 -0
  28. package/dist/ui/toggle-group/toggle-group-item.svelte +34 -4
  29. package/dist/ui/toggle-group/toggle-group-item.svelte.d.ts +16 -0
  30. package/dist/ui/toggle-group/toggle-group-root.svelte +42 -2
  31. package/dist/ui/toggle-group/toggle-group-root.svelte.d.ts +21 -1
  32. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## [23.3.0](https://github.com/sig-nine/era-ui/compare/v23.2.0...v23.3.0) (2026-08-10)
2
+
3
+ ### Features
4
+
5
+ * **toggle-group:** a segmented variant, because the group has to be the container ([dace35f](https://github.com/sig-nine/era-ui/commit/dace35ff944e527b1560b12f4c31443e7c97cc8a))
6
+
7
+ ## [23.2.0](https://github.com/sig-nine/era-ui/compare/v23.1.1...v23.2.0) (2026-08-09)
8
+
9
+ ### Features
10
+
11
+ * **ai:** Timeline — a build bar whose segment widths ARE the durations ([47a5304](https://github.com/sig-nine/era-ui/commit/47a5304d362b7dab57d905f998be54694d6c3e03))
12
+ * **ai:** Todo — an agent's plan board, ported from term the era way ([d1f39cd](https://github.com/sig-nine/era-ui/commit/d1f39cd82f538ad1711f0c092c77c221338193af))
13
+
1
14
  ## [23.1.1](https://github.com/sig-nine/era-ui/compare/v23.1.0...v23.1.1) (2026-08-09)
2
15
 
3
16
  ### Bug Fixes
@@ -9,6 +9,8 @@ export * as Suggestions from './suggestion/index.js';
9
9
  export * as Actions from './actions/index.js';
10
10
  export * as Artifact from './artifact/index.js';
11
11
  export * as Plan from './plan/index.js';
12
+ export * as Todo from './todo/index.js';
13
+ export * as Timeline from './timeline/index.js';
12
14
  export * as Queue from './queue/index.js';
13
15
  export * as Confirmation from './confirmation/index.js';
14
16
  export * as InlineCitation from './inline-citation/index.js';
package/dist/ai/index.js CHANGED
@@ -14,6 +14,8 @@ export * as Suggestions from './suggestion/index.js';
14
14
  export * as Actions from './actions/index.js';
15
15
  export * as Artifact from './artifact/index.js';
16
16
  export * as Plan from './plan/index.js';
17
+ export * as Todo from './todo/index.js';
18
+ export * as Timeline from './timeline/index.js';
17
19
  export * as Queue from './queue/index.js';
18
20
  export * as Confirmation from './confirmation/index.js';
19
21
  export * as InlineCitation from './inline-citation/index.js';
@@ -0,0 +1,67 @@
1
+ /** A step's lifecycle. `running` is the only one that ticks. */
2
+ export declare const TIMELINE_STATES: readonly ["pending", "running", "done", "failed"];
3
+ export type TimelineState = (typeof TIMELINE_STATES)[number];
4
+ export interface TimelineStep {
5
+ id?: string | number;
6
+ label: string;
7
+ state: TimelineState;
8
+ /** Final duration in ms. Omit while running — it is derived from `startedAt`. */
9
+ durationMs?: number;
10
+ /** Epoch ms. Required for a running step's live counter; also drives the caption. */
11
+ startedAt?: number;
12
+ }
13
+ /**
14
+ * WHY WIDTH IS SHARED STATE RATHER THAN A PER-STEP PROP.
15
+ *
16
+ * The whole point of this bar is that a step's WIDTH is its duration — you read
17
+ * "installing took three times as long as cloning" without reading a number. A
18
+ * step cannot compute that alone; it needs the total. So the root owns the
19
+ * steps, and each step asks for its share.
20
+ *
21
+ * A step that took 200ms next to one that took 60s would otherwise get 0.3% of
22
+ * the width — narrower than its own label, and an invisible sliver is not
23
+ * information. That is handled by a min-width on the item rather than by
24
+ * adjusting the share: flexbox reserves min-widths before distributing the
25
+ * rest, so the short step stays legible AND the remaining steps keep their true
26
+ * ratios. See `grow`.
27
+ */
28
+ export declare class TimelineData {
29
+ steps: TimelineStep[];
30
+ /** Live clock, ticked by the root while any step is running. */
31
+ now: number;
32
+ /** A step's elapsed ms: its final duration, or live if still running. */
33
+ elapsed(step: TimelineStep): number;
34
+ get total(): number;
35
+ get running(): boolean;
36
+ /**
37
+ * This step's GROW FACTOR — its duration, straight.
38
+ *
39
+ * The first version computed a percentage with a minimum share baked in, so
40
+ * a short step stayed clickable. It worked and it lied: with five steps the
41
+ * 8% floor spent 40% of the bar, so the Cloudflare run this was modelled on
42
+ * (6s / 2s / 19s / 16s / 4s) rendered its 9.5:1 ratio as about 3:1. Damping
43
+ * the proportions is not a rounding error here — the proportion IS the
44
+ * component. Caught by rendering it beside the reference, not by reading it.
45
+ *
46
+ * Flexbox already solves this exactly. `flex: <duration> 1 0` makes width
47
+ * strictly proportional to duration, and a `min-width` on the item is taken
48
+ * out of the distributable space FIRST by the layout algorithm — so short
49
+ * steps stay legible and the survivors keep their true ratios, with no floor
50
+ * stolen from the pool and no chance of the row overflowing.
51
+ *
52
+ * A pending step returns 0 and collapses to its min-width, which is what you
53
+ * want: it has taken no time, so it has earned no width.
54
+ */
55
+ grow(step: TimelineStep): number;
56
+ }
57
+ export declare function setTimelineData(data: TimelineData): TimelineData;
58
+ export declare function getTimelineData(): TimelineData | null;
59
+ /**
60
+ * Compact duration, the way a build log writes it: 6s, 1m 04s, 340ms.
61
+ *
62
+ * Sub-second is shown in ms because a step that takes 340ms reading as "0s" is
63
+ * the one case where rounding destroys the only information present.
64
+ */
65
+ export declare function formatDuration(ms: number): string;
66
+ /** Wall-clock caption under a step, matching the bar's own precision. */
67
+ export declare function formatClock(epochMs: number): string;
@@ -0,0 +1,99 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ /** A step's lifecycle. `running` is the only one that ticks. */
3
+ export const TIMELINE_STATES = ['pending', 'running', 'done', 'failed'];
4
+ /**
5
+ * WHY WIDTH IS SHARED STATE RATHER THAN A PER-STEP PROP.
6
+ *
7
+ * The whole point of this bar is that a step's WIDTH is its duration — you read
8
+ * "installing took three times as long as cloning" without reading a number. A
9
+ * step cannot compute that alone; it needs the total. So the root owns the
10
+ * steps, and each step asks for its share.
11
+ *
12
+ * A step that took 200ms next to one that took 60s would otherwise get 0.3% of
13
+ * the width — narrower than its own label, and an invisible sliver is not
14
+ * information. That is handled by a min-width on the item rather than by
15
+ * adjusting the share: flexbox reserves min-widths before distributing the
16
+ * rest, so the short step stays legible AND the remaining steps keep their true
17
+ * ratios. See `grow`.
18
+ */
19
+ export class TimelineData {
20
+ steps = $state([]);
21
+ /** Live clock, ticked by the root while any step is running. */
22
+ now = $state(0);
23
+ /** A step's elapsed ms: its final duration, or live if still running. */
24
+ elapsed(step) {
25
+ if (step.state === 'running' && step.startedAt) {
26
+ return Math.max(0, this.now - step.startedAt);
27
+ }
28
+ return step.durationMs ?? 0;
29
+ }
30
+ get total() {
31
+ return this.steps.reduce((sum, s) => sum + this.elapsed(s), 0);
32
+ }
33
+ get running() {
34
+ return this.steps.some((s) => s.state === 'running');
35
+ }
36
+ /**
37
+ * This step's GROW FACTOR — its duration, straight.
38
+ *
39
+ * The first version computed a percentage with a minimum share baked in, so
40
+ * a short step stayed clickable. It worked and it lied: with five steps the
41
+ * 8% floor spent 40% of the bar, so the Cloudflare run this was modelled on
42
+ * (6s / 2s / 19s / 16s / 4s) rendered its 9.5:1 ratio as about 3:1. Damping
43
+ * the proportions is not a rounding error here — the proportion IS the
44
+ * component. Caught by rendering it beside the reference, not by reading it.
45
+ *
46
+ * Flexbox already solves this exactly. `flex: <duration> 1 0` makes width
47
+ * strictly proportional to duration, and a `min-width` on the item is taken
48
+ * out of the distributable space FIRST by the layout algorithm — so short
49
+ * steps stay legible and the survivors keep their true ratios, with no floor
50
+ * stolen from the pool and no chance of the row overflowing.
51
+ *
52
+ * A pending step returns 0 and collapses to its min-width, which is what you
53
+ * want: it has taken no time, so it has earned no width.
54
+ */
55
+ grow(step) {
56
+ return this.elapsed(step);
57
+ }
58
+ }
59
+ const KEY = Symbol('era.timeline');
60
+ export function setTimelineData(data) {
61
+ setContext(KEY, data);
62
+ return data;
63
+ }
64
+ export function getTimelineData() {
65
+ return getContext(KEY) ?? null;
66
+ }
67
+ /**
68
+ * Compact duration, the way a build log writes it: 6s, 1m 04s, 340ms.
69
+ *
70
+ * Sub-second is shown in ms because a step that takes 340ms reading as "0s" is
71
+ * the one case where rounding destroys the only information present.
72
+ */
73
+ export function formatDuration(ms) {
74
+ if (ms < 1000)
75
+ return `${Math.round(ms)}ms`;
76
+ const secs = ms / 1000;
77
+ if (secs < 60)
78
+ return `${secs < 10 ? secs.toFixed(1).replace(/\.0$/, '') : Math.round(secs)}s`;
79
+ const m = Math.floor(secs / 60);
80
+ const s = Math.round(secs % 60);
81
+ return `${m}m ${String(s).padStart(2, '0')}s`;
82
+ }
83
+ /** Wall-clock caption under a step, matching the bar's own precision. */
84
+ export function formatClock(epochMs) {
85
+ // Intl rather than `new Date(...)` getters: the lint rightly bans a mutable
86
+ // Date here (a reactive component holding one is a stale-read waiting to
87
+ // happen), and this is a pure format of a number that is never stored.
88
+ // hourCycle h23 keeps it 24-hour regardless of locale, because this caption
89
+ // is read against a log file, not a wall clock.
90
+ const parts = new Intl.DateTimeFormat(undefined, {
91
+ hour: '2-digit',
92
+ minute: '2-digit',
93
+ second: '2-digit',
94
+ fractionalSecondDigits: 3,
95
+ hourCycle: 'h23'
96
+ }).formatToParts(epochMs);
97
+ const at = (type) => parts.find((p) => p.type === type)?.value ?? '00';
98
+ return `${at('hour')}:${at('minute')}:${at('second')}.${at('fractionalSecond')}`;
99
+ }
@@ -0,0 +1,3 @@
1
+ export { default as Root } from './timeline.svelte';
2
+ export { default as Step } from './timeline-step.svelte';
3
+ export { TimelineData, TIMELINE_STATES, formatDuration, formatClock, type TimelineState, type TimelineStep } from './context.svelte.js';
@@ -0,0 +1,3 @@
1
+ export { default as Root } from './timeline.svelte';
2
+ export { default as Step } from './timeline-step.svelte';
3
+ export { TimelineData, TIMELINE_STATES, formatDuration, formatClock } from './context.svelte.js';
@@ -0,0 +1,98 @@
1
+ <script lang="ts" module>
2
+ import type { TimelineState } from './context.svelte.js';
3
+
4
+ /** Semantic tokens only — the bar follows theme and surface. */
5
+ const TONE: Record<TimelineState, { label: string; bar: string; icon: string }> = {
6
+ pending: { label: 'text-muted/60', bar: 'bg-elevated opacity-60', icon: 'text-muted' },
7
+ running: { label: 'text-bright', bar: 'bg-elevated', icon: 'text-primary' },
8
+ done: { label: 'text-muted', bar: 'bg-elevated', icon: 'text-success' },
9
+ failed: { label: 'text-destructive', bar: 'bg-elevated', icon: 'text-destructive' }
10
+ };
11
+
12
+ const ANNOUNCE: Record<TimelineState, string> = {
13
+ pending: 'not started',
14
+ running: 'in progress',
15
+ done: 'completed',
16
+ failed: 'failed'
17
+ };
18
+ </script>
19
+
20
+ <script lang="ts">
21
+ import CircleCheck from '@lucide/svelte/icons/circle-check';
22
+ import CircleX from '@lucide/svelte/icons/circle-x';
23
+ import LoaderCircle from '@lucide/svelte/icons/loader-circle';
24
+ import { cn } from '../../utils/index.js';
25
+ import {
26
+ getTimelineData,
27
+ formatDuration,
28
+ formatClock,
29
+ type TimelineStep
30
+ } from './context.svelte.js';
31
+
32
+ let {
33
+ step,
34
+ showClock = true,
35
+ class: className
36
+ }: { step: TimelineStep; showClock?: boolean; class?: string } = $props();
37
+
38
+ const data = getTimelineData();
39
+ const tone = $derived(TONE[step.state]);
40
+ const elapsed = $derived(data?.elapsed(step) ?? step.durationMs ?? 0);
41
+ const grow = $derived(data?.grow(step) ?? 0);
42
+ </script>
43
+
44
+ <!--
45
+ One column: label above, bar, clock below — the three rows the Cloudflare
46
+ build timeline uses, because each answers a different question. The label is
47
+ WHAT, the bar's width is HOW LONG relative to the rest, and the caption is
48
+ WHEN in wall-clock, which is the one you need when correlating against logs.
49
+
50
+ flex-grow IS the duration, with a zero basis, so width is strictly
51
+ proportional. The min-width keeps a 200ms step readable beside a 60s one —
52
+ flexbox reserves it before distributing the rest, so the short step stays
53
+ legible without stealing proportion from the others. ch rather than a tier
54
+ rung because the constraint is "wide enough for its own text", which is a
55
+ text measure, not a step on era's ladder.
56
+
57
+ It is wider when the clock shows, because the caption is the whole reason the
58
+ clock is there: a timestamp is what you correlate against a log, and
59
+ `16:20:14.…` correlates against nothing. 7ch fits the duration alone; 12ch
60
+ fits `16:20:14.538`.
61
+ -->
62
+ <div
63
+ class={cn('flex flex-col gap-gutter', showClock ? 'min-w-[12ch]' : 'min-w-[7ch]')}
64
+ style="flex: {grow} 1 0"
65
+ >
66
+ <span class={cn('truncate text-body', tone.label)}>{step.label}</span>
67
+
68
+ <div
69
+ class={cn(
70
+ 'flex h-control items-center justify-end gap-inset-control rounded-control px-inset-control shadow-sm outline outline-divider',
71
+ tone.bar,
72
+ className
73
+ )}
74
+ >
75
+ <!-- Tabular figures: the counter ticks every 100ms while running, and
76
+ proportional digits make it jitter horizontally as 1s becomes 8s. -->
77
+ <span class="truncate font-mono text-body tabular-nums {tone.label}">
78
+ {elapsed > 0 || step.state !== 'pending' ? formatDuration(elapsed) : '—'}
79
+ </span>
80
+ {#if step.state === 'running'}
81
+ <LoaderCircle
82
+ class="size-icon shrink-0 {tone.icon} [animation-duration:calc(var(--era-duration)*8)] motion-safe:animate-spin"
83
+ aria-hidden="true"
84
+ />
85
+ {:else if step.state === 'done'}
86
+ <CircleCheck class="size-icon shrink-0 {tone.icon}" aria-hidden="true" />
87
+ {:else if step.state === 'failed'}
88
+ <CircleX class="size-icon shrink-0 {tone.icon}" aria-hidden="true" />
89
+ {/if}
90
+ </div>
91
+
92
+ {#if showClock}
93
+ <span class="truncate font-mono text-micro text-muted tabular-nums">
94
+ {step.startedAt ? formatClock(step.startedAt) : ''}
95
+ </span>
96
+ {/if}
97
+ <span class="sr-only">{step.label}: {ANNOUNCE[step.state]}, {formatDuration(elapsed)}</span>
98
+ </div>
@@ -0,0 +1,9 @@
1
+ import { type TimelineStep } from './context.svelte.js';
2
+ type $$ComponentProps = {
3
+ step: TimelineStep;
4
+ showClock?: boolean;
5
+ class?: string;
6
+ };
7
+ declare const TimelineStep: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type TimelineStep = ReturnType<typeof TimelineStep>;
9
+ export default TimelineStep;
@@ -0,0 +1,84 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { onDestroy } from 'svelte';
4
+ import { cn } from '../../utils/index.js';
5
+ import { TimelineData, setTimelineData, type TimelineStep } from './context.svelte.js';
6
+ import Step from './timeline-step.svelte';
7
+
8
+ let {
9
+ steps = [],
10
+ tick = 100,
11
+ showClock = true,
12
+ children,
13
+ class: className
14
+ }: {
15
+ steps?: TimelineStep[];
16
+ /** Live counter interval in ms. 0 stops the clock (the counter freezes). */
17
+ tick?: number;
18
+ showClock?: boolean;
19
+ children?: Snippet;
20
+ class?: string;
21
+ } = $props();
22
+
23
+ const data = setTimelineData(new TimelineData());
24
+ $effect(() => {
25
+ data.steps = steps;
26
+ });
27
+
28
+ /*
29
+ * ONE CLOCK FOR THE WHOLE BAR, and only while something is running.
30
+ *
31
+ * The naive version puts an interval in each running step. There is only
32
+ * ever one running step in a build, so that looks equivalent — until a step
33
+ * finishes and the next starts, and the two intervals overlap, drift, and
34
+ * update on different frames, which is visible as the counter stuttering at
35
+ * every handover. One clock on the root also means the bar stops ticking
36
+ * entirely once the run is done: no timer outlives the thing it measures.
37
+ */
38
+ let timer: ReturnType<typeof setInterval> | null = null;
39
+ $effect(() => {
40
+ const shouldRun = tick > 0 && data.running;
41
+ if (!shouldRun) {
42
+ if (timer) clearInterval(timer);
43
+ timer = null;
44
+ // Settle on a final reading rather than leaving the last tick's value.
45
+ data.now = Date.now();
46
+ return;
47
+ }
48
+ data.now = Date.now();
49
+ timer = setInterval(() => (data.now = Date.now()), tick);
50
+ return () => {
51
+ if (timer) clearInterval(timer);
52
+ timer = null;
53
+ };
54
+ });
55
+ onDestroy(() => {
56
+ if (timer) clearInterval(timer);
57
+ });
58
+ </script>
59
+
60
+ <!--
61
+ A build timeline: one full-width bar cut into a segment per step, where a
62
+ segment's WIDTH is how long that step took. You read "installing took three
63
+ times as long as cloning" without reading a number — which is the thing a
64
+ list of durations cannot do and the reason this is a component rather than a
65
+ table.
66
+
67
+ role="list" with the steps as items: this is a sequence of labelled states,
68
+ and without it a screen reader gets a wall of unrelated numbers. Each step
69
+ carries its own sr-only summary because the visual encoding — width — is
70
+ exactly the part that does not survive being read aloud.
71
+ -->
72
+ <div
73
+ role="list"
74
+ aria-label="Build timeline"
75
+ class={cn('flex w-full items-start gap-gutter', className)}
76
+ >
77
+ {#if children}
78
+ {@render children()}
79
+ {:else}
80
+ {#each data.steps as step, i (step.id ?? i)}
81
+ <div role="listitem" class="contents"><Step {step} {showClock} /></div>
82
+ {/each}
83
+ {/if}
84
+ </div>
@@ -0,0 +1,13 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type TimelineStep } from './context.svelte.js';
3
+ type $$ComponentProps = {
4
+ steps?: TimelineStep[];
5
+ /** Live counter interval in ms. 0 stops the clock (the counter freezes). */
6
+ tick?: number;
7
+ showClock?: boolean;
8
+ children?: Snippet;
9
+ class?: string;
10
+ };
11
+ declare const Timeline: import("svelte").Component<$$ComponentProps, {}, "">;
12
+ type Timeline = ReturnType<typeof Timeline>;
13
+ export default Timeline;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The four states a plan item can be in.
3
+ *
4
+ * Deliberately the same four (and the same spellings) that agent runtimes
5
+ * already emit — `pending` / `in_progress` / `completed` / `cancelled` — so a
6
+ * consumer maps nothing. A UI vocabulary that disagrees with the protocol's is
7
+ * a translation layer every call site has to write.
8
+ */
9
+ export declare const TODO_STATUSES: readonly ["pending", "in_progress", "completed", "cancelled"];
10
+ export type TodoStatus = (typeof TODO_STATUSES)[number];
11
+ export interface TodoItem {
12
+ /** Stable identity. Falls back to the index when absent. */
13
+ id?: string | number;
14
+ content: string;
15
+ status: TodoStatus;
16
+ }
17
+ /**
18
+ * WHY THE COUNTS COME FROM CONTEXT RATHER THAN FROM THE CALLER.
19
+ *
20
+ * The obvious shape is `<Todo.Header done={3} total={5} />`, and it is wrong in
21
+ * the specific way this library keeps finding: it is a second copy of a derived
22
+ * truth. The header and the list would each be told the plan separately, and
23
+ * they drift the first time a caller filters the list, or renders a subset, or
24
+ * updates one and not the other — a progress count that disagrees with the rows
25
+ * under it, with nothing to catch it.
26
+ *
27
+ * Root owns the array; the header and the list read the SAME array. There is
28
+ * one plan and every part renders from it.
29
+ */
30
+ export declare class TodoState {
31
+ todos: TodoItem[];
32
+ /** Cancelled counts as resolved: it is done being worked on. */
33
+ get done(): number;
34
+ get total(): number;
35
+ /** The one item being worked on, or null. */
36
+ get active(): TodoItem | null;
37
+ get complete(): boolean;
38
+ /** 0–100, for the progress meter. */
39
+ get percent(): number;
40
+ }
41
+ export declare function setTodoState(state: TodoState): TodoState;
42
+ /** Null outside a Todo.Root, so a part used standalone degrades rather than throws. */
43
+ export declare function getTodoState(): TodoState | null;
@@ -0,0 +1,53 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ /**
3
+ * The four states a plan item can be in.
4
+ *
5
+ * Deliberately the same four (and the same spellings) that agent runtimes
6
+ * already emit — `pending` / `in_progress` / `completed` / `cancelled` — so a
7
+ * consumer maps nothing. A UI vocabulary that disagrees with the protocol's is
8
+ * a translation layer every call site has to write.
9
+ */
10
+ export const TODO_STATUSES = ['pending', 'in_progress', 'completed', 'cancelled'];
11
+ /**
12
+ * WHY THE COUNTS COME FROM CONTEXT RATHER THAN FROM THE CALLER.
13
+ *
14
+ * The obvious shape is `<Todo.Header done={3} total={5} />`, and it is wrong in
15
+ * the specific way this library keeps finding: it is a second copy of a derived
16
+ * truth. The header and the list would each be told the plan separately, and
17
+ * they drift the first time a caller filters the list, or renders a subset, or
18
+ * updates one and not the other — a progress count that disagrees with the rows
19
+ * under it, with nothing to catch it.
20
+ *
21
+ * Root owns the array; the header and the list read the SAME array. There is
22
+ * one plan and every part renders from it.
23
+ */
24
+ export class TodoState {
25
+ todos = $state([]);
26
+ /** Cancelled counts as resolved: it is done being worked on. */
27
+ get done() {
28
+ return this.todos.filter((t) => t.status === 'completed' || t.status === 'cancelled').length;
29
+ }
30
+ get total() {
31
+ return this.todos.length;
32
+ }
33
+ /** The one item being worked on, or null. */
34
+ get active() {
35
+ return this.todos.find((t) => t.status === 'in_progress') ?? null;
36
+ }
37
+ get complete() {
38
+ return this.total > 0 && this.done === this.total;
39
+ }
40
+ /** 0–100, for the progress meter. */
41
+ get percent() {
42
+ return this.total === 0 ? 0 : (this.done / this.total) * 100;
43
+ }
44
+ }
45
+ const KEY = Symbol('era.todo');
46
+ export function setTodoState(state) {
47
+ setContext(KEY, state);
48
+ return state;
49
+ }
50
+ /** Null outside a Todo.Root, so a part used standalone degrades rather than throws. */
51
+ export function getTodoState() {
52
+ return getContext(KEY) ?? null;
53
+ }
@@ -0,0 +1,5 @@
1
+ export { default as Root } from './todo.svelte';
2
+ export { default as Header } from './todo-header.svelte';
3
+ export { default as List } from './todo-list.svelte';
4
+ export { default as Item } from './todo-item.svelte';
5
+ export { TodoState, TODO_STATUSES, type TodoStatus, type TodoItem } from './context.svelte.js';
@@ -0,0 +1,5 @@
1
+ export { default as Root } from './todo.svelte';
2
+ export { default as Header } from './todo-header.svelte';
3
+ export { default as List } from './todo-list.svelte';
4
+ export { default as Item } from './todo-item.svelte';
5
+ export { TodoState, TODO_STATUSES } from './context.svelte.js';
@@ -0,0 +1,56 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { Collapsible } from 'bits-ui';
4
+ import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down';
5
+ import CircleCheck from '@lucide/svelte/icons/circle-check';
6
+ import LoaderCircle from '@lucide/svelte/icons/loader-circle';
7
+ import { Badge } from '../../ui/badge/index.js';
8
+ import { cn } from '../../utils/index.js';
9
+ import { getTodoState } from './context.svelte.js';
10
+
11
+ let {
12
+ title = 'Plan',
13
+ children,
14
+ class: className
15
+ }: { title?: string; children?: Snippet; class?: string } = $props();
16
+
17
+ const state = getTodoState();
18
+ </script>
19
+
20
+ <!--
21
+ THE COLLAPSED STATE HAS TO CARRY THE ACTIVE ITEM, which is the one thing
22
+ term's board got exactly right and the reason it can sit collapsed above a
23
+ conversation indefinitely: a header reading "Plan 3/7" tells you nothing you
24
+ act on, while "Plan 3/7 · running tests" is a status line. It renders only
25
+ when collapsed, so it never duplicates a row that is already visible.
26
+ -->
27
+ <Collapsible.Trigger
28
+ class={cn(
29
+ 'group/todo flex h-control w-full era-interactive cursor-pointer items-center gap-gutter rounded-control px-inset-control text-body hover:bg-highlight',
30
+ className
31
+ )}
32
+ >
33
+ {#if children}
34
+ {@render children()}
35
+ {:else}
36
+ {#if state?.complete}
37
+ <CircleCheck class="size-icon shrink-0 text-success" aria-hidden="true" />
38
+ {:else if state?.active}
39
+ <LoaderCircle
40
+ class="size-icon shrink-0 text-primary [animation-duration:calc(var(--era-duration)*8)] motion-safe:animate-spin"
41
+ aria-hidden="true"
42
+ />
43
+ {/if}
44
+ <span class="font-medium text-bright">{title}</span>
45
+ <Badge tone={state?.complete ? 'success' : 'default'}>
46
+ {state?.done ?? 0}/{state?.total ?? 0}
47
+ </Badge>
48
+ <!-- Only while collapsed: expanded, the active row is right there. -->
49
+ {#if state?.active}
50
+ <span class="min-w-0 truncate text-muted group-data-[state=open]/todo:hidden">
51
+ {state.active.content}
52
+ </span>
53
+ {/if}
54
+ {/if}
55
+ <ChevronsUpDown class="ml-auto size-icon shrink-0 text-muted" aria-hidden="true" />
56
+ </Collapsible.Trigger>
@@ -0,0 +1,9 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ title?: string;
4
+ children?: Snippet;
5
+ class?: string;
6
+ };
7
+ declare const TodoHeader: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type TodoHeader = ReturnType<typeof TodoHeader>;
9
+ export default TodoHeader;