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,12 @@
/**
* Status pill — muted tint background, colored text. For state words like "ahead", "behind", "steeping".
*/
export interface BadgeProps {
/** @default 'neutral' */
tone?: 'ok' | 'warn' | 'danger' | 'info' | 'neutral' | 'jade';
/** Leading status dot @default false */
dot?: boolean;
children?: React.ReactNode;
style?: React.CSSProperties;
}
export declare function Badge(props: BadgeProps): JSX.Element;

View File

@@ -0,0 +1,32 @@
import React from 'react';
const CSS = `
.ct-badge {
display: inline-flex; align-items: center; gap: 6px;
font: 500 12px/1 var(--font-sans);
padding: 4px 9px;
border-radius: var(--radius-round);
}
.ct-badge__dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.ct-badge--ok { background: var(--ok-tint); color: var(--ok); }
.ct-badge--warn { background: var(--warn-tint); color: var(--warn); }
.ct-badge--danger { background: var(--danger-tint); color: var(--danger); }
.ct-badge--info { background: var(--info-tint); color: var(--info); }
.ct-badge--neutral { background: var(--paper-2); color: var(--ink-2); }
.ct-badge--jade { background: var(--jade-tint); color: var(--jade-7); }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-badge-css')) {
const s = document.createElement('style'); s.id = 'ct-badge-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Badge({ tone = 'neutral', dot = false, children, style, ...rest }) {
return (
<span className={`ct-badge ct-badge--${tone}`} style={style} {...rest}>
{dot ? <span className="ct-badge__dot"></span> : null}
{children}
</span>
);
}

View File

@@ -0,0 +1,9 @@
Status pill for state words (ahead / behind / steeping / blocked); sans text on a muted tint.
```jsx
<Badge tone="ok" dot>ahead</Badge>
<Badge tone="warn" dot>drifting</Badge>
<Badge tone="danger">blocked</Badge>
```
Tones: ok, warn, danger, info, neutral, jade (jade = agent/forecast flavor). Use `Tag` instead for verbatim gitea labels like `est/3d`.

View File

@@ -0,0 +1,18 @@
/**
* CommiTea button. Plain verbs only ("Approve", "Brew plan", "Defer") — never witty.
*/
export interface ButtonProps {
/** @default 'primary' */
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
/** @default 'md' */
size?: 'sm' | 'md';
/** Lucide icon name rendered before the label */
icon?: string;
/** Lucide icon name rendered after the label */
iconRight?: string;
disabled?: boolean;
onClick?: (e: React.MouseEvent) => void;
children?: React.ReactNode;
style?: React.CSSProperties;
}
export declare function Button(props: ButtonProps): JSX.Element;

View File

@@ -0,0 +1,63 @@
import React from 'react';
const CSS = `
.ct-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
font: 600 14px/1 var(--font-sans);
border-radius: var(--radius-2);
border: 1px solid transparent;
cursor: pointer;
white-space: nowrap;
transition: background var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
}
.ct-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.ct-btn--md { height: 34px; padding: 0 14px; }
.ct-btn--sm { height: 28px; padding: 0 10px; font-size: 13px; }
.ct-btn--primary { background: var(--accent); color: var(--text-on-accent); }
.ct-btn--primary:hover:not(:disabled) { background: var(--accent-hover); }
.ct-btn--primary:active:not(:disabled) { background: var(--accent-pressed); }
.ct-btn--secondary { background: var(--surface-card); border-color: var(--line-2); color: var(--ink-1); }
.ct-btn--secondary:hover:not(:disabled) { background: var(--paper-2); }
.ct-btn--secondary:active:not(:disabled) { background: var(--paper-3); }
.ct-btn--ghost { background: transparent; color: var(--ink-2); }
.ct-btn--ghost:hover:not(:disabled) { background: var(--paper-2); color: var(--ink-1); }
.ct-btn--ghost:active:not(:disabled) { background: var(--paper-3); }
.ct-btn--danger { background: var(--danger); color: var(--ink-inverse); }
.ct-btn--danger:hover:not(:disabled) { filter: brightness(0.92); }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-button-css')) {
const s = document.createElement('style'); s.id = 'ct-button-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
import { Icon } from './Icon';
export function Button({
variant = 'primary',
size = 'md',
icon,
iconRight,
disabled = false,
children,
style,
...rest
}) {
const iconSize = size === 'sm' ? 15 : 17;
return (
<button
type="button"
className={`ct-btn ct-btn--${size} ct-btn--${variant}`}
disabled={disabled}
style={style}
{...rest}
>
{icon ? <Icon name={icon} size={iconSize} /> : null}
{children}
{iconRight ? <Icon name={iconRight} size={iconSize} /> : null}
</button>
);
}

