From 0fc53d03be2850b5903777a51faf0e0d5353a405 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 16:22:59 -0400 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20Directives,=20Settings,=20Onbo?= =?UTF-8?q?arding=20=E2=80=94=20P3=20complete=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last three P3 screens, retiring every placeholder: - DirectivesScreen — jade consequence diff (propose-approve: Make it so / Amend / Withdraw, resolving moves it into the ledger) + append-only ledger (seq, who/when/why, verbatim quote, status badge). - SettingsScreen — gitea connection + managed repos, sync switches, model router, read-only label schema, rituals, appearance radios (wired to the shared theme), single danger action. - OnboardingScreen — full-window first run: welcome → connect (test gate) → repo pick → propose-approve bootstrap; onDone routes into the app. Shell early-returns it (no rail/chat), matching the design. Shell routes directives/settings and the firstrun full-window flow. DIRECTIVES fixture added. typecheck + 14 e2e green (incl. directive resolve, appearance↔theme sync, onboarding test gate); all three screenshot-verified. Closes P3-8. P3 (UI views) complete — all 14 screens live on fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/e2e/smoke.spec.ts | 36 ++++ .../components/screens/directives-screen.tsx | 104 +++++++++++ .../components/screens/onboarding-screen.tsx | 169 ++++++++++++++++++ .../components/screens/settings-screen.tsx | 137 ++++++++++++++ .../src/components/shell/app-shell.tsx | 12 ++ .../desktop/src/renderer/src/data/fixtures.ts | 54 ++++++ 6 files changed, 512 insertions(+) create mode 100644 apps/desktop/src/renderer/src/components/screens/directives-screen.tsx create mode 100644 apps/desktop/src/renderer/src/components/screens/onboarding-screen.tsx create mode 100644 apps/desktop/src/renderer/src/components/screens/settings-screen.tsx diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index 45daa76..eff9879 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -82,6 +82,42 @@ test('Capture: braindump advances to the interview', async ({ app, window }) => await expect(window.getByText(/Interview · 1 of 3/)).toBeVisible() }) +test('Directives: consequence diff + append-only ledger', async ({ app, window }) => { + await app.nav('Directives').click() + await expect(window.getByRole('heading', { name: 'Directives' })).toBeVisible() + await expect(window.getByText('Consequence diff')).toBeVisible() + await expect(window.getByText(/append-only · JSONL in pm-state/)).toBeVisible() + await app.screenshot('directives') + // resolving the pending directive moves it into the ledger + await window.getByRole('button', { name: 'Make it so' }).click() + await expect(window.getByText('Consequence diff')).toBeHidden() + await expect(window.getByText(/Nothing awaits your word/)).toBeVisible() +}) + +test('Settings: connection, schema, and appearance sync with theme', async ({ app, window }) => { + await app.nav('Settings').click() + await expect(window.getByRole('heading', { name: 'Settings' })).toBeVisible() + await expect(window.getByText('Managed repos')).toBeVisible() + await app.screenshot('settings') + // the Evening (dark) radio drives the shared theme + await window.getByText('Evening (dark)').click() + await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark') +}) + +test('Onboarding: full-window first-run flow with test gate', async ({ app, window }) => { + await app.nav('First run').click() + await expect(window.getByRole('heading', { name: 'Good morning.' })).toBeVisible() + // full-window: no rail wordmark link, brand only + await app.screenshot('onboarding') + await window.getByRole('button', { name: 'Begin' }).click() + await expect(window.getByRole('heading', { name: 'Your Gitea' })).toBeVisible() + // Continue is gated until the connection test passes + await expect(window.getByRole('button', { name: 'Continue' })).toBeDisabled() + await window.getByRole('button', { name: 'Test connection' }).click() + await expect(window.getByText(/connected · 3 repos visible/)).toBeVisible() + await expect(window.getByRole('button', { name: 'Continue' })).toBeEnabled() +}) + test('issue drill-in from a Focus card and back-stack of one', async ({ app, window }) => { await window.getByRole('link', { name: 'Fix lifecycle inference on merge events' }).click() await expect(window.getByText('#87 · stephen/commitea')).toBeVisible() diff --git a/apps/desktop/src/renderer/src/components/screens/directives-screen.tsx b/apps/desktop/src/renderer/src/components/screens/directives-screen.tsx new file mode 100644 index 0000000..13b5cc4 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/directives-screen.tsx @@ -0,0 +1,104 @@ +import React from 'react' + +import { DIRECTIVES, type DirectivePending, type DirectiveEntry } from '../../data/fixtures.js' +import { Card, Button, Badge, Icon } from '../ui/index.js' + +// Directive log — append-only ledger + the consequence diff (propose-approve) +export function DirectivesScreen() { + const [pending, setPending] = React.useState(DIRECTIVES.pending) + const [entries, setEntries] = React.useState(DIRECTIVES.entries) + + const resolve = (status: string) => { + setEntries((e) => [{ + seq: pending!.seq, who: pending!.who, when: pending!.when, what: pending!.what, why: 'pilot demo on the 14th', + status, consequence: status === 'applied' ? '#78 +5d · Beta 80% Mar 5–14' : 'withdrawn before apply', + }, ...e]); + setPending(null); + }; + + const toneColor: Record = { ok: 'var(--ok)', warn: 'var(--warn)', info: 'var(--info)', danger: 'var(--danger)' }; + const statusBadge: Record = { + applied: { tone: 'ok', label: 'applied' }, + withdrawn: { tone: 'neutral', label: 'withdrawn' }, + superseded: { tone: 'info', label: 'superseded' }, + }; + + return ( +
+
+

Directives

+

append-only · JSONL in pm-state · who, when, what, why

+
+ + {pending ? ( + + + + + }> +
+

+ {pending.who} + · {pending.when} +
“{pending.what}” +

+
+ {pending.diff.map((r) => ( +
+ + {r.change} + + {r.from} {r.to} + +
+ ))} +
+

+ Cheap, as consequences go. Shall I make it so? +

+
+
+ ) : ( +
+ + Nothing awaits your word. Directives are given in chat; consequences appear here first. +
+ )} + + +
+ {entries.map((e, i) => { + const sb = statusBadge[e.status]; + return ( +
+
+ #00{e.seq} + +
+
+
+ {e.who.split(' ').map((w: string) => w[0]).join('')} + {e.who} + {e.when} + {e.why ? · why: {e.why} : null} + {sb.label} +
+

“{e.what}”

+

{e.consequence}

+
+
+ ); + })} +
+
+ +

+ Entries are never edited. Corrections are new entries — the ledger remembers everything, politely. +

+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/screens/onboarding-screen.tsx b/apps/desktop/src/renderer/src/components/screens/onboarding-screen.tsx new file mode 100644 index 0000000..6e20ad9 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/onboarding-screen.tsx @@ -0,0 +1,169 @@ +import React from 'react' + +import logoIcon from '../../design/assets/logo-icon.png' +import { Badge, Button, Icon, Input, Radio, Tag } from '../ui/index.js' + +// Onboarding / first connect — welcome → connect gitea → choose repo → bootstrap +export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture') => void }) { + const [step, setStep] = React.useState(0) + const [conn, setConn] = React.useState<'idle' | 'testing' | 'ok'>('idle') + const [repo, setRepo] = React.useState('stephen/commitea') + const [boot, setBoot] = React.useState(-1) // -1 idle, 0..2 running, 3 done + + React.useEffect(() => { + if (conn !== 'testing') return + const t = setTimeout(() => setConn('ok'), 1100) + return () => clearTimeout(t) + }, [conn]) + + React.useEffect(() => { + if (boot < 0 || boot >= 3) return + const t = setTimeout(() => setBoot(boot + 1), 700) + return () => clearTimeout(t) + }, [boot]) + + const STEPS = ['Welcome', 'Connect', 'Repo', 'Bootstrap'] + const BOOT_TASKS = [ + 'Create stephen/pm-state (the sidecar)', + 'Apply the label schema to stephen/commitea', + 'Install a webhook · endpoint :48731', + ] + + const Frame = ({ children, footer }: { children: React.ReactNode; footer?: React.ReactNode }) => ( +
+ {children} + {footer ?
{footer}
: null} +
+ ) + + return ( +
+ {/* brand */} +
+ + + CommiTea + +
+ + {/* stepper */} +
+ {STEPS.map((s, i) => ( +
+ + {i < step ? '✓' : i + 1} + {s} + + {i < STEPS.length - 1 ? : null} +
+ ))} +
+ +
+ {step === 0 ? ( + setStep(1)}>Begin}> +

Good morning.

+

+ I'm Reginald, your project manager. I interview you instead of making you fill in forms, + I forecast in honest ranges, and I never do the arithmetic myself — there's a scheduler for that. +

+

+ Your plans live in your own Gitea as ordinary issues and labels. Delete me and nothing human is lost. +

+ + ) : null} + + {step === 1 ? ( + + + + }> +

Your Gitea

+ + +
+ + {conn === 'ok' ? connected · 3 repos visible : null} +
+ + ) : null} + + {step === 2 ? ( + + + + }> +

Which repo shall I manage?

+
+ {['stephen/commitea', 'stephen/novelpad', 'stephen/infra'].map((r: string) => ( + + ))} +
+

One to start. You can add more later in Settings.

+ + ) : null} + + {step === 3 ? ( + + + + : <> + + + }> +

+ {boot === 3 ? 'All set.' : 'With your approval'} +

+
+ {BOOT_TASKS.map((t: string, i: number) => ( +
+ i ? 'var(--ok)' : boot === i ? 'var(--warn)' : 'var(--ink-3)' }}> + i ? 'circle-check' : boot === i ? 'loader-circle' : 'circle-dashed'} size={15} /> + + i ? 'var(--ink-1)' : 'var(--ink-2)', whiteSpace: 'nowrap' }}>{t} +
+ ))} +
+ {['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d', 'p/1', 'p/2', 'p/3', 'p/4', 'deadline/hard'].map((l: string) => )} +
+
+ {boot === 3 ? ( +

+ The pot is empty. Tell me what you're planning and I'll draw up the tickets. +

+ ) : ( +

+ No bot comments, no body frontmatter, no synthetic issues — ever. Labels are the only footprint. +

+ )} + + ) : null} +
+ + first run · everything reversible +
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/screens/settings-screen.tsx b/apps/desktop/src/renderer/src/components/screens/settings-screen.tsx new file mode 100644 index 0000000..377983a --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/settings-screen.tsx @@ -0,0 +1,137 @@ +import React from 'react' + +import { Badge, Button, Card, Icon, IconButton, Input, Radio, Select, Switch, Tag } from '../ui/index.js' + +// Settings — gitea connection, sync, model roles, labels, rituals, appearance +export function SettingsScreen({ dark, setDark }: { dark: boolean; setDark: (v: boolean) => void }) { + const [webhooks, setWebhooks] = React.useState(true) + const [reconcile, setReconcile] = React.useState(true) + const [poll, setPoll] = React.useState(true) + const [nag, setNag] = React.useState(true) + + const Row = ({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) => ( +
{children}
+ ) + const Note = ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ) + + return ( +
+
+

Settings

+

config lives in pm-state · versioned, portable

+
+ + +
+ + +
+ Managed repos + + + stephen/commitea + syncing + + + + + stephen/pm-state + sidecar + + The sidecar holds machine-derived state only. Delete it and resync — no truth is lost. + +
+
+
+ + +
+ + setWebhooks(e.target.checked)} /> + endpoint :48731 · healthy + + setReconcile(e.target.checked)} /> + + setPoll(e.target.checked)} /> +
+ + + +
+ + hot memory ≤ 2k tokens · math is never delegated to either + +

