diff --git a/apps/web/src/lib/changes.test.ts b/apps/web/src/lib/changes.test.ts new file mode 100644 index 0000000..e370919 --- /dev/null +++ b/apps/web/src/lib/changes.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { summarizeChanges } from './changes'; +import type { DecisionState, TranscriptEntry } from './session'; + +/** A permission gate entry. Fixed ids keep tests independent — the summary never asserts on them. */ +function gate( + toolName: string, + input: Record, + decision: DecisionState = 'pending', +): TranscriptEntry { + return { kind: 'permission', id: 'e1', requestId: 'r1', toolName, input, decision }; +} + +const EDIT = { file_path: 'src/a.ts', old_string: 'a\nb', new_string: 'a\nc' }; // +1 −1 +const WRITE = { file_path: 'src/b.ts', content: 'x\ny' }; // +2 −0 +const MULTI_EDIT = { + file_path: 'src/c.ts', + edits: [ + { old_string: 'a', new_string: 'b' }, // +1 −1 + { old_string: 'c', new_string: 'd\ne' }, // +2 −1 + ], +}; + +describe('summarizeChanges', () => { + it('reads real +/- line counts from a pending edit gate and flags it pending', () => { + const summary = summarizeChanges([gate('Edit', EDIT, 'pending')]); + expect(summary.files).toEqual([{ path: 'src/a.ts', additions: 1, deletions: 1 }]); + expect(summary.additions).toBe(1); + expect(summary.deletions).toBe(1); + expect(summary.pending).toBe(1); + }); + + it('counts an approved write as applied (no longer pending)', () => { + const summary = summarizeChanges([gate('Write', WRITE, 'approved')]); + expect(summary.files).toEqual([{ path: 'src/b.ts', additions: 2, deletions: 0 }]); + expect(summary.pending).toBe(0); + }); + + it('counts an in-flight approving gate as a change, but not as pending', () => { + const summary = summarizeChanges([gate('Edit', EDIT, 'approving')]); + expect(summary.files).toEqual([{ path: 'src/a.ts', additions: 1, deletions: 1 }]); + expect(summary.pending).toBe(0); + }); + + it('excludes a rejected edit and an in-flight rejecting one — neither writes to disk', () => { + expect(summarizeChanges([gate('Edit', EDIT, 'rejected')]).files).toEqual([]); + expect(summarizeChanges([gate('Edit', EDIT, 'rejecting')]).files).toEqual([]); + }); + + it('reads additions/deletions from a MultiEdit gate (its own hunk parser)', () => { + const summary = summarizeChanges([gate('MultiEdit', MULTI_EDIT, 'approved')]); + expect(summary.files).toEqual([{ path: 'src/c.ts', additions: 3, deletions: 2 }]); + }); + + it('aggregates multiple edits to the same file', () => { + const summary = summarizeChanges([ + gate('Edit', EDIT, 'approved'), + gate('Edit', EDIT, 'pending'), + ]); + expect(summary.files).toEqual([{ path: 'src/a.ts', additions: 2, deletions: 2 }]); + expect(summary.pending).toBe(1); + }); + + it('ignores non-file gates for the diff totals but still counts them as pending', () => { + const summary = summarizeChanges([gate('Bash', { command: 'ls' }, 'pending')]); + expect(summary.files).toEqual([]); + expect(summary.pending).toBe(1); + }); +}); diff --git a/apps/web/src/lib/changes.ts b/apps/web/src/lib/changes.ts new file mode 100644 index 0000000..3b51d65 --- /dev/null +++ b/apps/web/src/lib/changes.ts @@ -0,0 +1,58 @@ +import { buildFileDiff } from './diff'; +import type { TranscriptEntry } from './session'; + +/** + * The session-rail "Changes" summary: what files this session is touching, with real +/− line counts. + * Every consequential file edit passes through the approval gate (architecture invariant #4), so the + * permission entries are the authoritative record of proposed/applied changes — folding them through + * {@link buildFileDiff} (the same model the diff card renders) yields honest totals, never invented ones. + * A rejected gate never ran, so it is excluded; a still-pending gate is counted and surfaced as `pending` + * ("not yet written to disk"). Pure, so the rail stays a thin renderer and this unit-tests directly. + */ +export interface FileChange { + readonly path: string; + readonly additions: number; + readonly deletions: number; +} + +export interface ChangesSummary { + /** Distinct files touched, with edits to the same path aggregated. */ + readonly files: readonly FileChange[]; + readonly additions: number; + readonly deletions: number; + /** Gates still awaiting a decision — proposed but not yet written. */ + readonly pending: number; +} + +/** + * The file diff a change-bearing entry implies, or null for non-file entries. A rejected gate — or an + * in-flight `rejecting` one, which is about to settle as rejected — never writes to disk, so it is not a + * change; everything else (pending, approving, approved) is a proposed or applied edit. + */ +function changeDiff(entry: TranscriptEntry): ReturnType { + if (entry.kind !== 'permission') return null; + if (entry.decision === 'rejected' || entry.decision === 'rejecting') return null; + return buildFileDiff(entry.toolName, entry.input); +} + +export function summarizeChanges(entries: readonly TranscriptEntry[]): ChangesSummary { + const byPath = new Map(); + let additions = 0; + let deletions = 0; + let pending = 0; + + for (const entry of entries) { + if (entry.kind === 'permission' && entry.decision === 'pending') pending += 1; + const diff = changeDiff(entry); + if (!diff) continue; + const acc = byPath.get(diff.path) ?? { additions: 0, deletions: 0 }; + acc.additions += diff.additions; + acc.deletions += diff.deletions; + byPath.set(diff.path, acc); + additions += diff.additions; + deletions += diff.deletions; + } + + const files = [...byPath.entries()].map(([path, totals]) => ({ path, ...totals })); + return { files, additions, deletions, pending }; +} diff --git a/apps/web/src/lib/components/LaunchDrawer.svelte b/apps/web/src/lib/components/LaunchDrawer.svelte new file mode 100644 index 0000000..6d91c2d --- /dev/null +++ b/apps/web/src/lib/components/LaunchDrawer.svelte @@ -0,0 +1,274 @@ + + + + {#if !device} +
+

No device is paired yet. Pair a machine to run agents on it.

+ (open = false)}>Pair a device → +
+ {:else} +
+
+ Run on +

{device.name}