View File

@@ -0,0 +1,10 @@
Button for all actions; labels are plain sentence-case verbs — the wit lives elsewhere.
```jsx
<Button onClick={approve}>Approve</Button>
<Button variant="secondary" icon="sparkles">Brew plan</Button>
<Button variant="ghost" size="sm">Defer</Button>
<Button variant="danger">Delete milestone</Button>
```
Variants: `primary` (spruce fill — one per view), `secondary` (hairline outline), `ghost` (bare), `danger` (madder red, destructive confirms only). Sizes `md` 34px / `sm` 28px. `icon`/`iconRight` take Lucide names.

View File

@@ -0,0 +1,21 @@
/**
* Paper card — hairline border, 10px radius, whispered shadow. `jade` adds the jade top rule
* reserved for the view's key card (e.g. the Focus card).
*/
export interface CardProps {
/** Caslon title */
title?: React.ReactNode;
/** Small uppercase label above the title */
overline?: string;
/** Right-aligned header actions (IconButtons) */
actions?: React.ReactNode;
/** Footer row below a hairline */
footer?: React.ReactNode;
/** Jade top rule — one per view @default false */
jade?: boolean;
/** Remove body padding (tables, charts) @default false */
flush?: boolean;
children?: React.ReactNode;
style?: React.CSSProperties;
}
export declare function Card(props: CardProps): JSX.Element;

View File

