- 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>
96 lines
4.5 KiB
Plaintext
96 lines
4.5 KiB
Plaintext
// Inbox — Reginald only rings the bell when it matters
|
|
function InboxScreen({ onOpenIssue, onOpenDirectives, readIds, setReadIds }) {
|
|
const DS = window.CommiTeaDesignSystem_20e63b;
|
|
const { Card, Tabs, Button, Icon } = DS;
|
|
const all = window.CT_DATA.inbox;
|
|
const [tab, setTab] = React.useState('all');
|
|
|
|
const isRead = (n) => !n.unread || readIds.includes(n.id);
|
|
const unreadCount = all.filter((n) => !isRead(n)).length;
|
|
|
|
const FILTERS = {
|
|
all: () => true,
|
|
mentions: (n) => n.type === 'mention' || n.type === 'assignment',
|
|
drift: (n) => n.type === 'drift' || n.type === 'nag' || n.type === 'milestone',
|
|
system: (n) => n.type === 'system' || n.type === 'review',
|
|
};
|
|
const items = all.filter(FILTERS[tab]);
|
|
const days = [...new Set(items.map((n) => n.day))];
|
|
|
|
const toneColor = { ok: 'var(--ok)', warn: 'var(--warn)', info: 'var(--info)', neutral: 'var(--ink-3)' };
|
|
|
|
const open = (n) => {
|
|
setReadIds((r) => (r.includes(n.id) ? r : [...r, n.id]));
|
|
if (n.issue) onOpenIssue(n.issue);
|
|
else if (n.to === 'directives') onOpenDirectives();
|
|
};
|
|
|
|
return (
|
|
<div style={{ maxWidth: 760, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
<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 }}>Inbox</h1>
|
|
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>
|
|
{unreadCount ? `${unreadCount} unread` : 'all read'} · nothing here rings twice
|
|
</p>
|
|
</div>
|
|
{unreadCount ? (
|
|
<Button variant="ghost" size="sm" onClick={() => setReadIds(all.map((n) => n.id))}>Mark all read</Button>
|
|
) : null}
|
|
</header>
|
|
|
|
<Tabs
|
|
items={[
|
|
{ id: 'all', label: 'All', count: all.length },
|
|
{ id: 'mentions', label: 'Mentions', icon: 'message-square' },
|
|
{ id: 'drift', label: 'Drift', icon: 'chart-line' },
|
|
{ id: 'system', label: 'System', icon: 'refresh-cw' },
|
|
]}
|
|
active={tab} onChange={setTab}
|
|
/>
|
|
|
|
<Card flush>
|
|
<div>
|
|
{days.map((day) => (
|
|
<div key={day}>
|
|
<div style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', padding: '12px 20px 4px' }}>{day}</div>
|
|
{items.filter((n) => n.day === day).map((n) => {
|
|
const read = isRead(n);
|
|
return (
|
|
<div key={n.id}
|
|
onClick={() => open(n)}
|
|
style={{
|
|
display: 'flex', alignItems: 'flex-start', gap: 12, padding: '11px 20px',
|
|
cursor: n.issue || n.to ? 'pointer' : 'default',
|
|
opacity: read ? 0.72 : 1,
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--paper-2)'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
|
>
|
|
<span style={{ width: 6, height: 6, borderRadius: '50%', background: read ? 'transparent' : 'var(--accent)', flexShrink: 0, marginTop: 7 }}></span>
|
|
<span style={{ color: toneColor[n.tone], display: 'inline-flex', marginTop: 1, flexShrink: 0 }}>
|
|
<Icon name={n.icon} size={15} />
|
|
</span>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ font: read ? 'var(--text-body)' : 'var(--text-body-strong)', color: 'var(--ink-1)' }}>
|
|
{n.who ? <span style={{ fontWeight: 600 }}>{n.who} </span> : null}{n.text}
|
|
</div>
|
|
<div style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)', marginTop: 2 }}>{n.detail}</div>
|
|
</div>
|
|
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap', marginTop: 2 }}>{n.time}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
|
|
I only ring the bell when it matters. The rest can wait for morning service.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
Object.assign(window, { InboxScreen });
|