Compare commits

..

9 Commits

Author SHA1 Message Date
103b6cc98d Merge pull request 'CommiTea foundation + P3 UI: gitea client, e2e harness, all product screens' (#35) from p1/gitea-read-client into main
Reviewed-on: #35
2026-07-08 20:42:13 +00:00
Croissant Le Doux
0fc53d03be feat(desktop): Directives, Settings, Onboarding — P3 complete (#21)
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) <noreply@anthropic.com>
2026-07-08 16:22:59 -04:00
Croissant Le Doux
5176589f0c feat(desktop): complete the P3 screens — Board, Runway, Issue, Capture (#17–#20)
Ports the nine remaining design-handoff screens to typed TSX and wires
them into the shell, retiring the placeholders for every primary view.

- The pot (#17 / P3-4): BoardScreen 5-column kanban with live search +
  empty state, Tabs into GanttView (state bars, critical inset rules,
  dotted 80% tails, today rule) and DepsGraph (layered DAG, bezier edges,
  spruce critical path, milestone terminal).
- Runway (#18 / P3-5): RunwayScreen (range bars via RunwayBar, capacity,
  calibration teaser), CalibrationScreen (scatter + honest diagonal +
  ×1.18 fit, per-label/per-person bias), MilestoneScreen (stats strip,
  cone, grouped issues).
- Issue + Inbox (#19 / P3-6): IssueScreen (human intent left, machine-
  derived lifecycle/forecast/deps right, "writes as you" composer),
  InboxScreen (day-grouped, filter tabs, unread dots, mark-all-read).
- Capture (#20 / P3-7): CaptureScreen dump→interview→review→filed state
  machine with running clock.

Shell: openIssue now carries an IssueRef; adds readIds (inbox) + issue
state; routes board/runway/calibration/milestone/issue/inbox/capture and
the runway→calibration/milestone + issue back-stack. Focus/Standup pass
IssueRefs. Select/Input gain defaultValue (Capture's editable tray).
Fixtures mirrored from data.js. typecheck + 11 e2e green; all nine
screens screenshot-verified in light.

Closes P3-4, P3-5, P3-6, P3-7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:41:45 -04:00
Croissant Le Doux
6879bedb13 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>
2026-07-08 12:40:37 -04:00
Croissant Le Doux
f18c6d1078 feat(desktop): app shell — rail, chat panel, routing, states (#15)
Reimplements the handoff Shell in React/TS: 208px left rail (nav +
connection dot + Evening service theme switch), max-1120 main column,
330px Reginald chat panel. Routing across top-level views with an
issue drill-in + back-stack of one; data-theme owned by the shell.

- ChatPanel: fixture write-path — echoes a canned reply so layout +
  interactions are real; model router wiring lands in P4.
- states.tsx: EmptyState / OfflineBanner / ModelAwayState + the States
  specimen gallery, ported from the handoff.
- PlaceholderScreen stands in for not-yet-built views (P3-3+), keeping
  navigation live; it also exposes the issue drill-in for now.
- Gallery loses its own theme toggle (shell owns data-theme); reachable
  via a Primitives rail entry as a living reference.

Fixtures mirrored from the handoff's data.js. typecheck + 7 e2e green
(nav, theme, offline banner + disabled composer, chat echo, drill-in
back-stack); light/dark/states screenshots verified.

Closes P3-2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:32:05 -04:00
Croissant Le Doux
635025113c feat(desktop): port the 15 design-system primitives (#14)
Verbatim port of the handoff primitives into components/ui/ — Icon,
Button, IconButton, Badge, Tag, Card, Tabs (core); Input, Select,
Checkbox, Radio, Switch (forms); Dialog, Toast, Tooltip (feedback).
Each keeps its injected token-referencing CSS byte-for-byte; the
handoff .d.ts contracts become the exported prop interfaces. Barrel at
components/ui/index.ts.

Adds a PrimitivesGallery (app root for now; real shell is P3-2) that
exercises every primitive with a light/dark toggle. Smoke suite asserts
the gallery, section coverage, theme flip, and dialog open/Escape;
screenshots captured for both themes. typecheck + 5 e2e green.

Closes P3-1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:12:00 -04:00
Croissant Le Doux
96c2b8b1c2 test(desktop): Playwright electron e2e harness
Launches the built app (out/main/index.js) via Playwright's _electron
API — no browser project, no chromium download. Adds a launch fixture
(electronApp/window/app), an AppPage page object with a screenshot
helper for autonomous visual review, and a boot smoke suite (shell
renders, @commitea/core label-parse runs in the renderer, preload API
exposed). Scripts: e2e (build+run), e2e:only, e2e:report. Artifacts
gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:38:59 -04:00
Croissant Le Doux
d6f531eb6d feat(core): gitea read client behind an injected fetch (#1)
createGiteaClient(config, fetchImpl).getIssue(index) fetches one issue
and normalizes raw gitea JSON to a typed GiteaIssue with scheduler
facts precomputed via extractLabelFacts. Network is an injected
FetchLike (core has no DOM lib; global fetch is structurally
assignable), so it unit-tests against a stub — no live calls in the
suite. Non-2xx responses throw GiteaApiError carrying status + body.

Closes P1-1. Verified end-to-end against the live repo's issue #9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:33:29 -04:00
Croissant Le Doux
9920634e74 docs: settle open design items + dogfood backlog
Resolve the five design-session open items from PLAN.md:
- decisions.md: soft write-path, poll-only sync (NAT), lognormal
  cold-start priors, purity test binds the SQLite cache
- pm-state.md: sidecar layout + directive/capacity/calibration
  schemas + lifecycle inference table
- agent-tools.md: query_project read tool + three write tools

Also gitignore .env.* (protect the gitea PAT) and record the P0
actual: 10 labels, 5 milestones, 34 tracer-bullet issues + 51
dependencies filed on christian/commitea as the first managed project.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:30:23 -04:00
57 changed files with 5527 additions and 33 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
GITEA_TOKEN=

8
.gitignore vendored
View File

@@ -3,7 +3,15 @@ dist/
out/
*.log
.DS_Store
# playwright e2e artifacts
.artifacts/
test-results/
playwright-report/
.last-run.json
.env
.env.*
!.env.example
.yarn/*
!.yarn/patches
!.yarn/plugins

View File

@@ -25,6 +25,20 @@ yarn test # unit tests (vitest)
yarn typecheck
```
### End-to-end (Electron + Playwright)
```sh
yarn workspace @commitea/desktop e2e # build, then drive the built app
yarn workspace @commitea/desktop e2e:only # reuse existing out/ build (tight loop)
yarn workspace @commitea/desktop e2e:report # open the last HTML report
```
Tests launch the built app (`out/main/index.js`) through Playwright's
`_electron` API — no browser project, no chromium download. Fixtures and page
objects live in `apps/desktop/e2e/`; screenshots land in
`e2e/.artifacts/screens/` for visual review. Page objects use user-facing
locators (`getByRole`/`getByText`), never CSS/DOM structure.
## Conventions
- Yarn 4 workspaces; ESM everywhere; `.js` extensions on relative imports

View File

@@ -0,0 +1,48 @@
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
_electron as electron,
expect,
test as base,
type ElectronApplication,
type Page,
} from '@playwright/test'
import { AppPage } from './pages/app.page.js'
const here = dirname(fileURLToPath(import.meta.url))
/** The built main-process entry Playwright launches. `yarn e2e` builds it first. */
const MAIN_ENTRY = join(here, '..', 'out', 'main', 'index.js')
interface CommiteaFixtures {
/** The launched Electron application. */
electronApp: ElectronApplication
/** The app's first (and only) BrowserWindow, as a Playwright Page. */
window: Page
/** Page Object over the app shell. */
app: AppPage
}
export const test = base.extend<CommiteaFixtures>({
electronApp: async ({}, use) => {
const electronApp = await electron.launch({
args: [MAIN_ENTRY],
env: { ...process.env, NODE_ENV: 'test', COMMITEA_E2E: '1' },
})
await use(electronApp)
await electronApp.close()
},
window: async ({ electronApp }, use) => {
const window = await electronApp.firstWindow()
await window.waitForLoadState('domcontentloaded')
await use(window)
},
app: async ({ window }, use) => {
await use(new AppPage(window))
},
})
export { expect }

View File

@@ -0,0 +1,50 @@
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { expect, type Locator, type Page } from '@playwright/test'
const here = dirname(fileURLToPath(import.meta.url))
/** Where `screenshot()` drops PNGs — gitignored, readable for visual review. */
const SCREENS_DIR = join(here, '..', '.artifacts', 'screens')
/**
* Page Object over the CommiTea app shell. As real screens land (P3), add
* per-screen page objects and accessor methods here; keep locators
* user-facing (getByRole/getByText), never CSS/DOM structure.
*/
export class AppPage {
constructor(readonly page: Page) {}
/** The rail wordmark — present on every in-app view. */
get wordmark(): Locator {
return this.page.getByText('CommiTea', { exact: true })
}
/** Navigate via a left-rail entry by its label. Scoped to the Primary rail so
* it never collides with in-screen breadcrumb buttons of the same name, and
* matches by prefix so count badges (e.g. "Inbox 3") still resolve. */
nav(label: string): Locator {
return this.page.getByRole('navigation', { name: 'Primary' }).getByRole('button', { name: label })
}
/** Assert the shell has rendered. */
async expectLoaded(): Promise<void> {
await expect(this.wordmark).toBeVisible()
}
/** Read the `commitea` preload API surface from the renderer. */
async preloadApi(): Promise<Record<string, unknown> | undefined> {
return this.page.evaluate(
() => (globalThis as unknown as { commitea?: Record<string, unknown> }).commitea,
)
}
/** 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> {
const path = join(SCREENS_DIR, `${name}.png`)
await this.page.screenshot({ path, fullPage: true, animations: 'disabled' })
return path
}
}

View File

@@ -0,0 +1,146 @@
import { expect, test } from './fixtures.js'
test('boots into the shell with the Focus screen', async ({ app, window }) => {
await app.expectLoaded()
await expect(window.getByText('Reginald', { exact: true })).toBeVisible()
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()
})
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('Stephen', { exact: true })).toBeVisible()
await app.screenshot('standup')
})
test('Board: columns, search filter, and Gantt/Deps tabs', async ({ app, window }) => {
await app.nav('The pot').click()
await expect(window.getByRole('heading', { name: 'The pot' })).toBeVisible()
await expect(window.getByText('Diagnosis', { exact: true })).toBeVisible()
await expect(window.getByText('Steeping', { exact: true })).toBeVisible()
await app.screenshot('board')
await window.getByPlaceholder(/Search the pot/).fill('lifecycle')
await expect(window.getByText('Fix lifecycle inference on merge events')).toBeVisible()
await expect(window.getByText('Dark theme: cone fill too faint')).toBeHidden()
await window.getByPlaceholder(/Search the pot/).fill('')
await window.getByRole('tab', { name: 'Gantt' }).click()
await expect(window.getByText(/80% · Mar 312/)).toBeVisible()
await app.screenshot('gantt')
await window.getByRole('tab', { name: 'Dependencies' }).click()
await expect(window.getByText('critical path', { exact: true })).toBeVisible()
await app.screenshot('deps')
})
test('Runway: milestones, capacity, and drill-ins', async ({ app, window }) => {
await app.nav('Runway').click()
await expect(window.getByRole('heading', { name: 'Runway' })).toBeVisible()
await expect(window.getByText('Pilot-ready')).toBeVisible()
await expect(window.getByText('deadline/hard')).toBeVisible()
await app.screenshot('runway')
// milestone drill-in
await window.getByText('Beta', { exact: true }).click()
await expect(window.getByRole('heading', { name: 'Beta' })).toBeVisible()
await expect(window.getByText('42 issues · est 61d')).toBeVisible()
await app.screenshot('milestone')
await window.getByRole('main').getByRole('button', { name: 'Runway' }).click()
// calibration drill-in
await window.getByRole('button', { name: 'Full report' }).click()
await expect(window.getByRole('heading', { name: 'Calibration' })).toBeVisible()
await expect(window.getByText('The shape of hope')).toBeVisible()
await app.screenshot('calibration')
})
test('Inbox: notifications and mark-all-read', async ({ app, window }) => {
await app.nav('Inbox').click()
await expect(window.getByRole('heading', { name: 'Inbox' })).toBeVisible()
await expect(window.getByText(/window moved/)).toBeVisible()
await app.screenshot('inbox')
await window.getByRole('button', { name: 'Mark all read' }).click()
await expect(window.getByText('all read · nothing here rings twice')).toBeVisible()
})
test('Capture: braindump advances to the interview', async ({ app, window }) => {
await app.nav('Capture').click()
await expect(window.getByText("Tell me what you're planning")).toBeVisible()
await app.screenshot('capture')
await window.getByRole('button', { name: 'Brew tickets' }).click()
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()
await expect(window.getByText('Machine-derived')).toBeVisible()
await app.screenshot('issue')
await window.getByRole('button', { name: 'Back' }).click()
await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible()
})
test('Evening service toggle flips the document theme', async ({ app, window }) => {
await window.getByText('Evening service', { exact: true }).click()
await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark')
await app.screenshot('shell-dark')
})
test('offline sim shows the banner and disables the composer', async ({ window }) => {
await window.getByRole('button', { name: /Connection/ }).click()
await expect(window.getByText(/Gitea isn.t answering/)).toBeVisible()
await expect(window.getByRole('textbox')).toBeDisabled()
})
test('exposes the commitea preload API', async ({ app }) => {
const api = await app.preloadApi()
expect(api).toBeDefined()
expect(api).toHaveProperty('platform')
})

View File

@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"],
"moduleResolution": "Bundler",
"noEmit": true
},
"include": ["."]
}

View File

@@ -7,7 +7,10 @@
"dev": "electron-vite dev 2>&1 | tee desktop.log",
"build": "electron-vite build",
"start": "electron-vite preview",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"e2e": "electron-vite build && playwright test",
"e2e:only": "playwright test",
"e2e:report": "playwright show-report e2e/.artifacts/report"
},
"dependencies": {
"@commitea/core": "workspace:*",
@@ -15,6 +18,7 @@
"react-dom": "^18.3.1"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "^22.13.1",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",

View File

@@ -0,0 +1,26 @@
import { defineConfig } from '@playwright/test'
/**
* E2E harness for the Electron app. Tests launch the *built* app
* (`out/main/index.js`) through Playwright's `_electron` API — see
* `e2e/fixtures.ts`. There is no browser project and no chromium download:
* `_electron` drives the app's own bundled electron.
*
* Run `yarn e2e` (builds first) for a fresh run, or `yarn e2e:only` to reuse
* the existing `out/` build during a tight iteration loop.
*/
export default defineConfig({
testDir: './e2e',
outputDir: './e2e/.artifacts/test-results',
fullyParallel: false,
workers: 1, // one electron instance at a time — deterministic, avoids window races
forbidOnly: !!process.env.CI,
retries: 0,
timeout: 30_000,
expect: { timeout: 5_000 },
reporter: [['list'], ['html', { outputFolder: './e2e/.artifacts/report', open: 'never' }]],
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
})

View File

@@ -1,26 +1,5 @@
import { extractLabelFacts } from '@commitea/core'
const facts = extractLabelFacts(['est/3d', 'p/2', 'deadline/hard'])
import { AppShell } from './components/shell/app-shell.js'
export function App() {
return (
<main className="min-h-screen bg-paper-0 font-sans text-ink-1">
<div className="mx-auto max-w-[1120px] px-7 py-16">
<header className="border-b-[3px] border-double border-ink-1 pb-4">
<h1 className="font-display text-5xl">CommiTea</h1>
<p className="mt-2 font-mono text-sm text-ink-3">
scaffold · phase 0 · gitea connection pending
</p>
</header>
<p className="mt-8 max-w-[60ch] font-agent text-lg leading-relaxed">
Good morning. The scaffold stands and the kettle is on, but I have nothing to manage
yet. Connect me to gitea and we shall put the pot to work.
</p>
<p className="mt-6 font-mono text-sm text-ink-2">
label parse check: est/{facts.estimateDays}d · p/{facts.priority} · hard{' '}
{String(facts.hardDeadline)}
</p>
</div>
</main>
)
return <AppShell />
}

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,211 @@
import React, { useState } from 'react'
import {
Badge,
Button,
Card,
Checkbox,
Dialog,
Icon,
IconButton,
Input,
Radio,
Select,
Switch,
Tabs,
Tag,
Toast,
Tooltip,
} from './ui/index.js'
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="mt-9">
<h2 className="mb-3 font-mono text-xs uppercase tracking-wider text-ink-3">{title}</h2>
<div className="flex flex-wrap items-start gap-3">{children}</div>
</section>
)
}
/**
* Visual + behavioral proof for the ported primitives (P3-1). Not a product
* screen — the real shell lands in P3-2. Exercises every primitive in both
* themes via the toggle.
*/
export function PrimitivesGallery() {
const [tab, setTab] = useState('board')
const [dialogOpen, setDialogOpen] = useState(false)
const [checked, setChecked] = useState(true)
const [radio, setRadio] = useState('a')
const [on, setOn] = useState(true)
return (
<main className="bg-paper-0 font-sans text-ink-1">
<div className="mx-auto max-w-[1120px]">
<header className="border-b-[3px] border-double border-ink-1 pb-4">
<h1 className="font-display text-4xl">Primitives</h1>
<p className="mt-1 font-mono text-sm text-ink-3">
15 components · toggle Evening service in the rail for dark
</p>
</header>
<Section title="Button">
<Button variant="primary" icon="sparkles">
Brew plan
</Button>
<Button variant="secondary">Amend</Button>
<Button variant="ghost" icon="clock">
Defer
</Button>
<Button variant="danger" icon="trash-2">
Withdraw
</Button>
<Button variant="primary" size="sm" iconRight="arrow-right">
Next
</Button>
<Button variant="secondary" disabled>
Disabled
</Button>
</Section>
<Section title="IconButton">
<IconButton icon="pencil" label="Edit" />
<IconButton icon="copy" label="Copy" />
<IconButton icon="ellipsis" label="More" variant="outline" />
<IconButton icon="x" label="Close" size="sm" />
</Section>
<Section title="Badge">
<Badge tone="ok" dot>
ahead
</Badge>
<Badge tone="warn" dot>
at risk
</Badge>
<Badge tone="danger" dot>
behind
</Badge>
<Badge tone="info">steeping</Badge>
<Badge tone="neutral">triage</Badge>
<Badge tone="jade">on track</Badge>
</Section>
<Section title="Tag">
<Tag label="est/3d" />
<Tag label="est/8d" />
<Tag label="p/1" />
<Tag label="p/2" />
<Tag label="p/3" />
<Tag label="p/4" />
<Tag label="deadline/hard" />
<Tag label="p/1" onRemove={() => {}} />
</Section>
<Section title="Card">
<Card
overline="Now"
title="Gitea read client"
jade
actions={<IconButton icon="external-link" label="Open" size="sm" />}
footer={
<>
<Button size="sm" variant="primary">
Start
</Button>
<Button size="sm" variant="ghost">
Defer
</Button>
</>
}
style={{ width: 340 }}
>
<p className="font-agent text-base leading-relaxed">
The tap-root. Everything sinks into it grab it first.
</p>
</Card>
<Card title="Plain card" style={{ width: 260 }}>
<p className="text-sm text-ink-2">Hairline border, whispered shadow.</p>
</Card>
</Section>
<Section title="Tabs">
<Tabs
items={[
{ id: 'board', label: 'Board', icon: 'square-kanban', count: 34 },
{ id: 'gantt', label: 'Gantt', icon: 'chart-no-axes-gantt' },
{ id: 'deps', label: 'Dependencies', icon: 'network', count: 51 },
]}
active={tab}
onChange={setTab}
/>
</Section>
<Section title="Icon">
<Icon name="coffee" size={20} />
<Icon name="leaf" size={20} />
<Icon name="git-branch" size={20} />
<Icon name="milestone" size={20} />
<Icon name="sparkles" size={20} />
</Section>
<Section title="Input & Select">
<Input label="Repository" placeholder="christian/commitea" style={{ width: 240 }} />
<Input label="Search" icon="search" placeholder="Filter issues" style={{ width: 220 }} />
<Input label="Token" mono error="Connection failed" placeholder="tok_xxx" style={{ width: 220 }} />
<Select
label="Priority"
options={[
{ value: 'p/1', label: 'p/1 — urgent' },
{ value: 'p/2', label: 'p/2' },
{ value: 'p/3', label: 'p/3' },
]}
style={{ width: 200 }}
/>
</Section>
<Section title="Checkbox · Radio · Switch">
<Checkbox label="Hard deadline" checked={checked} onChange={(e) => setChecked(e.target.checked)} />
<Checkbox label="Disabled" checked disabled onChange={() => {}} />
<Radio label="Option A" name="g" value="a" checked={radio === 'a'} onChange={() => setRadio('a')} />
<Radio label="Option B" name="g" value="b" checked={radio === 'b'} onChange={() => setRadio('b')} />
<Switch label="Live sync" checked={on} onChange={(e) => setOn(e.target.checked)} />
</Section>
<Section title="Feedback">
<Toast tone="ok" title="Plan brewed" onDismiss={() => {}}>
Milestone P2 lands Mar 312.
</Toast>
<Toast tone="warn" title="Stale blocker">
#9 has waited 6 days.
</Toast>
<Tooltip content="Writes to gitea, as you">
<Button variant="secondary" icon="info">
Hover me
</Button>
</Tooltip>
<Button variant="primary" onClick={() => setDialogOpen(true)}>
Open dialog
</Button>
</Section>
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
title="Withdraw directive?"
footer={
<>
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button variant="danger" onClick={() => setDialogOpen(false)}>
Withdraw
</Button>
</>
}
>
<p>This removes the directive from the ledger. The schedule reverts to its prior state.</p>
</Dialog>
</div>
</main>
)
}

View File

@@ -0,0 +1,84 @@
import React from 'react'
import { COLUMNS, type BoardIssue, type IssueRef } from '../../data/fixtures.js'
import { Card, Tag, Badge, Tabs, IconButton, Input, Icon } from '../ui/index.js'
import { EmptyState } from '../shell/states.js'
import { GanttView } from './gantt-view.js'
import { DepsGraph } from './deps-graph.js'
// Board — kanban over inferred lifecycle, with Gantt/Dependencies stubs
export function BoardScreen({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) {
const [tab, setTab] = React.useState('board');
const [query, setQuery] = React.useState('');
const q = query.trim().toLowerCase();
const filtered = COLUMNS.map((c) => ({ ...c, issues: q ? c.issues.filter((i) => (i.title + ' #' + i.id).toLowerCase().includes(q)) : c.issues }));
const anyMatch = filtered.some((c) => c.issues.length > 0);
const openCount = COLUMNS.reduce((n, c) => n + (c.id === 'done' ? 0 : c.issues.length), 0);
const IssueCard = ({ issue }: { issue: BoardIssue }) => (
<div
onClick={() => onOpenIssue(issue)}
style={{
background: 'var(--surface-card)', border: '1px solid var(--line-1)',
borderRadius: 'var(--radius-2)', padding: '10px 12px', cursor: 'pointer',
boxShadow: 'var(--shadow-1)', display: 'flex', flexDirection: 'column', gap: 8,
}}
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.borderColor = 'var(--line-2)'; }}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.borderColor = 'var(--line-1)'; }}
>
<div style={{ font: 'var(--text-small)', fontWeight: 500, color: 'var(--ink-1)' }}>{issue.title}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)' }}>#{issue.id}</span>
{issue.labels.map((l) => <Tag key={l} label={l} style={{ transform: 'scale(0.95)', transformOrigin: 'left center' }} />)}
{issue.days ? <span style={{ font: '400 11px var(--font-mono)', color: 'var(--warn)' }}>{issue.days}</span> : null}
{issue.pr ? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, font: '400 11px var(--font-mono)', color: 'var(--info)' }}><Icon name="git-pull-request" size={12} />{issue.pr}</span> : null}
<span style={{
marginLeft: 'auto', width: 20, height: 20, borderRadius: '50%',
background: 'var(--spruce-2)', color: 'var(--accent-text)',
font: '600 9px/20px var(--font-sans)', textAlign: 'center', flexShrink: 0,
}}>{issue.who}</span>
</div>
</div>
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, height: '100%', minHeight: 0 }}>
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0, whiteSpace: 'nowrap' }}>The pot</h1>
<div style={{ width: 240 }}><Input icon="search" placeholder="Search the pot…" value={query} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)} /></div>
</header>
<Tabs
items={[
{ id: 'board', label: 'Board', icon: 'square-kanban', count: openCount },
{ id: 'gantt', label: 'Gantt', icon: 'chart-no-axes-gantt' },
{ id: 'deps', label: 'Dependencies', icon: 'network' },
]}
active={tab} onChange={setTab}
/>
{tab === 'board' ? (
anyMatch ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 12, alignItems: 'start', flex: 1, minHeight: 0, overflow: 'auto' }}>
{filtered.map((col) => (
<div key={col.id} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 2px 4px', borderBottom: '1px solid var(--line-1)', whiteSpace: 'nowrap' }}>
<span style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-2)' }}>{col.label}</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>{col.issues.length}</span>
</div>
{col.issues.map((i) => <IssueCard key={i.id} issue={i} />)}
</div>
))}
</div>
) : (
<div style={{ flex: 1, border: '1px dashed var(--line-2)', borderRadius: 'var(--radius-3)' }}>
<EmptyState icon="search" title="Nothing by that name"
line={`The pot holds ${COLUMNS.reduce((n, c) => n + c.issues.length, 0)} issues; none of them answer to “${query.trim()}”.`} />
</div>
)
) : tab === 'deps' ? (
<DepsGraph onOpenIssue={onOpenIssue} />
) : (
<GanttView onOpenIssue={onOpenIssue} />
)}
</div>
);
}

