CommiTea foundation + P3 UI: gitea client, e2e harness, all product screens #35

Merged
christian merged 8 commits from p1/gitea-read-client into main 2026-07-08 20:42:13 +00:00
45 changed files with 3422 additions and 33 deletions
Showing only changes of commit 6879bedb13 - Show all commits

View File

@@ -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<string> { async screenshot(name: string): Promise<string> {
const path = join(SCREENS_DIR, `${name}.png`) 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 return path
} }
} }

View File

@@ -1,13 +1,31 @@
import { expect, test } from './fixtures.js' 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 app.expectLoaded()
await expect(window.getByText('Reginald', { exact: true })).toBeVisible() await expect(window.getByText('Reginald', { exact: true })).toBeVisible()
// default view is Morning service
await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible() await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible()
await app.screenshot('shell-light') 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 }) => { test('rail navigation switches the main view', async ({ app, window }) => {
await app.nav('The pot').click() await app.nav('The pot').click()
await expect(window.getByRole('heading', { name: 'The pot' })).toBeVisible() 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 }) => { 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 window.getByText('Evening service', { exact: true }).click()
await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark') await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark')
await expect(window.getByRole('switch', { name: 'Evening service' })).toBeChecked() 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() await expect(window.getByText(/Noted and logged as a directive/)).toBeVisible()
}) })
test('issue drill-in and back-stack of one', async ({ window }) => { test('issue drill-in from a Focus card and back-stack of one', async ({ window }) => {
await window.getByRole('button', { name: 'Preview an issue page' }).click() await window.getByRole('link', { name: 'Fix lifecycle inference on merge events' }).click()
await expect(window.getByRole('heading', { name: 'Issue' })).toBeVisible() await expect(window.getByRole('heading', { name: 'Issue' })).toBeVisible()
await window.getByRole('button', { name: 'Back' }).click() await window.getByRole('button', { name: 'Back' }).click()
// returns to the view we drilled in from
await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible() await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible()
}) })

View File

@@ -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 (
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ display: 'block' }}>
{/* gridlines */}
{[0, 0.25, 0.5, 0.75, 1].map((f) => (
<line key={f} x1={pad.l} x2={width - pad.r} y1={y(f)} y2={y(f)} stroke="var(--line-1)" strokeWidth="1" />
))}
{/* scope */}
<line x1={pad.l} x2={width - pad.r} y1={y(1)} y2={y(1)} stroke="var(--line-2)" strokeWidth="1.5" />
<text x={pad.l} y={y(1) - 6} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
scope · 42 issues
</text>
{/* cone */}
<polygon points={pts(cone)} fill="var(--cone-fill)" />
<polyline points={pts(coneHi)} fill="none" stroke="var(--cone-line)" strokeWidth="1.2" strokeDasharray="3 3" />
<polyline points={pts(coneLo)} fill="none" stroke="var(--cone-line)" strokeWidth="1.2" strokeDasharray="3 3" />
<polyline points={pts(mid)} fill="none" stroke="var(--cone-line)" strokeWidth="1.4" />
{/* actual */}
<polyline points={pts(actual)} fill="none" stroke="var(--cone-actual)" strokeWidth="2" />
<circle cx={x(0.58)} cy={y(0.47)} r="3.5" fill="var(--cone-actual)" />
{/* today rule */}
<line x1={x(today)} x2={x(today)} y1={pad.t} y2={height - pad.b} stroke="var(--jade)" strokeWidth="1" />
<text x={x(today) + 5} y={pad.t + 10} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--jade-7)' }}>
today
</text>
{/* 80% band label */}
<text x={width - pad.r + 10} y={y(0.95)} style={{ font: '500 11.5px var(--font-mono)', fill: 'var(--ink-1)' }}>
80%
</text>
<text x={width - pad.r + 10} y={y(0.95) + 14} style={{ font: '400 11px var(--font-mono)', fill: 'var(--ink-2)' }}>
Mar 312
</text>
{/* x labels */}
<text x={pad.l} y={height - 8} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
Jan 6
</text>
<text x={width - pad.r - 34} y={height - 8} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
Mar 15
</text>
</svg>
)
}
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 (
<div style={{ position: 'relative', height: 22, background: 'var(--paper-2)', borderRadius: 4, overflow: 'hidden' }}>
<div
style={{
position: 'absolute',
left: `${left}%`,
width: `${w}%`,
top: 0,
bottom: 0,
background: 'var(--cone-fill)',
borderLeft: `1.5px solid ${toneColor}`,
borderRight: `1.5px solid ${toneColor}`,
}}
/>
<div style={{ position: 'absolute', left: `${m.pos * 100}%`, top: 0, bottom: 0, width: 2, background: toneColor }} />
<div style={{ position: 'absolute', left: 'calc(88% - 1px)', top: 0, bottom: 0, width: 2, background: 'var(--ink-1)' }} />
</div>
)
}

