Scaffold CommiTea: yarn workspaces, Electron shell, core label schema, design system

- apps/desktop: electron-vite + React + Tailwind mapped onto design tokens
  (preflight off; tokens/base.css owns the reset); boots to a Reginald
  placeholder proving fonts/tokens/core wiring
- packages/core: pure TS; gitea label schema (est/*, p/*, deadline/hard)
  with pessimistic conflict resolution + 15 unit tests
- docs/design: full design handoff (tokens, 16 component contracts,
  interactive 14-screen prototype, Reginald voice rules)
- docs/PLAN.md: product plan (purity rule, pm-state repo, deterministic
  scheduler + Monte Carlo, directive log)
- Deliberate deviation from novelpad stack: no ElectricSQL/PGlite — local
  store is a rebuildable cache over gitea REST/webhooks (better-sqlite3
  in main process, arriving in P1)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Christian LeDoux
2026-07-07 20:42:46 -04:00
commit 7a5cacc54c
268 changed files with 15766 additions and 0 deletions

View File

@@ -0,0 +1,185 @@
// Capture interview — braindump → interview → approved ticket set (< 2 min)
function CaptureScreen({ onDone }) {
const DS = window.CommiTeaDesignSystem_20e63b;
const { Button, Card, Tag, Badge, Select, Icon } = DS;
const [stage, setStage] = React.useState('dump'); // dump | interview | review | filed
const [dump, setDump] = React.useState(
'auth is flaky \u2014 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([]);
const [split, setSplit] = React.useState(null);
const [webEst, setWebEst] = React.useState(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 = [
{ q: 'The auth work \u2014 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 \u2014 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) => {
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 = [];
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 }) => (
<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) => 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 \u00b7 ${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 \u00b7 ~${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>
);
}
Object.assign(window, { CaptureScreen });