View File

@@ -0,0 +1,180 @@
import React from 'react'
import { CALIBRATION } from '../../data/fixtures.js'
import { Badge, Card, Icon } from '../ui/index.js'
// Calibration report — estimate-vs-actual evidence behind the cones
export function CalibrationScreen({ onBack }: { onBack: () => void }) {
const c = CALIBRATION
// scatter chart geometry
const W = 420,
H = 300,
pad = { l: 36, r: 16, t: 14, b: 30 }
const maxD = 9
const X = (d: number) => pad.l + (d / maxD) * (W - pad.l - pad.r)
const Y = (d: number) => H - pad.b - (d / maxD) * (H - pad.t - pad.b)
const BiasBar = ({ bias }: { bias: number | null }) => {
if (bias == null)
return <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>n too small</span>
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
<div style={{ position: 'relative', flex: 1, height: 10, background: 'var(--paper-2)', borderRadius: 3 }}>
<div style={{ position: 'absolute', left: '30%', top: -2, bottom: -2, width: 1.5, background: 'var(--line-2)' }}></div>
<div
style={{
position: 'absolute',
left: '30%',
top: 1.5,
height: 7,
width: `${Math.min(bias * 1.6, 66)}%`,
background: bias > 15 ? 'var(--warn)' : 'var(--ok)',
borderRadius: '0 3px 3px 0',
opacity: 0.75,
}}
></div>
</div>
<span style={{ font: '500 11.5px var(--font-mono)', color: bias > 15 ? 'var(--warn)' : 'var(--ok)', width: 42, textAlign: 'right' }}>
+{bias}%
</span>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<button
type="button"
onClick={onBack}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
background: 'none',
border: 'none',
font: '500 12.5px var(--font-sans)',
color: 'var(--ink-2)',
cursor: 'pointer',
padding: '2px 0',
marginBottom: 10,
}}
>
<Icon name="arrow-left" size={14} /> Runway
</button>
<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 }}>Calibration</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>{c.n} closed issues with estimates · evidence, not opinion</p>
</div>
<Badge tone="ok" dot>curve active · n 20</Badge>
</header>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, alignItems: 'start' }}>
{/* scatter */}
<Card overline="Estimate vs actual" title="The shape of hope" jade>
<svg width="100%" viewBox={`0 0 ${W} ${H}`} style={{ display: 'block' }}>
{[1, 3, 5, 8].map((d) => (
<React.Fragment key={d}>
<line x1={X(d)} x2={X(d)} y1={pad.t} y2={H - pad.b} stroke="var(--line-1)" strokeWidth="1" />
<text x={X(d)} y={H - 12} textAnchor="middle" style={{ font: '400 10px var(--font-mono)', fill: 'var(--ink-3)' }}>{d}d</text>
<line x1={pad.l} x2={W - pad.r} y1={Y(d)} y2={Y(d)} stroke="var(--line-1)" strokeWidth="1" />
<text x={pad.l - 6} y={Y(d) + 3} textAnchor="end" style={{ font: '400 10px var(--font-mono)', fill: 'var(--ink-3)' }}>{d}d</text>
</React.Fragment>
))}
{/* perfect line */}
<line x1={X(0)} y1={Y(0)} x2={X(maxD)} y2={Y(maxD)} stroke="var(--line-2)" strokeWidth="1.2" strokeDasharray="4 4" />
<text x={X(7.6)} y={Y(7.6) + 14} style={{ font: '400 10px var(--font-mono)', fill: 'var(--ink-3)' }}>honest</text>
{/* fit */}
<line x1={X(0)} y1={Y(0)} x2={X(maxD / c.fit)} y2={Y(maxD)} stroke="var(--cone-line)" strokeWidth="1.6" />
<text x={X(4.1)} y={Y(4.1 * c.fit) - 8} style={{ font: '500 10px var(--font-mono)', fill: 'var(--cone-line)' }}>you · ×{c.fit}</text>
{/* points */}
{c.scatter.map(([e, a], i) => (
<circle key={i} cx={X(e) + ((i % 5) - 2) * 3} cy={Y(a)} r="3" fill="var(--accent)" opacity="0.55" />
))}
</svg>
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: '8px 0 0' }}>estimated (x) vs actual days (y) · actuals inferred from git events, never tracked</p>
</Card>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* per-label bias */}
<Card overline="Bias by estimate label" flush>
<div>
{c.labels.map((r, i) => (
<div
key={r.label}
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
padding: '10px 20px',
borderTop: i === 0 ? 'none' : '1px solid var(--line-1)',
}}
>
<span style={{ font: '500 11.5px var(--font-mono)', color: 'var(--ink-1)', width: 52 }}>{r.label}</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', width: 66 }}>n={r.n} · {r.median}</span>
<BiasBar bias={r.bias} />
</div>
))}
</div>
</Card>
{/* per-person */}
<Card overline="By person" flush>
<div>
{c.people.map((p, i) => (
<div
key={p.who}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '11px 20px',
borderTop: i === 0 ? 'none' : '1px solid var(--line-1)',
}}
>
<span
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: 'var(--spruce-2)',
color: 'var(--accent-text)',
font: '600 9px/24px var(--font-sans)',
textAlign: 'center',
flexShrink: 0,
}}
>
{p.who
.split(' ')
.map((w) => w[0])
.join('')}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>{p.who}</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}> · n={p.n} · {p.note}</span>
</div>
<span style={{ font: '500 12px var(--font-mono)', color: p.bias > 15 ? 'var(--warn)' : 'var(--ok)' }}>+{p.bias}%</span>
</div>
))}
</div>
</Card>
{/* effect on forecasts */}
<Card overline="What this does to your forecasts">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span style={{ font: '400 12.5px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}>{c.effect.raw}</span>
<Icon name="arrow-right" size={14} style={{ color: 'var(--ink-3)' }} />
<span style={{ font: '500 12.5px var(--font-mono)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{c.effect.banded}</span>
</div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
You are not bad at estimating; you are optimistic in a very stable way. Stable, I can work with.
</p>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,209 @@
import React from 'react'
import { Badge, Button, Card, Icon, Select, Tag } from '../ui/index.js'
// Capture interview — braindump → interview → approved ticket set (< 2 min)
interface Ticket {
title: string
est: string
p: string
dep?: string
byReginald?: boolean
}
interface Question {
q: string
chips: string[]
set: (a: string) => void
}
export function CaptureScreen({ onDone }: { onDone: () => void }) {
const [stage, setStage] = React.useState<'dump' | 'interview' | 'review' | 'filed'>('dump')
const [dump, setDump] = React.useState(
'auth is flaky — token refresh dies silently, sometimes session storage goes stale. ' +
'also the webhook debounce thing keeps double-firing. and we owe docs for auth setup'
)
const [qi, setQi] = React.useState(0)
const [log, setLog] = React.useState<{ q: string; a: string }[]>([])
const [split, setSplit] = React.useState<boolean | null>(null)
const [webEst, setWebEst] = React.useState<string | null>(null)
const [secs, setSecs] = React.useState(0)
const running = stage === 'interview' || stage === 'review'
React.useEffect(() => {
if (!running) return
const t = setInterval(() => setSecs((s) => s + 1), 1000)
return () => clearInterval(t)
}, [running])
const clock = `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`
const QUESTIONS: Question[] = [
{
q: 'The auth work — one ticket, or shall I split token refresh from session storage? They fail differently.',
chips: ['One ticket', 'Split them'],
set: (a) => setSplit(a === 'Split them'),
},
{
q: 'The webhook double-fire — how long? I should mention your "quick" has averaged two days.',
chips: ['est/1d', 'est/2d', 'est/3d'],
set: (a) => setWebEst(a),
},
{
q: 'Milestone Beta, I presume? It has room, provided the auth work stays under four days.',
chips: ['Beta', 'New milestone'],
set: () => {},
},
]
const answer = (a: string) => {
QUESTIONS[qi].set(a)
setLog((l) => [...l, { q: QUESTIONS[qi].q, a }])
if (qi + 1 < QUESTIONS.length) setQi(qi + 1)
else setStage('review')
}
// draft tickets build as the interview progresses
const tickets: Ticket[] = []
if (split === true) {
tickets.push({ title: 'Token refresh: retry with backoff', est: 'est/2d', p: 'p/2' })
tickets.push({ title: 'Session storage: stale reads on wake', est: 'est/1d', p: 'p/3' })
} else if (split === false) {
tickets.push({ title: 'Auth: token refresh + session storage', est: 'est/3d', p: 'p/2' })
}
if (webEst) tickets.push({ title: 'Webhook debounce: double-fire guard', est: webEst, p: 'p/1', dep: 'blocked by auth work' })
if (stage === 'review' || stage === 'filed') {
tickets.push({ title: 'Docs: auth setup guide', est: 'est/1d', p: 'p/4', byReginald: true })
}
const totalDays = tickets.reduce((n, t) => n + parseInt(t.est.replace('est/', '')), 0)
const estOptions = ['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'].map((v) => ({ value: v, label: v }))
const pOptions = ['p/1', 'p/2', 'p/3', 'p/4'].map((v) => ({ value: v, label: v }))
const Tray = ({ editable }: { editable?: boolean }) => (
<Card overline="The tray" title={tickets.length ? `${tickets.length} draft${tickets.length > 1 ? 's' : ''}` : 'Empty, for now'} flush>
<div>
{tickets.length === 0 ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-3)', margin: 0, padding: '14px 20px 18px' }}>
Tickets appear here as we talk.
</p>
) : tickets.map((t, i) => (
<div key={t.title} style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: '12px 20px', borderTop: i === 0 ? 'none' : '1px solid var(--line-1)',
}}>
<div style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>{t.title}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{editable ? (
<>
<Select options={estOptions} defaultValue={t.est} style={{ width: 96 }} />
<Select options={pOptions} defaultValue={t.p} style={{ width: 76 }} />
</>
) : (
<>
<Tag label={t.est} />
<Tag label={t.p} />
</>
)}
{t.dep ? <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="network" size={11} />{t.dep}</span> : null}
{t.byReginald ? <Badge tone="jade">added by Reginald</Badge> : null}
</div>
</div>
))}
</div>
</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 }}>Capture</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>braindump approved tickets</p>
</div>
{stage !== 'dump' ? (
<div style={{ textAlign: 'right' }}>
<div style={{ font: '400 24px/1 var(--font-serif-display)', color: secs > 120 ? 'var(--warn)' : 'var(--ink-1)' }}>{clock}</div>
<div style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', marginTop: 4 }}>budget 2:00</div>
</div>
) : null}
</header>
{stage === 'dump' ? (
<Card overline="Braindump" title="Tell me what you're planning" jade>
<textarea
value={dump}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDump(e.target.value)}
rows={5}
style={{
width: '100%', resize: 'vertical', font: 'var(--text-body)', color: 'var(--ink-1)',
background: 'var(--paper-0)', border: '1px solid var(--line-2)', borderRadius: 'var(--radius-2)',
padding: '10px 12px', outline: 'none', lineHeight: 1.55,
}}
></textarea>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 14px' }}>
Sentences, fragments, grievances all welcome. I'll sort it into tickets and only ask what I can't infer.
</p>
<Button icon="sparkles" onClick={() => setStage('interview')}>Brew tickets</Button>
</Card>
) : null}
{stage === 'interview' ? (
<div style={{ display: 'grid', gridTemplateColumns: '1.3fr 1fr', gap: 14, alignItems: 'start' }}>
<Card overline={`Interview · ${qi + 1} of ${QUESTIONS.length}`} jade>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{log.map((e, i) => (
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6, opacity: 0.66 }}>
<span style={{ font: 'var(--text-agent)', color: 'var(--ink-2)' }}>{e.q}</span>
<span style={{ alignSelf: 'flex-start', font: 'var(--text-small)', background: 'var(--paper-2)', borderRadius: 'var(--radius-round)', padding: '4px 11px' }}>{e.a}</span>
</div>
))}
<p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-1)', margin: 0 }}>{QUESTIONS[qi].q}</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{QUESTIONS[qi].chips.map((c) => (
<Button key={c} variant="secondary" size="sm" onClick={() => answer(c)}>{c}</Button>
))}
</div>
</div>
</Card>
<Tray />
</div>
) : null}
{stage === 'review' ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.1fr', gap: 14, alignItems: 'start' }}>
<Card overline="Consequence" title={`${tickets.length} tickets · ~${totalDays}d of work`} jade
footer={<>
<Button onClick={() => setStage('filed')}>Approve all</Button>
<Button variant="ghost" onClick={() => { setStage('dump'); setQi(0); setLog([]); setSplit(null); setWebEst(null); setSecs(0); }}>Discard</Button>
</>}>
<p style={{ font: 'var(--text-body)', margin: '0 0 8px' }}>
Beta's 80% window moves <span style={{ font: 'var(--text-data)' }}>Mar 312 → Mar 514</span>. Capacity absorbs the rest.
</p>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
I added the docs ticket you mentioned and wired the dependency. Shall I make it so?
</p>
</Card>
<Tray editable />
</div>
) : null}
{stage === 'filed' ? (
<Card jade style={{ maxWidth: 560 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'flex-start' }}>
<span style={{ color: 'var(--ok)', display: 'inline-flex', alignItems: 'center', gap: 8, font: 'var(--text-title)' }}>
<Icon name="circle-check" size={22} /> Filed
</span>
<p style={{ font: 'var(--text-body)', margin: 0 }}>
{tickets.length} issues opened in gitea with <span style={{ font: 'var(--text-data)' }}>est/*</span> and <span style={{ font: 'var(--text-data)' }}>p/*</span> labels — nothing else touched.
</p>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
Elapsed {clock} under budget. No bot comments, no synthetic issues; your repo remains yours.
</p>
<Button iconRight="arrow-right" onClick={onDone}>To morning service</Button>
</div>
</Card>
) : null}
</div>
)
}

View File

@@ -0,0 +1,130 @@
import React from 'react'
import { DEPS, type IssueRef } from '../../data/fixtures.js'
import { Tag, Icon } from '../ui/index.js'
// Dependency graph drill-in — layered DAG, critical path in spruce
export function DepsGraph({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) {
const g = DEPS
const PAD = 14, COLW = 206, ROWH = 106, NW = 176, NH = 84
const X = (c: number) => PAD + c * COLW
const Y = (r: number) => PAD + r * ROWH
const maxCol = g.milestone.col
const maxRow = Math.max(...g.nodes.map((n) => n.row), g.milestone.row)
const W = PAD * 2 + maxCol * COLW + NW
const H = PAD * 2 + maxRow * ROWH + NH
const MSW = 158, MSH = 44
const pos: Record<string, { x: number; y: number; w: number; h: number }> = {}
g.nodes.forEach((n) => { pos[n.id] = { x: X(n.col), y: Y(n.row), w: NW, h: NH } })
pos['ms'] = { x: X(g.milestone.col), y: Y(g.milestone.row) + (NH - MSH) / 2, w: MSW, h: MSH }
const edgePath = (e: { from: number; to: number | 'ms'; crit?: boolean }) => {
const a = pos[e.from], b = pos[e.to]
const x1 = a.x + a.w, y1 = a.y + a.h / 2
const x2 = b.x, y2 = b.y + b.h / 2
const dx = Math.max(28, (x2 - x1) / 2)
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2 - 5} ${y2}`
}
const STATES: Record<string, { icon: string; color: string; label: string }> = {
done: { icon: 'circle-check', color: 'var(--ok)', label: 'done' },
steeping: { icon: 'clock', color: 'var(--warn)', label: 'steeping' },
review: { icon: 'git-pull-request', color: 'var(--info)', label: 'in review' },
triage: { icon: 'circle-dot', color: 'var(--ink-3)', label: 'triage' },
diagnosis: { icon: 'circle-dashed', color: 'var(--ink-3)', label: 'diagnosis' },
}
const Node = ({ n }: { n: (typeof DEPS.nodes)[number] }) => {
const st = STATES[n.state]
const crit = g.critical.includes(n.id)
return (
<div
onClick={() => onOpenIssue({ id: n.id, title: n.title, labels: n.tags, rationale: n.rationale, days: n.state === 'steeping' ? n.days : undefined })}
style={{
position: 'absolute', left: pos[n.id].x, top: pos[n.id].y, width: NW, height: NH,
background: n.state === 'done' ? 'var(--paper-2)' : 'var(--surface-card)',
border: `1px solid ${crit ? 'var(--spruce-5)' : 'var(--line-1)'}`,
boxShadow: crit ? 'var(--shadow-1), inset 2px 0 0 var(--accent)' : 'var(--shadow-1)',
borderRadius: 'var(--radius-2)', padding: '9px 11px', cursor: 'pointer',
display: 'flex', flexDirection: 'column', gap: 6,
opacity: n.state === 'done' ? 0.72 : 1,
transition: 'border-color var(--duration-fast) var(--ease-out)',
}}
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.borderColor = crit ? 'var(--accent)' : 'var(--line-2)' }}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.borderColor = crit ? 'var(--spruce-5)' : 'var(--line-1)' }}
>
<div style={{
font: '500 12px/1.3 var(--font-sans)', color: 'var(--ink-1)',
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
}}>{n.title}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 'auto' }}>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>#{n.id}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, font: '400 10.5px var(--font-mono)', color: st.color, whiteSpace: 'nowrap' }}>
<Icon name={st.icon} size={11} />
{st.label}{n.state === 'steeping' && n.days ? ` ${n.days}` : ''}
</span>
{n.tags.filter((t) => t.startsWith('p/')).map((t) => (
<span key={t} style={{ font: '500 10.5px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}>{t}</span>
))}
</div>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, flex: 1, minHeight: 0 }}>
{/* legend */}
<div style={{ display: 'flex', alignItems: 'center', gap: 18, font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
<span style={{ width: 22, height: 2, background: 'var(--accent)', display: 'inline-block' }}></span>critical path
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
<span style={{ width: 22, height: 0, borderTop: '1.5px solid var(--line-2)', display: 'inline-block' }}></span>blocks
</span>
<span style={{ marginLeft: 'auto', color: 'var(--ink-3)' }}>unattached: {g.unattached.map((i) => `#${i}`).join(' · ')}</span>
</div>
{/* graph canvas */}
<div style={{ overflow: 'auto', flex: 1, minHeight: 0, border: '1px solid var(--line-1)', borderRadius: 'var(--radius-3)', background: 'var(--surface-app)' }}>
<div style={{ position: 'relative', width: W, height: H }}>
<svg width={W} height={H} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
<defs>
<marker id="dg-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0.5 L 7.5 4 L 0 7.5 z" fill="var(--line-2)"></path>
</marker>
<marker id="dg-arrow-crit" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0.5 L 7.5 4 L 0 7.5 z" fill="var(--accent)"></path>
</marker>
</defs>
{g.edges.map((e, i) => (
<path key={i} d={edgePath(e)} fill="none"
stroke={e.crit ? 'var(--accent)' : 'var(--line-2)'}
strokeWidth={e.crit ? 2 : 1.5}
markerEnd={`url(#${e.crit ? 'dg-arrow-crit' : 'dg-arrow'})`} />
))}
</svg>
{g.nodes.map((n) => <Node key={n.id} n={n} />)}
{/* milestone terminal */}
<div style={{
position: 'absolute', left: pos['ms'].x, top: pos['ms'].y, width: MSW, height: MSH,
display: 'flex', alignItems: 'center', gap: 8, padding: '0 14px',
background: 'var(--accent)', color: 'var(--ink-inverse)',
borderRadius: 'var(--radius-round)', boxShadow: 'var(--shadow-2)',
}}>
<Icon name="milestone" size={15} />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ font: '600 12.5px/1.2 var(--font-sans)' }}>{g.milestone.name}</span>
<span style={{ font: '400 10.5px/1.2 var(--font-mono)', opacity: 0.8 }}>due {g.milestone.due}</span>
</div>
</div>
</div>
</div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
Four issues sit on the critical path, and #87 is the cork in the bottle. Remove it and everything pours.
</p>
</div>
)
}

