From 6879bedb1306ca8495d8b29acaad19b91f0bedef Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 12:40:37 -0400 Subject: [PATCH] feat(desktop): Focus + Standup screens + burn-up cone (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real P3 screens, replacing the placeholders: - FocusScreen — Now/Next/Later cards (Now = jade + Start/Defer footer), each with #id, label tags, steeping badge, agent-font rationale, and a title link that drives the shell issue drill-in. Milestone card wraps the burn-up cone. - StandupScreen — typeset letter: overnight drift (dot + delta), per- person plan (initials avatars), warn-tint stale-blocker nag; sections settle in on a 90ms stagger, reduced-motion-safe. - charts/chart.tsx — BurnUpCone (actual polyline, forecast cone with dashed 80% bounds, jade today rule) + RunwayBar (for P3-5). Fixed sample geometry until P2 feeds it. Fixtures (today/focus/standup) mirrored from data.js. Shell routes focus/standup to the real screens; the drill-in now starts from a Focus card. Screenshot helper freezes animations so fade-in screens capture settled. typecheck + 9 e2e green; light/standup/states verified. Closes P3-3. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/e2e/pages/app.page.ts | 6 +- apps/desktop/e2e/smoke.spec.ts | 28 ++- .../renderer/src/components/charts/chart.tsx | 121 ++++++++++ .../src/components/screens/focus-screen.tsx | 99 +++++++++ .../src/components/screens/standup-screen.tsx | 206 ++++++++++++++++++ .../src/components/shell/app-shell.tsx | 6 + .../desktop/src/renderer/src/data/fixtures.ts | 94 ++++++++ 7 files changed, 552 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/charts/chart.tsx create mode 100644 apps/desktop/src/renderer/src/components/screens/focus-screen.tsx create mode 100644 apps/desktop/src/renderer/src/components/screens/standup-screen.tsx diff --git a/apps/desktop/e2e/pages/app.page.ts b/apps/desktop/e2e/pages/app.page.ts index 8cc5ac9..f1fea05 100644 --- a/apps/desktop/e2e/pages/app.page.ts +++ b/apps/desktop/e2e/pages/app.page.ts @@ -37,10 +37,12 @@ export class AppPage { ) } - /** Capture a full-page screenshot for visual review; returns the path. */ + /** Capture a full-page screenshot for visual review; returns the path. + * `animations: 'disabled'` fast-forwards finite CSS animations to their end + * state, so fade-in screens (e.g. Standup) capture settled, not mid-fade. */ async screenshot(name: string): Promise { const path = join(SCREENS_DIR, `${name}.png`) - await this.page.screenshot({ path, fullPage: true }) + await this.page.screenshot({ path, fullPage: true, animations: 'disabled' }) return path } } diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index 7f38224..5a7d11e 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -1,13 +1,31 @@ import { expect, test } from './fixtures.js' -test('boots into the shell: rail, main, and Reginald panel', async ({ app, window }) => { +test('boots into the shell with the Focus screen', async ({ app, window }) => { await app.expectLoaded() await expect(window.getByText('Reginald', { exact: true })).toBeVisible() - // default view is Morning service await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible() await app.screenshot('shell-light') }) +test('Focus shows Now/Next/Later cards and the burn-up cone', async ({ window }) => { + for (const slot of ['Now', 'Next', 'Later']) { + await expect(window.getByText(slot, { exact: true })).toBeVisible() + } + await expect(window.getByText('Fix lifecycle inference on merge events')).toBeVisible() + await expect(window.getByText(/scope · 42 issues/)).toBeVisible() + await expect(window.getByText(/80% this lands/)).toBeVisible() +}) + +test('Standup renders the drift / plan / nag letter', async ({ app, window }) => { + await app.nav('Standup').click() + await expect(window.getByRole('heading', { name: 'Morning standup' })).toBeVisible() + await expect(window.getByText('Overnight drift')).toBeVisible() + await expect(window.getByText("Today's plan")).toBeVisible() + await expect(window.getByText('Stephen', { exact: true })).toBeVisible() + await expect(window.getByText(/Four days is a long steep/)).toBeVisible() + await app.screenshot('standup') +}) + test('rail navigation switches the main view', async ({ app, window }) => { await app.nav('The pot').click() await expect(window.getByRole('heading', { name: 'The pot' })).toBeVisible() @@ -19,7 +37,6 @@ test('rail navigation switches the main view', async ({ app, window }) => { }) test('Evening service toggle flips the document theme', async ({ app, window }) => { - // the switch's real input is visually hidden — click the label text to toggle await window.getByText('Evening service', { exact: true }).click() await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark') await expect(window.getByRole('switch', { name: 'Evening service' })).toBeChecked() @@ -39,11 +56,10 @@ test('chat composer echoes a canned reply (fixture)', async ({ window }) => { await expect(window.getByText(/Noted and logged as a directive/)).toBeVisible() }) -test('issue drill-in and back-stack of one', async ({ window }) => { - await window.getByRole('button', { name: 'Preview an issue page' }).click() +test('issue drill-in from a Focus card and back-stack of one', async ({ window }) => { + await window.getByRole('link', { name: 'Fix lifecycle inference on merge events' }).click() await expect(window.getByRole('heading', { name: 'Issue' })).toBeVisible() await window.getByRole('button', { name: 'Back' }).click() - // returns to the view we drilled in from await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible() }) diff --git a/apps/desktop/src/renderer/src/components/charts/chart.tsx b/apps/desktop/src/renderer/src/components/charts/chart.tsx new file mode 100644 index 0000000..a9c16fb --- /dev/null +++ b/apps/desktop/src/renderer/src/components/charts/chart.tsx @@ -0,0 +1,121 @@ +/** + * Charts — geometry, not decoration. Ported from the handoff's Chart.js. The + * fixed sample paths here stand in for scheduler/Monte Carlo output (P2); the + * shapes (cone from today, 80% band, actual polyline, today rule) are final. + */ + +export interface BurnUpConeProps { + width?: number + height?: number +} + +export function BurnUpCone({ width = 640, height = 220 }: BurnUpConeProps) { + const pad = { l: 34, r: 96, t: 16, b: 26 } + const W = width - pad.l - pad.r + const H = height - pad.t - pad.b + const x = (f: number) => pad.l + f * W + const y = (f: number) => pad.t + (1 - f) * H + + const today = 0.58 + const actual: number[][] = [ + [0, 0], + [0.08, 0.05], + [0.18, 0.13], + [0.26, 0.16], + [0.36, 0.27], + [0.46, 0.38], + [0.58, 0.47], + ] + const coneHi: number[][] = [ + [0.58, 0.47], + [0.72, 0.66], + [0.86, 0.88], + [0.95, 1.0], + ] + const coneLo: number[][] = [ + [0.58, 0.47], + [0.74, 0.58], + [0.9, 0.74], + [1.0, 0.86], + ] + const mid: number[][] = [ + [0.58, 0.47], + [0.76, 0.63], + [0.92, 0.83], + [1.0, 0.93], + ] + const pts = (arr: number[][]) => arr.map(([a, b]) => `${x(a)},${y(b)}`).join(' ') + const cone = [...coneHi, ...[...coneLo].reverse()] + + return ( + + {/* gridlines */} + {[0, 0.25, 0.5, 0.75, 1].map((f) => ( + + ))} + {/* scope */} + + + scope · 42 issues + + {/* cone */} + + + + + {/* actual */} + + + {/* today rule */} + + + today + + {/* 80% band label */} + + 80% + + + Mar 3–12 + + {/* x labels */} + + Jan 6 + + + Mar 15 + + + ) +} + +export interface RunwayBarMilestone { + tone: 'ok' | 'warn' + pos: number + spread: number +} + +/** Milestone due-date vs forecast-range position. Used by the Runway screen (P3-5). */ +export function RunwayBar({ m }: { m: RunwayBarMilestone }) { + const toneColor = m.tone === 'warn' ? 'var(--warn)' : 'var(--ok)' + const left = Math.max(0, (m.pos - m.spread / 2) * 100) + const w = Math.min(100 - left, m.spread * 100) + return ( +
+
+
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/screens/focus-screen.tsx b/apps/desktop/src/renderer/src/components/screens/focus-screen.tsx new file mode 100644 index 0000000..07282a0 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/focus-screen.tsx @@ -0,0 +1,99 @@ +import React from 'react' + +import { FOCUS, type FocusIssue, TODAY } from '../../data/fixtures.js' +import { BurnUpCone } from '../charts/chart.js' +import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js' + +/** + * Morning service — the Now/Next/Later focus cards + the milestone burn-up cone. + * Data is fixture (data.js) until P2's scheduler + Monte Carlo feed it. + */ +export function FocusScreen({ onOpenIssue }: { onOpenIssue: (id: number) => void }) { + const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => ( + { + e.preventDefault() + onOpenIssue(issue.id) + }} + style={{ color: 'inherit', border: 'none' }} + > + {issue.title} + + } + actions={} + footer={ + jade ? ( + <> + + + + scheduler pick · critical path + + + ) : null + } + > +
+ #{issue.id} + {issue.labels.map((l) => ( + + ))} + {issue.steeping ? ( + + steeping {issue.steeping} + + ) : null} +
+

{issue.rationale}

+
+ ) + + return ( +
+
+
+

Morning service

+

+ {TODAY} · reconcile 3.2s +

+
+ + ahead of forecast + +
+ +
+ + + +
+ + 80% this lands Mar 3–12} + actions={} + > + +