View File

@@ -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 }) => (
<Card
overline={slot}
jade={jade}
title={
<a
href="#"
onClick={(e) => {
e.preventDefault()
onOpenIssue(issue.id)
}}
style={{ color: 'inherit', border: 'none' }}
>
{issue.title}
</a>
}
actions={<IconButton icon="ellipsis" label="More" size="sm" />}
footer={
jade ? (
<>
<Button size="sm">Start</Button>
<Button size="sm" variant="ghost">
Defer
</Button>
<span style={{ marginLeft: 'auto', font: 'var(--text-caption)', color: 'var(--ink-3)' }}>
scheduler pick · critical path
</span>
</>
) : null
}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
<span style={{ font: 'var(--text-data)', color: 'var(--ink-3)' }}>#{issue.id}</span>
{issue.labels.map((l) => (
<Tag key={l} label={l} />
))}
{issue.steeping ? (
<Badge tone="warn" dot>
steeping {issue.steeping}
</Badge>
) : null}
</div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>{issue.rationale}</p>
</Card>
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<header
style={{
borderBottom: 'var(--rule-double)',
paddingBottom: 14,
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 12,
}}
>
<div>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Morning service</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>
{TODAY} · reconcile 3.2s
</p>
</div>
<Badge tone="ok" dot>
ahead of forecast
</Badge>
</header>
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr 1fr', gap: 14, alignItems: 'start' }}>
<FocusRow slot="Now" issue={FOCUS.now} jade />
<FocusRow slot="Next" issue={FOCUS.next} />
<FocusRow slot="Later" issue={FOCUS.later} />
</div>
<Card
overline="Milestone · Beta"
title={<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>Mar 312</span></>}
actions={<IconButton icon="chart-line" label="Open runway" size="sm" />}
>
<BurnUpCone />
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
The cone has narrowed since Friday. Im quietly pleased.
</p>
</Card>
</div>
)
}

View File