View File

@@ -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<DirectivePending | null>(DIRECTIVES.pending)
const [entries, setEntries] = React.useState<DirectiveEntry[]>(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 514' : 'withdrawn before apply',
}, ...e]);
setPending(null);
};
const toneColor: Record<string, string> = { ok: 'var(--ok)', warn: 'var(--warn)', info: 'var(--info)', danger: 'var(--danger)' };
const statusBadge: Record<string, { tone: 'ok' | 'neutral' | 'info'; label: string }> = {
applied: { tone: 'ok', label: 'applied' },
withdrawn: { tone: 'neutral', label: 'withdrawn' },
superseded: { tone: 'info', label: 'superseded' },
};
return (
<div style={{ maxWidth: 760, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 16 }}>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Directives</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>append-only · JSONL in pm-state · who, when, what, why</p>
</header>
{pending ? (
<Card overline={`Awaiting your word · directive #00${pending.seq}`} title="Consequence diff" jade
footer={<>
<Button onClick={() => resolve('applied')}>Make it so</Button>
<Button variant="secondary" onClick={() => {}}>Amend</Button>
<Button variant="ghost" onClick={() => resolve('withdrawn')}>Withdraw</Button>
</>}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ font: 'var(--text-body)', margin: 0, color: 'var(--ink-2)' }}>
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>{pending.who}</span>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)' }}> · {pending.when}</span>
<br />{pending.what}
</p>
<div style={{ borderTop: '1px solid var(--line-1)' }}>
{pending.diff.map((r) => (
<div key={r.change} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 0', borderBottom: '1px solid var(--line-1)' }}>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: toneColor[r.tone], flexShrink: 0 }}></span>
<span style={{ font: 'var(--text-small)', color: 'var(--ink-1)', flex: 1 }}>{r.change}</span>
<span style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>
{r.from} <span style={{ color: 'var(--ink-2)' }}></span> <span style={{ color: r.tone === 'warn' ? 'var(--warn)' : 'var(--ink-1)' }}>{r.to}</span>
</span>
</div>
))}
</div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
Cheap, as consequences go. Shall I make it so?
</p>
</div>
</Card>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, font: 'var(--text-small)', color: 'var(--ink-3)', padding: '2px 2px' }}>
<Icon name="circle-check" size={14} style={{ color: 'var(--ok)' }} />
Nothing awaits your word. Directives are given in chat; consequences appear here first.
</div>
)}
<Card overline="The ledger" flush>
<div>
{entries.map((e, i) => {
const sb = statusBadge[e.status];
return (
<div key={e.seq} style={{ display: 'flex', gap: 14, padding: '14px 20px', borderTop: i === 0 ? 'none' : '1px solid var(--line-1)' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<span style={{ font: '500 11px var(--font-mono)', color: 'var(--ink-3)' }}>#00{e.seq}</span>
<span style={{ width: 1, flex: 1, background: 'var(--line-1)' }}></span>
</div>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{
width: 20, height: 20, borderRadius: '50%', background: 'var(--spruce-2)', color: 'var(--accent-text)',
font: '600 8.5px/20px var(--font-sans)', textAlign: 'center', flexShrink: 0,
}}>{e.who.split(' ').map((w: string) => w[0]).join('')}</span>
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{e.who}</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>{e.when}</span>
{e.why ? <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>· why: {e.why}</span> : null}
<span style={{ marginLeft: 'auto' }}><Badge tone={sb.tone}>{sb.label}</Badge></span>
</div>
<p style={{ font: 'var(--text-body)', color: 'var(--ink-1)', margin: 0 }}>{e.what}</p>
<p style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)', margin: 0 }}>{e.consequence}</p>
</div>
</div>
);
})}
</div>
</Card>
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
Entries are never edited. Corrections are new entries the ledger remembers everything, politely.
</p>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import React from 'react'
import { FOCUS, type FocusIssue, type IssueRef, 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: (issue: IssueRef) => 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({ id: issue.id, title: issue.title, labels: issue.labels })
}}
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,105 @@
import React from 'react'
import { GANTT, type IssueRef } from '../../data/fixtures.js'
import { Icon } from '../ui/index.js'
// Gantt drill-in — scheduler-derived bars, critical chain, 80% forecast tails
export function GanttView({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) {
const g = GANTT;
const LABELW = 232, DAYW = 21, ROWH = 36, HEADH = 30;
const chartW = g.days * DAYW;
const W = LABELW + chartW;
const H = HEADH + g.rows.length * ROWH;
const X = (d: number) => LABELW + d * DAYW;
const BAR: Record<string, { bg: string; border: string; text: string }> = {
done: { bg: 'var(--paper-3)', border: 'transparent', text: 'var(--ink-3)' },
steeping: { bg: 'var(--accent)', border: 'transparent', text: 'var(--ink-inverse)' },
review: { bg: 'var(--info-tint)', border: 'var(--info)', text: 'var(--info)' },
scheduled: { bg: 'var(--spruce-2)', border: 'var(--spruce-3)', text: 'var(--accent-text)' },
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, flex: 1, minHeight: 0 }}>
{/* legend */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap', flexWrap: 'wrap' }}>
{[['steeping', 'in work'], ['review', 'in review'], ['scheduled', 'scheduled'], ['done', 'done']].map(([k, label]) => (
<span key={k} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 16, height: 9, borderRadius: 3, background: BAR[k].bg, border: `1px solid ${BAR[k].border}`, display: 'inline-block' }}></span>{label}
</span>
))}
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 18, height: 0, borderTop: '2px dotted var(--cone-line)', display: 'inline-block' }}></span>80% tail
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 2, height: 12, background: 'var(--jade)', display: 'inline-block' }}></span>today
</span>
</div>
{/* chart */}
<div style={{ overflow: 'auto', flex: 1, minHeight: 0, border: '1px solid var(--line-1)', borderRadius: 'var(--radius-3)', background: 'var(--surface-card)' }}>
<div style={{ position: 'relative', width: W, height: H }}>
{/* week gridlines + labels */}
{g.weeks.map((w) => (
<React.Fragment key={w.at}>
<div style={{ position: 'absolute', left: X(w.at), top: HEADH, bottom: 0, width: 1, background: 'var(--line-1)' }}></div>
<span style={{ position: 'absolute', left: X(w.at) + 5, top: 8, font: '400 10.5px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>{w.label}</span>
</React.Fragment>
))}
{/* milestone 80% band */}
<div style={{ position: 'absolute', left: X(g.band.from), width: (g.band.to - g.band.from) * DAYW, top: HEADH, bottom: 0, background: 'var(--cone-fill)' }}></div>
<span style={{ position: 'absolute', left: X(g.band.from) + 5, bottom: 6, font: '500 10.5px var(--font-mono)', color: 'var(--cone-line)', whiteSpace: 'nowrap' }}>{g.band.label}</span>
{/* due marker */}
<div style={{ position: 'absolute', left: X(g.due.at) - 1, top: HEADH, bottom: 0, width: 2, background: 'var(--ink-1)' }}></div>
<span style={{ position: 'absolute', left: X(g.due.at) - 4, top: HEADH - 12, width: 8, height: 8, background: 'var(--ink-1)', transform: 'rotate(45deg)' }}></span>
{/* today rule */}
<div style={{ position: 'absolute', left: X(g.today), top: HEADH, bottom: 0, width: 2, background: 'var(--jade)' }}></div>
{/* rows */}
{g.rows.map((r, i) => {
const top = HEADH + i * ROWH;
const st = BAR[r.state];
return (
<React.Fragment key={r.id}>
{/* row hairline */}
<div style={{ position: 'absolute', left: 0, right: 0, top: top, height: 1, background: 'var(--line-1)', opacity: 0.6 }}></div>
{/* label cell (sticky) */}
<div
onClick={() => onOpenIssue({ id: r.id, title: r.title, labels: [], days: r.state === 'steeping' ? '4d' : undefined })}
style={{
position: 'absolute', left: 0, width: LABELW, height: ROWH, top: top, zIndex: 2, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px 0 14px', background: 'var(--surface-card)',
borderRight: '1px solid var(--line-1)',
boxShadow: r.crit ? 'inset 2px 0 0 var(--accent)' : 'none',
}}
>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', flexShrink: 0 }}>#{r.id}</span>
<span style={{ font: '500 12px/1.3 var(--font-sans)', color: 'var(--ink-1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
<span style={{ font: '600 8.5px/16px var(--font-sans)', color: 'var(--accent-text)', background: 'var(--spruce-2)', width: 16, height: 16, borderRadius: '50%', textAlign: 'center', flexShrink: 0, marginLeft: 'auto' }}>{r.who}</span>
</div>
{/* bar */}
<div style={{
position: 'absolute', left: X(r.start), width: (r.end - r.start) * DAYW, top: top + 9, height: 18,
background: st.bg, border: `1px solid ${st.border}`, borderRadius: 4,
boxShadow: r.crit && r.state !== 'steeping' ? 'inset 0 -2px 0 var(--accent)' : 'none',
}}></div>
{/* 80% tail */}
{r.p80 ? (
<>
<div style={{ position: 'absolute', left: X(r.end), width: (r.p80 - r.end) * DAYW, top: top + 17, height: 0, borderTop: '2px dotted var(--cone-line)' }}></div>
<div style={{ position: 'absolute', left: X(r.p80) - 1, top: top + 13, width: 2, height: 10, background: 'var(--cone-line)' }}></div>
</>
) : null}
</React.Fragment>
);
})}
</div>
</div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
The path holds if #87 lands by Wednesday. The dotted tails are your own history, wagging.
</p>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import React from 'react'
import { INBOX, type InboxItem, type IssueRef } from '../../data/fixtures.js'
import { Button, Card, Icon, Tabs } from '../ui/index.js'
// Inbox — Reginald only rings the bell when it matters
export function InboxScreen({
onOpenIssue,
onOpenDirectives,
readIds,
setReadIds,
}: {
onOpenIssue: (issue: IssueRef) => void
onOpenDirectives: () => void
readIds: number[]
setReadIds: React.Dispatch<React.SetStateAction<number[]>>
}) {
const all = INBOX
const [tab, setTab] = React.useState('all')
const isRead = (n: InboxItem) => !n.unread || readIds.includes(n.id)
const unreadCount = all.filter((n) => !isRead(n)).length
const FILTERS: Record<string, (n: InboxItem) => boolean> = {
all: () => true,
mentions: (n) => n.type === 'mention' || n.type === 'assignment',
drift: (n) => n.type === 'drift' || n.type === 'nag' || n.type === 'milestone',
system: (n) => n.type === 'system' || n.type === 'review',
}
const items = all.filter(FILTERS[tab])
const days = [...new Set(items.map((n) => n.day))]
const toneColor: Record<string, string> = { ok: 'var(--ok)', warn: 'var(--warn)', info: 'var(--info)', neutral: 'var(--ink-3)' }
const open = (n: InboxItem) => {
setReadIds((r) => (r.includes(n.id) ? r : [...r, n.id]))
if (n.issue) onOpenIssue(n.issue)
else if (n.to === 'directives') onOpenDirectives()
}
return (
<div style={{ maxWidth: 760, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 14 }}>
<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 }}>Inbox</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>
{unreadCount ? `${unreadCount} unread` : 'all read'} · nothing here rings twice
</p>
</div>
{unreadCount ? (
<Button variant="ghost" size="sm" onClick={() => setReadIds(all.map((n) => n.id))}>Mark all read</Button>
) : null}
</header>
<Tabs
items={[
{ id: 'all', label: 'All', count: all.length },
{ id: 'mentions', label: 'Mentions', icon: 'message-square' },
{ id: 'drift', label: 'Drift', icon: 'chart-line' },
{ id: 'system', label: 'System', icon: 'refresh-cw' },
]}
active={tab} onChange={setTab}
/>
<Card flush>
<div>
{days.map((day) => (
<div key={day}>
<div style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', padding: '12px 20px 4px' }}>{day}</div>
{items.filter((n) => n.day === day).map((n) => {
const read = isRead(n)
return (
<div key={n.id}
onClick={() => open(n)}
style={{
display: 'flex', alignItems: 'flex-start', gap: 12, padding: '11px 20px',
cursor: n.issue || n.to ? 'pointer' : 'default',
opacity: read ? 0.72 : 1,
}}
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'var(--paper-2)' }}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'transparent' }}
>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: read ? 'transparent' : 'var(--accent)', flexShrink: 0, marginTop: 7 }}></span>
<span style={{ color: toneColor[n.tone], display: 'inline-flex', marginTop: 1, flexShrink: 0 }}>
<Icon name={n.icon} size={15} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: read ? 'var(--text-body)' : 'var(--text-body-strong)', color: 'var(--ink-1)' }}>
{n.who ? <span style={{ fontWeight: 600 }}>{n.who} </span> : null}{n.text}
</div>
<div style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)', marginTop: 2 }}>{n.detail}</div>
</div>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap', marginTop: 2 }}>{n.time}</span>
</div>
)
})}
</div>
))}
</div>
</Card>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
I only ring the bell when it matters. The rest can wait for morning service.
</p>
</div>
)
}

View File

@@ -0,0 +1,184 @@
// Issue detail — human intent (gitea) on the left, machine-derived (pm-state) on the right
import React from 'react'
import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js'
import { Badge, Button, Card, Icon, Tag } from '../ui/index.js'
export function IssueScreen({
issue,
onBack,
onOpenIssue,
}: {
issue: IssueRef
onBack: () => void
onOpenIssue: (issue: IssueRef) => void
}) {
const det: IssueDetail = ISSUE_DETAIL[issue.id] || {
state: 'triage',
assignee: 'Stephen',
milestone: 'Beta',
body: '',
comments: [],
lifecycle: [
{ stage: 'Diagnosis', event: 'issue opened', when: 'Feb 4 · 10:20', icon: 'circle-dot', done: true },
{ stage: 'Triage', event: 'labeled · milestoned Beta', when: 'Feb 5 · 09:12', icon: 'tag', done: true },
{ stage: 'Work start', event: 'first branch or commit ref', when: 'pending', icon: 'git-commit-horizontal', done: false },
{ stage: 'Deploy', event: 'PR merged', when: 'pending', icon: 'git-merge', done: false },
{ stage: 'Complete', event: 'issue closed', when: 'pending', icon: 'circle-check', done: false },
],
forecast: { p80: 'starts wk of Feb 16', note: 'queue position from scheduler' },
blocks: [],
blockedBy: [],
note: '',
}
const stateBadge = (
{
steeping: { tone: 'warn', label: `steeping${issue.days ? ' ' + issue.days : ''}` },
triage: { tone: 'neutral', label: 'triage' },
review: { tone: 'info', label: 'in review' },
done: { tone: 'ok', label: 'done' },
} as Record<string, { tone: 'warn' | 'neutral' | 'info' | 'ok'; label: string }>
)[det.state] || { tone: 'neutral', label: det.state }
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* breadcrumb + header */}
<div>
<button type="button" onClick={onBack} style={{
display: 'inline-flex', alignItems: 'center', gap: 6, background: 'none', border: 'none',
font: '500 12.5px var(--font-sans)', color: 'var(--ink-2)', cursor: 'pointer', padding: '2px 0', marginBottom: 10,
}}>
<Icon name="arrow-left" size={14} /> Back
</button>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14, display: 'flex', alignItems: 'flex-start', gap: 16 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ font: '400 13px var(--font-mono)', color: 'var(--ink-3)', margin: '0 0 6px' }}>#{issue.id} · stephen/commitea</p>
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>{issue.title}</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
<Badge tone={stateBadge.tone} dot>{stateBadge.label}</Badge>
{(issue.labels || []).map((l) => <Tag key={l} label={l} />)}
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<Icon name="milestone" size={12} />{det.milestone}
</span>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<Icon name="user" size={12} />{det.assignee}
</span>
</div>
</div>
<Button variant="secondary" icon="arrow-up-right">Open in Gitea</Button>
</header>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 16, alignItems: 'start' }}>
{/* left: human intent */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card overline="Description">
{det.body ? (
<p style={{ font: 'var(--text-body)', color: 'var(--ink-1)', margin: 0, lineHeight: 1.6 }}>{det.body}</p>
) : (
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-3)', margin: 0 }}>
No description was written. I have opinions about that, but I'll keep them warm.
</p>
)}
</Card>
<Card overline={`Comments · ${det.comments.length}`} flush>
<div>
{det.comments.map((c, i) => (
<div key={i} style={{ display: 'flex', gap: 12, padding: '14px 20px', borderBottom: '1px solid var(--line-1)' }}>
<span style={{
width: 24, height: 24, borderRadius: '50%', background: 'var(--spruce-2)', color: 'var(--accent-text)',
font: '600 9px/24px var(--font-sans)', textAlign: 'center', flexShrink: 0,
}}>{c.who.split(' ').map((w) => w[0]).join('')}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{c.who}</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>{c.when}</span>
</div>
<p style={{ font: 'var(--text-body)', color: 'var(--ink-1)', margin: '4px 0 0' }}>{c.text}</p>
</div>
</div>
))}
<div style={{ display: 'flex', gap: 8, padding: '14px 20px', alignItems: 'flex-end' }}>
<textarea placeholder="Comment — this writes to gitea, as you" rows={2} style={{
flex: 1, resize: 'none', font: 'var(--text-body)', color: 'var(--ink-1)', lineHeight: 1.5,
background: 'var(--paper-0)', border: '1px solid var(--line-2)', borderRadius: 'var(--radius-2)',
padding: '8px 11px', outline: 'none',
}}></textarea>
<Button size="sm" variant="secondary">Comment</Button>
</div>
</div>
</Card>
{det.note ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0, padding: '0 2px' }}>{det.note}</p>
) : null}
</div>
{/* right: machine-derived sidecar */}
<Card overline="Machine-derived" flush>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{/* lifecycle */}
<div style={{ padding: '14px 20px', display: 'flex', flexDirection: 'column', gap: 0 }}>
{det.lifecycle.map((s, i) => (
<div key={s.stage} style={{ display: 'flex', gap: 10 }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<span style={{ display: 'inline-flex', color: s.done ? 'var(--ok)' : 'var(--ink-3)', padding: '2px 0' }}>
<Icon name={s.icon} size={14} />
</span>
{i < det.lifecycle.length - 1 ? <span style={{ width: 1, flex: 1, minHeight: 14, background: s.done ? 'var(--spruce-3)' : 'var(--line-1)' }}></span> : null}
</div>
<div style={{ paddingBottom: i < det.lifecycle.length - 1 ? 12 : 0 }}>
<div style={{ font: `500 12px var(--font-sans)`, color: s.done ? 'var(--ink-1)' : 'var(--ink-3)' }}>{s.stage}</div>
<div style={{ font: '400 10.5px var(--font-mono)', color: 'var(--ink-3)', marginTop: 2, whiteSpace: 'nowrap' }}>{s.event}</div>
<div style={{ font: '400 10.5px var(--font-mono)', color: s.done ? 'var(--ink-2)' : 'var(--ink-3)', whiteSpace: 'nowrap' }}>{s.when}</div>
</div>
</div>
))}
</div>
{/* forecast */}
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--line-1)' }}>
<div style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 6 }}>Forecast</div>
<div style={{ font: '500 13px var(--font-mono)', color: 'var(--ink-1)' }}>80% {det.forecast.p80}</div>
<div style={{ font: '400 10.5px var(--font-mono)', color: 'var(--ink-3)', marginTop: 3 }}>{det.forecast.note}</div>
</div>
{/* dependencies */}
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--line-1)' }}>
<div style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 6 }}>Dependencies</div>
{det.blocks.length === 0 && det.blockedBy.length === 0 ? (
<div style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)' }}>none</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{det.blocks.length ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>blocks</span>
{det.blocks.map((b) => (
<button key={b} type="button"
onClick={() => onOpenIssue({ id: b, title: b === 91 ? 'Webhook listener: reconcile on reconnect' : 'Monte Carlo engine: percentile bands', labels: b === 91 ? ['est/3d', 'p/2'] : ['est/5d', 'p/1'] })}
style={{ font: '500 11px var(--font-mono)', color: 'var(--accent-text)', background: 'var(--spruce-1)', border: '1px solid var(--spruce-2)', borderRadius: 'var(--radius-1)', padding: '2px 7px', cursor: 'pointer' }}>
#{b}
</button>
))}
</div>
) : null}
{det.blockedBy.length ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>blocked by</span>
{det.blockedBy.map((b) => <span key={b} style={{ font: '500 11px var(--font-mono)', color: 'var(--ink-2)' }}>#{b}</span>)}
</div>
) : null}
</div>
)}
</div>
{/* provenance note */}
<div style={{ padding: '10px 20px 14px', borderTop: '1px solid var(--line-1)' }}>
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
Lives in pm-state. Your repo never sees any of it.
</p>
</div>
</div>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,102 @@
import React from 'react'
import { COLUMNS, type IssueRef } from '../../data/fixtures.js'
import { BurnUpCone } from '../charts/chart.js'
import { Badge, Button, Card, Icon, Tag } from '../ui/index.js'
// Milestone detail — scope, cone, issues; forecasts stay ranges
export function MilestoneScreen({ onBack, onOpenIssue }: { onBack: () => void; onOpenIssue: (issue: IssueRef) => void }) {
const cols = COLUMNS
const byState = (ids: number[]) =>
cols.flatMap((c) => c.issues.map((i) => ({ ...i, col: c.label }))).filter((i) => ids.includes(i.id))
const groups = [
{ label: 'Steeping', issues: byState([87, 84]) },
{ label: 'In review', issues: byState([92]) },
{ label: 'Queued', issues: byState([102, 103, 99, 96, 78]) },
{ label: 'Done', issues: byState([71, 69, 65]), muted: true },
]
const Stat = ({ label, value, tone }: { label: string; value: string; tone?: string }) => (
<div style={{ flex: 1, padding: '12px 18px', borderRight: '1px solid var(--line-1)' }}>
<div style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 5 }}>{label}</div>
<div style={{ font: `500 14px var(--font-mono)`, color: tone || 'var(--ink-1)', whiteSpace: 'nowrap' }}>{value}</div>
</div>
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<button type="button" onClick={onBack} style={{
display: 'inline-flex', alignItems: 'center', gap: 6, background: 'none', border: 'none',
font: '500 12.5px var(--font-sans)', color: 'var(--ink-2)', cursor: 'pointer', padding: '2px 0', marginBottom: 10,
}}>
<Icon name="arrow-left" size={14} /> Runway
</button>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14, display: 'flex', alignItems: 'flex-start', gap: 16 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-3)', margin: '0 0 6px', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Icon name="milestone" size={13} /> milestone · due Mar 15 · soft scope may flex
</p>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Beta</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 }}>
<Badge tone="ok" dot>ahead of forecast</Badge>
<span style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}>80% Mar 312</span>
</div>
</div>
<Button variant="secondary" icon="arrow-up-right">Open in Gitea</Button>
</header>
</div>
{/* stats strip */}
<Card flush>
<div style={{ display: 'flex' }}>
<Stat label="Scope" value="42 issues · est 61d" />
<Stat label="Done" value="24 · 57%" />
<Stat label="Forecast" value="80% Mar 312" />
<div style={{ flex: 1, padding: '12px 18px' }}>
<div style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 5 }}>Drift · 7d</div>
<div style={{ font: '500 14px var(--font-mono)', color: 'var(--ok)', whiteSpace: 'nowrap' }}>2d · cone narrowed</div>
</div>
</div>
</Card>
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 14, alignItems: 'start' }}>
<Card overline="Burn-up" title={<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>Mar 312</span></>} jade>
<BurnUpCone />
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
Comfortably ahead. Beta needs #87 more than it needs my commentary.
</p>
</Card>
<Card overline="Issues" flush>
<div>
{groups.map((g) => (
<div key={g.label}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '10px 20px 6px' }}>
<span style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)' }}>{g.label}</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>{g.issues.length}</span>
</div>
{g.issues.map((i) => (
<div key={i.id}
onClick={() => onOpenIssue({ id: i.id, title: i.title, labels: i.labels, days: i.days })}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '7px 20px', cursor: 'pointer',
opacity: g.muted ? 0.6 : 1,
}}
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'var(--paper-2)'; }}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'transparent'; }}
>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', width: 34, flexShrink: 0 }}>#{i.id}</span>
<span style={{ font: 'var(--text-small)', color: 'var(--ink-1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{i.title}</span>
{(i.labels || []).filter((l) => l.startsWith('est/')).map((l) => <Tag key={l} label={l} />)}
</div>
))}
</div>
))}
</div>
</Card>
</div>
</div>
)
}