+
+ + {#if githubConnected && repos.length > 0} +
+ +
+ + +
+
+ {:else if githubConnected} +

No repositories found for your GitHub account.

+ {:else} +

+ Connect GitHub in Settings to run in one of your repos. The session runs in the daemon’s + default workspace until then. +

+ {/if} + + + +
+ + +
+ + {#if launchError} + + {/if} + + {/if} + + {#snippet footer()} + + {#if device} + + {/if} + {/snippet} +
+ + diff --git a/apps/web/src/lib/components/MobileNav.svelte b/apps/web/src/lib/components/MobileNav.svelte new file mode 100644 index 0000000..6b692a6 --- /dev/null +++ b/apps/web/src/lib/components/MobileNav.svelte @@ -0,0 +1,112 @@ + + + + + diff --git a/apps/web/src/lib/components/PageHeader.svelte b/apps/web/src/lib/components/PageHeader.svelte new file mode 100644 index 0000000..11d92f8 --- /dev/null +++ b/apps/web/src/lib/components/PageHeader.svelte @@ -0,0 +1,49 @@ + + +
+
+

{title}

+ {#if sub}

{sub}

{/if} +
+ {#if actions}
{@render actions()}
{/if} +
+ + diff --git a/apps/web/src/lib/components/PermissionModeField.svelte b/apps/web/src/lib/components/PermissionModeField.svelte new file mode 100644 index 0000000..a6b1c2c --- /dev/null +++ b/apps/web/src/lib/components/PermissionModeField.svelte @@ -0,0 +1,113 @@ + + +
+ Permission mode +
+ {#each PERMISSION_MODES as option (option.value)} + + {/each} +
+

{hint}

+
+ + diff --git a/apps/web/src/lib/components/SessionGroupHeader.svelte b/apps/web/src/lib/components/SessionGroupHeader.svelte new file mode 100644 index 0000000..f6cb7c0 --- /dev/null +++ b/apps/web/src/lib/components/SessionGroupHeader.svelte @@ -0,0 +1,31 @@ + + +
+ {label} + +
+ + diff --git a/apps/web/src/lib/components/SessionHeader.svelte b/apps/web/src/lib/components/SessionHeader.svelte new file mode 100644 index 0000000..c3c0c25 --- /dev/null +++ b/apps/web/src/lib/components/SessionHeader.svelte @@ -0,0 +1,139 @@ + + +
+ + + + +
+

{title}

+

+ {#if deviceName}{deviceName} · {/if}{sessionId.slice(0, 12)} +

+
+ + + + {#if showControls} +
+ {#if isBusy} + + {/if} + {#if !isTerminal} + + {/if} +
+ {/if} +
+ + diff --git a/apps/web/src/lib/components/SessionRail.svelte b/apps/web/src/lib/components/SessionRail.svelte new file mode 100644 index 0000000..f9b78ac --- /dev/null +++ b/apps/web/src/lib/components/SessionRail.svelte @@ -0,0 +1,183 @@ + + + + + diff --git a/apps/web/src/lib/components/SessionRow.svelte b/apps/web/src/lib/components/SessionRow.svelte new file mode 100644 index 0000000..eaeba55 --- /dev/null +++ b/apps/web/src/lib/components/SessionRow.svelte @@ -0,0 +1,128 @@ + + + + + + + {row.title ?? row.id} + {#if row.deviceName} + {row.deviceName} + {/if} + {relativeTime(row.createdAt)} + + + + diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte new file mode 100644 index 0000000..b32bdfc --- /dev/null +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -0,0 +1,318 @@ + + + + + diff --git a/apps/web/src/lib/components/SystemBar.svelte b/apps/web/src/lib/components/SystemBar.svelte new file mode 100644 index 0000000..fc3c2b2 --- /dev/null +++ b/apps/web/src/lib/components/SystemBar.svelte @@ -0,0 +1,104 @@ + + +
+ + + + + + + + + end-to-end encrypted + +
+ {agents} {agents === 1 ? 'agent' : 'agents'} + {#if counts.awaiting > 0} + + {counts.awaiting} awaiting + {/if} +
+
+ + diff --git a/apps/web/src/lib/components/TopBar.svelte b/apps/web/src/lib/components/TopBar.svelte deleted file mode 100644 index 0578d53..0000000 --- a/apps/web/src/lib/components/TopBar.svelte +++ /dev/null @@ -1,98 +0,0 @@ - - -
- - - - -
- {#if device} - {device.name} - - {/if} - - {user?.displayName ?? 'Account'} -
- -
-
-
- - diff --git a/apps/web/src/lib/devices.test.ts b/apps/web/src/lib/devices.test.ts new file mode 100644 index 0000000..2746b45 --- /dev/null +++ b/apps/web/src/lib/devices.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { deviceStatus } from './devices'; + +const NOW = new Date('2026-06-29T12:00:00Z').getTime(); + +describe('deviceStatus', () => { + it('reports the watched device online only while the connection is up', () => { + const status = deviceStatus( + { lastSeenAt: new Date(NOW), isWatched: true, connection: 'connected' }, + NOW, + ); + expect(status).toEqual({ tone: 'success', label: 'ONLINE', online: true, lastSeen: 'now' }); + }); + + it('shows the watched device as connecting before the channel is up', () => { + const status = deviceStatus( + { lastSeenAt: new Date(NOW - 2 * 3_600_000), isWatched: true, connection: 'connecting' }, + NOW, + ); + expect(status.online).toBe(false); + expect(status.label).toBe('CONNECTING…'); + expect(status.lastSeen).toBe('2 hr ago'); + }); + + it('reports the watched device offline when the channel drops (idle/error)', () => { + const status = deviceStatus( + { lastSeenAt: new Date(NOW - 5 * 60_000), isWatched: true, connection: 'idle' }, + NOW, + ); + expect(status).toEqual({ + tone: 'muted', + label: 'OFFLINE', + online: false, + lastSeen: '5 min ago', + }); + }); + + it('reports any non-watched device offline with its last-seen time (no live signal to guess from)', () => { + const status = deviceStatus( + { lastSeenAt: new Date(NOW - 2 * 3_600_000), isWatched: false, connection: 'connected' }, + NOW, + ); + expect(status).toEqual({ + tone: 'muted', + label: 'OFFLINE', + online: false, + lastSeen: '2 hr ago', + }); + }); + + it("reads a never-seen device's last-seen as 'never'", () => { + const status = deviceStatus({ lastSeenAt: null, isWatched: false, connection: 'idle' }, NOW); + expect(status.lastSeen).toBe('never'); + expect(status.online).toBe(false); + }); +}); diff --git a/apps/web/src/lib/devices.ts b/apps/web/src/lib/devices.ts new file mode 100644 index 0000000..79452d1 --- /dev/null +++ b/apps/web/src/lib/devices.ts @@ -0,0 +1,37 @@ +import type { Tone } from './session-display'; +import type { ConnectionState } from './session-store'; +import { relativeTime } from './time'; + +/** + * Honest presence for a paired device, the single source for the sidebar device list and the Devices + * page. We only hold a live channel to the *watched* device, so only it can be truly "online" (and only + * while the connection is up); any other paired device has no live signal, so we report it offline with + * its last-seen time rather than guess. Pure (clock injected) so it unit-tests without a connection. + */ +export interface DeviceStatusInput { + readonly lastSeenAt: Date | null; + /** The device whose channel this browser is watching (the relay multiplexes one at a time). */ + readonly isWatched: boolean; + readonly connection: ConnectionState; +} + +export interface DeviceStatus { + readonly tone: Tone; + /** UPPERCASE label for the StatusDot (`ONLINE` / `CONNECTING…` / `OFFLINE`). */ + readonly label: string; + readonly online: boolean; + /** Relative last-seen for the row meta ('now' when online, else 'never' / 'N min ago'). */ + readonly lastSeen: string; +} + +export function deviceStatus(input: DeviceStatusInput, now: number = Date.now()): DeviceStatus { + const lastSeen = input.lastSeenAt ? relativeTime(input.lastSeenAt, now) : 'never'; + + if (input.isWatched && input.connection === 'connected') { + return { tone: 'success', label: 'ONLINE', online: true, lastSeen: 'now' }; + } + if (input.isWatched && input.connection === 'connecting') { + return { tone: 'warning', label: 'CONNECTING…', online: false, lastSeen }; + } + return { tone: 'muted', label: 'OFFLINE', online: false, lastSeen }; +} diff --git a/apps/web/src/lib/launch-drawer.ts b/apps/web/src/lib/launch-drawer.ts new file mode 100644 index 0000000..da582a9 --- /dev/null +++ b/apps/web/src/lib/launch-drawer.ts @@ -0,0 +1,8 @@ +import { writable } from 'svelte/store'; + +/** + * Shared open-state for the launch drawer, which is mounted once in the app shell but triggered from many + * places — the sidebar button, the mobile FAB, the ⌘N shortcut, and the dashboard's empty state. A single + * store decouples those triggers from where the drawer lives; callers open it with `.set(true)`. + */ +export const launchDrawerOpen = writable(false); diff --git a/apps/web/src/lib/nav.test.ts b/apps/web/src/lib/nav.test.ts new file mode 100644 index 0000000..f83002d --- /dev/null +++ b/apps/web/src/lib/nav.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { isActive } from './nav'; + +describe('isActive', () => { + it('marks the Sessions link active on the dashboard and any session detail page', () => { + expect(isActive('/', '/')).toBe(true); + expect(isActive('/sessions/tc_abc', '/')).toBe(true); + }); + + it('does not mark Sessions active on an unrelated route', () => { + expect(isActive('/devices', '/')).toBe(false); + expect(isActive('/settings', '/')).toBe(false); + }); + + it('matches a route exactly and as a path prefix, but not a sibling that merely shares a string start', () => { + expect(isActive('/devices', '/devices')).toBe(true); + expect(isActive('/devices/dv_1', '/devices')).toBe(true); + expect(isActive('/devices-archive', '/devices')).toBe(false); + }); +}); diff --git a/apps/web/src/lib/nav.ts b/apps/web/src/lib/nav.ts new file mode 100644 index 0000000..e935398 --- /dev/null +++ b/apps/web/src/lib/nav.ts @@ -0,0 +1,9 @@ +/** + * Whether a nav link is the active route, shared by the sidebar and the mobile nav so the highlight + * logic lives in one tested place. Pure (takes the pathname rather than reading `$page`), so the + * Sessions-link special case — active on the dashboard AND any `/sessions/...` detail page — is unit-tested. + */ +export function isActive(pathname: string, href: string): boolean { + if (href === '/') return pathname === '/' || pathname.startsWith('/sessions'); + return pathname === href || pathname.startsWith(`${href}/`); +} diff --git a/apps/web/src/lib/session-groups.test.ts b/apps/web/src/lib/session-groups.test.ts new file mode 100644 index 0000000..f70681c --- /dev/null +++ b/apps/web/src/lib/session-groups.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import type { SessionStatus } from './session'; +import { groupSessions, sessionCounts, type SessionRow } from './session-groups'; + +function row(id: string, status: SessionStatus, createdAt: string): SessionRow { + return { id, title: id, status, deviceName: 'studio-mbp', createdAt: new Date(createdAt) }; +} + +describe('groupSessions', () => { + it('partitions rows into awaiting / active / recent by status', () => { + const groups = groupSessions([ + row('done', 'done', '2026-06-29T10:00:00Z'), + row('await', 'awaiting_input', '2026-06-29T10:00:00Z'), + row('run', 'running', '2026-06-29T10:00:00Z'), + row('start', 'starting', '2026-06-29T10:00:00Z'), + row('err', 'error', '2026-06-29T10:00:00Z'), + row('paused', 'offline_paused', '2026-06-29T10:00:00Z'), + row('idle', 'idle', '2026-06-29T10:00:00Z'), + ]); + expect(groups.awaiting.map((r) => r.id)).toEqual(['await']); + expect(groups.active.map((r) => r.id).sort()).toEqual(['run', 'start']); + expect(groups.recent.map((r) => r.id).sort()).toEqual(['done', 'err', 'idle', 'paused']); + }); + + it('orders each group newest-first', () => { + const groups = groupSessions([ + row('old', 'running', '2026-06-29T09:00:00Z'), + row('new', 'running', '2026-06-29T11:00:00Z'), + row('mid', 'running', '2026-06-29T10:00:00Z'), + ]); + expect(groups.active.map((r) => r.id)).toEqual(['new', 'mid', 'old']); + }); + + it('returns empty buckets rather than omitting them', () => { + expect(groupSessions([])).toEqual({ awaiting: [], active: [], recent: [] }); + }); +}); + +describe('sessionCounts', () => { + it('tallies running (incl. starting) and awaiting, ignoring terminal/idle', () => { + const counts = sessionCounts([ + row('a', 'awaiting_input', '2026-06-29T10:00:00Z'), + row('b', 'running', '2026-06-29T10:00:00Z'), + row('c', 'starting', '2026-06-29T10:00:00Z'), + row('d', 'done', '2026-06-29T10:00:00Z'), + row('e', 'idle', '2026-06-29T10:00:00Z'), + ]); + expect(counts).toEqual({ running: 2, awaiting: 1 }); + }); + + it('does not count error or offline_paused sessions as running or awaiting', () => { + const counts = sessionCounts([ + row('a', 'error', '2026-06-29T10:00:00Z'), + row('b', 'offline_paused', '2026-06-29T10:00:00Z'), + ]); + expect(counts).toEqual({ running: 0, awaiting: 0 }); + }); +}); diff --git a/apps/web/src/lib/session-groups.ts b/apps/web/src/lib/session-groups.ts new file mode 100644 index 0000000..2f94514 --- /dev/null +++ b/apps/web/src/lib/session-groups.ts @@ -0,0 +1,79 @@ +import type { SessionStatus } from './session'; + +/** + * The dashboard presentation layer: how the session list is bucketed, ordered, and tallied. Kept apart + * from the live frame reducer (`sessions.ts`) so each file owns one concern. Pure and unit-tested. + * + * One dashboard row: a persisted-registry session overlaid with live status, plus the watching device's + * display name for the row meta. Built in the page from `data.sessions` + the live session map. + */ +export interface SessionRow { + readonly id: string; + readonly title: string | null; + readonly status: SessionStatus; + readonly deviceName: string | null; + readonly createdAt: Date; +} + +/** The dashboard's three buckets (the mockup's "Needs your decision" / "Active" / "Recent"). */ +export type SessionGroupKey = 'awaiting' | 'active' | 'recent'; + +export interface SessionGroups { + readonly awaiting: readonly SessionRow[]; + readonly active: readonly SessionRow[]; + readonly recent: readonly SessionRow[]; +} + +/** + * Which bucket a status belongs to. Every status is listed explicitly so adding a new one to the protocol + * is a compile error here (via the `never` check) rather than a silent fall into "recent". + */ +function groupKey(status: SessionStatus): SessionGroupKey { + switch (status) { + case 'awaiting_input': + return 'awaiting'; + case 'running': + case 'starting': + return 'active'; + case 'done': + case 'error': + case 'offline_paused': + case 'idle': + return 'recent'; + default: { + const _exhaustive: never = status; + return _exhaustive; + } + } +} + +/** Partition rows into the dashboard's three groups, newest-first within each. */ +export function groupSessions(rows: readonly SessionRow[]): SessionGroups { + const groups: Record = { awaiting: [], active: [], recent: [] }; + for (const row of rows) groups[groupKey(row.status)].push(row); + const newestFirst = (a: SessionRow, b: SessionRow): number => + b.createdAt.getTime() - a.createdAt.getTime(); + return { + awaiting: groups.awaiting.sort(newestFirst), + active: groups.active.sort(newestFirst), + recent: groups.recent.sort(newestFirst), + }; +} + +/** Live tallies for the system bar / dashboard header: agents doing work, and those blocked on you. */ +export interface SessionCounts { + /** Sessions actively working (running or starting). */ + readonly running: number; + /** Sessions blocked awaiting a human decision — the loud signal. */ + readonly awaiting: number; +} + +export function sessionCounts(rows: readonly Pick[]): SessionCounts { + let running = 0; + let awaiting = 0; + for (const row of rows) { + if (row.status === 'awaiting_input') awaiting += 1; + else if (row.status === 'running' || row.status === 'starting') running += 1; + } + return { running, awaiting }; +} diff --git a/apps/web/src/lib/sessions.test.ts b/apps/web/src/lib/sessions.test.ts index 66dc2d0..2ff25db 100644 --- a/apps/web/src/lib/sessions.test.ts +++ b/apps/web/src/lib/sessions.test.ts @@ -1,7 +1,7 @@ import { makeEnvelope, type Envelope } from '@telecode/protocol'; import { describe, expect, it } from 'vitest'; -import { foldSessionFrame, statusPriority, type SessionMap } from './sessions'; +import { foldSessionFrame, type SessionMap } from './sessions'; const USER = 'u_1'; const DEVICE = 'd_1'; @@ -65,12 +65,3 @@ describe('multi-session demux (foldSessionFrame)', () => { expect(map.get('b')?.status).toBe('running'); // untouched }); }); - -describe('dashboard sort priority', () => { - it('puts awaiting-input first, live work next, terminal/idle last', () => { - expect(statusPriority('awaiting_input')).toBeLessThan(statusPriority('running')); - expect(statusPriority('running')).toBeLessThan(statusPriority('done')); - expect(statusPriority('starting')).toBe(statusPriority('running')); - expect(statusPriority('offline_paused')).toBe(statusPriority('error')); - }); -}); diff --git a/apps/web/src/lib/sessions.ts b/apps/web/src/lib/sessions.ts index c957e62..c6eabad 100644 --- a/apps/web/src/lib/sessions.ts +++ b/apps/web/src/lib/sessions.ts @@ -1,11 +1,11 @@ -import { type Envelope, type SessionStatusName } from '@telecode/protocol'; +import { type Envelope } from '@telecode/protocol'; import { applyEnvelope, initialSessionState, type SessionState } from './session'; /** * The browser watches a device's whole channel, so it receives every session's frames; this is the live * per-session state, demultiplexed by `session_id`. Pure logic (no Svelte/DOM) so it unit-tests directly; - * the reactive store in `session-store.ts` wraps it. + * the reactive store in `session-store.ts` wraps it. Dashboard bucketing/tallies live in `session-groups.ts`. */ export type SessionMap = ReadonlyMap; @@ -45,19 +45,3 @@ export function markChannelOffline(map: SessionMap): SessionMap { } return changed ? next : map; } - -/** - * Dashboard sort priority: a blocked session ("awaiting input") is the loudest signal and sorts to the - * top; live work next; everything terminal/idle last. Ties break on recency at the call site. - */ -export function statusPriority(status: SessionStatusName | 'idle'): number { - switch (status) { - case 'awaiting_input': - return 0; - case 'running': - case 'starting': - return 1; - default: - return 2; // done · error · offline_paused · idle - } -} diff --git a/apps/web/src/lib/settings.test.ts b/apps/web/src/lib/settings.test.ts new file mode 100644 index 0000000..2ffa385 --- /dev/null +++ b/apps/web/src/lib/settings.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_PERMISSION_MODE, + PERMISSION_MODES, + readPermissionMode, + writePermissionMode, +} from './settings'; + +/** A key-honouring in-memory Storage, so read and write must agree on the key to round-trip. */ +function memoryStorage(): Pick { + const store = new Map(); + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + }; +} + +describe('permission-mode persistence', () => { + it('round-trips a written mode (read and write use the same key)', () => { + const storage = memoryStorage(); + writePermissionMode(storage, 'acceptEdits'); + expect(readPermissionMode(storage)).toBe('acceptEdits'); + }); + + it('falls back to the conservative default when unset', () => { + expect(readPermissionMode(memoryStorage())).toBe(DEFAULT_PERMISSION_MODE); + }); + + it('falls back to the default for a corrupt/unknown stored value', () => { + const corrupt: Pick = { getItem: () => 'garbage' }; + expect(readPermissionMode(corrupt)).toBe(DEFAULT_PERMISSION_MODE); + }); + + it('offers exactly the three surfaced modes and omits the gate-bypassing one', () => { + const values = PERMISSION_MODES.map((mode) => mode.value); + expect(values).toEqual(['plan', 'default', 'acceptEdits']); + expect(values).not.toContain('bypassPermissions'); + }); +}); diff --git a/apps/web/src/lib/settings.ts b/apps/web/src/lib/settings.ts new file mode 100644 index 0000000..7854e2c --- /dev/null +++ b/apps/web/src/lib/settings.ts @@ -0,0 +1,48 @@ +import { permissionModeSchema, type PermissionModeName } from '@telecode/protocol'; + +/** + * The default launch permission mode the operator picks once and reuses (the launch drawer seeds from it, + * Settings edits it). The persistence is split into pure read/write over a `Storage`-shaped seam so it + * unit-tests without a DOM; the Svelte surfaces call these with the real `localStorage` (browser-guarded). + * + * We surface three of the SDK's four modes; `bypassPermissions` (skip every gate) is deliberately omitted + * — the approval gate is telecode's safety boundary (architecture invariant #4), not a casual default. + */ +export interface PermissionModeOption { + readonly value: PermissionModeName; + readonly label: string; + readonly hint: string; +} + +export const PERMISSION_MODES: readonly PermissionModeOption[] = [ + { value: 'plan', label: 'Plan only', hint: 'The agent explores and plans but makes no changes.' }, + { + value: 'default', + label: 'Approve edits', + hint: 'You approve each consequential action before it runs.', + }, + { + value: 'acceptEdits', + label: 'Auto-accept edits', + hint: 'File edits apply automatically; other actions still ask.', + }, +]; + +/** The conservative fallback for an unset or unrecognized stored value. */ +export const DEFAULT_PERMISSION_MODE: PermissionModeName = 'default'; + +const STORAGE_KEY = 'telecode:default-permission-mode'; + +/** Read the saved default mode, falling back to {@link DEFAULT_PERMISSION_MODE} for unset/invalid values. */ +export function readPermissionMode(storage: Pick): PermissionModeName { + const parsed = permissionModeSchema.safeParse(storage.getItem(STORAGE_KEY)); + return parsed.success ? parsed.data : DEFAULT_PERMISSION_MODE; +} + +/** Persist the default launch permission mode. */ +export function writePermissionMode( + storage: Pick, + mode: PermissionModeName, +): void { + storage.setItem(STORAGE_KEY, mode); +} diff --git a/apps/web/src/lib/time.test.ts b/apps/web/src/lib/time.test.ts new file mode 100644 index 0000000..33707ea --- /dev/null +++ b/apps/web/src/lib/time.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { relativeTime } from './time'; + +const NOW = new Date('2026-06-29T12:00:00Z').getTime(); + +describe('relativeTime', () => { + it("reads the last few seconds as 'just now'", () => { + expect(relativeTime(new Date(NOW - 20_000), NOW)).toBe('just now'); + expect(relativeTime(new Date(NOW), NOW)).toBe('just now'); + }); + + it('reads minutes within the hour', () => { + expect(relativeTime(new Date(NOW - 5 * 60_000), NOW)).toBe('5 min ago'); + expect(relativeTime(new Date(NOW - 59 * 60_000), NOW)).toBe('59 min ago'); + }); + + it('rolls up to hours, then days', () => { + expect(relativeTime(new Date(NOW - 2 * 3_600_000), NOW)).toBe('2 hr ago'); + expect(relativeTime(new Date(NOW - 3 * 86_400_000), NOW)).toBe('3 d ago'); + }); + + it('rounds at the unit boundaries (30s → minutes, 60min → hours, 24hr → days)', () => { + expect(relativeTime(new Date(NOW - 30_000), NOW)).toBe('1 min ago'); + expect(relativeTime(new Date(NOW - 60 * 60_000), NOW)).toBe('1 hr ago'); + expect(relativeTime(new Date(NOW - 24 * 3_600_000), NOW)).toBe('1 d ago'); + }); +}); diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts new file mode 100644 index 0000000..9cf4ac8 --- /dev/null +++ b/apps/web/src/lib/time.ts @@ -0,0 +1,13 @@ +/** + * Compact relative timestamps for the session list and device rows ('just now', '5 min ago', + * '2 hr ago', '3 d ago'). Pure (the clock is an injectable `now`) so it unit-tests directly and the + * Svelte views stay thin renderers. A single source of truth keeps every surface phrasing identical. + */ +export function relativeTime(date: Date, now: number = Date.now()): string { + const mins = Math.round((now - date.getTime()) / 60_000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins} min ago`; + const hrs = Math.round(mins / 60); + if (hrs < 24) return `${hrs} hr ago`; + return `${Math.round(hrs / 24)} d ago`; +} diff --git a/apps/web/src/routes/(app)/+layout.server.ts b/apps/web/src/routes/(app)/+layout.server.ts new file mode 100644 index 0000000..4b233a9 --- /dev/null +++ b/apps/web/src/routes/(app)/+layout.server.ts @@ -0,0 +1,28 @@ +import { redirect } from '@sveltejs/kit'; + +import { listDevices, listRepos } from '$lib/server/relay-api'; +import { getSessionToken } from '$lib/server/session-cookie'; + +import type { LayoutServerLoad } from './$types'; + +/** + * The authenticated app shell's shared data: the signed-in user, their paired devices, and their repos + * for the launch drawer. Loaded once for every page under `(app)` (the sidebar, system bar, and launch + * drawer all consume it), so individual pages no longer re-fetch devices/repos. Unauthenticated requests + * are bounced to sign-in here, the single guard for the whole group. + */ +export const load: LayoutServerLoad = async ({ locals, cookies }) => { + if (!locals.user) { + redirect(303, '/signin'); + } + const token = getSessionToken(cookies); + const [devices, repoList] = token + ? await Promise.all([listDevices(token), listRepos(token)]) + : [[], { connected: false, repos: [] }]; + return { + user: locals.user, + devices, + githubConnected: repoList.connected, + repos: repoList.repos, + }; +}; diff --git a/apps/web/src/routes/(app)/+layout.svelte b/apps/web/src/routes/(app)/+layout.svelte new file mode 100644 index 0000000..49ce22a --- /dev/null +++ b/apps/web/src/routes/(app)/+layout.svelte @@ -0,0 +1,108 @@ + + + + +
+ + +
+ {@render children()} +
+ +
+ + + + diff --git a/apps/web/src/routes/(app)/+page.server.ts b/apps/web/src/routes/(app)/+page.server.ts new file mode 100644 index 0000000..62ad4d5 --- /dev/null +++ b/apps/web/src/routes/(app)/+page.server.ts @@ -0,0 +1,27 @@ +import { redirect } from '@sveltejs/kit'; + +import { destroyRelaySession, listSessions } from '$lib/server/relay-api'; +import { clearSessionCookie, getSessionToken } from '$lib/server/session-cookie'; + +import type { Actions, PageServerLoad } from './$types'; + +/** + * The dashboard's own data: the persisted session registry (survives reloads; live status overlays it in + * the page). The user + devices + repos come from the `(app)` layout load, so they aren't re-fetched here. + */ +export const load: PageServerLoad = async ({ cookies }) => { + const token = getSessionToken(cookies); + const sessions = token ? await listSessions(token) : []; + return { sessions }; +}; + +export const actions: Actions = { + logout: async ({ cookies }) => { + const token = getSessionToken(cookies); + if (token) { + await destroyRelaySession(token); + } + clearSessionCookie(cookies); + redirect(303, '/signin'); + }, +}; diff --git a/apps/web/src/routes/(app)/+page.svelte b/apps/web/src/routes/(app)/+page.svelte new file mode 100644 index 0000000..f9248c1 --- /dev/null +++ b/apps/web/src/routes/(app)/+page.svelte @@ -0,0 +1,226 @@ + + + + Sessions · telecode + + +{#if !device} +
+ +
+{:else} + + {#snippet actions()} +
+
+
{counts.running}
+
Running
+
+
+
0}>{counts.awaiting}
+
Awaiting input
+
+
+
{devicesOnline}
+
{devicesOnline === 1 ? 'Device online' : 'Devices online'}
+
+
+ {/snippet} +
+ +
+ {#if rows.length === 0} +
+

No sessions yet

+

Launch a session on {device.name} to watch the agent work.

+ +
+ {:else} +
+ {#if groups.awaiting.length > 0} + +
    + {#each groups.awaiting as row (row.id)} +
  • + {/each} +
+ {/if} + {#if groups.active.length > 0} + +
    + {#each groups.active as row (row.id)} +
  • + {/each} +
+ {/if} + {#if groups.recent.length > 0} + +
    + {#each groups.recent as row (row.id)} +
  • + {/each} +
+ {/if} +
+ {/if} +
+{/if} + + diff --git a/apps/web/src/routes/activate/+page.server.ts b/apps/web/src/routes/(app)/activate/+page.server.ts similarity index 87% rename from apps/web/src/routes/activate/+page.server.ts rename to apps/web/src/routes/(app)/activate/+page.server.ts index c3a3a52..320625e 100644 --- a/apps/web/src/routes/activate/+page.server.ts +++ b/apps/web/src/routes/(app)/activate/+page.server.ts @@ -3,7 +3,7 @@ import { fail, redirect } from '@sveltejs/kit'; import { pairingInstructions } from '$lib/pairing-instructions'; import { approveDevice } from '$lib/server/relay-api'; -import type { Actions, PageServerLoad } from './$types'; +import type { Actions } from './$types'; // A code that doesn't approve is invalid or expired; tell the user how to mint a fresh one in this // environment. In dev `make run` reuses a healthy daemon (no new code), so point at a restart + the log. @@ -11,13 +11,7 @@ const codeExpiredError = pairingInstructions.codeLocation ? `That code is invalid or expired. Restart the daemon and use the new code in \`${pairingInstructions.codeLocation}\`.` : `That code is invalid or expired. Run \`${pairingInstructions.command}\` again for a new one.`; -export const load: PageServerLoad = ({ locals }) => { - if (!locals.user) { - redirect(303, '/signin'); - } - return {}; -}; - +// Auth + device/repo loading are handled by the `(app)` layout; this route only owns the approve action. export const actions: Actions = { default: async ({ request, locals }) => { if (!locals.user) { diff --git a/apps/web/src/routes/activate/+page.svelte b/apps/web/src/routes/(app)/activate/+page.svelte similarity index 98% rename from apps/web/src/routes/activate/+page.svelte rename to apps/web/src/routes/(app)/activate/+page.svelte index 1105ef7..b3f21c9 100644 --- a/apps/web/src/routes/activate/+page.svelte +++ b/apps/web/src/routes/(app)/activate/+page.svelte @@ -17,7 +17,7 @@ Activate a device · telecode -
+

PAIR A DEVICE

Activate a device

@@ -71,11 +71,12 @@

Back to sessions

{/if}
-
+ diff --git a/apps/web/src/routes/(app)/sessions/[id]/+page.server.ts b/apps/web/src/routes/(app)/sessions/[id]/+page.server.ts new file mode 100644 index 0000000..7b20ddc --- /dev/null +++ b/apps/web/src/routes/(app)/sessions/[id]/+page.server.ts @@ -0,0 +1,7 @@ +import type { PageServerLoad } from './$types'; + +/** + * The session view only needs the route's session id; the user, devices, and auth guard come from the + * `(app)` layout load (the live transcript itself streams over the shared channel, not from here). + */ +export const load: PageServerLoad = ({ params }) => ({ sessionId: params.id }); diff --git a/apps/web/src/routes/(app)/sessions/[id]/+page.svelte b/apps/web/src/routes/(app)/sessions/[id]/+page.svelte new file mode 100644 index 0000000..31836b7 --- /dev/null +++ b/apps/web/src/routes/(app)/sessions/[id]/+page.svelte @@ -0,0 +1,192 @@ + + + + {sessionTitle} · telecode + + +
+ onControl('interrupt')} + onend={() => onControl('end')} + /> + +
+
+ {#if !known} +
+

{$connectionState === 'error' ? 'OFFLINE' : 'RECONNECTING…'}

+

+ {$connectionState === 'error' + ? 'The channel is offline. It will restore when the connection returns.' + : 'Restoring this session’s transcript.'} +

+
+ {:else if session.entries.length === 0} +
+

{display.label}

+

No activity yet — send an instruction to steer this session.

+
+ {:else} + onDecide('allow')} + onreject={() => onDecide('deny')} + /> + {/if} + + {#if known} +
+ +
+ {/if} +
+ + {#if known} + + {/if} +
+
+ + diff --git a/apps/web/src/routes/(app)/settings/+page.svelte b/apps/web/src/routes/(app)/settings/+page.svelte new file mode 100644 index 0000000..ee9101a --- /dev/null +++ b/apps/web/src/routes/(app)/settings/+page.svelte @@ -0,0 +1,164 @@ + + + + Settings · telecode + + + + +
+
+ +
+
+ Relay endpoint +

{RELAY_URL}

+

Set PUBLIC_TELECODE_RELAY_URL to point at your own relay.

+
+ + + +
+ Notifications + {#if pushState === 'granted'} +

On — you’ll be pinged when a session needs you.

+ {:else if pushState === 'denied'} +

Blocked in your browser settings. Re-enable notifications for this site to turn them on.

+ {:else if pushState === 'unsupported'} +

This browser can’t do web push. Install telecode to your home screen to enable it.

+ {:else} +

Get pinged when a session is awaiting your input.

+
+ +
+ {/if} +
+
+
+ + +
+
+ +
+
+
+
+
+ + diff --git a/apps/web/src/routes/+page.server.ts b/apps/web/src/routes/+page.server.ts deleted file mode 100644 index 3062c18..0000000 --- a/apps/web/src/routes/+page.server.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { redirect } from '@sveltejs/kit'; - -import { destroyRelaySession, listDevices, listRepos, listSessions } from '$lib/server/relay-api'; -import { clearSessionCookie, getSessionToken } from '$lib/server/session-cookie'; - -import type { Actions, PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals, cookies }) => { - if (!locals.user) { - redirect(303, '/signin'); - } - const token = getSessionToken(cookies); - // The persisted session list survives UI restarts (reopen = reconnect); live status overlays it. - // Repos populate the launch picker (clone-on-demand); `connected:false` until the user links GitHub. - const [devices, sessions, repoList] = token - ? await Promise.all([listDevices(token), listSessions(token), listRepos(token)]) - : [[], [], { connected: false, repos: [] }]; - return { - user: locals.user, - devices, - sessions, - githubConnected: repoList.connected, - repos: repoList.repos, - }; -}; - -export const actions: Actions = { - logout: async ({ cookies }) => { - const token = getSessionToken(cookies); - if (token) { - await destroyRelaySession(token); - } - clearSessionCookie(cookies); - redirect(303, '/signin'); - }, -}; diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte deleted file mode 100644 index 882a4b2..0000000 --- a/apps/web/src/routes/+page.svelte +++ /dev/null @@ -1,484 +0,0 @@ - - - - Sessions · telecode - - - - -
- {#if !device} -
- -
- {:else} - - -
- {#if pushState === 'default'} -
- Get pinged when a session needs your input. - -
- {:else if pushState === 'denied'} -
- Notifications are blocked in your browser settings. -
- {/if} - -
-

LAUNCH A SESSION ON {device.name}

- {#if data.githubConnected && repos.length > 0} - - {:else if data.githubConnected} -

No repositories found for your GitHub account.

- {:else} -

- Connect GitHub to run a session in one of your repos. The session runs in the daemon’s - default workspace until then. -

- {/if} - -
- - -
- {#if launchError} - {launchError} - {:else} - ⌘↵ to launch. You’ll approve each consequential action. - {/if} -
- -
- SESSIONS - {rows.length} -
- - {#if rows.length === 0} -
-

NO SESSIONS YET

-

Launch one above to watch the agent work.

-
- {:else} - - {/if} -
- {/if} -
- - diff --git a/apps/web/src/routes/sessions/[id]/+page.server.ts b/apps/web/src/routes/sessions/[id]/+page.server.ts deleted file mode 100644 index 78975a1..0000000 --- a/apps/web/src/routes/sessions/[id]/+page.server.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { redirect } from '@sveltejs/kit'; - -import { listDevices } from '$lib/server/relay-api'; -import { getSessionToken } from '$lib/server/session-cookie'; - -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals, cookies, params }) => { - if (!locals.user) { - redirect(303, '/signin'); - } - const token = getSessionToken(cookies); - const devices = token ? await listDevices(token) : []; - return { user: locals.user, devices, sessionId: params.id }; -}; diff --git a/apps/web/src/routes/sessions/[id]/+page.svelte b/apps/web/src/routes/sessions/[id]/+page.svelte deleted file mode 100644 index 9d6f00b..0000000 --- a/apps/web/src/routes/sessions/[id]/+page.svelte +++ /dev/null @@ -1,217 +0,0 @@ - - - - Session · telecode - - - - -
-
- ← Sessions - {sessionId.slice(0, 8)} - - {#if showControls} -
- {#if isBusy} - - {/if} - {#if !isTerminal} - - {/if} -
- {/if} -
- - {#if !known} -
-

{$connectionState === 'error' ? 'OFFLINE' : 'RECONNECTING…'}

-

- {$connectionState === 'error' - ? 'The channel is offline. It will restore when the connection returns.' - : 'Restoring this session’s transcript.'} -

-
- {:else} - {#if session.entries.length === 0} -
-

{display.label}

-

No activity yet — send an instruction to steer this session.

-
- {:else} - onDecide('allow')} - onreject={() => onDecide('deny')} - /> - {/if} -
- -
- {/if} -
- - diff --git a/packages/ui/src/Drawer/Drawer.svelte b/packages/ui/src/Drawer/Drawer.svelte new file mode 100644 index 0000000..3864be5 --- /dev/null +++ b/packages/ui/src/Drawer/Drawer.svelte @@ -0,0 +1,141 @@ + + +{#if open} + + + + + +{/if} + + open && event.key === 'Escape' && close()} /> + + diff --git a/packages/ui/src/Drawer/index.ts b/packages/ui/src/Drawer/index.ts new file mode 100644 index 0000000..2b5941b --- /dev/null +++ b/packages/ui/src/Drawer/index.ts @@ -0,0 +1 @@ +export { default as Drawer } from './Drawer.svelte'; diff --git a/packages/ui/src/IconButton/IconButton.svelte b/packages/ui/src/IconButton/IconButton.svelte new file mode 100644 index 0000000..acb84ab --- /dev/null +++ b/packages/ui/src/IconButton/IconButton.svelte @@ -0,0 +1,120 @@ + + + + + diff --git a/packages/ui/src/IconButton/index.ts b/packages/ui/src/IconButton/index.ts new file mode 100644 index 0000000..8ba804f --- /dev/null +++ b/packages/ui/src/IconButton/index.ts @@ -0,0 +1 @@ +export { default as IconButton } from './IconButton.svelte'; diff --git a/packages/ui/src/Panel/Panel.svelte b/packages/ui/src/Panel/Panel.svelte new file mode 100644 index 0000000..2c24811 --- /dev/null +++ b/packages/ui/src/Panel/Panel.svelte @@ -0,0 +1,59 @@ + + +
+ {#if header} + {@render header()} + {:else if title} +
+

{title}

+ {#if meta}{meta}{/if} +
+ {/if} + {@render children()} +
+ + diff --git a/packages/ui/src/Panel/index.ts b/packages/ui/src/Panel/index.ts new file mode 100644 index 0000000..9dd7c9c --- /dev/null +++ b/packages/ui/src/Panel/index.ts @@ -0,0 +1 @@ +export { default as Panel } from './Panel.svelte'; diff --git a/packages/ui/src/Pill/Pill.svelte b/packages/ui/src/Pill/Pill.svelte new file mode 100644 index 0000000..a10bf13 --- /dev/null +++ b/packages/ui/src/Pill/Pill.svelte @@ -0,0 +1,90 @@ + + + + {#if dot}{/if} + {label} + + + diff --git a/packages/ui/src/Pill/index.ts b/packages/ui/src/Pill/index.ts new file mode 100644 index 0000000..77c20cc --- /dev/null +++ b/packages/ui/src/Pill/index.ts @@ -0,0 +1,2 @@ +export { default as Pill } from './Pill.svelte'; +export type { PillTone } from './types'; diff --git a/packages/ui/src/Pill/types.ts b/packages/ui/src/Pill/types.ts new file mode 100644 index 0000000..5df0c98 --- /dev/null +++ b/packages/ui/src/Pill/types.ts @@ -0,0 +1,2 @@ +/** The tones a {@link Pill} can take — accent is the scalpel; the rest are neutral/semantic. */ +export type PillTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger'; diff --git a/packages/ui/src/actions/index.ts b/packages/ui/src/actions/index.ts new file mode 100644 index 0000000..85596f3 --- /dev/null +++ b/packages/ui/src/actions/index.ts @@ -0,0 +1 @@ +export { trapFocus } from './trapFocus'; diff --git a/packages/ui/src/actions/trapFocus.ts b/packages/ui/src/actions/trapFocus.ts new file mode 100644 index 0000000..6e2e30b --- /dev/null +++ b/packages/ui/src/actions/trapFocus.ts @@ -0,0 +1,43 @@ +/** + * Confine keyboard focus to a node while it's mounted (modal/drawer requirement, enterprise-ui §3/§4): + * Tab/Shift+Tab cycle within the node, focus moves to the first focusable element on mount, and the + * previously-focused element is restored on destroy. Pair with `{#if open}` so the action lives exactly + * as long as the overlay; Escape-to-close and backdrop dismissal are handled by the consumer. + */ +export function trapFocus(node: HTMLElement) { + const previous = document.activeElement as HTMLElement | null; + const selector = + 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),' + + 'textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; + + function focusable(): HTMLElement[] { + return Array.from(node.querySelectorAll(selector)).filter( + (el) => el.offsetParent !== null || el === document.activeElement, + ); + } + + function onKeydown(event: KeyboardEvent): void { + if (event.key !== 'Tab') return; + const items = focusable(); + if (items.length === 0) return; + const first = items[0]!; + const last = items[items.length - 1]!; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + + node.addEventListener('keydown', onKeydown); + (focusable()[0] ?? node).focus(); + + return { + destroy(): void { + node.removeEventListener('keydown', onKeydown); + previous?.focus(); + }, + }; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 60b38b6..90d3fd2 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -4,4 +4,9 @@ */ export { BrandLogo } from './BrandLogo'; export { Button } from './Button'; +export { Drawer } from './Drawer'; +export { IconButton } from './IconButton'; +export { Panel } from './Panel'; +export { Pill, type PillTone } from './Pill'; export { StatusDot } from './StatusDot'; +export { trapFocus } from './actions'; diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index 436738f..40bf289 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -32,6 +32,7 @@ --accent-hover: #f0b257; --accent-press: #d4922f; --accent-soft: #16140d; + --accent-line: rgba(232, 163, 61, 0.4); --accent-ring: #e8a33d; /* Status — muted, reserved meanings (dot + UPPERCASE MONO label, never a saturated pill) */ @@ -138,6 +139,7 @@ --accent-hover: #a86f0a; --accent-press: #8a5b08; --accent-soft: #fbf2dd; + --accent-line: rgba(200, 134, 15, 0.4); --text-on-accent: #ffffff; --success: #3f9d76; --warning: #bb8a36; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 430ec5d..11dfe33 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -3,7 +3,10 @@ "compilerOptions": { "noEmit": true, "module": "esnext", - "moduleResolution": "bundler" + "moduleResolution": "bundler", + // The design system is browser-targeted (Svelte components + DOM `use:` actions), so it needs the + // DOM lib on top of the base ES libs. + "lib": ["ES2023", "DOM", "DOM.Iterable"] }, "include": ["src"] }