@@ -0,0 +1,58 @@
import React from 'react';
const CSS = `
.ct-card {
background: var(--surface-card);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-3);
box-shadow: var(--shadow-1);
}
.ct-card--jade { box-shadow: var(--shadow-jade-line), var(--shadow-1); }
.ct-card__header {
display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
padding: 16px 20px 0;
}
.ct-card__title {
font: var(--text-title);
color: var(--ink-1);
margin: 0;
}
.ct-card__overline {
font: var(--text-overline);
letter-spacing: var(--letter-spacing-wide);
text-transform: uppercase;
color: var(--ink-3);
margin: 0 0 6px;
}
.ct-card__body { padding: 14px 20px 18px; }
.ct-card__body--flush { padding: 0; }
.ct-card__footer {
display: flex; align-items: center; gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--border-hairline);
}
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-card-css')) {
const s = document.createElement('style'); s.id = 'ct-card-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Card({ title, overline, actions, footer, jade = false, flush = false, children, style, ...rest }) {
return (
<section className={`ct-card${jade ? ' ct-card--jade' : ''}`} style={style} {...rest}>
{(title || overline || actions) ? (
<header className="ct-card__header">
<div>
{overline ? <p className="ct-card__overline">{overline}</p> : null}
{title ? <h2 className="ct-card__title">{title}</h2> : null}
</div>
{actions ? <div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>{actions}</div> : null}
</header>
) : null}
<div className={`ct-card__body${flush ? ' ct-card__body--flush' : ''}`}>{children}</div>
{footer ? <footer className="ct-card__footer">{footer}</footer> : null}
</section>
);
}

View File

@@ -0,0 +1,11 @@
Paper card container; title is set in Caslon, elevation is whispered.
```jsx
<Card overline="Now" title="Fix lifecycle inference" jade
actions={<IconButton icon="ellipsis" label="More" size="sm" />}
footer={<Button size="sm">Start</Button>}>
Body content.
</Card>
```
`jade` (jade top rule) marks the single most important card in a view. `flush` removes body padding for tables/charts.

View File

@@ -0,0 +1,15 @@
/**
* Inline Lucide icon (1.5px stroke), tinted by currentColor.
*/
export interface IconProps {
/** Lucide icon name, e.g. "circle-dot", "sparkles", "git-branch" */
name: string;
/** Square size in px. 16 inline, 1820 in buttons/nav. @default 16 */
size?: number;
/** @default 1.5 */
strokeWidth?: number;
/** Accessible label; omit for decorative icons */
title?: string;
style?: React.CSSProperties;
}
export declare function Icon(props: IconProps): JSX.Element;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,8 @@
Inline SVG icon from the local Lucide set (69 icons, 1.5px stroke); color inherits currentColor.
```jsx
<Icon name="circle-dot" />
<Icon name="sparkles" size={18} style={{ color: 'var(--jade)' }} />
```
Domain mapping: issues circle-dot · milestones milestone · board square-kanban · forecast chart-line · deps network · agent sparkles · standup coffee · capacity gauge · directives flag. ICON_NAMES export lists all available names. Icons are decorative by default (aria-hidden); pass title for meaningful ones.

View File

@@ -0,0 +1,17 @@
/**
* Square icon-only button; label is mandatory (becomes aria-label + title).
*/
export interface IconButtonProps {
/** Lucide icon name */
icon: string;
/** Required accessible label (also the tooltip) */
label: string;
/** @default 'ghost' */
variant?: 'ghost' | 'outline';
/** @default 'md' */
size?: 'sm' | 'md';
disabled?: boolean;
onClick?: (e: React.MouseEvent) => void;
style?: React.CSSProperties;
}
export declare function IconButton(props: IconButtonProps): JSX.Element;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Icon } from './Icon';
const CSS = `
.ct-iconbtn {
display: inline-flex; align-items: center; justify-content: center;
border-radius: var(--radius-2);
border: 1px solid transparent;
background: transparent;
color: var(--ink-2);
cursor: pointer;
transition: background var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out);
}
.ct-iconbtn:hover:not(:disabled) { background: var(--paper-2); color: var(--ink-1); }
.ct-iconbtn:active:not(:disabled) { background: var(--paper-3); }
.ct-iconbtn:disabled { opacity: 0.45; cursor: not-allowed; }
.ct-iconbtn--md { width: 34px; height: 34px; }
.ct-iconbtn--sm { width: 28px; height: 28px; }
.ct-iconbtn--outline { border-color: var(--line-2); background: var(--surface-card); }
.ct-iconbtn--outline:hover:not(:disabled) { background: var(--paper-2); }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-iconbtn-css')) {
const s = document.createElement('style'); s.id = 'ct-iconbtn-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function IconButton({
icon,
label,
variant = 'ghost',
size = 'md',
disabled = false,
style,
...rest
}) {
return (
<button
type="button"
className={`ct-iconbtn ct-iconbtn--${size}${variant === 'outline' ? ' ct-iconbtn--outline' : ''}`}
aria-label={label}
title={label}
disabled={disabled}
style={style}
{...rest}
>
<Icon name={icon} size={size === 'sm' ? 15 : 17} />
</button>
);
}

View File

@@ -0,0 +1,8 @@
Icon-only square button for toolbars and row actions; `label` is required and doubles as the tooltip.
```jsx
<IconButton icon="ellipsis" label="More actions" />
<IconButton icon="refresh-cw" label="Reconcile now" variant="outline" size="sm" />
```
Variants: `ghost` (default), `outline`. Sizes `md` 34px / `sm` 28px.