View File

@@ -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<number>(0)
const [conn, setConn] = React.useState<'idle' | 'testing' | 'ok'>('idle')
const [repo, setRepo] = React.useState('stephen/commitea')
const [boot, setBoot] = React.useState<number>(-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 }) => (
<div style={{
background: 'var(--surface-card)', border: '1px solid var(--line-1)', borderRadius: 'var(--radius-3)',
boxShadow: 'var(--shadow-jade-line), var(--shadow-2)', padding: '30px 36px',
display: 'flex', flexDirection: 'column', gap: 16, width: '100%',
}}>
{children}
{footer ? <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', borderTop: '1px solid var(--line-1)', paddingTop: 16 }}>{footer}</div> : null}
</div>
)
return (
<div style={{
minHeight: '100vh', background: 'var(--surface-app)', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', padding: 32, gap: 22,
}} data-screen-label="onboarding">
{/* brand */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<img src={logoIcon} width="34" height="34" alt="" style={{ borderRadius: 8, display: 'block' }} />
<span style={{ font: '400 27px/1 var(--font-serif-display)', color: 'var(--ink-1)' }}>
Commi<span style={{ color: 'var(--accent-text)' }}>Tea</span>
</span>
</div>
{/* stepper */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
{STEPS.map((s, i) => (
<div key={s} style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
<span style={{
width: 20, height: 20, borderRadius: '50%', textAlign: 'center',
font: '500 10.5px/20px var(--font-mono)',
background: i < step ? 'var(--accent)' : i === step ? 'var(--spruce-2)' : 'var(--paper-2)',
color: i < step ? 'var(--ink-inverse)' : i === step ? 'var(--accent-text)' : 'var(--ink-3)',
}}>{i < step ? '✓' : i + 1}</span>
<span style={{ font: `${i === step ? 600 : 400} 12px/1 var(--font-sans)`, color: i === step ? 'var(--ink-1)' : 'var(--ink-3)' }}>{s}</span>
</span>
{i < STEPS.length - 1 ? <span style={{ width: 24, height: 1, background: 'var(--line-2)' }}></span> : null}
</div>
))}
</div>
<div style={{ width: 'min(540px, 100%)' }}>
{step === 0 ? (
<Frame footer={<Button iconRight="arrow-right" onClick={() => setStep(1)}>Begin</Button>}>
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Good morning.</h1>
<p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-2)', margin: 0 }}>
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.
</p>
<p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: 0 }}>
Your plans live in your own Gitea as ordinary issues and labels. Delete me and nothing human is lost.
</p>
</Frame>
) : null}
{step === 1 ? (
<Frame footer={<>
<Button variant="ghost" onClick={() => setStep(0)}>Back</Button>
<Button iconRight="arrow-right" disabled={conn !== 'ok'} onClick={() => setStep(2)}>Continue</Button>
</>}>
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Your Gitea</h1>
<Input label="Base URL" icon="link" mono defaultValue="https://gitea.stephenmann.io" />
<Input label="Access token" icon="keyboard" mono type="password" defaultValue="ct_9f2e81c4a7d6" hint="Scopes: repo, issue. Nothing more." />
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button variant="secondary" icon={conn === 'testing' ? 'loader-circle' : 'zap'} disabled={conn === 'testing'} onClick={() => setConn('testing')}>
{conn === 'testing' ? 'Ringing the bell…' : 'Test connection'}
</Button>
{conn === 'ok' ? <Badge tone="ok" dot>connected · 3 repos visible</Badge> : null}
</div>
</Frame>
) : null}
{step === 2 ? (
<Frame footer={<>
<Button variant="ghost" onClick={() => setStep(1)}>Back</Button>
<Button iconRight="arrow-right" onClick={() => setStep(3)}>Continue</Button>
</>}>
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Which repo shall I manage?</h1>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{['stephen/commitea', 'stephen/novelpad', 'stephen/infra'].map((r: string) => (
<label key={r} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', cursor: 'pointer',
background: repo === r ? 'var(--spruce-1)' : 'var(--paper-0)',
border: `1px solid ${repo === r ? 'var(--spruce-3)' : 'var(--line-1)'}`,
borderRadius: 'var(--radius-2)',
}}>
<Radio name="repo" checked={repo === r} onChange={() => setRepo(r)} />
<Icon name="git-branch" size={14} style={{ color: 'var(--ink-3)' }} />
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)' }}>{r}</span>
</label>
))}
</div>
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>One to start. You can add more later in Settings.</p>
</Frame>
) : null}
{step === 3 ? (
<Frame footer={boot === 3 ? <>
<Button variant="ghost" onClick={() => onDone('focus')}>Just look around</Button>
<Button icon="sparkles" onClick={() => onDone('capture')}>Start a capture</Button>
</> : <>
<Button variant="ghost" onClick={() => setStep(2)} disabled={boot >= 0}>Back</Button>
<Button disabled={boot >= 0} onClick={() => setBoot(0)}>Make it so</Button>
</>}>
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>
{boot === 3 ? 'All set.' : 'With your approval'}
</h1>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{BOOT_TASKS.map((t: string, i: number) => (
<div key={t} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ display: 'inline-flex', color: boot > i ? 'var(--ok)' : boot === i ? 'var(--warn)' : 'var(--ink-3)' }}>
<Icon name={boot > i ? 'circle-check' : boot === i ? 'loader-circle' : 'circle-dashed'} size={15} />
</span>
<span style={{ font: 'var(--text-body)', color: boot > i ? 'var(--ink-1)' : 'var(--ink-2)', whiteSpace: 'nowrap' }}>{t}</span>
</div>
))}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, paddingLeft: 25 }}>
{['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d', 'p/1', 'p/2', 'p/3', 'p/4', 'deadline/hard'].map((l: string) => <Tag key={l} label={l} />)}
</div>
</div>
{boot === 3 ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
The pot is empty. Tell me what you're planning and I'll draw up the tickets.
</p>
) : (
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
No bot comments, no body frontmatter, no synthetic issues ever. Labels are the only footprint.
</p>
)}
</Frame>
) : null}
</div>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>first run · everything reversible</span>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import React from 'react'
import { RunwayBar } from '../charts/chart.js'
import { Card, Badge, Tag, Icon, IconButton } from '../ui/index.js'
import { RUNWAY, CAPACITY } from '../../data/fixtures.js'
// Runway — capacity vs milestone dates; ranges, never points
export function RunwayScreen({
onOpenCalibration,
onOpenMilestone,
}: {
onOpenCalibration: () => void
onOpenMilestone: () => void
}) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Runway</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>capacity vs milestone dates · calibrated on 27 closed issues</p>
</header>
<Card overline="Milestones" flush>
<div>
{RUNWAY.map((m, i) => (
<div key={m.name} onClick={onOpenMilestone} style={{
display: 'grid', gridTemplateColumns: '160px 1fr 150px 90px', gap: 16, alignItems: 'center', cursor: 'pointer',
padding: '14px 20px', borderTop: i === 0 ? 'none' : '1px solid var(--line-1)',
}}
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'var(--paper-2)'; }}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'transparent'; }}>
<div>
<div style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)', display: 'flex', alignItems: 'center', gap: 7 }}>
<Icon name="milestone" size={14} style={{ color: 'var(--ink-3)' }} />{m.name}
</div>
<div style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)', marginTop: 3 }}>
due {m.due}{m.hard ? ' ' : ''}
</div>
{m.hard ? <Tag label="deadline/hard" style={{ marginTop: 5 }} /> : null}
</div>
<RunwayBar m={m} />
<span style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-2)' }}>80% {m.p80}</span>
<Badge tone={m.tone} dot>{m.note}</Badge>
</div>
))}
</div>
</Card>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, alignItems: 'start' }}>
<Card overline="Capacity" flush>
<div>
{CAPACITY.map((p, i) => (
<div key={p.who} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 20px',
borderTop: i === 0 ? 'none' : '1px solid var(--line-1)',
}}>
<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: string) => w[0]).join('')}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: 'var(--text-body-strong)' }}>{p.who}</div>
<div style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)', marginTop: 2 }}>{p.slices}</div>
</div>
<span style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-2)' }}>{p.hours}</span>
</div>
))}
</div>
</Card>
<Card overline="Calibration" actions={<IconButton icon="arrow-up-right" label="Full report" size="sm" onClick={onOpenCalibration} />}>
<p style={{ font: 'var(--text-body)', margin: '0 0 8px' }}>
Your estimates run <strong>18% optimistic</strong> on <code>est/3d</code> and above. Smaller tickets are honest.
</p>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
I widen the cone accordingly. No judgement it's the most common shape of hope.
</p>
</Card>
</div>
</div>
);
}

View File

@@ -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 }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, ...style }}>{children}</div>
)
const Note = ({ children }: { children: React.ReactNode }) => (
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>{children}</p>
)
return (
<div style={{ maxWidth: 720, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 16 }}>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Settings</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>config lives in pm-state · versioned, portable</p>
</header>
<Card overline="Gitea" title="Connection">
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<Input label="Base URL" icon="link" mono defaultValue="https://gitea.stephenmann.io" />
<Input label="Access token" icon="keyboard" mono type="password" defaultValue="ct_9f2e81c4a7d6" hint="Scopes: repo, issue. Nothing more." />
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ font: '600 13px/1.2 var(--font-sans)', color: 'var(--ink-1)' }}>Managed repos</span>
<Row style={{ padding: '8px 12px', background: 'var(--paper-0)', border: '1px solid var(--line-1)', borderRadius: 'var(--radius-2)' }}>
<Icon name="git-branch" size={14} style={{ color: 'var(--ink-3)' }} />
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)', flex: 1 }}>stephen/commitea</span>
<Badge tone="ok" dot>syncing</Badge>
<IconButton icon="x" label="Stop managing" size="sm" />
</Row>
<Row style={{ padding: '8px 12px', background: 'var(--paper-0)', border: '1px solid var(--line-1)', borderRadius: 'var(--radius-2)' }}>
<Icon name="layers" size={14} style={{ color: 'var(--ink-3)' }} />
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)', flex: 1 }}>stephen/pm-state</span>
<Badge>sidecar</Badge>
</Row>
<Note>The sidecar holds machine-derived state only. Delete it and resync no truth is lost.</Note>
<Button variant="secondary" size="sm" icon="plus" style={{ alignSelf: 'flex-start' }}>Add repo</Button>
</div>
</div>
</Card>
<Card overline="Sync" title="Staying current">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Row>
<Switch label="Webhooks while running" checked={webhooks} onChange={(e) => setWebhooks(e.target.checked)} />
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}>endpoint :48731 · healthy</span>
</Row>
<Switch label="Full reconcile on launch" checked={reconcile} onChange={(e) => setReconcile(e.target.checked)} />
<Row>
<Switch label="Poll fallback" checked={poll} onChange={(e) => setPoll(e.target.checked)} />
<div style={{ marginLeft: 'auto', width: 140 }}>
<Select options={[{ value: '2', label: 'every 2 min' }, { value: '5', label: 'every 5 min' }, { value: '15', label: 'every 15 min' }]} defaultValue="5" />
</div>
</Row>
<Note>last reconcile 3.2s · 500 issues · nothing lost</Note>
</div>
</Card>
<Card overline="Models" title="The router">
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px 14px' }}>
<Select label="Prose & rituals" options={[
{ value: 'gemma-4b', label: 'gemma-4b · local' },
{ value: 'qwen-7b', label: 'qwen-7b · local' },
]} defaultValue="gemma-4b" />
<Input label="Base URL" mono defaultValue="http://localhost:1234/v1" />
<Select label="Decomposition & negotiation" options={[
{ value: 'qwen-72b', label: 'qwen-72b · lm-studio box' },
{ value: 'gpt-4o', label: 'gpt-4o · OpenAI API' },
]} defaultValue="qwen-72b" />
<Input label="Base URL" mono defaultValue="http://10.0.0.42:1234/v1" />
</div>
<Row>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)' }}>hot memory 2k tokens · math is never delegated to either</span>
</Row>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
The small one writes my standup; the large one argues with your estimates. Neither is allowed near the arithmetic.
</p>
</div>
</Card>
<Card overline="Labels" title="Schema">
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Row style={{ flexWrap: 'wrap', gap: 6 }}>
{['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'].map((l) => <Tag key={l} label={l} />)}
</Row>
<Row style={{ flexWrap: 'wrap', gap: 6 }}>
{['p/1', 'p/2', 'p/3', 'p/4'].map((l) => <Tag key={l} label={l} />)}
<Tag label="deadline/hard" />
</Row>
<Note>Fixed sets, human-meaningful, visible in gitea. Not configurable that is rather the point.</Note>
</div>
</Card>
<Card overline="Rituals" title="Reginald's calendar">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Row>
<span style={{ font: 'var(--text-body)', color: 'var(--ink-1)' }}>Morning standup</span>
<div style={{ marginLeft: 'auto', width: 120 }}>
<Select options={[{ value: '0630', label: '06:30' }, { value: '0700', label: '07:00' }, { value: '0800', label: '08:00' }]} defaultValue="0700" />
</div>
</Row>
<Row>
<Switch label="Stale-blocker nagging" checked={nag} onChange={(e) => setNag(e.target.checked)} />
<div style={{ marginLeft: 'auto', width: 140 }}>
<Select options={[{ value: '2', label: 'after 2 days' }, { value: '3', label: 'after 3 days' }, { value: '5', label: 'after 5 days' }]} defaultValue="3" />
</div>
</Row>
</div>
</Card>
<Card overline="Appearance" title="Service">
<div style={{ display: 'flex', gap: 20 }}>
<Radio name="theme" label="Morning (light)" checked={!dark} onChange={() => setDark(false)} />
<Radio name="theme" label="Evening (dark)" checked={dark} onChange={() => setDark(true)} />
</div>
</Card>
<Card>
<Row>
<div style={{ flex: 1 }}>
<div style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>Forget this gitea</div>
<Note>Removes the connection and the local cache. Gitea itself is untouched.</Note>
</div>
<Button variant="danger">Forget</Button>
</Row>
</Card>
</div>
)
}

View File

@@ -0,0 +1,212 @@
import React from 'react'
import { type IssueRef, 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: (issue: IssueRef) => void
}) {
const s = STANDUP
const NAG_REF: IssueRef = {
id: s.nag.id,
title: 'Fix lifecycle inference on merge events',
labels: ['est/2d', 'p/1'],
days: s.nag.days,
}
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(NAG_REF)}
onKeyDown={(e) => {
if (e.key === 'Enter') onOpenIssue(NAG_REF)
}}
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

