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>
This commit is contained in:
Croissant Le Doux
2026-07-08 12:32:05 -04:00
parent 635025113c
commit f18c6d1078
10 changed files with 802 additions and 45 deletions

View File

@@ -15,13 +15,19 @@ const SCREENS_DIR = join(here, '..', '.artifacts', 'screens')
export class AppPage {
constructor(readonly page: Page) {}
get heading(): Locator {
return this.page.getByRole('heading', { name: 'CommiTea' })
/** 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. */
nav(label: string): Locator {
return this.page.getByRole('button', { name: label, exact: true })
}
/** Assert the shell has rendered. */
async expectLoaded(): Promise<void> {
await expect(this.heading).toBeVisible()
await expect(this.wordmark).toBeVisible()
}
/** Read the `commitea` preload API surface from the renderer. */

View File

@@ -1,33 +1,50 @@
import { expect, test } from './fixtures.js'
test('boots and renders the primitives gallery', async ({ app, window }) => {
await expect(window.getByRole('heading', { name: 'Primitives' })).toBeVisible()
await expect(window.getByRole('button', { name: 'Brew plan' })).toBeVisible()
await app.screenshot('primitives-light')
test('boots into the shell: rail, main, and Reginald panel', async ({ app, window }) => {
await app.expectLoaded()
await expect(window.getByText('Reginald', { exact: true })).toBeVisible()
// default view is Morning service
await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible()
await app.screenshot('shell-light')
})
test('primitives cover core, forms, and feedback', async ({ window }) => {
// section headings prove each cluster ported and rendered
for (const section of ['Button', 'Badge', 'Tag', 'Card', 'Input & Select', 'Feedback']) {
await expect(window.getByRole('heading', { name: section, exact: true })).toBeVisible()
}
// a mono label chip renders verbatim
await expect(window.getByText('deadline/hard')).toBeVisible()
test('rail navigation switches the main view', async ({ app, window }) => {
await app.nav('The pot').click()
await expect(window.getByRole('heading', { name: 'The pot' })).toBeVisible()
await app.nav('States').click()
await expect(window.getByRole('heading', { name: 'States' })).toBeVisible()
await expect(window.getByText('The pot is empty')).toBeVisible()
await app.screenshot('shell-states')
})
test('dark theme toggle flips the document theme', async ({ app, window }) => {
await window.getByRole('button', { name: 'Switch to dark' }).click()
test('Evening service toggle flips the document theme', async ({ app, window }) => {
// the switch's real input is visually hidden — click the label text to toggle
await window.getByText('Evening service', { exact: true }).click()
await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark')
await app.screenshot('primitives-dark')
await expect(window.getByRole('switch', { name: 'Evening service' })).toBeChecked()
await app.screenshot('shell-dark')
})
test('dialog opens on demand and closes on Escape', async ({ window }) => {
await window.getByRole('button', { name: 'Open dialog' }).click()
const dialog = window.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(dialog.getByText('Withdraw directive?')).toBeVisible()
await window.keyboard.press('Escape')
await expect(dialog).toBeHidden()
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('chat composer echoes a canned reply (fixture)', async ({ window }) => {
await window.getByRole('textbox').fill('Push pilots first')
await window.keyboard.press('Enter')
await expect(window.getByText('Push pilots first')).toBeVisible()
await expect(window.getByText(/Noted and logged as a directive/)).toBeVisible()
})
test('issue drill-in and back-stack of one', async ({ window }) => {
await window.getByRole('button', { name: 'Preview an issue page' }).click()
await expect(window.getByRole('heading', { name: 'Issue' })).toBeVisible()
await window.getByRole('button', { name: 'Back' }).click()
// returns to the view we drilled in from
await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible()
})
test('exposes the commitea preload API', async ({ app }) => {

View File

@@ -1,5 +1,5 @@
import { PrimitivesGallery } from './components/gallery.js'
import { AppShell } from './components/shell/app-shell.js'
export function App() {
return <PrimitivesGallery />
return <AppShell />
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'
import React, { useState } from 'react'
import {
Badge,
@@ -33,31 +33,20 @@ function Section({ title, children }: { title: string; children: React.ReactNode
* themes via the toggle.
*/
export function PrimitivesGallery() {
const [dark, setDark] = useState(false)
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)
useEffect(() => {
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
}, [dark])
return (
<main className="min-h-screen bg-paper-0 font-sans text-ink-1">
<div className="mx-auto max-w-[1120px] px-7 py-10">
<header className="flex items-baseline justify-between border-b-[3px] border-double border-ink-1 pb-4">
<div>
<h1 className="font-display text-4xl">Primitives</h1>
<p className="mt-1 font-mono text-sm text-ink-3">15 components · light + dark</p>
</div>
<IconButton
icon={dark ? 'sun' : 'moon'}
label={dark ? 'Switch to light' : 'Switch to dark'}
variant="outline"
onClick={() => setDark((d) => !d)}
/>
<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">

View File

@@ -0,0 +1,278 @@
import React, { useEffect, useState } from 'react'
import logoIcon from '../../design/assets/logo-icon.png'
import { PrimitivesGallery } from '../gallery.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)
useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
}, [dark])
const openIssue = (n: number) => {
if (view !== 'issue') setPrevView(view)
void n
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 'states':
return <StatesScreen onCapture={() => setView('capture')} />
case 'primitives':
return <PrimitivesGallery />
case 'issue':
return (
<div>
<button
type="button"
onClick={() => setView(prevView)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
font: 'var(--text-small)',
color: 'var(--ink-2)',
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
marginBottom: 12,
}}
>
<Icon name="arrow-left" size={15} /> Back
</button>
<PlaceholderScreen title={TITLE.issue!} phase={PHASE.issue!} />
</div>
)
default:
return (
<PlaceholderScreen
title={TITLE[view] ?? view}
phase={PHASE[view] ?? 'a later phase'}
onOpenIssue={() => openIssue(1)}
/>
)
}
}
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,26 @@
/**
* 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.'

View File

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