@@ -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<string, string> = {
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 }) => (
<section
className="ct-standup-section"
style={{ animationDelay: `${order * 90}ms`, display: 'flex', flexDirection: 'column', gap: 12 }}
>
<p
style={{
font: 'var(--text-overline)',
letterSpacing: 'var(--letter-spacing-wide)',
textTransform: 'uppercase',
color: 'var(--ink-3)',
margin: 0,
borderTop: '1px solid var(--line-1)',
paddingTop: 14,
}}
>
{overline}
</p>
{children}
</section>
)
return (
<div style={{ maxWidth: 660, margin: '0 auto' }}>
<article
style={{
background: 'var(--surface-card)',
border: '1px solid var(--line-1)',
borderRadius: 'var(--radius-3)',
boxShadow: 'var(--shadow-jade-line), var(--shadow-1)',
padding: '36px 44px 32px',
display: 'flex',
flexDirection: 'column',
gap: 20,
}}
>
<header className="ct-standup-section" style={{ borderBottom: 'var(--rule-double)', paddingBottom: 16 }}>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '0 0 8px' }}>{s.date} · prepared 07:00</p>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Morning standup</h1>
</header>
<Section overline="Overnight drift" order={1}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{s.drift.map((d) => (
<div key={d.text} style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
<span
style={{
width: 7,
height: 7,
borderRadius: '50%',
background: toneColor[d.tone],
flexShrink: 0,
position: 'relative',
top: -1,
}}
/>
<span style={{ font: 'var(--text-body)', color: 'var(--ink-1)', flex: 1 }}>{d.text}</span>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>
{d.delta}
</span>
</div>
))}
</div>
</Section>
<Section overline="Today's plan" order={2}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{s.plan.map((p) => (
<div key={p.who} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<span
style={{
width: 26,
height: 26,
borderRadius: '50%',
background: 'var(--spruce-2)',
color: 'var(--accent-text)',
font: '600 10px/26px var(--font-sans)',
textAlign: 'center',
flexShrink: 0,
}}
>
{p.who
.split(' ')
.map((w) => w[0])
.join('')}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>
{p.who}
</span>
<span style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-2)' }}>{p.pick}</span>
<span style={{ font: 'var(--text-small)', color: 'var(--ink-2)' }}>{p.title}</span>
</div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '4px 0 0' }}>{p.why}</p>
</div>
</div>
))}
</div>
</Section>
<Section overline="Stale blockers" order={3}>
<div
role="button"
tabIndex={0}
onClick={() => 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',
}}
>
<span style={{ color: 'var(--warn)', display: 'inline-flex', marginTop: 2 }}>
<Icon name="clock" size={15} />
</span>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ font: '500 12.5px var(--font-mono)', color: 'var(--ink-1)' }}>#{s.nag.id}</span>
<Badge tone="warn" dot>
steeping {s.nag.days}
</Badge>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)' }}>
blocks {s.nag.blocks.join(', ')}
</span>
</div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '5px 0 0' }}>{s.nag.text}</p>
</div>
</div>
</Section>
<footer
className="ct-standup-section"
style={{
animationDelay: '360ms',
borderTop: '1px solid var(--line-1)',
paddingTop: 16,
display: 'flex',
alignItems: 'center',
gap: 10,
}}
>
<p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-1)', margin: 0, flex: 1 }}>
The kettles on. R.
</p>
<Button variant="ghost" onClick={onBegin}>
Ask about the drift
</Button>
<Button iconRight="arrow-right" onClick={onBegin}>
Begin the day
</Button>
</footer>
</article>
</div>
)
}

View File

@@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react'
import logoIcon from '../../design/assets/logo-icon.png' import logoIcon from '../../design/assets/logo-icon.png'
import { PrimitivesGallery } from '../gallery.js' 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 { Icon, Switch } from '../ui/index.js'
import { ChatPanel } from './chat-panel.js' import { ChatPanel } from './chat-panel.js'
import { PlaceholderScreen } from './placeholder-screen.js' import { PlaceholderScreen } from './placeholder-screen.js'
@@ -137,6 +139,10 @@ export function AppShell() {
const renderScreen = () => { const renderScreen = () => {
switch (view) { switch (view) {
case 'focus':
return <FocusScreen onOpenIssue={openIssue} />
case 'standup':
return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} />
case 'states': case 'states':
return <StatesScreen onCapture={() => setView('capture')} /> return <StatesScreen onCapture={() => setView('capture')} />
case 'primitives': case 'primitives':

View File

@@ -24,3 +24,97 @@ export const CHAT: ChatMessage[] = [
export const CANNED_REPLY = export const CANNED_REPLY =
'Noted and logged as a directive. The scheduler is re-running — Ill show you the consequence diff in a moment.' 'Noted and logged as a directive. The scheduler is re-running — Ill 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. Id 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:1402: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.',
},
}