CommiTea foundation + P3 UI: gitea client, e2e harness, all product screens #35
@@ -82,6 +82,42 @@ test('Capture: braindump advances to the interview', async ({ app, window }) =>
|
|||||||
await expect(window.getByText(/Interview · 1 of 3/)).toBeVisible()
|
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 }) => {
|
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 window.getByRole('link', { name: 'Fix lifecycle inference on merge events' }).click()
|
||||||
await expect(window.getByText('#87 · stephen/commitea')).toBeVisible()
|
await expect(window.getByText('#87 · stephen/commitea')).toBeVisible()
|
||||||
|
|||||||
@@ -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 { BoardScreen } from '../screens/board-screen.js'
|
||||||
import { CalibrationScreen } from '../screens/calibration-screen.js'
|
import { CalibrationScreen } from '../screens/calibration-screen.js'
|
||||||
import { CaptureScreen } from '../screens/capture-screen.js'
|
import { CaptureScreen } from '../screens/capture-screen.js'
|
||||||
|
import { DirectivesScreen } from '../screens/directives-screen.js'
|
||||||
import { FocusScreen } from '../screens/focus-screen.js'
|
import { FocusScreen } from '../screens/focus-screen.js'
|
||||||
import { InboxScreen } from '../screens/inbox-screen.js'
|
import { InboxScreen } from '../screens/inbox-screen.js'
|
||||||
import { IssueScreen } from '../screens/issue-screen.js'
|
import { IssueScreen } from '../screens/issue-screen.js'
|
||||||
import { MilestoneScreen } from '../screens/milestone-screen.js'
|
import { MilestoneScreen } from '../screens/milestone-screen.js'
|
||||||
|
import { OnboardingScreen } from '../screens/onboarding-screen.js'
|
||||||
import { RunwayScreen } from '../screens/runway-screen.js'
|
import { RunwayScreen } from '../screens/runway-screen.js'
|
||||||
|
import { SettingsScreen } from '../screens/settings-screen.js'
|
||||||
import { StandupScreen } from '../screens/standup-screen.js'
|
import { StandupScreen } from '../screens/standup-screen.js'
|
||||||
import { Icon, Switch } from '../ui/index.js'
|
import { Icon, Switch } from '../ui/index.js'
|
||||||
import { ChatPanel } from './chat-panel.js'
|
import { ChatPanel } from './chat-panel.js'
|
||||||
@@ -177,6 +180,10 @@ export function AppShell() {
|
|||||||
)
|
)
|
||||||
case 'capture':
|
case 'capture':
|
||||||
return <CaptureScreen onDone={() => setView('focus')} />
|
return <CaptureScreen onDone={() => setView('focus')} />
|
||||||
|
case 'directives':
|
||||||
|
return <DirectivesScreen />
|
||||||
|
case 'settings':
|
||||||
|
return <SettingsScreen dark={dark} setDark={setDark} />
|
||||||
case 'issue':
|
case 'issue':
|
||||||
return issue ? (
|
return issue ? (
|
||||||
<IssueScreen issue={issue} onBack={() => setView(prevView)} onOpenIssue={openIssue} />
|
<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 (
|
return (
|
||||||
<div
|
<div
|
||||||
data-screen-label={`app-${view}`}
|
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.',
|
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