feat(desktop): Focus + Standup screens + burn-up cone (#16)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
121
apps/desktop/src/renderer/src/components/charts/chart.tsx
Normal file
121
apps/desktop/src/renderer/src/components/charts/chart.tsx
Normal 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 3–12
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 3–12</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. I’m quietly pleased.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 kettle’s 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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':
|
||||||
|
|||||||
@@ -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 — I’ll show you the consequence diff in a moment.'
|
'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.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user