+ The cone has narrowed since Friday. I’m quietly pleased. +

+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/screens/standup-screen.tsx b/apps/desktop/src/renderer/src/components/screens/standup-screen.tsx new file mode 100644 index 0000000..f469740 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/standup-screen.tsx @@ -0,0 +1,206 @@ +import React from 'react' + +import { STANDUP } from '../../data/fixtures.js' +import { Badge, Button, Icon } from '../ui/index.js' + +const standupCSS = ` +@keyframes ct-standup-settle { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: none; } +} +.ct-standup-section { opacity: 1; } +@media (prefers-reduced-motion: no-preference) { + .ct-standup-section { animation: ct-standup-settle 320ms var(--ease-out) both; } +} +` +;(function inject() { + if (typeof document !== 'undefined' && !document.getElementById('ct-standup-css')) { + const s = document.createElement('style') + s.id = 'ct-standup-css' + s.textContent = standupCSS + document.head.appendChild(s) + } +})() + +const toneColor: Record = { + ok: 'var(--ok)', + warn: 'var(--warn)', + danger: 'var(--danger)', +} + +/** + * Morning standup — a typeset letter from Reginald: overnight drift, today's + * plan, stale-blocker nag. Sections settle in once, reduced-motion-safe. Data + * is fixture (data.js) until the scheduler + lifecycle inference feed it. + */ +export function StandupScreen({ + onBegin, + onOpenIssue, +}: { + onBegin: () => void + onOpenIssue: (id: number) => void +}) { + const s = STANDUP + + const Section = ({ overline, order, children }: { overline: string; order: number; children: React.ReactNode }) => ( +
+

+ {overline} +

+ {children} +
+ ) + + return ( +
+
+
+

{s.date} · prepared 07:00

+

Morning standup

+
+ +
+
+ {s.drift.map((d) => ( +
+ + {d.text} + + {d.delta} + +
+ ))} +
+
+ +
+
+ {s.plan.map((p) => ( +
+ + {p.who + .split(' ') + .map((w) => w[0]) + .join('')} + +
+
+ + {p.who} + + {p.pick} + {p.title} +
+

{p.why}

+
+
+ ))} +
+
+ +
+
onOpenIssue(s.nag.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') onOpenIssue(s.nag.id) + }} + style={{ + display: 'flex', + gap: 10, + alignItems: 'flex-start', + cursor: 'pointer', + background: 'var(--warn-tint)', + borderRadius: 'var(--radius-2)', + padding: '12px 14px', + }} + > + + + +
+
+ #{s.nag.id} + + steeping {s.nag.days} + + + blocks {s.nag.blocks.join(', ')} + +
+

{s.nag.text}

+
+
+
+ +
+

+ The kettle’s on. — R. +

+ + +
+
+
+ ) +} 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 b798312..e3068b9 100644 --- a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx +++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react' import logoIcon from '../../design/assets/logo-icon.png' import { PrimitivesGallery } from '../gallery.js' +import { FocusScreen } from '../screens/focus-screen.js' +import { StandupScreen } from '../screens/standup-screen.js' import { Icon, Switch } from '../ui/index.js' import { ChatPanel } from './chat-panel.js' import { PlaceholderScreen } from './placeholder-screen.js' @@ -137,6 +139,10 @@ export function AppShell() { const renderScreen = () => { switch (view) { + case 'focus': + return + case 'standup': + return setView('focus')} onOpenIssue={openIssue} /> case 'states': return setView('capture')} /> case 'primitives': diff --git a/apps/desktop/src/renderer/src/data/fixtures.ts b/apps/desktop/src/renderer/src/data/fixtures.ts index ad625f8..0054de0 100644 --- a/apps/desktop/src/renderer/src/data/fixtures.ts +++ b/apps/desktop/src/renderer/src/data/fixtures.ts @@ -24,3 +24,97 @@ export const CHAT: ChatMessage[] = [ export const CANNED_REPLY = 'Noted and logged as a directive. The scheduler is re-running — I’ll show you the consequence diff in a moment.' + +export const TODAY = 'Tuesday 7 July 2026' + +export interface FocusIssue { + id: number + title: string + labels: string[] + steeping?: string + rationale: string +} + +export interface FocusData { + now: FocusIssue + next: FocusIssue + later: FocusIssue +} + +export const FOCUS: FocusData = { + now: { + id: 87, + title: 'Fix lifecycle inference on merge events', + labels: ['est/2d', 'p/1'], + steeping: '4d', + rationale: 'It blocks #91 and #92. I’d take it first — the critical path agrees with me.', + }, + next: { + id: 91, + title: 'Webhook listener: reconcile on reconnect', + labels: ['est/3d', 'p/2'], + rationale: 'Ready the moment #87 lands. The estimate is yours; history says add a day.', + }, + later: { + id: 78, + title: 'Calibration store cold-start distributions', + labels: ['est/5d', 'p/3'], + rationale: 'Nothing depends on it yet. It can steep.', + }, +} + +export interface DriftItem { + tone: 'ok' | 'warn' | 'danger' + text: string + delta: string +} + +export interface PlanItem { + who: string + pick: string + title: string + why: string +} + +export interface Nag { + id: number + days: string + blocks: string[] + text: string +} + +export interface StandupData { + date: string + drift: DriftItem[] + plan: PlanItem[] + nag: Nag +} + +export const STANDUP: StandupData = { + date: 'Tuesday 7 July 2026', + drift: [ + { tone: 'warn', text: '#84 grew a dependency on #92 overnight.', delta: 'reordered · no date impact' }, + { tone: 'warn', text: '#92 has sat in review for two days.', delta: 'Beta 80% +1d if idle past Thu' }, + { tone: 'ok', text: 'Webhook outage 02:14–02:31; full reconcile ran.', delta: 'nothing lost' }, + ], + plan: [ + { + who: 'Stephen', + pick: '#87', + title: 'Fix lifecycle inference on merge events', + why: 'It blocks two others and the critical path runs straight through it.', + }, + { + who: 'Ana K.', + pick: '#84', + title: 'Capacity model: focus factor per person', + why: 'Already steeping — finish it before anything new is poured.', + }, + ], + nag: { + id: 87, + days: '4d', + blocks: ['#91', '#92'], + text: 'Four days is a long steep. It blocks #91 and #92 — worth a look before it stains.', + }, +}