View File

@@ -0,0 +1,19 @@
/**
* Underline tabs for view switching (Board / Gantt / Dependencies).
*/
export interface TabItem {
id: string;
label: string;
/** Lucide icon name */
icon?: string;
/** Mono count rendered after the label */
count?: number;
}
export interface TabsProps {
items: TabItem[];
/** id of the active tab */
active: string;
onChange?: (id: string) => void;
style?: React.CSSProperties;
}
export declare function Tabs(props: TabsProps): JSX.Element;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { Icon } from './Icon';
const CSS = `
.ct-tabs {
display: flex; gap: 2px;
border-bottom: 1px solid var(--border-hairline);
}
.ct-tab {
display: inline-flex; align-items: center; gap: 7px;
font: 500 13.5px/1 var(--font-sans);
color: var(--ink-2);
background: none; border: none;
padding: 10px 12px;
margin-bottom: -1px;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color var(--duration-fast) var(--ease-out);
}
.ct-tab:hover { color: var(--ink-1); }
.ct-tab--active {
color: var(--ink-1);
font-weight: 600;
border-bottom-color: var(--accent);
}
.ct-tab__count { font: 400 11.5px/1 var(--font-mono); color: var(--ink-3); }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-tabs-css')) {
const s = document.createElement('style'); s.id = 'ct-tabs-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Tabs({ items, active, onChange, style, ...rest }) {
return (
<div className="ct-tabs" role="tablist" style={style} {...rest}>
{items.map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={item.id === active}
className={`ct-tab${item.id === active ? ' ct-tab--active' : ''}`}
onClick={() => onChange && onChange(item.id)}
>
{item.icon ? <Icon name={item.icon} size={15} /> : null}
{item.label}
{item.count != null ? <span className="ct-tab__count">{item.count}</span> : null}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,13 @@
Underline tab strip for switching sibling views; active tab gets a 2px spruce underline.
```jsx
<Tabs
items={[
{ id: 'board', label: 'Board', icon: 'square-kanban', count: 24 },
{ id: 'gantt', label: 'Gantt', icon: 'chart-no-axes-gantt' },
{ id: 'deps', label: 'Dependencies', icon: 'network' },
]}
active={view}
onChange={setView}
/>
```

View File

@@ -0,0 +1,12 @@
/**
* Gitea label chip — renders label text verbatim in mono; tone derives from the label itself
* (est/[1d 2d 3d 5d 8d], p/1..4, deadline/hard).
*/
export interface TagProps {
/** Verbatim gitea label, e.g. "est/3d", "p/1", "deadline/hard" */
label: string;
/** Renders a small remove button when provided */
onRemove?: (e: React.MouseEvent) => void;
style?: React.CSSProperties;
}
export declare function Tag(props: TagProps): JSX.Element;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { Icon } from './Icon';
const CSS = `
.ct-tag {
display: inline-flex; align-items: center; gap: 5px;
font: 500 11.5px/1 var(--font-mono);
letter-spacing: var(--letter-spacing-label);
padding: 4px 9px;
border-radius: var(--radius-round);
white-space: nowrap;
}
.ct-tag--est { background: var(--label-est-bg); color: var(--label-est-text); }
.ct-tag--p1 { background: var(--danger-tint); color: var(--label-p1); }
.ct-tag--p2 { background: var(--warn-tint); color: var(--label-p2); }
.ct-tag--p3 { background: var(--info-tint); color: var(--label-p3); }
.ct-tag--p4 { background: var(--paper-2); color: var(--label-p4); }
.ct-tag--hard { background: var(--label-hard); color: var(--ink-inverse); }
.ct-tag--plain { background: var(--paper-2); color: var(--ink-2); }
.ct-tag__x {
display: inline-flex; padding: 0; margin: 0 -3px 0 0;
background: none; border: none; color: inherit; cursor: pointer; opacity: 0.6;
}
.ct-tag__x:hover { opacity: 1; }
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-tag-css')) {
const s = document.createElement('style'); s.id = 'ct-tag-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
function toneFor(label) {
if (label.startsWith('est/')) return 'est';
if (label === 'p/1') return 'p1';
if (label === 'p/2') return 'p2';
if (label === 'p/3') return 'p3';
if (label === 'p/4') return 'p4';
if (label === 'deadline/hard') return 'hard';
return 'plain';
}
export function Tag({ label, onRemove, style, ...rest }) {
return (
<span className={`ct-tag ct-tag--${toneFor(label)}`} style={style} {...rest}>
{label}
{onRemove ? (
<button type="button" className="ct-tag__x" aria-label={`Remove ${label}`} onClick={onRemove}>
<Icon name="x" size={11} strokeWidth={2} />
</button>
) : null}
</span>
);
}

