- 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>
186 lines
9.4 KiB
Plaintext
186 lines
9.4 KiB
Plaintext
// 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 3–12 → Mar 5–14</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 });
|