+ The small one writes my standup; the large one argues with your estimates. Neither is allowed near the arithmetic. +

+
+
+ + +
+ + {['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'].map((l) => )} + + + {['p/1', 'p/2', 'p/3', 'p/4'].map((l) => )} + + + Fixed sets, human-meaningful, visible in gitea. Not configurable — that is rather the point. +
+
+ + +
+ + Morning standup +
+ +
+
+
+
+ + +
+ setDark(false)} /> + setDark(true)} /> +
+
+ + + +
+
Forget this gitea
+ Removes the connection and the local cache. Gitea itself is untouched. +
+ +
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx index e8647aa..3f56c01 100644 --- a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx +++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx @@ -6,11 +6,14 @@ import { PrimitivesGallery } from '../gallery.js' import { BoardScreen } from '../screens/board-screen.js' import { CalibrationScreen } from '../screens/calibration-screen.js' import { CaptureScreen } from '../screens/capture-screen.js' +import { DirectivesScreen } from '../screens/directives-screen.js' import { FocusScreen } from '../screens/focus-screen.js' import { InboxScreen } from '../screens/inbox-screen.js' import { IssueScreen } from '../screens/issue-screen.js' import { MilestoneScreen } from '../screens/milestone-screen.js' +import { OnboardingScreen } from '../screens/onboarding-screen.js' import { RunwayScreen } from '../screens/runway-screen.js' +import { SettingsScreen } from '../screens/settings-screen.js' import { StandupScreen } from '../screens/standup-screen.js' import { Icon, Switch } from '../ui/index.js' import { ChatPanel } from './chat-panel.js' @@ -177,6 +180,10 @@ export function AppShell() { ) case 'capture': return setView('focus')} /> + case 'directives': + return + case 'settings': + return case 'issue': return issue ? ( setView(prevView)} onOpenIssue={openIssue} /> @@ -190,6 +197,11 @@ export function AppShell() { } } + // First run is full-window — no rail, no chat panel + if (view === 'firstrun') { + return setView(dest)} /> + } + return (
= { note: 'It blocks #91 and #92. I’d take it first — the critical path agrees with me.', }, } + +// ---- Directives ---- + +export interface DirectiveDiffRow { + tone: 'ok' | 'warn' | 'info' | 'danger' + change: string + from: string + to: string +} + +export interface DirectivePending { + seq: number + who: string + when: string + what: string + diff: DirectiveDiffRow[] +} + +export interface DirectiveEntry { + seq: number + who: string + when: string + what: string + why: string + status: string + consequence: string +} + +export interface DirectivesData { + pending: DirectivePending | null + entries: DirectiveEntry[] +} + +export const DIRECTIVES: DirectivesData = { + pending: { + seq: 7, + who: 'Stephen', + when: 'today 09:12', + what: 'Pilots before calibration — push #78 to next week.', + diff: [ + { tone: 'info', change: '#78 Calibration store', from: 'this week', to: 'wk of Feb 23' }, + { tone: 'warn', change: 'Beta · 80% window', from: 'Mar 3–12', to: 'Mar 5–14' }, + { tone: 'ok', change: "Today's plan", from: '#87', to: '#87 · unchanged' }, + ], + }, + entries: [ + { seq: 6, who: 'Stephen', when: 'Feb 8 · 16:40', what: 'Ana takes nothing new until #84 lands.', why: 'context thrash', status: 'applied', consequence: 'WIP capped · v1.0 unmoved' }, + { seq: 5, who: 'Stephen', when: 'Feb 6 · 09:03', what: 'Ship Beta a week early.', why: 'board meeting', status: 'withdrawn', consequence: '80% would need scope −9d — withdrawn after diff' }, + { seq: 4, who: 'Stephen', when: 'Feb 3 · 11:21', what: 'deadline/hard on Pilot-ready.', why: 'contract date', status: 'applied', consequence: 'label applied · runway flag raised' }, + { seq: 3, who: 'Stephen', when: 'Jan 28 · 08:47', what: 'Webhook work ahead of UI polish.', why: '', status: 'applied', consequence: '#91 +2 ranks · Beta unmoved' }, + { seq: 2, who: 'Stephen', when: 'Jan 20 · 14:02', what: 'Estimates in days, never hours.', why: 'sanity', status: 'applied', consequence: 'label schema est/* confirmed' }, + { seq: 1, who: 'Stephen', when: 'Jan 19 · 09:00', what: 'CommiTea manages its own backlog.', why: 'dogfood', status: 'applied', consequence: 'stephen/commitea under management' }, + ], +}