@@ -0,0 +1,304 @@
import React, { useEffect, useState } from 'react'
import logoIcon from '../../design/assets/logo-icon.png'
import type { IssueRef } from '../../data/fixtures.js'
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'
import { PlaceholderScreen } from './placeholder-screen.js'
import { OfflineBanner, StatesScreen } from './states.js'
type View =
| 'standup'
| 'focus'
| 'inbox'
| 'capture'
| 'board'
| 'runway'
| 'directives'
| 'settings'
| 'states'
| 'firstrun'
| 'primitives'
| 'issue'
| 'calibration'
| 'milestone'
interface NavEntry {
id: View
label: string
icon: string
count?: number | null
}
// which phase builds each not-yet-real view (shown on its placeholder)
const PHASE: Partial<Record<View, string>> = {
standup: 'P3-3',
focus: 'P3-3',
inbox: 'P3-6',
capture: 'P3-7',
board: 'P3-4',
runway: 'P3-5',
directives: 'P3-8',
settings: 'P3-8',
firstrun: 'P3-8',
issue: 'P3-6',
calibration: 'P3-5',
milestone: 'P3-5',
}
const TITLE: Partial<Record<View, string>> = {
standup: 'Standup',
focus: 'Morning service',
inbox: 'Inbox',
capture: 'Capture',
board: 'The pot',
runway: 'Runway',
directives: 'Directives',
settings: 'Settings',
firstrun: 'First run',
issue: 'Issue',
calibration: 'Calibration',
milestone: 'Milestone',
}
const INBOX_UNREAD = 3
export function AppShell() {
const [view, setView] = useState<View>('focus')
const [prevView, setPrevView] = useState<View>('focus')
const [dark, setDark] = useState(false)
const [offline, setOffline] = useState(false)
const [issue, setIssue] = useState<IssueRef | null>(null)
const [readIds, setReadIds] = useState<number[]>([])
useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
}, [dark])
const openIssue = (ref: IssueRef) => {
if (view !== 'issue') setPrevView(view)
setIssue(ref)
setView('issue')
}
const NAV: NavEntry[] = [
{ id: 'standup', label: 'Standup', icon: 'sun' },
{ id: 'focus', label: 'Morning service', icon: 'coffee' },
{ id: 'inbox', label: 'Inbox', icon: 'bell', count: INBOX_UNREAD || null },
{ id: 'capture', label: 'Capture', icon: 'plus' },
{ id: 'board', label: 'The pot', icon: 'square-kanban' },
{ id: 'runway', label: 'Runway', icon: 'chart-line' },
{ id: 'directives', label: 'Directives', icon: 'flag' },
]
const NavItem = ({ item }: { item: NavEntry }) => {
const active =
view === item.id ||
(view === 'issue' && prevView === item.id) ||
((view === 'calibration' || view === 'milestone') && item.id === 'runway')
return (
<button
type="button"
onClick={() => setView(item.id)}
aria-current={active ? 'page' : undefined}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
width: '100%',
font: `${active ? 600 : 500} 13.5px/1 var(--font-sans)`,
color: active ? 'var(--ink-1)' : 'var(--ink-2)',
background: active ? 'var(--paper-2)' : 'transparent',
border: 'none',
borderRadius: 'var(--radius-2)',
padding: '9px 12px',
cursor: 'pointer',
textAlign: 'left',
transition: 'background var(--duration-fast) var(--ease-out)',
}}
>
<Icon name={item.icon} size={16} style={{ color: active ? 'var(--accent-text)' : 'var(--ink-3)' }} />
{item.label}
{item.count ? (
<span
style={{
marginLeft: 'auto',
font: '500 10.5px/16px var(--font-mono)',
color: 'var(--accent-text)',
background: 'var(--spruce-2)',
borderRadius: 'var(--radius-round)',
padding: '0 6px',
}}
>
{item.count}
</span>
) : null}
</button>
)
}
const renderScreen = () => {
switch (view) {
case 'focus':
return <FocusScreen onOpenIssue={openIssue} />
case 'standup':
return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} />
case 'board':
return <BoardScreen onOpenIssue={openIssue} />
case 'runway':
return (
<RunwayScreen
onOpenCalibration={() => setView('calibration')}
onOpenMilestone={() => setView('milestone')}
/>
)
case 'calibration':
return <CalibrationScreen onBack={() => setView('runway')} />
case 'milestone':
return <MilestoneScreen onBack={() => setView('runway')} onOpenIssue={openIssue} />
case 'inbox':
return (
<InboxScreen
onOpenIssue={openIssue}
onOpenDirectives={() => setView('directives')}
readIds={readIds}
setReadIds={setReadIds}
/>
)
case 'capture':
return <CaptureScreen onDone={() => setView('focus')} />
case 'directives':
return <DirectivesScreen />
case 'settings':
return <SettingsScreen dark={dark} setDark={setDark} />
case 'issue':
return issue ? (
<IssueScreen issue={issue} onBack={() => setView(prevView)} onOpenIssue={openIssue} />
) : null
case 'states':
return <StatesScreen onCapture={() => setView('capture')} />
case 'primitives':
return <PrimitivesGallery />
default:
return <PlaceholderScreen title={TITLE[view] ?? view} phase={PHASE[view] ?? 'a later phase'} />
}
}
// First run is full-window — no rail, no chat panel
if (view === 'firstrun') {
return <OnboardingScreen onDone={(dest) => setView(dest)} />
}
return (
<div
data-screen-label={`app-${view}`}
style={{
display: 'flex',
height: '100vh',
minWidth: 1280,
background: 'var(--surface-app)',
overflow: 'hidden',
}}
>
{/* left rail */}
<nav
aria-label="Primary"
style={{
width: 208,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
gap: 4,
padding: '18px 12px 14px',
borderRight: '1px solid var(--line-1)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '0 12px 16px' }}>
<img src={logoIcon} width="24" height="24" alt="" style={{ borderRadius: 6, display: 'block' }} />
<span style={{ font: '400 21px/1 var(--font-serif-display)', color: 'var(--ink-1)' }}>
Commi<span style={{ color: 'var(--accent-text)' }}>Tea</span>
</span>
</div>
{NAV.map((n) => (
<NavItem key={n.id} item={n} />
))}
<div style={{ borderTop: '1px solid var(--line-1)', margin: '10px 8px' }} />
<NavItem item={{ id: 'settings', label: 'Settings', icon: 'settings-2' }} />
<NavItem item={{ id: 'firstrun', label: 'First run', icon: 'play' }} />
<NavItem item={{ id: 'states', label: 'States', icon: 'circle-dashed' }} />
<NavItem item={{ id: 'primitives', label: 'Primitives', icon: 'layers' }} />
<div style={{ marginTop: 'auto', padding: '0 12px', display: 'flex', flexDirection: 'column', gap: 12 }}>
<button
type="button"
onClick={() => setOffline((o) => !o)}
title="Toggle the connection (demo)"
aria-label={offline ? 'Connection: offline' : 'Connection: online'}
style={{
font: '400 11px var(--font-mono)',
color: 'var(--ink-3)',
display: 'flex',
alignItems: 'center',
gap: 6,
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
textAlign: 'left',
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: offline ? 'var(--danger)' : 'var(--ok)',
display: 'inline-block',
}}
/>
gitea.stephenmann.io
</button>
<Switch
label={<span style={{ font: 'var(--text-caption)' }}>Evening service</span>}
checked={dark}
onChange={(e) => setDark(e.target.checked)}
/>
</div>
</nav>
{/* main */}
<main style={{ flex: 1, minWidth: 0, overflowY: 'auto', padding: '24px 28px' }}>
<div
style={{
maxWidth: 1120,
margin: '0 auto',
height: view === 'board' ? '100%' : 'auto',
display: 'flex',
flexDirection: 'column',
gap: 14,
}}
>
{offline ? <OfflineBanner /> : null}
{renderScreen()}
</div>
</main>
<ChatPanel onOpenDirectives={() => setView('directives')} offline={offline} />
</div>
)
}

View File

@@ -0,0 +1,167 @@
import React, { useEffect, useRef, useState } from 'react'
import { CANNED_REPLY, CHAT, type ChatMessage } from '../../data/fixtures.js'
import { Icon, IconButton } from '../ui/index.js'
/**
* Reginald's panel — chat is the write-path (decisions.md D1). This is the P3-2
* fixture shell: it echoes a canned reply so the layout + interactions are real,
* but no model is wired. P4 replaces `send` with the model router + tools.
*/
export interface ChatPanelProps {
onOpenDirectives?: () => void
offline?: boolean
}
export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
const [msgs, setMsgs] = useState<ChatMessage[]>(CHAT)
const [text, setText] = useState('')
const [thinking, setThinking] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = scrollRef.current
if (el) el.scrollTop = el.scrollHeight
}, [msgs, thinking])
const send = () => {
const t = text.trim()
if (!t) return
setMsgs((m) => [...m, { from: 'user', text: t }])
setText('')
setThinking(true)
setTimeout(() => {
setThinking(false)
setMsgs((m) => [...m, { from: 'agent', text: CANNED_REPLY }])
}, 900)
}
return (
<aside
style={{
width: 330,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
background: 'var(--surface-card)',
borderLeft: '1px solid var(--line-1)',
minHeight: 0,
}}
>
<header
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '14px 16px',
borderBottom: '1px solid var(--line-1)',
flexShrink: 0,
}}
>
<Icon name="sparkles" size={16} style={{ color: offline ? 'var(--ink-3)' : 'var(--jade)' }} />
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>Reginald</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}>
{offline ? 'offline · queueing' : 'gemma-4b · local'}
</span>
<IconButton icon="history" label="Directive log" size="sm" onClick={onOpenDirectives} />
</header>
<div
ref={scrollRef}
style={{ flex: 1, overflowY: 'auto', padding: 16, display: 'flex', flexDirection: 'column', gap: 12, minHeight: 0 }}
>
{msgs.map((m, i) =>
m.from === 'agent' ? (
<div key={i} style={{ font: 'var(--text-agent)', color: 'var(--ink-1)', lineHeight: 1.55 }}>
{m.text}
</div>
) : (
<div
key={i}
style={{
alignSelf: 'flex-end',
maxWidth: '85%',
background: 'var(--paper-2)',
borderRadius: '10px 10px 2px 10px',
padding: '8px 12px',
font: 'var(--text-small)',
color: 'var(--ink-1)',
}}
>
{m.text}
</div>
),
)}
{offline ? (
<div style={{ font: 'var(--text-agent)', color: 'var(--ink-3)' }}>
The model is away from its desk. Reads still work; writes will wait their turn.
</div>
) : null}
{thinking ? <div style={{ font: 'var(--text-agent)', color: 'var(--ink-3)' }}>considering</div> : null}
</div>
<div style={{ padding: 14, borderTop: '1px solid var(--line-1)', flexShrink: 0 }}>
<div
style={{
display: 'flex',
alignItems: 'flex-end',
gap: 8,
background: 'var(--paper-0)',
border: '1px solid var(--line-2)',
borderRadius: 'var(--radius-2)',
padding: '8px 8px 8px 12px',
}}
>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}}
placeholder={offline ? 'Writes wait for the connection…' : 'Tell me what to do…'}
disabled={offline}
rows={1}
style={{
flex: 1,
resize: 'none',
border: 'none',
outline: 'none',
background: 'transparent',
font: 'var(--text-body)',
color: 'var(--ink-1)',
lineHeight: 1.45,
maxHeight: 96,
}}
/>
<button
type="button"
onClick={send}
aria-label="Send"
disabled={offline}
style={{
width: 30,
height: 30,
borderRadius: 'var(--radius-2)',
border: 'none',
cursor: offline ? 'not-allowed' : 'pointer',
background: offline ? 'var(--paper-3)' : 'var(--accent)',
color: offline ? 'var(--ink-3)' : 'var(--ink-inverse)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Icon name="send" size={14} />
</button>
</div>
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: '8px 2px 0' }}>
Chat is the write-path. Destructive changes are proposed, never assumed.
</p>
</div>
</aside>
)
}

View File

@@ -0,0 +1,41 @@
import { Button } from '../ui/index.js'
import { EmptyState } from './states.js'
/**
* Stand-in for views not yet built (Focus, Board, Runway, …). Keeps navigation
* live so the shell is demonstrable before the real screens land in P3-3+.
* `onOpenIssue`, when given, exercises the shell's issue drill-in + back-stack;
* real screens replace this with actual issue rows in P3-3+.
*/
export function PlaceholderScreen({
title,
phase,
onOpenIssue,
}: {
title: string
phase: string
onOpenIssue?: () => void
}) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>{title}</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>
arrives in {phase} · the shell and navigation are live
</p>
</header>
<EmptyState
icon="coffee"
title="Not yet poured"
line="This view is on the boil for a later phase. The rail, routing, theme, and Reginalds panel are already working around it."
/>
{onOpenIssue ? (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Button variant="ghost" iconRight="arrow-right" onClick={onOpenIssue}>
Preview an issue page
</Button>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,232 @@
import React from 'react'
import { Badge, Button, Icon } from '../ui/index.js'
/**
* Shared empty/trouble states + the States gallery screen, ported from the
* handoff. EmptyState/OfflineBanner/ModelAwayState are reused across real
* screens as they land; StatesScreen is the specimen gallery.
*/
export interface EmptyStateProps {
icon: string
title: React.ReactNode
line: React.ReactNode
action?: string
onAction?: () => void
compact?: boolean
}
export function EmptyState({ icon, title, line, action, onAction, compact }: EmptyStateProps) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: 10,
padding: compact ? '28px 20px' : '52px 24px',
}}
>
<span
style={{
width: 44,
height: 44,
borderRadius: '50%',
background: 'var(--paper-2)',
color: 'var(--ink-3)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Icon name={icon} size={19} />
</span>
<div style={{ font: '400 20px/1.25 var(--font-serif-display)', color: 'var(--ink-1)' }}>{title}</div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0, maxWidth: 380 }}>{line}</p>
{action ? (
<Button style={{ marginTop: 6 }} onClick={onAction}>
{action}
</Button>
) : null}
</div>
)
}
export interface OfflineBannerProps {
retryIn?: string
}
export function OfflineBanner({ retryIn }: OfflineBannerProps) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
background: 'var(--warn-tint)',
border: '1px solid var(--warn)',
borderRadius: 'var(--radius-2)',
padding: '9px 14px',
}}
>
<span style={{ color: 'var(--warn)', display: 'inline-flex' }}>
<Icon name="triangle-alert" size={15} />
</span>
<span style={{ font: 'var(--text-small)', color: 'var(--ink-1)', flex: 1 }}>
Gitea isnt answering. Ill keep trying and say nothing more about it.
</span>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}>
retry in {retryIn || '0:12'} · reads from cache
</span>
</div>
)
}
export function ModelAwayState() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '14px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: 'var(--ink-3)', display: 'inline-flex' }}>
<Icon name="sparkles" size={15} />
</span>
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-2)' }}>Reginald</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>model offline</span>
<span style={{ marginLeft: 'auto' }}>
<Badge>queued: 1 directive</Badge>
</span>
</div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
The model is away from its desk. Reads still work; writes will wait their turn.
</p>
</div>
)
}
function Specimen({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>{label}</span>
<div
style={{
border: '1px dashed var(--line-2)',
borderRadius: 'var(--radius-3)',
background: 'var(--surface-card)',
overflow: 'hidden',
}}
>
{children}
</div>
</div>
)
}
export function StatesScreen({ onCapture }: { onCapture?: () => void }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>States</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>
empty &amp; trouble · specimens as wired in the app
</p>
</header>
<p
style={{
font: 'var(--text-overline)',
letterSpacing: 'var(--letter-spacing-wide)',
textTransform: 'uppercase',
color: 'var(--ink-3)',
margin: '4px 0 0',
}}
>
Empty
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<Specimen label="the pot · no issues">
<EmptyState
compact
icon="inbox"
title="The pot is empty"
line="Tell me what youre planning and Ill draw up the tickets."
action="Start a capture"
onAction={onCapture}
/>
</Specimen>
<Specimen label="morning service · nothing scheduled">
<EmptyState
compact
icon="coffee"
title="Nothing to pour"
line="Capture some work, or enjoy the silence — it never lasts."
/>
</Specimen>
<Specimen label="directives · no entries">
<EmptyState
compact
icon="flag"
title="No directives yet"
line="When you overrule the scheduler, it goes on the record here — who, when, what, why."
/>
</Specimen>
<Specimen label="board search · no match">
<EmptyState
compact
icon="search"
title="Nothing by that name"
line="The pot holds 24 issues; none of them answer to that."
/>
</Specimen>
</div>
<p
style={{
font: 'var(--text-overline)',
letterSpacing: 'var(--letter-spacing-wide)',
textTransform: 'uppercase',
color: 'var(--ink-3)',
margin: '8px 0 0',
}}
>
Trouble
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<Specimen label="gitea unreachable · banner above main content (toggle the rails connection dot to see it live)">
<div style={{ padding: 12 }}>
<OfflineBanner />
</div>
</Specimen>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<Specimen label="reginalds panel · model offline">
<ModelAwayState />
</Specimen>
<Specimen label="webhooks down · poll fallback">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px' }}>
<Badge tone="warn" dot>
webhooks down
</Badge>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)' }}>
polling every 2 min · updates may lag
</span>
</div>
</Specimen>
</div>
<Specimen label="first reconcile failed · full-screen">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 0 20px' }}>
<EmptyState
compact
icon="refresh-cw"
title="The reconcile failed"
line="Gitea answered, then hung up mid-sentence. Your cache is intact; nothing human is lost."
/>
<div style={{ display: 'flex', gap: 8, marginTop: -6 }}>
<Button>Try again</Button>
<Button variant="ghost">Work from cache</Button>
</div>
</div>
</Specimen>
</div>
</div>
)
}

View File

@@ -0,0 +1,44 @@
import React from 'react';
/**
* Status pill — muted tint background, colored text. For state words like "ahead", "behind", "steeping".
*/
export interface BadgeProps {
/** @default 'neutral' */
tone?: 'ok' | 'warn' | 'danger' | 'info' | 'neutral' | 'jade';
/** Leading status dot @default false */
dot?: boolean;
children?: React.ReactNode;
style?: React.CSSProperties;
}
const CSS = `
.ct-badge {
display: inline-flex; align-items: center; gap: 6px;
font: 500 12px/1 var(--font-sans);
padding: 4px 9px;
border-radius: var(--radius-round);
}
.ct-badge__dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.ct-badge--ok { background: var(--ok-tint); color: var(--ok); }
.ct-badge--warn { background: var(--warn-tint); color: var(--warn); }
.ct-badge--danger { background: var(--danger-tint); color: var(--danger); }
.ct-badge--info { background: var(--info-tint); color: var(--info); }
.ct-badge--neutral { background: var(--paper-2); color: var(--ink-2); }
.ct-badge--jade { background: var(--jade-tint); color: var(--jade-7); }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-badge-css')) {
const s = document.createElement('style'); s.id = 'ct-badge-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Badge({ tone = 'neutral', dot = false, children, style, ...rest }: BadgeProps) {
return (
<span className={`ct-badge ct-badge--${tone}`} style={style} {...rest}>
{dot ? <span className="ct-badge__dot"></span> : null}
{children}
</span>
);
}

View File

@@ -0,0 +1,81 @@
import React from 'react'
import { Icon } from './icon.js'
/**
* CommiTea button. Plain verbs only ("Approve", "Brew plan", "Defer") — never witty.
*/
export interface ButtonProps {
/** @default 'primary' */
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
/** @default 'md' */
size?: 'sm' | 'md';
/** Lucide icon name rendered before the label */
icon?: string;
/** Lucide icon name rendered after the label */
iconRight?: string;
disabled?: boolean;
onClick?: (e: React.MouseEvent) => void;
children?: React.ReactNode;
style?: React.CSSProperties;
}
const CSS = `
.ct-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
font: 600 14px/1 var(--font-sans);
border-radius: var(--radius-2);
border: 1px solid transparent;
cursor: pointer;
white-space: nowrap;
transition: background var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
}
.ct-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.ct-btn--md { height: 34px; padding: 0 14px; }
.ct-btn--sm { height: 28px; padding: 0 10px; font-size: 13px; }
.ct-btn--primary { background: var(--accent); color: var(--text-on-accent); }
.ct-btn--primary:hover:not(:disabled) { background: var(--accent-hover); }
.ct-btn--primary:active:not(:disabled) { background: var(--accent-pressed); }
.ct-btn--secondary { background: var(--surface-card); border-color: var(--line-2); color: var(--ink-1); }
.ct-btn--secondary:hover:not(:disabled) { background: var(--paper-2); }
.ct-btn--secondary:active:not(:disabled) { background: var(--paper-3); }
.ct-btn--ghost { background: transparent; color: var(--ink-2); }
.ct-btn--ghost:hover:not(:disabled) { background: var(--paper-2); color: var(--ink-1); }
.ct-btn--ghost:active:not(:disabled) { background: var(--paper-3); }
.ct-btn--danger { background: var(--danger); color: var(--ink-inverse); }
.ct-btn--danger:hover:not(:disabled) { filter: brightness(0.92); }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-button-css')) {
const s = document.createElement('style'); s.id = 'ct-button-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Button({
variant = 'primary',
size = 'md',
icon,
iconRight,
disabled = false,
children,
style,
...rest
}: ButtonProps) {
const iconSize = size === 'sm' ? 15 : 17;
return (
<button
type="button"
className={`ct-btn ct-btn--${size} ct-btn--${variant}`}
disabled={disabled}
style={style}
{...rest}
>
{icon ? <Icon name={icon} size={iconSize} /> : null}
{children}
{iconRight ? <Icon name={iconRight} size={iconSize} /> : null}
</button>
);
}

View File

@@ -0,0 +1,79 @@
import React from 'react';
/**
* Paper card — hairline border, 10px radius, whispered shadow. `jade` adds the jade top rule
* reserved for the view's key card (e.g. the Focus card).
*/
export interface CardProps {
/** Caslon title */
title?: React.ReactNode;
/** Small uppercase label above the title */
overline?: string;
/** Right-aligned header actions (IconButtons) */
actions?: React.ReactNode;
/** Footer row below a hairline */
footer?: React.ReactNode;
/** Jade top rule — one per view @default false */
jade?: boolean;
/** Remove body padding (tables, charts) @default false */
flush?: boolean;
children?: React.ReactNode;
style?: React.CSSProperties;
}
const CSS = `
.ct-card {
background: var(--surface-card);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-3);
box-shadow: var(--shadow-1);
}
.ct-card--jade { box-shadow: var(--shadow-jade-line), var(--shadow-1); }
.ct-card__header {
display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
padding: 16px 20px 0;
}
.ct-card__title {
font: var(--text-title);
color: var(--ink-1);
margin: 0;
}
.ct-card__overline {
font: var(--text-overline);
letter-spacing: var(--letter-spacing-wide);
text-transform: uppercase;
color: var(--ink-3);
margin: 0 0 6px;
}
.ct-card__body { padding: 14px 20px 18px; }
.ct-card__body--flush { padding: 0; }
.ct-card__footer {
display: flex; align-items: center; gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--border-hairline);
}
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-card-css')) {
const s = document.createElement('style'); s.id = 'ct-card-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Card({ title, overline, actions, footer, jade = false, flush = false, children, style, ...rest }: CardProps) {
return (
<section className={`ct-card${jade ? ' ct-card--jade' : ''}`} style={style} {...rest}>
{(title || overline || actions) ? (
<header className="ct-card__header">
<div>
{overline ? <p className="ct-card__overline">{overline}</p> : null}
{title ? <h2 className="ct-card__title">{title}</h2> : null}
</div>
{actions ? <div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>{actions}</div> : null}
</header>
) : null}
<div className={`ct-card__body${flush ? ' ct-card__body--flush' : ''}`}>{children}</div>
{footer ? <footer className="ct-card__footer">{footer}</footer> : null}
</section>
);
}

View File

@@ -0,0 +1,54 @@
import React from 'react'
import { Icon } from './icon.js'
/**
* Checkbox with label; 16px box, spruce when checked.
*/
export interface CheckboxProps {
label?: React.ReactNode;
checked?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
style?: React.CSSProperties;
}
const CSS = `
.ct-check { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; font: var(--text-body); color: var(--ink-1); }
.ct-check--disabled { opacity: 0.5; cursor: not-allowed; }
.ct-check__input { position: absolute; opacity: 0; width: 0; height: 0; }
.ct-check__box {
width: 16px; height: 16px; flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
background: var(--surface-card);
border: 1px solid var(--line-2);
border-radius: var(--radius-1);
color: transparent;
transition: background var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out);
}
.ct-check:hover:not(.ct-check--disabled) .ct-check__box { border-color: var(--border-strong); }
.ct-check__input:checked + .ct-check__box { background: var(--accent); border-color: var(--accent); color: var(--ink-inverse); }
.ct-check__input:focus-visible + .ct-check__box { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-check-css')) {
const s = document.createElement('style'); s.id = 'ct-check-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Checkbox({ label, checked, onChange, disabled = false, style, ...rest }: CheckboxProps) {
return (
<label className={`ct-check${disabled ? ' ct-check--disabled' : ''}`} style={style}>
<input
type="checkbox"
className="ct-check__input"
checked={checked}
onChange={onChange}
disabled={disabled}
{...rest}
/>
<span className="ct-check__box"><Icon name="check" size={12} strokeWidth={2.5} /></span>
{label ? <span>{label}</span> : null}
</label>
);
}