View File

@@ -0,0 +1,10 @@
Gitea label chip; pass the label verbatim and the tone is derived (est/* neutral, p/1 red → p/4 gray, deadline/hard filled red).
```jsx
<Tag label="est/3d" />
<Tag label="p/1" />
<Tag label="deadline/hard" />
<Tag label="est/5d" onRemove={remove} />
```
Machine data stays mono and verbatim — never paraphrase a label. Use `Badge` for human state words.

View File

@@ -0,0 +1,75 @@
<!-- handoff copy (card tag removed) -->
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../../styles.css">
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="../../_ds_bundle.js"></script>
<style>
body { padding: 18px 22px; }
.demo-grid { display: flex; flex-direction: column; gap: 14px; }
.demo-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { Button, IconButton, Badge, Tag, Card, Tabs, Icon } = window.CommiTeaDesignSystem_20e63b;
function Demo() {
const [tab, setTab] = React.useState('board');
return (
<div className="demo-grid">
<div className="demo-row">
<Button>Approve</Button>
<Button variant="secondary" icon="sparkles">Brew plan</Button>
<Button variant="ghost">Defer</Button>
<Button variant="danger" size="sm">Delete</Button>
<Button disabled>Approve</Button>
<Button size="sm" iconRight="arrow-right">Next</Button>
<IconButton icon="ellipsis" label="More" />
<IconButton icon="refresh-cw" label="Reconcile" variant="outline" size="sm" />
</div>
<div className="demo-row">
<Badge tone="ok" dot>ahead</Badge>
<Badge tone="warn" dot>drifting</Badge>
<Badge tone="danger">blocked</Badge>
<Badge tone="info">syncing</Badge>
<Badge tone="jade">forecast</Badge>
<Badge>neutral</Badge>
<Tag label="est/3d" />
<Tag label="p/1" />
<Tag label="p/3" />
<Tag label="deadline/hard" />
<Tag label="est/5d" onRemove={() => {}} />
</div>
<Tabs
items={[
{ id: 'board', label: 'Board', icon: 'square-kanban', count: 24 },
{ id: 'gantt', label: 'Gantt', icon: 'chart-no-axes-gantt' },
{ id: 'deps', label: 'Dependencies', icon: 'network', count: 7 },
]}
active={tab}
onChange={setTab}
/>
<Card overline="Now" title="Fix lifecycle inference" jade
actions={<IconButton icon="ellipsis" label="More" size="sm" />}
footer={<><Button size="sm">Start</Button><Button size="sm" variant="ghost">Defer</Button></>}>
<div className="demo-row" style={{ marginBottom: 8 }}>
<Tag label="est/2d" /><Tag label="p/1" />
<span style={{ font: 'var(--text-data)', color: 'var(--ink-3)' }}>#87 · steeping 4d</span>
</div>
<span style={{ font: 'var(--text-agent)', color: 'var(--ink-2)' }}>
It blocks #91 and #92. I'd take it first — the critical path agrees with me.
</span>
</Card>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<Demo />);
</script>
</body>
</html>