@signal9/era-ui 23.1.0 → 23.2.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/CHANGELOG.md +14 -0
- package/dist/ai/index.d.ts +2 -0
- package/dist/ai/index.js +2 -0
- package/dist/ai/timeline/context.svelte.d.ts +67 -0
- package/dist/ai/timeline/context.svelte.js +99 -0
- package/dist/ai/timeline/index.d.ts +3 -0
- package/dist/ai/timeline/index.js +3 -0
- package/dist/ai/timeline/timeline-step.svelte +98 -0
- package/dist/ai/timeline/timeline-step.svelte.d.ts +9 -0
- package/dist/ai/timeline/timeline.svelte +84 -0
- package/dist/ai/timeline/timeline.svelte.d.ts +13 -0
- package/dist/ai/todo/context.svelte.d.ts +43 -0
- package/dist/ai/todo/context.svelte.js +53 -0
- package/dist/ai/todo/index.d.ts +5 -0
- package/dist/ai/todo/index.js +5 -0
- package/dist/ai/todo/todo-header.svelte +56 -0
- package/dist/ai/todo/todo-header.svelte.d.ts +9 -0
- package/dist/ai/todo/todo-item.svelte +97 -0
- package/dist/ai/todo/todo-item.svelte.d.ts +10 -0
- package/dist/ai/todo/todo-list.svelte +40 -0
- package/dist/ai/todo/todo-list.svelte.d.ts +10 -0
- package/dist/ai/todo/todo.svelte +49 -0
- package/dist/ai/todo/todo.svelte.d.ts +12 -0
- package/dist/era-ui.css +1 -1
- package/dist/eslint/index.js +3 -0
- package/dist/ui/avatar/avatar-fallback.svelte +4 -4
- package/dist/ui/calendar/calendar-day.svelte +8 -1
- package/dist/ui/date-field/date-field-segment.svelte +11 -1
- package/dist/ui/date-picker/date-picker-segment.svelte +11 -1
- package/dist/ui/date-range-field/date-range-field-segment.svelte +11 -1
- package/dist/ui/date-range-picker/date-range-picker-segment.svelte +11 -1
- package/dist/ui/time-field/time-field-segment.svelte +11 -1
- package/dist/ui/time-range-field/time-range-field-segment.svelte +11 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [23.2.0](https://github.com/sig-nine/era-ui/compare/v23.1.1...v23.2.0) (2026-08-09)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **ai:** Timeline — a build bar whose segment widths ARE the durations ([47a5304](https://github.com/sig-nine/era-ui/commit/47a5304d362b7dab57d905f998be54694d6c3e03))
|
|
6
|
+
* **ai:** Todo — an agent's plan board, ported from term the era way ([d1f39cd](https://github.com/sig-nine/era-ui/commit/d1f39cd82f538ad1711f0c092c77c221338193af))
|
|
7
|
+
|
|
8
|
+
## [23.1.1](https://github.com/sig-nine/era-ui/compare/v23.1.0...v23.1.1) (2026-08-09)
|
|
9
|
+
|
|
10
|
+
### Bug Fixes
|
|
11
|
+
|
|
12
|
+
* **docs:** hoist the embedded example out of a mustache ([4da5ee0](https://github.com/sig-nine/era-ui/commit/4da5ee0a911970f009acd967e0b10e3c6aa5723d))
|
|
13
|
+
* **typography:** close the token-cell half of anonymous-text — 106 findings to 26 ([682c5df](https://github.com/sig-nine/era-ui/commit/682c5df012547ad191a9034a3acf880e832a5b69))
|
|
14
|
+
|
|
1
15
|
## [23.1.0](https://github.com/sig-nine/era-ui/compare/v23.0.0...v23.1.0) (2026-08-09)
|
|
2
16
|
|
|
3
17
|
### Features
|
package/dist/ai/index.d.ts
CHANGED
|
@@ -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,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;
|