View File

@@ -0,0 +1,82 @@
import React from 'react'
import { IconButton } from './icon-button.js'
/**
* Modal dialog — Caslon title over a double stationery rule; used for propose-approve
* moments and destructive confirms.
*/
export interface DialogProps {
open: boolean;
onClose?: () => void;
title: React.ReactNode;
/** Right-aligned action row (Buttons) */
footer?: React.ReactNode;
children?: React.ReactNode;
style?: React.CSSProperties;
}
const CSS = `
.ct-dialog-scrim {
position: fixed; inset: 0;
background: rgba(32, 38, 29, 0.4);
display: flex; align-items: center; justify-content: center;
z-index: 100;
animation: ct-dialog-fade var(--duration-base) var(--ease-out);
}
.ct-dialog {
background: var(--surface-card);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-3);
box-shadow: var(--shadow-3);
width: min(480px, calc(100vw - 48px));
max-height: calc(100vh - 96px);
display: flex; flex-direction: column;
animation: ct-dialog-rise var(--duration-base) var(--ease-out);
}
.ct-dialog__header {
display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
padding: 20px 20px 12px;
border-bottom: 3px double var(--border-strong);
margin: 0 20px; padding-left: 0; padding-right: 0;
}
.ct-dialog__title { font: var(--text-title); color: var(--ink-1); margin: 0; }
.ct-dialog__body { padding: 16px 20px; overflow-y: auto; font: var(--text-body); color: var(--ink-1); }
.ct-dialog__footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 12px 20px 20px;
}
@keyframes ct-dialog-fade { from { opacity: 0; } }
@keyframes ct-dialog-rise { from { opacity: 0; transform: translateY(8px); } }
@media (prefers-reduced-motion: reduce) {
.ct-dialog-scrim, .ct-dialog { animation: none; }
}
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-dialog-css')) {
const s = document.createElement('style'); s.id = 'ct-dialog-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Dialog({ open, onClose, title, footer, children, style }: DialogProps) {
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && onClose) onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="ct-dialog-scrim" onClick={(e: React.MouseEvent) => { if (e.target === e.currentTarget && onClose) onClose(); }}>
<div className="ct-dialog" role="dialog" aria-modal="true" style={style}>
<header className="ct-dialog__header">
<h2 className="ct-dialog__title">{title}</h2>
{onClose ? <IconButton icon="x" label="Close" size="sm" onClick={onClose} /> : null}
</header>
<div className="ct-dialog__body">{children}</div>
{footer ? <footer className="ct-dialog__footer">{footer}</footer> : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { Icon } from './icon.js';
/**
* Square icon-only button; label is mandatory (becomes aria-label + title).
*/
export interface IconButtonProps {
/** Lucide icon name */
icon: string;
/** Required accessible label (also the tooltip) */
label: string;
/** @default 'ghost' */
variant?: 'ghost' | 'outline';
/** @default 'md' */
size?: 'sm' | 'md';
disabled?: boolean;
onClick?: (e: React.MouseEvent) => void;
style?: React.CSSProperties;
}
const CSS = `
.ct-iconbtn {
display: inline-flex; align-items: center; justify-content: center;
border-radius: var(--radius-2);
border: 1px solid transparent;
background: transparent;
color: var(--ink-2);
cursor: pointer;
transition: background var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out);
}
.ct-iconbtn:hover:not(:disabled) { background: var(--paper-2); color: var(--ink-1); }
.ct-iconbtn:active:not(:disabled) { background: var(--paper-3); }
.ct-iconbtn:disabled { opacity: 0.45; cursor: not-allowed; }
.ct-iconbtn--md { width: 34px; height: 34px; }
.ct-iconbtn--sm { width: 28px; height: 28px; }
.ct-iconbtn--outline { border-color: var(--line-2); background: var(--surface-card); }
.ct-iconbtn--outline:hover:not(:disabled) { background: var(--paper-2); }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-iconbtn-css')) {
const s = document.createElement('style'); s.id = 'ct-iconbtn-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function IconButton({
icon,
label,
variant = 'ghost',
size = 'md',
disabled = false,
style,
...rest
}: IconButtonProps) {
return (
<button
type="button"
className={`ct-iconbtn ct-iconbtn--${size}${variant === 'outline' ? ' ct-iconbtn--outline' : ''}`}
aria-label={label}
title={label}
disabled={disabled}
style={style}
{...rest}
>
<Icon name={icon} size={size === 'sm' ? 15 : 17} />
</button>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,21 @@
/**
* CommiTea design-system primitives, ported verbatim from the design handoff
* (docs/design/components/). Each keeps its injected token-referencing CSS; the
* contracts in the handoff's `.d.ts` files are reproduced here as the exported
* prop interfaces. Import from `#~/components/ui` — never reach into a file.
*/
export * from './icon.js'
export * from './button.js'
export * from './icon-button.js'
export * from './badge.js'
export * from './tag.js'
export * from './card.js'
export * from './tabs.js'
export * from './input.js'
export * from './checkbox.js'
export * from './radio.js'
export * from './switch.js'
export * from './select.js'
export * from './dialog.js'
export * from './toast.js'
export * from './tooltip.js'

View File

@@ -0,0 +1,76 @@
import React from 'react'
import { Icon } from './icon.js'
/**
* Single-line text input with optional label, hint/error and leading icon.
*/
export interface InputProps {
label?: string;
/** Small gray helper under the field */
hint?: string;
/** Replaces hint; turns the border madder red */
error?: string;
/** Lucide icon name, leading */
icon?: string;
/** Mono text for machine values (urls, tokens) @default false */
mono?: boolean;
placeholder?: string;
value?: string;
defaultValue?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
type?: string;
style?: React.CSSProperties;
}
const CSS = `
.ct-field { display: flex; flex-direction: column; gap: 6px; }
.ct-field__label { font: 600 13px/1.2 var(--font-sans); color: var(--ink-1); }
.ct-field__wrap { position: relative; display: flex; align-items: center; }
.ct-field__icon { position: absolute; left: 10px; color: var(--ink-3); pointer-events: none; display: flex; }
.ct-input {
width: 100%; height: 34px;
font: var(--text-body);
color: var(--ink-1);
background: var(--surface-card);
border: 1px solid var(--line-2);
border-radius: var(--radius-2);
padding: 0 12px;
transition: border-color var(--duration-fast) var(--ease-out);
}
.ct-input::placeholder { color: var(--ink-3); }
.ct-input:hover:not(:disabled):not(:focus) { border-color: var(--border-strong); background: var(--paper-1); }
.ct-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.ct-input:disabled { opacity: 0.5; background: var(--paper-2); cursor: not-allowed; }
.ct-input--icon { padding-left: 32px; }
.ct-input--error { border-color: var(--danger); }
.ct-input--error:focus { border-color: var(--danger); box-shadow: 0 0 0 1px var(--danger); }
.ct-input--mono { font: var(--text-data); }
.ct-field__hint { font: var(--text-caption); color: var(--ink-3); margin: 0; }
.ct-field__error { font: var(--text-caption); color: var(--danger); margin: 0; }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-input-css')) {
const s = document.createElement('style'); s.id = 'ct-input-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Input({ label, hint, error, icon, mono = false, style, ...rest }: InputProps) {
const cls = [
'ct-input',
icon ? 'ct-input--icon' : '',
error ? 'ct-input--error' : '',
mono ? 'ct-input--mono' : '',
].filter(Boolean).join(' ');
return (
<div className="ct-field" style={style}>
{label ? <label className="ct-field__label">{label}</label> : null}
<div className="ct-field__wrap">
{icon ? <span className="ct-field__icon"><Icon name={icon} size={15} /></span> : null}
<input className={cls} {...rest} />
</div>
{error ? <p className="ct-field__error">{error}</p> : hint ? <p className="ct-field__hint">{hint}</p> : null}
</div>
);
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
/**
* Radio button with label; group by `name`.
*/
export interface RadioProps {
label?: React.ReactNode;
checked?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
name?: string;
value?: string;
disabled?: boolean;
style?: React.CSSProperties;
}
const CSS = `
.ct-radio { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; font: var(--text-body); color: var(--ink-1); }
.ct-radio--disabled { opacity: 0.5; cursor: not-allowed; }
.ct-radio__input { position: absolute; opacity: 0; width: 0; height: 0; }
.ct-radio__dot {
width: 16px; height: 16px; flex-shrink: 0;
border: 1px solid var(--line-2);
border-radius: 50%;
background: var(--surface-card);
display: inline-flex; align-items: center; justify-content: center;
transition: border-color var(--duration-fast) var(--ease-out);
}
.ct-radio__dot::after {
content: '';
width: 8px; height: 8px; border-radius: 50%;
background: transparent;
transition: background var(--duration-fast) var(--ease-out);
}
.ct-radio:hover:not(.ct-radio--disabled) .ct-radio__dot { border-color: var(--border-strong); }
.ct-radio__input:checked + .ct-radio__dot { border-color: var(--accent); }
.ct-radio__input:checked + .ct-radio__dot::after { background: var(--accent); }
.ct-radio__input:focus-visible + .ct-radio__dot { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-radio-css')) {
const s = document.createElement('style'); s.id = 'ct-radio-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Radio({ label, checked, onChange, name, value, disabled = false, style, ...rest }: RadioProps) {
return (
<label className={`ct-radio${disabled ? ' ct-radio--disabled' : ''}`} style={style}>
<input
type="radio"
className="ct-radio__input"
checked={checked}
onChange={onChange}
name={name}
value={value}
disabled={disabled}
{...rest}
/>
<span className="ct-radio__dot"></span>
{label ? <span>{label}</span> : null}
</label>
);
}

View File

@@ -0,0 +1,64 @@
import React from 'react'
import { Icon } from './icon.js'
/**
* Styled native select with Lucide chevron.
*/
export interface SelectOption {
value: string;
label: string;
}
export interface SelectProps {
label?: string;
options: SelectOption[];
value?: string;
defaultValue?: string;
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
disabled?: boolean;
style?: React.CSSProperties;
}
const CSS = `
.ct-select-field { display: flex; flex-direction: column; gap: 6px; }
.ct-select-field__label { font: 600 13px/1.2 var(--font-sans); color: var(--ink-1); }
.ct-select__wrap { position: relative; display: flex; align-items: center; }
.ct-select {
width: 100%; height: 34px;
font: var(--text-body);
color: var(--ink-1);
background: var(--surface-card);
border: 1px solid var(--line-2);
border-radius: var(--radius-2);
padding: 0 30px 0 12px;
appearance: none;
cursor: pointer;
transition: border-color var(--duration-fast) var(--ease-out);
}
.ct-select:hover:not(:disabled):not(:focus) { border-color: var(--border-strong); }
.ct-select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.ct-select:disabled { opacity: 0.5; cursor: not-allowed; }
.ct-select__chevron { position: absolute; right: 10px; color: var(--ink-3); pointer-events: none; display: flex; }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-select-css')) {
const s = document.createElement('style'); s.id = 'ct-select-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Select({ label, options, style, ...rest }: SelectProps) {
return (
<div className="ct-select-field" style={style}>
{label ? <label className="ct-select-field__label">{label}</label> : null}
<div className="ct-select__wrap">
<select className="ct-select" {...rest}>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<span className="ct-select__chevron"><Icon name="chevron-down" size={15} /></span>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import React from 'react';
/**
* On/off switch; spruce when on. For live state, not form options.
*/
export interface SwitchProps {
label?: React.ReactNode;
checked?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
style?: React.CSSProperties;
}
const CSS = `
.ct-switch { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; font: var(--text-body); color: var(--ink-1); }
.ct-switch--disabled { opacity: 0.5; cursor: not-allowed; }
.ct-switch__input { position: absolute; opacity: 0; width: 0; height: 0; }
.ct-switch__track {
width: 34px; height: 20px; flex-shrink: 0;
border-radius: var(--radius-round);
background: var(--paper-3);
border: 1px solid var(--line-2);
position: relative;
transition: background var(--duration-base) var(--ease-out), border-color var(--duration-base) var(--ease-out);
}
.ct-switch__track::after {
content: '';
position: absolute; top: 2px; left: 2px;
width: 14px; height: 14px; border-radius: 50%;
background: var(--surface-card);
box-shadow: var(--shadow-1);
transition: transform var(--duration-base) var(--ease-out);
}
.ct-switch__input:checked + .ct-switch__track { background: var(--accent); border-color: var(--accent); }
.ct-switch__input:checked + .ct-switch__track::after { transform: translateX(14px); }
.ct-switch__input:focus-visible + .ct-switch__track { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-switch-css')) {
const s = document.createElement('style'); s.id = 'ct-switch-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Switch({ label, checked, onChange, disabled = false, style, ...rest }: SwitchProps) {
return (
<label className={`ct-switch${disabled ? ' ct-switch--disabled' : ''}`} style={style}>
<input
type="checkbox"
role="switch"
className="ct-switch__input"
checked={checked}
onChange={onChange}
disabled={disabled}
{...rest}
/>
<span className="ct-switch__track"></span>
{label ? <span>{label}</span> : null}
</label>
);
}

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { Icon } from './icon.js';
/**
* Underline tabs for view switching (Board / Gantt / Dependencies).
*/
export interface TabItem {
id: string;
label: string;
/** Lucide icon name */
icon?: string;
/** Mono count rendered after the label */
count?: number;
}
export interface TabsProps {
items: TabItem[];
/** id of the active tab */
active: string;
onChange?: (id: string) => void;
style?: React.CSSProperties;
}
const CSS = `
.ct-tabs {
display: flex; gap: 2px;
border-bottom: 1px solid var(--border-hairline);
}
.ct-tab {
display: inline-flex; align-items: center; gap: 7px;
font: 500 13.5px/1 var(--font-sans);
color: var(--ink-2);
background: none; border: none;
padding: 10px 12px;
margin-bottom: -1px;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color var(--duration-fast) var(--ease-out);
}
.ct-tab:hover { color: var(--ink-1); }
.ct-tab--active {
color: var(--ink-1);
font-weight: 600;
border-bottom-color: var(--accent);
}
.ct-tab__count { font: 400 11.5px/1 var(--font-mono); color: var(--ink-3); }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-tabs-css')) {
const s = document.createElement('style'); s.id = 'ct-tabs-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Tabs({ items, active, onChange, style, ...rest }: TabsProps) {
return (
<div className="ct-tabs" role="tablist" style={style} {...rest}>
{items.map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={item.id === active}
className={`ct-tab${item.id === active ? ' ct-tab--active' : ''}`}
onClick={() => onChange && onChange(item.id)}
>
{item.icon ? <Icon name={item.icon} size={15} /> : null}
{item.label}
{item.count != null ? <span className="ct-tab__count">{item.count}</span> : null}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import React from 'react'
import { Icon } from './icon.js'
/**
* Gitea label chip — renders label text verbatim in mono; tone derives from the label itself
* (est/[1d 2d 3d 5d 8d], p/1..4, deadline/hard).
*/
export interface TagProps {
/** Verbatim gitea label, e.g. "est/3d", "p/1", "deadline/hard" */
label: string;
/** Renders a small remove button when provided */
onRemove?: (e: React.MouseEvent) => void;
style?: React.CSSProperties;
}
const CSS = `
.ct-tag {
display: inline-flex; align-items: center; gap: 5px;
font: 500 11.5px/1 var(--font-mono);
letter-spacing: var(--letter-spacing-label);
padding: 4px 9px;
border-radius: var(--radius-round);
white-space: nowrap;
}
.ct-tag--est { background: var(--label-est-bg); color: var(--label-est-text); }
.ct-tag--p1 { background: var(--danger-tint); color: var(--label-p1); }
.ct-tag--p2 { background: var(--warn-tint); color: var(--label-p2); }
.ct-tag--p3 { background: var(--info-tint); color: var(--label-p3); }
.ct-tag--p4 { background: var(--paper-2); color: var(--label-p4); }
.ct-tag--hard { background: var(--label-hard); color: var(--ink-inverse); }
.ct-tag--plain { background: var(--paper-2); color: var(--ink-2); }
.ct-tag__x {
display: inline-flex; padding: 0; margin: 0 -3px 0 0;
background: none; border: none; color: inherit; cursor: pointer; opacity: 0.6;
}
.ct-tag__x:hover { opacity: 1; }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-tag-css')) {
const s = document.createElement('style'); s.id = 'ct-tag-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
function toneFor(label: string) {
if (label.startsWith('est/')) return 'est';
if (label === 'p/1') return 'p1';
if (label === 'p/2') return 'p2';
if (label === 'p/3') return 'p3';
if (label === 'p/4') return 'p4';
if (label === 'deadline/hard') return 'hard';
return 'plain';
}
export function Tag({ label, onRemove, style, ...rest }: TagProps) {
return (
<span className={`ct-tag ct-tag--${toneFor(label)}`} style={style} {...rest}>
{label}
{onRemove ? (
<button type="button" className="ct-tag__x" aria-label={`Remove ${label}`} onClick={onRemove}>
<Icon name="x" size={11} strokeWidth={2} />
</button>
) : null}
</span>
);
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { Icon } from './icon.js';
import { IconButton } from './icon-button.js';
/**
* Transient notification card. Presentational — the consumer owns positioning/stacking.
*/
export interface ToastProps {
/** @default 'info' */
tone?: 'ok' | 'warn' | 'danger' | 'info';
title?: React.ReactNode;
onDismiss?: () => void;
children?: React.ReactNode;
style?: React.CSSProperties;
}
const CSS = `
.ct-toast {
display: flex; align-items: flex-start; gap: 10px;
background: var(--surface-card);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-2);
box-shadow: var(--shadow-2);
padding: 12px 14px;
max-width: 420px;
font: var(--text-body);
color: var(--ink-1);
animation: ct-toast-in var(--duration-slow) var(--ease-out);
}
.ct-toast__icon { display: flex; margin-top: 1px; }
.ct-toast--ok .ct-toast__icon { color: var(--ok); }
.ct-toast--warn .ct-toast__icon { color: var(--warn); }
.ct-toast--danger .ct-toast__icon { color: var(--danger); }
.ct-toast--info .ct-toast__icon { color: var(--info); }
.ct-toast__content { flex: 1; min-width: 0; }
.ct-toast__title { font: var(--text-body-strong); margin: 0 0 2px; }
@keyframes ct-toast-in { from { opacity: 0; transform: translateY(6px); } }
@media (prefers-reduced-motion: reduce) { .ct-toast { animation: none; } }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-toast-css')) {
const s = document.createElement('style'); s.id = 'ct-toast-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
const TOAST_ICONS: Record<string, string> = {
ok: 'circle-check',
warn: 'triangle-alert',
danger: 'circle-alert',
info: 'info',
};
export function Toast({ tone = 'info', title, onDismiss, children, style, ...rest }: ToastProps) {
return (
<div className={`ct-toast ct-toast--${tone}`} role="status" style={style} {...rest}>
<span className="ct-toast__icon"><Icon name={TOAST_ICONS[tone]} size={16} /></span>
<div className="ct-toast__content">
{title ? <p className="ct-toast__title">{title}</p> : null}
{children}
</div>
{onDismiss ? <IconButton icon="x" label="Dismiss" size="sm" onClick={onDismiss} /> : null}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
/**
* Hover/focus tooltip — ink capsule, plain facts only.
*/
export interface TooltipProps {
content: React.ReactNode;
/** @default 'top' */
side?: 'top' | 'bottom';
children?: React.ReactNode;
style?: React.CSSProperties;
}
const CSS = `
.ct-tooltip-wrap { position: relative; display: inline-flex; }
.ct-tooltip {
position: absolute; bottom: calc(100% + 7px); left: 50%;
transform: translateX(-50%) translateY(2px);
background: var(--ink-1);
color: var(--ink-inverse);
font: 500 12px/1.4 var(--font-sans);
padding: 5px 9px;
border-radius: var(--radius-1);
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out);
z-index: 50;
}
.ct-tooltip--bottom { bottom: auto; top: calc(100% + 7px); transform: translateX(-50%) translateY(-2px); }
.ct-tooltip-wrap:hover .ct-tooltip,
.ct-tooltip-wrap:focus-within .ct-tooltip {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.ct-tooltip code { font: 500 11px var(--font-mono); }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-tooltip-css')) {
const s = document.createElement('style'); s.id = 'ct-tooltip-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Tooltip({ content, side = 'top', children, style }: TooltipProps) {
return (
<span className="ct-tooltip-wrap" style={style}>
{children}
<span className={`ct-tooltip${side === 'bottom' ? ' ct-tooltip--bottom' : ''}`} role="tooltip">
{content}
</span>
</span>
);
}

View File

@@ -0,0 +1,528 @@
/**
* Demo fixtures mirrored from the design handoff's `data.js`. Stand-ins until
* P1 fills the SQLite cache and P4 wires the real agent. Replace, don't extend
* into production code.
*/
export interface ChatMessage {
from: 'agent' | 'user'
text: string
}
export const CHAT: ChatMessage[] = [
{ from: 'agent', text: 'Morning service. Two things drifted overnight; one needs your opinion.' },
{
from: 'agent',
text: '#84 grew a dependency on #92 — Ive reordered. And #87 has been steeping for four days; it blocks two others. Worth a look.',
},
{ from: 'user', text: 'Push the calibration work to next week, pilots come first' },
{
from: 'agent',
text: 'Done — #78 moves to next week. Milestone Beta is unmoved; the cone doesnt care for calibration either. Logged as a directive.',
},
]
export const CANNED_REPLY =
'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.',
},
}
/** Lightweight issue reference handed to the shell's drill-in from any screen. */
export interface IssueRef {
id: number
title?: string
labels?: string[]
days?: string
rationale?: string
}
// ---- Board (The pot) ----
export interface BoardIssue {
id: number
title: string
labels: string[]
who: string
blocked?: boolean
days?: string
pr?: string
}
export interface BoardColumn {
id: string
label: string
issues: BoardIssue[]
}
export const COLUMNS: BoardColumn[] = [
{
id: 'diagnosis',
label: 'Diagnosis',
issues: [
{ id: 102, title: 'Scheduler ignores standing allocation slices', labels: ['p/2'], who: 'SM' },
{ id: 103, title: 'Dark theme: cone fill too faint', labels: ['p/4'], who: 'SM' },
],
},
{
id: 'triage',
label: 'Triage',
issues: [
{ id: 99, title: 'Directive log: conflict-free merge test', labels: ['est/1d', 'p/2'], who: 'SM' },
{ id: 96, title: 'Label schema bootstrap for new repos', labels: ['est/2d', 'p/3'], who: 'AK' },
{ id: 78, title: 'Calibration store cold-start distributions', labels: ['est/5d', 'p/3'], who: 'SM' },
],
},
{
id: 'steeping',
label: 'Steeping',
issues: [
{ id: 87, title: 'Fix lifecycle inference on merge events', labels: ['est/2d', 'p/1'], who: 'SM', blocked: false, days: '4d' },
{ id: 84, title: 'Capacity model: focus factor per person', labels: ['est/3d', 'p/2'], who: 'AK', days: '1d' },
],
},
{
id: 'review',
label: 'In review',
issues: [{ id: 92, title: 'Monte Carlo engine: percentile bands', labels: ['est/5d', 'p/1'], who: 'SM', pr: '#141' }],
},
{
id: 'done',
label: 'Done',
issues: [
{ id: 71, title: 'Gitea client: token auth + retries', labels: ['est/2d', 'p/2'], who: 'SM' },
{ id: 69, title: 'pm-state repo bootstrap', labels: ['est/1d', 'p/1'], who: 'SM' },
{ id: 65, title: 'Electron shell + window state', labels: ['est/3d', 'p/2'], who: 'AK' },
],
},
]
// ---- Gantt ----
export interface GanttWeek {
at: number
label: string
}
export interface GanttRow {
id: number
title: string
who: string
state: 'done' | 'steeping' | 'review' | 'scheduled'
start: number
end: number
p80?: number
crit?: boolean
}
export interface GanttData {
days: number
weeks: GanttWeek[]
today: number
band: { from: number; to: number; label: string }
due: { at: number; label: string }
rows: GanttRow[]
}
export const GANTT: GanttData = {
days: 42,
weeks: [
{ at: 0, label: 'Feb 2' },
{ at: 7, label: 'Feb 9' },
{ at: 14, label: 'Feb 16' },
{ at: 21, label: 'Feb 23' },
{ at: 28, label: 'Mar 2' },
{ at: 35, label: 'Mar 9' },
{ at: 41, label: 'Mar 15' },
],
today: 8,
band: { from: 29, to: 38, label: '80% · Mar 312' },
due: { at: 41, label: 'Beta due' },
rows: [
{ id: 71, title: 'Gitea client: token auth + retries', who: 'SM', state: 'done', start: 0, end: 4 },
{ id: 69, title: 'pm-state repo bootstrap', who: 'SM', state: 'done', start: 0, end: 1 },
{ id: 87, title: 'Fix lifecycle inference on merge events', who: 'SM', state: 'steeping', start: 4, end: 9, p80: 11, crit: true },
{ id: 92, title: 'Monte Carlo engine: percentile bands', who: 'SM', state: 'review', start: 9, end: 18, p80: 21, crit: true },
{ id: 91, title: 'Webhook listener: reconcile on reconnect', who: 'AK', state: 'scheduled', start: 9, end: 14, p80: 16 },
{ id: 99, title: 'Directive log: conflict-free merge test', who: 'SM', state: 'scheduled', start: 10, end: 11 },
{ id: 96, title: 'Label schema bootstrap for new repos', who: 'AK', state: 'scheduled', start: 14, end: 16 },
{ id: 84, title: 'Capacity model: focus factor per person', who: 'AK', state: 'scheduled', start: 18, end: 23, p80: 26, crit: true },
{ id: 102, title: 'Scheduler ignores standing allocation slices', who: 'SM', state: 'scheduled', start: 23, end: 25, p80: 28, crit: true },
{ id: 78, title: 'Calibration store cold-start distributions', who: 'SM', state: 'scheduled', start: 25, end: 30 },
],
}
// ---- Dependencies ----
export interface DepNode {
id: number
title: string
tags: string[]
state: 'done' | 'steeping' | 'review' | 'triage' | 'diagnosis'
days?: string
col: number
row: number
rationale?: string
}
export interface DepEdge {
from: number
to: number | 'ms'
crit?: boolean
}
export interface DepsData {
nodes: DepNode[]
milestone: { name: string; due: string; col: number; row: number }
edges: DepEdge[]
critical: number[]
unattached: number[]
}
export const DEPS: DepsData = {
nodes: [
{ id: 71, title: 'Gitea client: token auth + retries', tags: ['est/2d'], state: 'done', col: 0, row: 0.2 },
{ id: 69, title: 'pm-state repo bootstrap', tags: ['est/1d'], state: 'done', col: 0, row: 1.9 },
{
id: 87,
title: 'Fix lifecycle inference on merge events',
tags: ['est/2d', 'p/1'],
state: 'steeping',
days: '4d',
col: 1,
row: 0.7,
rationale: 'It blocks #91 and #92. Id take it first — the critical path agrees with me.',
},
{ id: 99, title: 'Directive log: conflict-free merge test', tags: ['est/1d', 'p/2'], state: 'triage', col: 1, row: 1.9 },
{ id: 91, title: 'Webhook listener: reconcile on reconnect', tags: ['est/3d', 'p/2'], state: 'triage', col: 2, row: 0 },
{ id: 92, title: 'Monte Carlo engine: percentile bands', tags: ['est/5d', 'p/1'], state: 'review', col: 2, row: 1.4 },
{ id: 84, title: 'Capacity model: focus factor per person', tags: ['est/3d', 'p/2'], state: 'steeping', days: '1d', col: 3, row: 1.4 },
{ id: 102, title: 'Scheduler ignores standing allocation slices', tags: ['p/2'], state: 'diagnosis', col: 4, row: 1.4 },
],
milestone: { name: 'Beta', due: 'Mar 15', col: 5, row: 1.4 },
edges: [
{ from: 71, to: 87 },
{ from: 69, to: 99 },
{ from: 87, to: 91 },
{ from: 87, to: 92, crit: true },
{ from: 92, to: 84, crit: true },
{ from: 84, to: 102, crit: true },
{ from: 102, to: 'ms', crit: true },
],
critical: [87, 92, 84, 102],
unattached: [78, 96, 103, 65],
}
// ---- Runway ----
export interface RunwayMilestone {
name: string
due: string
hard: boolean
p80: string
pos: number
spread: number
tone: 'ok' | 'warn'
note: string
}
export const RUNWAY: RunwayMilestone[] = [
{ name: 'Beta', due: 'Mar 15', hard: false, p80: 'Mar 312', pos: 0.62, spread: 0.18, tone: 'ok', note: 'ahead' },
{ name: 'Pilot-ready', due: 'Apr 30', hard: true, p80: 'Apr 21 May 9', pos: 0.94, spread: 0.26, tone: 'warn', note: 'drifting' },
{ name: 'v1.0', due: 'Jun 12', hard: false, p80: 'May 30 Jun 20', pos: 0.88, spread: 0.3, tone: 'ok', note: 'on watch' },
]
export interface CapacityMember {
who: string
hours: string
focus: number
slices: string
}
export const CAPACITY: CapacityMember[] = [
{ who: 'Stephen', hours: '5.2h/day', focus: 0.65, slices: 'dev 70% · pilots 20% · compliance 10%' },
{ who: 'Ana K.', hours: '3.8h/day', focus: 0.8, slices: 'dev 100%' },
]
// ---- Calibration ----
export interface CalLabel {
label: string
n: number
median: string
bias: number | null
}
export interface CalPerson {
who: string
n: number
bias: number
note: string
}
export interface CalibrationData {
n: number
active: boolean
labels: CalLabel[]
people: CalPerson[]
scatter: number[][]
fit: number
effect: { raw: string; banded: string; p50: string }
}
export const CALIBRATION: CalibrationData = {
n: 27,
active: true,
labels: [
{ label: 'est/1d', n: 8, median: '1.1d', bias: 8 },
{ label: 'est/2d', n: 9, median: '2.4d', bias: 18 },
{ label: 'est/3d', n: 6, median: '3.7d', bias: 22 },
{ label: 'est/5d', n: 3, median: '6.5d', bias: 30 },
{ label: 'est/8d', n: 1, median: '8.5d', bias: null },
],
people: [
{ who: 'Stephen', n: 19, bias: 21, note: 'optimism grows with ticket size' },
{ who: 'Ana K.', n: 8, bias: 9, note: 'close to honest — suspicious' },
],
scatter: [
[1, 1], [1, 1.2], [1, 0.9], [1, 1.5], [1, 1.1], [1, 0.8], [1, 1.3], [1, 1.2],
[2, 2], [2, 2.5], [2, 3.1], [2, 2.2], [2, 2.6], [2, 1.8], [2, 2.4], [2, 2.9], [2, 2.3],
[3, 3.5], [3, 4.1], [3, 3.1], [3, 4.6], [3, 3.6], [3, 3.9],
[5, 6.1], [5, 7.2], [5, 6.4],
[8, 8.5],
],
fit: 1.18,
effect: { raw: '42d of estimates', banded: '80% band 4653d', p50: '48d' },
}
// ---- Inbox ----
export interface InboxIssueRef {
id: number
title: string
labels: string[]
days?: string
}
export interface InboxItem {
id: number
day: string
type: string
icon: string
tone: 'ok' | 'warn' | 'info' | 'neutral'
who?: string
text: string
detail: string
time: string
unread: boolean
to?: string
issue?: InboxIssueRef
}
export const INBOX: InboxItem[] = [
{ id: 1, day: 'Today', type: 'drift', icon: 'chart-line', tone: 'warn', text: 'Betas 80% window moved', detail: 'Mar 312 → Mar 514 · directive #007', time: '09:14', unread: true, to: 'directives' },
{ id: 2, day: 'Today', type: 'mention', icon: 'message-square', tone: 'info', who: 'Ana K.', text: 'mentioned you on #92', detail: '“percentile bands ready for a second pair of eyes”', time: '08:52', unread: true, issue: { id: 92, title: 'Monte Carlo engine: percentile bands', labels: ['est/5d', 'p/1'] } },
{ id: 3, day: 'Today', type: 'nag', icon: 'clock', tone: 'warn', text: '#87 is steeping · 4d', detail: 'blocks #91 and #92 — worth a look before it stains', time: '07:00', unread: true, issue: { id: 87, title: 'Fix lifecycle inference on merge events', labels: ['est/2d', 'p/1'], days: '4d' } },
{ id: 4, day: 'Yesterday', type: 'review', icon: 'git-pull-request', tone: 'info', text: 'PR #141 awaits review', detail: '#92 · idle two days', time: '16:20', unread: false, issue: { id: 92, title: 'Monte Carlo engine: percentile bands', labels: ['est/5d', 'p/1'] } },
{ id: 5, day: 'Yesterday', type: 'assignment', icon: 'user', tone: 'neutral', who: 'Ana K.', text: 'took #96 from the pot', detail: 'Label schema bootstrap for new repos', time: '11:03', unread: false, issue: { id: 96, title: 'Label schema bootstrap for new repos', labels: ['est/2d', 'p/3'] } },
{ id: 6, day: 'Yesterday', type: 'system', icon: 'refresh-cw', tone: 'ok', text: 'Webhook outage 02:1402:31', detail: 'full reconcile ran · nothing lost', time: '02:31', unread: false },
{ id: 7, day: 'Yesterday', type: 'milestone', icon: 'milestone', tone: 'ok', text: 'P2 — Scheduler closed two days early', detail: 'the calibration noticed. So did I.', time: '09:40', unread: false },
]
// ---- Issue detail ----
export interface IssueComment {
who: string
when: string
text: string
}
export interface IssueLifecycleStage {
stage: string
event: string
when: string
icon: string
done: boolean
}
export interface IssueDetail {
state: string
assignee: string
milestone: string
body: string
comments: IssueComment[]
lifecycle: IssueLifecycleStage[]
forecast: { p80: string; note: string }
blocks: number[]
blockedBy: number[]
note: string
}
export const ISSUE_DETAIL: Record<number, IssueDetail> = {
87: {
state: 'steeping',
assignee: 'Stephen',
milestone: 'Beta',
body: 'Squash-merges emit events out of order when CI runs long: the PR-closed webhook lands before the merge event, so lifecycle inference marks deploy before work-end and the actuals go negative. Regular merges infer correctly.',
comments: [
{ who: 'Ana K.', when: 'Feb 8 · 14:12', text: 'Repro: squash-merge only. Regular merges infer fine.' },
{ who: 'Stephen', when: 'Feb 9 · 09:30', text: 'Confirmed — the event order flips whenever CI takes more than ~5 minutes.' },
],
lifecycle: [
{ stage: 'Diagnosis', event: 'issue opened', when: 'Feb 2 · 09:14', icon: 'circle-dot', done: true },
{ stage: 'Triage', event: 'labeled est/2d · milestoned Beta', when: 'Feb 3 · 10:02', icon: 'tag', done: true },
{ stage: 'Work start', event: 'first commit ref a41f09', when: 'Feb 6 · 11:47', icon: 'git-commit-horizontal', done: true },
{ stage: 'Deploy', event: 'PR merged', when: 'pending', icon: 'git-merge', done: false },
{ stage: 'Complete', event: 'issue closed', when: 'pending', icon: 'circle-check', done: false },
],
forecast: { p80: 'done Feb 1113', note: 'from your est/2d history · n=14' },
blocks: [91, 92],
blockedBy: [],
note: 'It blocks #91 and #92. Id 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 312', to: 'Mar 514' },
{ 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' },
],
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -87,7 +87,11 @@ directives. Electron app, one window, "tell me what to do" experience.
Deliverables per phase; replace TBD with actuals at phase close.
- **P0 — Scaffold** — repo, Electron shell, gitea API client + token auth,
`pm-state` repo bootstrap. Actual: TBD
`pm-state` repo bootstrap. Actual (2026-07-08): yarn4 workspaces + electron
shell + `@commitea/core` label schema + design system mirrored. **Dogfood
backlog filed** on `christian/commitea`: 10 labels (est/p/deadline, exclusive
scopes), 5 phase milestones, 34 tracer-bullet issues with 51 native
dependencies. `pm-state` repo bootstrap still pending (P1-6 / P5-1).
- **P1 — Sync + data model** — read mirror into SQLite cache, webhook
listener + reconcile-on-launch, lifecycle inference from event stream,
label schema applied. Actual: TBD
@@ -113,12 +117,16 @@ vertical slices within phases where possible.)
- Concurrent multi-PM directive writing
- Cloud LLM as *requirement* (it's a config option, not a dependency)
## Open items for design session
## Open items for design session — RESOLVED 2026-07-08
- Chat-as-only-write-path: strict or soft (UI edits allowed but agent
observes/objects)?
- `pm-state` file formats (JSONL event log settled; capacity/calibration
schemas TBD)
- Tool schema for the fat `query_project` tool
- Webhook endpoint mechanics per instance (port allocation, cleanup on quit)
- Cold-start estimate distributions (industry priors vs uniform pessimism)
Settled in [decisions.md](./decisions.md), [pm-state.md](./pm-state.md),
[agent-tools.md](./agent-tools.md):
- Chat-as-only-write-path → **soft, split by semantics** (decisions.md D1)
- `pm-state` file formats → directive/capacity/calibration schemas (pm-state.md)
- Fat `query_project` tool schema → one read tool + three write tools
(agent-tools.md)
- Webhook mechanics → **poll + reconcile only in v1** (NAT), behind a
`ChangeSource` interface (decisions.md D2)
- Cold-start distributions **lognormal per-bucket priors** in code
(decisions.md D3)

119
docs/agent-tools.md Normal file
View File

@@ -0,0 +1,119 @@
# Agent tools
Reginald gets **few, fat tools** so a small local model (gemma-4b class) can
survive with one thing to reach for. One read tool, three write tools. All I/O
is compact JSON; ticket data is fetched through tools, never copied into hot
memory ([PLAN.md](./PLAN.md) memory layers).
Model routing (per PLAN.md): the read tool + prose/standup run on the small
local model; `capture_work` decomposition and `record_directive` negotiation
route to the big model.
## `query_project` (read)
The single read tool. A `view` enum selects the shape; `filters` narrows it. The
scheduler's deterministic output backs every forecast field — the model never
computes, it reports.
```jsonc
query_project({
view: "focus" | "issue" | "milestone" | "board" | "runway"
| "calibration" | "directives" | "standup" | "search",
filters?: {
issueId?: number,
milestoneId?: number,
state?: "diagnosis" | "triage" | "steeping" | "in_review" | "done",
assignee?: string, // gitea username
label?: string,
query?: string, // free text, for view:"search"
limit?: number // default 20, max 100
}
})
```
View payloads (compact; forecasts always ranges, never point dates):
- **focus** — `{ now, next[], later[] }`, each `{ issue, title, rationale }`.
- **issue** — intent (title, description, comments, assignee, labels) + derived
(lifecycle timeline, per-issue forecast range, dependency ids, provenance).
- **milestone** — `{ due, hard, stats, cone: {p10,p50,p80 dates}, issues[] }`.
- **board** — issues grouped by the five lifecycle columns.
- **runway** — per-milestone `{ due, band: {p50,p80}, slack }` + capacity list.
- **calibration** — `{ n, coldStart, globalMultiplier, byLabelBias[], byPersonBias[] }`.
- **directives** — pending consequence diff + recent ledger entries.
- **standup** — drift report, per-person plan, stale blockers.
- **search** — issues matching `query`.
## `capture_work` (write — big model)
Braindump → interview → proposed issue set. Returns a **proposal**, never files
directly; the Capture screen's review tray edits it before `apply_changes` files
it. Decomposition + estimate negotiation is the one place the big model earns its
keep.
```jsonc
capture_work({
braindump: string,
answers?: { question: string, answer: string }[] // interview turns so far
})
// → { needsMoreInfo?: string[], // follow-up questions; present as chips
// proposal?: { issues: [{ title, body, estimate: EstimateLabel,
// priority?: PriorityLabel, deps?: number[],
// milestone?: number }],
// consequence: string } } // one-line schedule impact
```
## `apply_changes` (write — unified mutation)
Every mutation funnels here: filing captured issues, label/estimate/priority/
milestone/dependency edits. Additive ops apply directly; **destructive ops
require `approved: true`** (the caller obtains approval via the consequence
diff / Dialog first — see [decisions.md](./decisions.md) D1). Batched so one call
= one coherent change with one consequence.
```jsonc
apply_changes({
ops: [
{ op: "create_issue", title, body, labels?, milestone?, deps? },
{ op: "set_estimate", issue, estimate: EstimateLabel },
{ op: "set_priority", issue, priority: PriorityLabel },
{ op: "set_milestone", issue, milestone: number | null },
{ op: "set_deadline_hard", milestone: number, hard: boolean },
{ op: "add_dep", issue, dependsOn: number },
{ op: "remove_dep", issue, dependsOn: number }, // destructive
{ op: "close_issue", issue }, // destructive
{ op: "remove_label", issue, label } // destructive
],
approved?: boolean // required iff any op is destructive
})
// → { applied: number, consequence: string, rejected?: {op, reason}[] }
```
Only touches labels in the CommiTea namespaces (`est/*`, `p/*`, `deadline/hard`)
plus native issue fields — never invents labels, comments, or synthetic issues
(zero-pollution goal).
## `record_directive` (write — big model)
Appends to the directive log ([pm-state.md](./pm-state.md)), triggers a scheduler
re-run, and returns the consequence diff for propose-approve. Does **not** mutate
gitea itself — a directive is intent; its effects land through `apply_changes`
after approval.
```jsonc
record_directive({
kind: "reprioritize" | "reestimate" | "set-deadline" | "scope" | "capacity" | "note",
quote: string, // verbatim PM words, stored in the ledger
target?: { issue?: number, milestone?: number, member?: string },
params?: object, // structured effect, e.g. { priority: 1 }
rationale?: string
})
// → { directiveId, consequence: { before, after }[], summary: string }
```
## Not tools
Reads that are pure UI state (theme, current view, back-stack) never go through
tools. The scheduler, Monte Carlo, calibration fit, and lifecycle inference are
**code**, invoked by the runtime around these tools — the model requests a view
or proposes a change; deterministic code produces every number.

73
docs/decisions.md Normal file
View File

@@ -0,0 +1,73 @@
# Settled design decisions
Resolves the "Open items for design session" in [PLAN.md](./PLAN.md). Dated
2026-07-08. Companion docs: [pm-state.md](./pm-state.md) (sidecar formats),
[agent-tools.md](./agent-tools.md) (tool schemas).
## D1 — Write path is soft, split by semantics
Chat (Reginald) is the write path for **PM-semantic** mutations; gitea-native
content stays directly editable.
- **Through the agent** — estimates (`est/*`), priority (`p/*`),
`deadline/hard`, milestone assignment, dependency edits, and directives.
Additive ops act directly; destructive ops go propose-approve (Dialog or the
consequence diff on the Directives screen).
- **Direct, reconciled** — issue title/description, comments, assignees. Edit
them in the CommiTea UI ("composer writes to gitea, as you") or in the gitea
web UI; reconcile absorbs out-of-band edits because **gitea is the source of
truth** for intent. The agent observes changes on the next reconcile and may
object in standup, but never blocks them.
Rejected: strict (every write through chat). Hostile to quick edits and fights
the reconcile-from-gitea model — an edit made in gitea's own web UI would be
un-representable.
## D2 — Sync is poll + reconcile, no live webhooks in v1
`gitea.stephenmann.io` is remote and the desktop app sits behind NAT, so the
server cannot POST to a localhost webhook. v1:
- **Reconcile on launch** — full read of the work repo(s) + `pm-state` repo into
the local SQLite cache.
- **Light poll while running** — `since`/conditional-request poll of issues and
the issue timeline (target 3060 s cadence; visible < 2 s is a webhook-era
goal, relaxed to the poll interval for v1).
- The change-source is an **interface** (`ChangeSource`) with a polling
implementation; a webhook implementation can plug in later for a LAN /
self-hosted / tunnelled instance without touching the reconcile core.
Rejected now: reachability-detection hybrid (moving parts, cleanup-on-quit),
outbound tunnel (runtime dependency + public ingress). Both remain future
options behind the same interface.
## D3 — Cold-start forecasts use lognormal per-bucket priors
Before the team has n 20 closed issues with estimates, Monte Carlo samples a
**lognormal** actual-duration distribution per estimate bucket
(`1d/2d/3d/5d/8d`), with a pessimism-skewed median (actuals run long). The
priors live in code (`@commitea/core`), not in a data file. At n 20 the
scheduler switches to the team's own empirical fit (see calibration model in
[pm-state.md](./pm-state.md)); `byLabel` / `byPerson` bias terms layer on once
their own sample sizes clear a floor.
Rejected: uniform pessimism multiplier (`actual = est × U[1.3, 2.0]`) cruder,
dishonest tails, no path to per-bucket calibration.
## D4 — Purity test applies to the SQLite cache, not the pm-state repo
The plan's invariant *delete the sidecar → resync → no truth lost* is about
the **local SQLite cache**, which is a rebuildable index over two durable
sources:
- **Work repo(s) in gitea** human-authored intent (issues, milestones + due
dates, dependencies, assignees, labels, comments).
- **`pm-state` repo in gitea** the sidecar's own durable truth that is *not*
regenerable from the work repo: directive log, capacity config, charter, and
the (cached-but-committed) calibration model.
Delete SQLite rebuild from both repos nothing lost. The `pm-state` repo is
never the thing you delete; it is versioned and backed up in gitea like any
other repo. Regenerable state (issue mirror, inferred lifecycle timestamps,
Monte Carlo forecasts, focus snapshot) lives in SQLite only and is recomputed on
rebuild. See [pm-state.md](./pm-state.md) for the file/table split.

