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>
This commit is contained in:
@@ -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 5–14' : '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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -6,11 +6,14 @@ 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'
|
||||
@@ -177,6 +180,10 @@ export function AppShell() {
|
||||
)
|
||||
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} />
|
||||
@@ -190,6 +197,11 @@ export function AppShell() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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}`}
|
||||
|
||||
@@ -472,3 +472,57 @@ export const ISSUE_DETAIL: Record<number, IssueDetail> = {
|
||||
note: 'It blocks #91 and #92. I’d 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 3–12', to: 'Mar 5–14' },
|
||||
{ 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' },
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user