123
docs/pm-state.md Normal file
View File

@@ -0,0 +1,123 @@
# pm-state: the sidecar store
Machine-derived and PM-authored state that has no home in the work repo. Split
across two tiers per [decisions.md](./decisions.md) D4:
- **`pm-state` gitea repo** — durable, versioned truth. Committed files below.
- **local SQLite** — rebuildable cache/index. Never the source of truth.
## `pm-state` repo layout
```
charter.md durable project charter + hot-memory seed (human + agent authored)
directives/log.jsonl durable append-only directive ledger (conflict-free merge)
capacity/members.yaml durable per-person capacity model (human-set)
calibration/model.json cached fitted calibration; regenerable from actuals, committed for
reproducibility + offline forecasting
```
`forecasts/`, `focus/`, and inferred lifecycle timestamps are **not** committed
— they are SQLite-only and recomputed on rebuild (regenerable from the work repo
+ calibration model).
## Directive log — `directives/log.jsonl`
Append-only, one JSON object per line. **Merge is concatenation**: order is
derived from `ts` at read time, so two clients appending never produce a git
conflict. `id` is the durable key; `seq` is a display ordinal computed on read,
never stored (avoids the "who owns the next number" contention). v1 is
single-writer (one PM); this format is already safe for the deferred
multi-writer case.
```jsonc
{
"id": "d_01J8...", // crypto.randomUUID at write; durable identity
"ts": "2026-07-08T14:03:00Z", // ISO 8601 UTC; sole ordering key
"actor": "christian", // gitea username of the directive-giver
"kind": "reprioritize", // reprioritize | reestimate | set-deadline | scope | capacity | note
"target": { "issue": 87 }, // { issue } | { milestone } | { member } | null (project-wide)
"quote": "bump the auth bug above everything", // verbatim, shown in the ledger
"params": { "priority": 1 }, // structured effect the scheduler applies
"rationale": "pilot customer blocked", // why (optional but nagged for)
"status": "accepted" // proposed | accepted | amended | withdrawn
}
```
Lifecycle: a directive is recorded as `proposed`, the scheduler re-runs, the
agent presents the consequence diff, and the PM's response flips it to
`accepted` / `amended` / `withdrawn`. All four states stay in the ledger
(append a status-change line; never mutate a prior line).
## Capacity — `capacity/members.yaml`
Estimate unit is **ideal person-days**. Capacity is expressed in ideal
person-days available per calendar day.
```yaml
members:
- gitea: christian
focusFactor: 0.8 # productive fraction of a working day (0..1)
projectAllocation: 0.6 # share of focused time on THIS project (0..1)
workdays: [mon, tue, wed, thu, fri]
daysOff: [] # ISO dates, e.g. ["2026-07-14"]; PTO calendars deferred
# other standing slices (compliance 0.2, pilots 0.2) are documentation only —
# only projectAllocation feeds the scheduler.
```
Derived: `capacityPerWorkday = focusFactor * projectAllocation` (ideal
person-days per working day). The scheduler spreads this across `workdays`,
zeroing `daysOff`. Missing member ⇒ excluded from capacity, flagged by the agent.
## Calibration — `calibration/model.json`
Fitted from closed-issue actuals (estimate label vs inferred elapsed working
time). Lognormal on `log(actual / estimate)`.
```jsonc
{
"version": 1,
"fittedAt": "2026-07-08T00:00:00Z",
"n": 42, // closed issues with an estimate feeding the fit
"coldStart": false, // true while n < 20 → scheduler uses code priors instead
"global": { "mu": 0.166, "sigma": 0.45 }, // lognormal params on log-ratio; median ratio = e^mu ≈ 1.18
"byBucket": { // per estimate label; falls back to global when its n is thin
"1d": { "mu": 0.30, "sigma": 0.55, "n": 12 },
"2d": { "mu": 0.18, "sigma": 0.40, "n": 9 }
// 3d / 5d / 8d ...
},
"byLabel": { "backend": { "biasMu": 0.12, "n": 7 } }, // additive to mu; applied when n ≥ floor
"byPerson": { "christian": { "biasMu": -0.05, "n": 20 } }
}
```
Cold-start (`coldStart: true`, or a bucket with `n` below floor): the scheduler
ignores the file's fitted params for that axis and samples the **code-resident
lognormal priors** in `@commitea/core` (per D3). The file still records whatever
partial `n` exists so the UI's calibration teaser can show progress toward 20.
## SQLite cache (rebuildable — not committed)
Mirror + derived tables, rebuilt from both gitea repos on reconcile:
- `issues`, `labels`, `milestones`, `comments`, `issue_events` — verbatim work-repo mirror
- `lifecycle` — inferred per-issue timestamps (see below), keyed by issue
- `forecasts` — last Monte Carlo run per milestone/issue (regenerable)
- `focus` — current Now/Next/Later snapshot (regenerable)
- `directives` — indexed view of `log.jsonl` for fast querying
## Lifecycle inference
Timestamps derived from the gitea issue timeline; no manual time tracking. Maps
onto the board's five columns:
| Board column | Enter when | Source event |
|--------------|--------------------------------------------------------|-------------------------|
| Diagnosis | issue opened | `opened` |
| Triage | first label or milestone applied | `label` / `milestone` |
| Steeping | first branch or commit references the issue | `commit_ref` / branch |
| In review | a PR referencing the issue is opened | PR `opened` |
| Done | issue closed (PR merged is the deploy signal within) | `closed` / PR `merged` |
Elapsed **working** time between Steeping→Done (minus non-workdays/daysOff) is
the "actual" that feeds calibration. Re-openings append new segments; the fit
uses summed working time.

View File

@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import { createGiteaClient, normalizeIssue } from './client.js'
import { GiteaApiError, type FetchLike, type GiteaConfig, type GiteaRequestInit } from './types.js'
const CONFIG: GiteaConfig = {
baseUrl: 'https://gitea.stephenmann.io',
token: 'tok_secret',
owner: 'christian',
repo: 'commitea',
}
/** Raw gitea issue JSON as the API returns it. */
const RAW_ISSUE = {
number: 9,
title: 'Deterministic scheduler',
body: 'topo order → dated plan',
state: 'open',
labels: [{ name: 'est/5d' }, { name: 'p/1' }, { name: 'deadline/hard' }],
milestone: { id: 7, title: 'P2 — Scheduler + Monte Carlo', due_on: '2026-09-01T00:00:00Z' },
assignee: { login: 'christian' },
assignees: [{ login: 'christian' }, { login: 'stephen' }],
created_at: '2026-07-08T00:00:00Z',
updated_at: '2026-07-08T01:00:00Z',
closed_at: null,
html_url: 'https://gitea.stephenmann.io/christian/commitea/issues/9',
}
/** A stub fetch that records calls and returns a canned 200 JSON body. */
function stubFetch(body: unknown, status = 200): {
fetch: FetchLike
calls: { url: string; init?: GiteaRequestInit }[]
} {
const calls: { url: string; init?: GiteaRequestInit }[] = []
const fetch: FetchLike = (url, init) => {
calls.push({ url, init })
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
})
}
return { fetch, calls }
}
describe('createGiteaClient.getIssue', () => {
it('returns a normalized, typed issue from canned JSON', async () => {
const { fetch } = stubFetch(RAW_ISSUE)
const issue = await createGiteaClient(CONFIG, fetch).getIssue(9)
expect(issue.number).toBe(9)
expect(issue.title).toBe('Deterministic scheduler')
expect(issue.state).toBe('open')
expect(issue.labels).toEqual(['est/5d', 'p/1', 'deadline/hard'])
expect(issue.url).toBe('https://gitea.stephenmann.io/christian/commitea/issues/9')
})
it('precomputes scheduler facts via extractLabelFacts', async () => {
const { fetch } = stubFetch(RAW_ISSUE)
const issue = await createGiteaClient(CONFIG, fetch).getIssue(9)
expect(issue.facts.estimateDays).toBe(5)
expect(issue.facts.priority).toBe(1)
expect(issue.facts.hardDeadline).toBe(true)
})
it('maps the milestone ref and assignees', async () => {
const { fetch } = stubFetch(RAW_ISSUE)
const issue = await createGiteaClient(CONFIG, fetch).getIssue(9)
expect(issue.milestone).toEqual({
id: 7,
title: 'P2 — Scheduler + Monte Carlo',
dueOn: '2026-09-01T00:00:00Z',
})
expect(issue.assignee).toBe('christian')
expect(issue.assignees).toEqual(['christian', 'stephen'])
})
it('hits the configured repo path with the token auth header', async () => {
const { fetch, calls } = stubFetch(RAW_ISSUE)
await createGiteaClient(CONFIG, fetch).getIssue(9)
expect(calls).toHaveLength(1)
expect(calls[0].url).toBe(
'https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues/9',
)
expect(calls[0].init?.headers?.Authorization).toBe('token tok_secret')
})
it('tolerates a trailing slash on baseUrl', async () => {
const { fetch, calls } = stubFetch(RAW_ISSUE)
await createGiteaClient({ ...CONFIG, baseUrl: 'https://gitea.stephenmann.io/' }, fetch).getIssue(9)
expect(calls[0].url).toBe(
'https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues/9',
)
})
it('throws GiteaApiError carrying status + body on a non-2xx response', async () => {
const { fetch } = stubFetch('not found', 404)
const client = createGiteaClient(CONFIG, fetch)
await expect(client.getIssue(999)).rejects.toBeInstanceOf(GiteaApiError)
await expect(client.getIssue(999)).rejects.toMatchObject({ status: 404, body: 'not found' })
})
})
describe('normalizeIssue', () => {
it('defaults missing labels/assignees/body to empty and maps a closed state', () => {
const issue = normalizeIssue({
number: 1,
title: 'bare issue',
body: null,
state: 'closed',
labels: null,
milestone: null,
assignee: null,
assignees: null,
created_at: '2026-07-01T00:00:00Z',
updated_at: '2026-07-02T00:00:00Z',
closed_at: '2026-07-02T00:00:00Z',
html_url: 'https://gitea.stephenmann.io/christian/commitea/issues/1',
})
expect(issue.state).toBe('closed')
expect(issue.body).toBe('')
expect(issue.labels).toEqual([])
expect(issue.assignee).toBeNull()
expect(issue.assignees).toEqual([])
expect(issue.milestone).toBeNull()
expect(issue.facts.estimateDays).toBeNull()
expect(issue.closedAt).toBe('2026-07-02T00:00:00Z')
})
})

View File

@@ -0,0 +1,97 @@
/**
* Gitea read client. One method for now — `getIssue` — proving the vertical
* slice end to end: config + injected fetch → typed, normalized `GiteaIssue`
* with scheduler facts precomputed. Later reconcile work (P1-4) layers list
* reads on the same `request` seam.
*/
import { extractLabelFacts } from '../labels/label-schema.js'
import {
GiteaApiError,
type FetchLike,
type GiteaConfig,
type GiteaIssue,
type GiteaMilestoneRef,
} from './types.js'
/** The subset of gitea's raw issue JSON we read. */
interface RawLabel {
name: string
}
interface RawMilestone {
id: number
title: string
due_on: string | null
}
interface RawUser {
login: string
}
interface RawIssue {
number: number
title: string
body: string | null
state: string
labels: RawLabel[] | null
milestone: RawMilestone | null
assignee: RawUser | null
assignees: RawUser[] | null
created_at: string
updated_at: string
closed_at: string | null
html_url: string
}
export interface GiteaClient {
/** Fetch one issue by its per-repo index, normalized. */
getIssue(index: number): Promise<GiteaIssue>
}
/** Map raw gitea issue JSON to the normalized domain shape. Pure. */
export function normalizeIssue(raw: RawIssue): GiteaIssue {
const labels = (raw.labels ?? []).map((l) => l.name)
const milestone: GiteaMilestoneRef | null = raw.milestone
? { id: raw.milestone.id, title: raw.milestone.title, dueOn: raw.milestone.due_on }
: null
const assignees = (raw.assignees ?? []).map((u) => u.login)
return {
number: raw.number,
title: raw.title,
body: raw.body ?? '',
state: raw.state === 'closed' ? 'closed' : 'open',
labels,
facts: extractLabelFacts(labels),
milestone,
assignee: raw.assignee?.login ?? null,
assignees,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
closedAt: raw.closed_at,
url: raw.html_url,
}
}
export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): GiteaClient {
const apiBase = `${config.baseUrl.replace(/\/+$/, '')}/api/v1`
const repoBase = `${apiBase}/repos/${config.owner}/${config.repo}`
async function request(path: string): Promise<unknown> {
const res = await fetchImpl(`${repoBase}${path}`, {
headers: {
Authorization: `token ${config.token}`,
Accept: 'application/json',
},
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new GiteaApiError(res.status, `GET ${path} failed (${res.status})`, body)
}
return res.json()
}
return {
async getIssue(index) {
return normalizeIssue((await request(`/issues/${index}`)) as RawIssue)
},
}
}

View File

@@ -0,0 +1,79 @@
/**
* Types for the gitea read client. Deliberately minimal: `@commitea/core` is
* pure (no DOM lib, no Electron, no network), so we don't reach for the global
* `fetch`/`Response`/`RequestInit` DOM types — we define the small structural
* surface we actually use. The real `globalThis.fetch` is structurally
* assignable to `FetchLike`, so the desktop main process passes it verbatim
* while tests pass a stub.
*/
import type { LabelFacts } from '../labels/label-schema.js'
/** Connection config. `baseUrl` is the instance root (no `/api/v1`). */
export interface GiteaConfig {
/** e.g. `https://gitea.stephenmann.io` — trailing slash tolerated. */
baseUrl: string
/** Personal access token (scopes: issue/repository/user). */
token: string
owner: string
repo: string
}
export interface GiteaRequestInit {
method?: string
headers?: Record<string, string>
body?: string
}
/** The slice of a `fetch` Response we consume. */
export interface GiteaHttpResponse {
ok: boolean
status: number
json(): Promise<unknown>
text(): Promise<string>
}
export type FetchLike = (url: string, init?: GiteaRequestInit) => Promise<GiteaHttpResponse>
/** Milestone as referenced from an issue (not the full milestone resource). */
export interface GiteaMilestoneRef {
id: number
title: string
/** ISO date the milestone is due, or null. */
dueOn: string | null
}
/**
* Normalized issue — camelCase, label names flattened, scheduler-facing
* `facts` precomputed via `extractLabelFacts`. This is the domain shape the
* rest of CommiTea works with; raw gitea JSON never escapes this module.
*/
export interface GiteaIssue {
number: number
title: string
body: string
state: 'open' | 'closed'
labels: string[]
facts: LabelFacts
milestone: GiteaMilestoneRef | null
/** Primary assignee username, or null. */
assignee: string | null
/** All assignee usernames (includes the primary). */
assignees: string[]
createdAt: string
updatedAt: string
closedAt: string | null
url: string
}
/** Thrown on a non-2xx gitea response; carries the status + raw body. */
export class GiteaApiError extends Error {
constructor(
readonly status: number,
message: string,
readonly body?: string,
) {
super(message)
this.name = 'GiteaApiError'
}
}

View File

@@ -8,3 +8,15 @@ export {
parsePriorityLabel,
} from './labels/label-schema.js'
export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-schema.js'
export { createGiteaClient, normalizeIssue } from './gitea/client.js'
export type { GiteaClient } from './gitea/client.js'
export { GiteaApiError } from './gitea/types.js'
export type {
FetchLike,
GiteaConfig,
GiteaHttpResponse,
GiteaIssue,
GiteaMilestoneRef,
GiteaRequestInit,
} from './gitea/types.js'

View File

@@ -241,6 +241,7 @@ __metadata:
resolution: "@commitea/desktop@workspace:apps/desktop"
dependencies:
"@commitea/core": "workspace:*"
"@playwright/test": "npm:^1.61.1"
"@types/node": "npm:^22.13.1"
"@types/react": "npm:^18.3.18"
"@types/react-dom": "npm:^18.3.5"
@@ -720,6 +721,17 @@ __metadata:
languageName: node
linkType: hard
"@playwright/test@npm:^1.61.1":
version: 1.61.1
resolution: "@playwright/test@npm:1.61.1"
dependencies:
playwright: "npm:1.61.1"
bin:
playwright: cli.js
checksum: 10c0/45004139eaa7c7ec8129e4ed77a9d734aa09ed40b69162b871e4123f1d70217b7b73651431a9343ed5090d5fed11c6df1bff2a56ff4b87dc7b0371b5da87cf9c
languageName: node
linkType: hard
"@rolldown/pluginutils@npm:1.0.0-beta.27":
version: 1.0.0-beta.27
resolution: "@rolldown/pluginutils@npm:1.0.0-beta.27"
@@ -1890,6 +1902,16 @@ __metadata:
languageName: node
linkType: hard
"fsevents@npm:2.3.2":
version: 2.3.2
resolution: "fsevents@npm:2.3.2"
dependencies:
node-gyp: "npm:latest"
checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b
conditions: os=darwin
languageName: node
linkType: hard
"fsevents@npm:~2.3.2, fsevents@npm:~2.3.3":
version: 2.3.3
resolution: "fsevents@npm:2.3.3"
@@ -1900,6 +1922,15 @@ __metadata:
languageName: node
linkType: hard
"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>":
version: 2.3.2
resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>::version=2.3.2&hash=df0bf1"
dependencies:
node-gyp: "npm:latest"
conditions: os=darwin
languageName: node
linkType: hard
"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin<compat/fsevents>, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin<compat/fsevents>":
version: 2.3.3
resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin<compat/fsevents>::version=2.3.3&hash=df0bf1"
@@ -2458,6 +2489,30 @@ __metadata:
languageName: node
linkType: hard
"playwright-core@npm:1.61.1":
version: 1.61.1
resolution: "playwright-core@npm:1.61.1"
bin:
playwright-core: cli.js
checksum: 10c0/c28896ba82a602182e240ed4f9c467fb0dd9cb57d510f8aba9f25183fe1cde3a0105725a29baffa4157fe885bbb11e228032a2aad0424111584fde45303f07bd
languageName: node
linkType: hard
"playwright@npm:1.61.1":
version: 1.61.1
resolution: "playwright@npm:1.61.1"
dependencies:
fsevents: "npm:2.3.2"
playwright-core: "npm:1.61.1"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
checksum: 10c0/cea1bc4d2a64ec3ef683891606774029da7b8a8036ed98332565cec2f8e89549ca1fab9a89fbfba4e08e27e0d7e7d148031e965af66d5ff97bb3c34058cfcad0
languageName: node
linkType: hard
"postcss-import@npm:^15.1.0":
version: 15.1.0
resolution: "postcss-import@npm:15.1.0"