From 9920634e7465b007114ef56ae0001b3f754acc3e Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 11:30:23 -0400 Subject: [PATCH 1/8] docs: settle open design items + dogfood backlog Resolve the five design-session open items from PLAN.md: - decisions.md: soft write-path, poll-only sync (NAT), lognormal cold-start priors, purity test binds the SQLite cache - pm-state.md: sidecar layout + directive/capacity/calibration schemas + lifecycle inference table - agent-tools.md: query_project read tool + three write tools Also gitignore .env.* (protect the gitea PAT) and record the P0 actual: 10 labels, 5 milestones, 34 tracer-bullet issues + 51 dependencies filed on christian/commitea as the first managed project. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 1 + .gitignore | 2 + docs/PLAN.md | 26 ++++++---- docs/agent-tools.md | 119 ++++++++++++++++++++++++++++++++++++++++++ docs/decisions.md | 73 ++++++++++++++++++++++++++ docs/pm-state.md | 123 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 335 insertions(+), 9 deletions(-) create mode 100644 .env.example create mode 100644 docs/agent-tools.md create mode 100644 docs/decisions.md create mode 100644 docs/pm-state.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b0428ea --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +GITEA_TOKEN= diff --git a/.gitignore b/.gitignore index 5065492..79f0335 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ out/ *.log .DS_Store .env +.env.* +!.env.example .yarn/* !.yarn/patches !.yarn/plugins diff --git a/docs/PLAN.md b/docs/PLAN.md index 63d92cb..6aec5e9 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -87,7 +87,11 @@ directives. Electron app, one window, "tell me what to do" experience. Deliverables per phase; replace TBD with actuals at phase close. - **P0 — Scaffold** — repo, Electron shell, gitea API client + token auth, - `pm-state` repo bootstrap. Actual: TBD + `pm-state` repo bootstrap. Actual (2026-07-08): yarn4 workspaces + electron + shell + `@commitea/core` label schema + design system mirrored. **Dogfood + backlog filed** on `christian/commitea`: 10 labels (est/p/deadline, exclusive + scopes), 5 phase milestones, 34 tracer-bullet issues with 51 native + dependencies. `pm-state` repo bootstrap still pending (P1-6 / P5-1). - **P1 — Sync + data model** — read mirror into SQLite cache, webhook listener + reconcile-on-launch, lifecycle inference from event stream, label schema applied. Actual: TBD @@ -113,12 +117,16 @@ vertical slices within phases where possible.) - Concurrent multi-PM directive writing - Cloud LLM as *requirement* (it's a config option, not a dependency) -## Open items for design session +## Open items for design session — RESOLVED 2026-07-08 -- Chat-as-only-write-path: strict or soft (UI edits allowed but agent - observes/objects)? -- `pm-state` file formats (JSONL event log settled; capacity/calibration - schemas TBD) -- Tool schema for the fat `query_project` tool -- Webhook endpoint mechanics per instance (port allocation, cleanup on quit) -- Cold-start estimate distributions (industry priors vs uniform pessimism) +Settled in [decisions.md](./decisions.md), [pm-state.md](./pm-state.md), +[agent-tools.md](./agent-tools.md): + +- Chat-as-only-write-path → **soft, split by semantics** (decisions.md D1) +- `pm-state` file formats → directive/capacity/calibration schemas (pm-state.md) +- Fat `query_project` tool schema → one read tool + three write tools + (agent-tools.md) +- Webhook mechanics → **poll + reconcile only in v1** (NAT), behind a + `ChangeSource` interface (decisions.md D2) +- Cold-start distributions → **lognormal per-bucket priors** in code + (decisions.md D3) diff --git a/docs/agent-tools.md b/docs/agent-tools.md new file mode 100644 index 0000000..08c01d7 --- /dev/null +++ b/docs/agent-tools.md @@ -0,0 +1,119 @@ +# Agent tools + +Reginald gets **few, fat tools** so a small local model (gemma-4b class) can +survive with one thing to reach for. One read tool, three write tools. All I/O +is compact JSON; ticket data is fetched through tools, never copied into hot +memory ([PLAN.md](./PLAN.md) memory layers). + +Model routing (per PLAN.md): the read tool + prose/standup run on the small +local model; `capture_work` decomposition and `record_directive` negotiation +route to the big model. + +## `query_project` (read) + +The single read tool. A `view` enum selects the shape; `filters` narrows it. The +scheduler's deterministic output backs every forecast field — the model never +computes, it reports. + +```jsonc +query_project({ + view: "focus" | "issue" | "milestone" | "board" | "runway" + | "calibration" | "directives" | "standup" | "search", + filters?: { + issueId?: number, + milestoneId?: number, + state?: "diagnosis" | "triage" | "steeping" | "in_review" | "done", + assignee?: string, // gitea username + label?: string, + query?: string, // free text, for view:"search" + limit?: number // default 20, max 100 + } +}) +``` + +View payloads (compact; forecasts always ranges, never point dates): + +- **focus** — `{ now, next[], later[] }`, each `{ issue, title, rationale }`. +- **issue** — intent (title, description, comments, assignee, labels) + derived + (lifecycle timeline, per-issue forecast range, dependency ids, provenance). +- **milestone** — `{ due, hard, stats, cone: {p10,p50,p80 dates}, issues[] }`. +- **board** — issues grouped by the five lifecycle columns. +- **runway** — per-milestone `{ due, band: {p50,p80}, slack }` + capacity list. +- **calibration** — `{ n, coldStart, globalMultiplier, byLabelBias[], byPersonBias[] }`. +- **directives** — pending consequence diff + recent ledger entries. +- **standup** — drift report, per-person plan, stale blockers. +- **search** — issues matching `query`. + +## `capture_work` (write — big model) + +Braindump → interview → proposed issue set. Returns a **proposal**, never files +directly; the Capture screen's review tray edits it before `apply_changes` files +it. Decomposition + estimate negotiation is the one place the big model earns its +keep. + +```jsonc +capture_work({ + braindump: string, + answers?: { question: string, answer: string }[] // interview turns so far +}) +// → { needsMoreInfo?: string[], // follow-up questions; present as chips +// proposal?: { issues: [{ title, body, estimate: EstimateLabel, +// priority?: PriorityLabel, deps?: number[], +// milestone?: number }], +// consequence: string } } // one-line schedule impact +``` + +## `apply_changes` (write — unified mutation) + +Every mutation funnels here: filing captured issues, label/estimate/priority/ +milestone/dependency edits. Additive ops apply directly; **destructive ops +require `approved: true`** (the caller obtains approval via the consequence +diff / Dialog first — see [decisions.md](./decisions.md) D1). Batched so one call += one coherent change with one consequence. + +```jsonc +apply_changes({ + ops: [ + { op: "create_issue", title, body, labels?, milestone?, deps? }, + { op: "set_estimate", issue, estimate: EstimateLabel }, + { op: "set_priority", issue, priority: PriorityLabel }, + { op: "set_milestone", issue, milestone: number | null }, + { op: "set_deadline_hard", milestone: number, hard: boolean }, + { op: "add_dep", issue, dependsOn: number }, + { op: "remove_dep", issue, dependsOn: number }, // destructive + { op: "close_issue", issue }, // destructive + { op: "remove_label", issue, label } // destructive + ], + approved?: boolean // required iff any op is destructive +}) +// → { applied: number, consequence: string, rejected?: {op, reason}[] } +``` + +Only touches labels in the CommiTea namespaces (`est/*`, `p/*`, `deadline/hard`) +plus native issue fields — never invents labels, comments, or synthetic issues +(zero-pollution goal). + +## `record_directive` (write — big model) + +Appends to the directive log ([pm-state.md](./pm-state.md)), triggers a scheduler +re-run, and returns the consequence diff for propose-approve. Does **not** mutate +gitea itself — a directive is intent; its effects land through `apply_changes` +after approval. + +```jsonc +record_directive({ + kind: "reprioritize" | "reestimate" | "set-deadline" | "scope" | "capacity" | "note", + quote: string, // verbatim PM words, stored in the ledger + target?: { issue?: number, milestone?: number, member?: string }, + params?: object, // structured effect, e.g. { priority: 1 } + rationale?: string +}) +// → { directiveId, consequence: { before, after }[], summary: string } +``` + +## Not tools + +Reads that are pure UI state (theme, current view, back-stack) never go through +tools. The scheduler, Monte Carlo, calibration fit, and lifecycle inference are +**code**, invoked by the runtime around these tools — the model requests a view +or proposes a change; deterministic code produces every number. diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..6a8b281 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,73 @@ +# Settled design decisions + +Resolves the "Open items for design session" in [PLAN.md](./PLAN.md). Dated +2026-07-08. Companion docs: [pm-state.md](./pm-state.md) (sidecar formats), +[agent-tools.md](./agent-tools.md) (tool schemas). + +## D1 — Write path is soft, split by semantics + +Chat (Reginald) is the write path for **PM-semantic** mutations; gitea-native +content stays directly editable. + +- **Through the agent** — estimates (`est/*`), priority (`p/*`), + `deadline/hard`, milestone assignment, dependency edits, and directives. + Additive ops act directly; destructive ops go propose-approve (Dialog or the + consequence diff on the Directives screen). +- **Direct, reconciled** — issue title/description, comments, assignees. Edit + them in the CommiTea UI ("composer writes to gitea, as you") or in the gitea + web UI; reconcile absorbs out-of-band edits because **gitea is the source of + truth** for intent. The agent observes changes on the next reconcile and may + object in standup, but never blocks them. + +Rejected: strict (every write through chat). Hostile to quick edits and fights +the reconcile-from-gitea model — an edit made in gitea's own web UI would be +un-representable. + +## D2 — Sync is poll + reconcile, no live webhooks in v1 + +`gitea.stephenmann.io` is remote and the desktop app sits behind NAT, so the +server cannot POST to a localhost webhook. v1: + +- **Reconcile on launch** — full read of the work repo(s) + `pm-state` repo into + the local SQLite cache. +- **Light poll while running** — `since`/conditional-request poll of issues and + the issue timeline (target 30–60 s cadence; visible < 2 s is a webhook-era + goal, relaxed to the poll interval for v1). +- The change-source is an **interface** (`ChangeSource`) with a polling + implementation; a webhook implementation can plug in later for a LAN / + self-hosted / tunnelled instance without touching the reconcile core. + +Rejected now: reachability-detection hybrid (moving parts, cleanup-on-quit), +outbound tunnel (runtime dependency + public ingress). Both remain future +options behind the same interface. + +## D3 — Cold-start forecasts use lognormal per-bucket priors + +Before the team has n ≥ 20 closed issues with estimates, Monte Carlo samples a +**lognormal** actual-duration distribution per estimate bucket +(`1d/2d/3d/5d/8d`), with a pessimism-skewed median (actuals run long). The +priors live in code (`@commitea/core`), not in a data file. At n ≥ 20 the +scheduler switches to the team's own empirical fit (see calibration model in +[pm-state.md](./pm-state.md)); `byLabel` / `byPerson` bias terms layer on once +their own sample sizes clear a floor. + +Rejected: uniform pessimism multiplier (`actual = est × U[1.3, 2.0]`) — cruder, +dishonest tails, no path to per-bucket calibration. + +## D4 — Purity test applies to the SQLite cache, not the pm-state repo + +The plan's invariant — *delete the sidecar → resync → no truth lost* — is about +the **local SQLite cache**, which is a rebuildable index over two durable +sources: + +- **Work repo(s) in gitea** — human-authored intent (issues, milestones + due + dates, dependencies, assignees, labels, comments). +- **`pm-state` repo in gitea** — the sidecar's own durable truth that is *not* + regenerable from the work repo: directive log, capacity config, charter, and + the (cached-but-committed) calibration model. + +Delete SQLite → rebuild from both repos → nothing lost. The `pm-state` repo is +never the thing you delete; it is versioned and backed up in gitea like any +other repo. Regenerable state (issue mirror, inferred lifecycle timestamps, +Monte Carlo forecasts, focus snapshot) lives in SQLite only and is recomputed on +rebuild. See [pm-state.md](./pm-state.md) for the file/table split. diff --git a/docs/pm-state.md b/docs/pm-state.md new file mode 100644 index 0000000..547621c --- /dev/null +++ b/docs/pm-state.md @@ -0,0 +1,123 @@ +# pm-state: the sidecar store + +Machine-derived and PM-authored state that has no home in the work repo. Split +across two tiers per [decisions.md](./decisions.md) D4: + +- **`pm-state` gitea repo** — durable, versioned truth. Committed files below. +- **local SQLite** — rebuildable cache/index. Never the source of truth. + +## `pm-state` repo layout + +``` +charter.md durable project charter + hot-memory seed (human + agent authored) +directives/log.jsonl durable append-only directive ledger (conflict-free merge) +capacity/members.yaml durable per-person capacity model (human-set) +calibration/model.json cached fitted calibration; regenerable from actuals, committed for + reproducibility + offline forecasting +``` + +`forecasts/`, `focus/`, and inferred lifecycle timestamps are **not** committed +— they are SQLite-only and recomputed on rebuild (regenerable from the work repo ++ calibration model). + +## Directive log — `directives/log.jsonl` + +Append-only, one JSON object per line. **Merge is concatenation**: order is +derived from `ts` at read time, so two clients appending never produce a git +conflict. `id` is the durable key; `seq` is a display ordinal computed on read, +never stored (avoids the "who owns the next number" contention). v1 is +single-writer (one PM); this format is already safe for the deferred +multi-writer case. + +```jsonc +{ + "id": "d_01J8...", // crypto.randomUUID at write; durable identity + "ts": "2026-07-08T14:03:00Z", // ISO 8601 UTC; sole ordering key + "actor": "christian", // gitea username of the directive-giver + "kind": "reprioritize", // reprioritize | reestimate | set-deadline | scope | capacity | note + "target": { "issue": 87 }, // { issue } | { milestone } | { member } | null (project-wide) + "quote": "bump the auth bug above everything", // verbatim, shown in the ledger + "params": { "priority": 1 }, // structured effect the scheduler applies + "rationale": "pilot customer blocked", // why (optional but nagged for) + "status": "accepted" // proposed | accepted | amended | withdrawn +} +``` + +Lifecycle: a directive is recorded as `proposed`, the scheduler re-runs, the +agent presents the consequence diff, and the PM's response flips it to +`accepted` / `amended` / `withdrawn`. All four states stay in the ledger +(append a status-change line; never mutate a prior line). + +## Capacity — `capacity/members.yaml` + +Estimate unit is **ideal person-days**. Capacity is expressed in ideal +person-days available per calendar day. + +```yaml +members: + - gitea: christian + focusFactor: 0.8 # productive fraction of a working day (0..1) + projectAllocation: 0.6 # share of focused time on THIS project (0..1) + workdays: [mon, tue, wed, thu, fri] + daysOff: [] # ISO dates, e.g. ["2026-07-14"]; PTO calendars deferred + # other standing slices (compliance 0.2, pilots 0.2) are documentation only — + # only projectAllocation feeds the scheduler. +``` + +Derived: `capacityPerWorkday = focusFactor * projectAllocation` (ideal +person-days per working day). The scheduler spreads this across `workdays`, +zeroing `daysOff`. Missing member ⇒ excluded from capacity, flagged by the agent. + +## Calibration — `calibration/model.json` + +Fitted from closed-issue actuals (estimate label vs inferred elapsed working +time). Lognormal on `log(actual / estimate)`. + +```jsonc +{ + "version": 1, + "fittedAt": "2026-07-08T00:00:00Z", + "n": 42, // closed issues with an estimate feeding the fit + "coldStart": false, // true while n < 20 → scheduler uses code priors instead + "global": { "mu": 0.166, "sigma": 0.45 }, // lognormal params on log-ratio; median ratio = e^mu ≈ 1.18 + "byBucket": { // per estimate label; falls back to global when its n is thin + "1d": { "mu": 0.30, "sigma": 0.55, "n": 12 }, + "2d": { "mu": 0.18, "sigma": 0.40, "n": 9 } + // 3d / 5d / 8d ... + }, + "byLabel": { "backend": { "biasMu": 0.12, "n": 7 } }, // additive to mu; applied when n ≥ floor + "byPerson": { "christian": { "biasMu": -0.05, "n": 20 } } +} +``` + +Cold-start (`coldStart: true`, or a bucket with `n` below floor): the scheduler +ignores the file's fitted params for that axis and samples the **code-resident +lognormal priors** in `@commitea/core` (per D3). The file still records whatever +partial `n` exists so the UI's calibration teaser can show progress toward 20. + +## SQLite cache (rebuildable — not committed) + +Mirror + derived tables, rebuilt from both gitea repos on reconcile: + +- `issues`, `labels`, `milestones`, `comments`, `issue_events` — verbatim work-repo mirror +- `lifecycle` — inferred per-issue timestamps (see below), keyed by issue +- `forecasts` — last Monte Carlo run per milestone/issue (regenerable) +- `focus` — current Now/Next/Later snapshot (regenerable) +- `directives` — indexed view of `log.jsonl` for fast querying + +## Lifecycle inference + +Timestamps derived from the gitea issue timeline; no manual time tracking. Maps +onto the board's five columns: + +| Board column | Enter when | Source event | +|--------------|--------------------------------------------------------|-------------------------| +| Diagnosis | issue opened | `opened` | +| Triage | first label or milestone applied | `label` / `milestone` | +| Steeping | first branch or commit references the issue | `commit_ref` / branch | +| In review | a PR referencing the issue is opened | PR `opened` | +| Done | issue closed (PR merged is the deploy signal within) | `closed` / PR `merged` | + +Elapsed **working** time between Steeping→Done (minus non-workdays/daysOff) is +the "actual" that feeds calibration. Re-openings append new segments; the fit +uses summed working time. From d6f531eb6da93017364552df91f113c8ff226889 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 11:33:29 -0400 Subject: [PATCH 2/8] feat(core): gitea read client behind an injected fetch (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createGiteaClient(config, fetchImpl).getIssue(index) fetches one issue and normalizes raw gitea JSON to a typed GiteaIssue with scheduler facts precomputed via extractLabelFacts. Network is an injected FetchLike (core has no DOM lib; global fetch is structurally assignable), so it unit-tests against a stub — no live calls in the suite. Non-2xx responses throw GiteaApiError carrying status + body. Closes P1-1. Verified end-to-end against the live repo's issue #9. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/gitea/client.test.ts | 136 +++++++++++++++++++++++++ packages/core/src/gitea/client.ts | 97 ++++++++++++++++++ packages/core/src/gitea/types.ts | 79 ++++++++++++++ packages/core/src/index.ts | 12 +++ 4 files changed, 324 insertions(+) create mode 100644 packages/core/src/gitea/client.test.ts create mode 100644 packages/core/src/gitea/client.ts create mode 100644 packages/core/src/gitea/types.ts diff --git a/packages/core/src/gitea/client.test.ts b/packages/core/src/gitea/client.test.ts new file mode 100644 index 0000000..5e02528 --- /dev/null +++ b/packages/core/src/gitea/client.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' + +import { createGiteaClient, normalizeIssue } from './client.js' +import { GiteaApiError, type FetchLike, type GiteaConfig, type GiteaRequestInit } from './types.js' + +const CONFIG: GiteaConfig = { + baseUrl: 'https://gitea.stephenmann.io', + token: 'tok_secret', + owner: 'christian', + repo: 'commitea', +} + +/** Raw gitea issue JSON as the API returns it. */ +const RAW_ISSUE = { + number: 9, + title: 'Deterministic scheduler', + body: 'topo order → dated plan', + state: 'open', + labels: [{ name: 'est/5d' }, { name: 'p/1' }, { name: 'deadline/hard' }], + milestone: { id: 7, title: 'P2 — Scheduler + Monte Carlo', due_on: '2026-09-01T00:00:00Z' }, + assignee: { login: 'christian' }, + assignees: [{ login: 'christian' }, { login: 'stephen' }], + created_at: '2026-07-08T00:00:00Z', + updated_at: '2026-07-08T01:00:00Z', + closed_at: null, + html_url: 'https://gitea.stephenmann.io/christian/commitea/issues/9', +} + +/** A stub fetch that records calls and returns a canned 200 JSON body. */ +function stubFetch(body: unknown, status = 200): { + fetch: FetchLike + calls: { url: string; init?: GiteaRequestInit }[] +} { + const calls: { url: string; init?: GiteaRequestInit }[] = [] + const fetch: FetchLike = (url, init) => { + calls.push({ url, init }) + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)), + }) + } + return { fetch, calls } +} + +describe('createGiteaClient.getIssue', () => { + it('returns a normalized, typed issue from canned JSON', async () => { + const { fetch } = stubFetch(RAW_ISSUE) + const issue = await createGiteaClient(CONFIG, fetch).getIssue(9) + + expect(issue.number).toBe(9) + expect(issue.title).toBe('Deterministic scheduler') + expect(issue.state).toBe('open') + expect(issue.labels).toEqual(['est/5d', 'p/1', 'deadline/hard']) + expect(issue.url).toBe('https://gitea.stephenmann.io/christian/commitea/issues/9') + }) + + it('precomputes scheduler facts via extractLabelFacts', async () => { + const { fetch } = stubFetch(RAW_ISSUE) + const issue = await createGiteaClient(CONFIG, fetch).getIssue(9) + + expect(issue.facts.estimateDays).toBe(5) + expect(issue.facts.priority).toBe(1) + expect(issue.facts.hardDeadline).toBe(true) + }) + + it('maps the milestone ref and assignees', async () => { + const { fetch } = stubFetch(RAW_ISSUE) + const issue = await createGiteaClient(CONFIG, fetch).getIssue(9) + + expect(issue.milestone).toEqual({ + id: 7, + title: 'P2 — Scheduler + Monte Carlo', + dueOn: '2026-09-01T00:00:00Z', + }) + expect(issue.assignee).toBe('christian') + expect(issue.assignees).toEqual(['christian', 'stephen']) + }) + + it('hits the configured repo path with the token auth header', async () => { + const { fetch, calls } = stubFetch(RAW_ISSUE) + await createGiteaClient(CONFIG, fetch).getIssue(9) + + expect(calls).toHaveLength(1) + expect(calls[0].url).toBe( + 'https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues/9', + ) + expect(calls[0].init?.headers?.Authorization).toBe('token tok_secret') + }) + + it('tolerates a trailing slash on baseUrl', async () => { + const { fetch, calls } = stubFetch(RAW_ISSUE) + await createGiteaClient({ ...CONFIG, baseUrl: 'https://gitea.stephenmann.io/' }, fetch).getIssue(9) + + expect(calls[0].url).toBe( + 'https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues/9', + ) + }) + + it('throws GiteaApiError carrying status + body on a non-2xx response', async () => { + const { fetch } = stubFetch('not found', 404) + const client = createGiteaClient(CONFIG, fetch) + + await expect(client.getIssue(999)).rejects.toBeInstanceOf(GiteaApiError) + await expect(client.getIssue(999)).rejects.toMatchObject({ status: 404, body: 'not found' }) + }) +}) + +describe('normalizeIssue', () => { + it('defaults missing labels/assignees/body to empty and maps a closed state', () => { + const issue = normalizeIssue({ + number: 1, + title: 'bare issue', + body: null, + state: 'closed', + labels: null, + milestone: null, + assignee: null, + assignees: null, + created_at: '2026-07-01T00:00:00Z', + updated_at: '2026-07-02T00:00:00Z', + closed_at: '2026-07-02T00:00:00Z', + html_url: 'https://gitea.stephenmann.io/christian/commitea/issues/1', + }) + + expect(issue.state).toBe('closed') + expect(issue.body).toBe('') + expect(issue.labels).toEqual([]) + expect(issue.assignee).toBeNull() + expect(issue.assignees).toEqual([]) + expect(issue.milestone).toBeNull() + expect(issue.facts.estimateDays).toBeNull() + expect(issue.closedAt).toBe('2026-07-02T00:00:00Z') + }) +}) diff --git a/packages/core/src/gitea/client.ts b/packages/core/src/gitea/client.ts new file mode 100644 index 0000000..02fe22c --- /dev/null +++ b/packages/core/src/gitea/client.ts @@ -0,0 +1,97 @@ +/** + * Gitea read client. One method for now — `getIssue` — proving the vertical + * slice end to end: config + injected fetch → typed, normalized `GiteaIssue` + * with scheduler facts precomputed. Later reconcile work (P1-4) layers list + * reads on the same `request` seam. + */ + +import { extractLabelFacts } from '../labels/label-schema.js' +import { + GiteaApiError, + type FetchLike, + type GiteaConfig, + type GiteaIssue, + type GiteaMilestoneRef, +} from './types.js' + +/** The subset of gitea's raw issue JSON we read. */ +interface RawLabel { + name: string +} +interface RawMilestone { + id: number + title: string + due_on: string | null +} +interface RawUser { + login: string +} +interface RawIssue { + number: number + title: string + body: string | null + state: string + labels: RawLabel[] | null + milestone: RawMilestone | null + assignee: RawUser | null + assignees: RawUser[] | null + created_at: string + updated_at: string + closed_at: string | null + html_url: string +} + +export interface GiteaClient { + /** Fetch one issue by its per-repo index, normalized. */ + getIssue(index: number): Promise +} + +/** Map raw gitea issue JSON to the normalized domain shape. Pure. */ +export function normalizeIssue(raw: RawIssue): GiteaIssue { + const labels = (raw.labels ?? []).map((l) => l.name) + const milestone: GiteaMilestoneRef | null = raw.milestone + ? { id: raw.milestone.id, title: raw.milestone.title, dueOn: raw.milestone.due_on } + : null + const assignees = (raw.assignees ?? []).map((u) => u.login) + + return { + number: raw.number, + title: raw.title, + body: raw.body ?? '', + state: raw.state === 'closed' ? 'closed' : 'open', + labels, + facts: extractLabelFacts(labels), + milestone, + assignee: raw.assignee?.login ?? null, + assignees, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + closedAt: raw.closed_at, + url: raw.html_url, + } +} + +export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): GiteaClient { + const apiBase = `${config.baseUrl.replace(/\/+$/, '')}/api/v1` + const repoBase = `${apiBase}/repos/${config.owner}/${config.repo}` + + async function request(path: string): Promise { + const res = await fetchImpl(`${repoBase}${path}`, { + headers: { + Authorization: `token ${config.token}`, + Accept: 'application/json', + }, + }) + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new GiteaApiError(res.status, `GET ${path} failed (${res.status})`, body) + } + return res.json() + } + + return { + async getIssue(index) { + return normalizeIssue((await request(`/issues/${index}`)) as RawIssue) + }, + } +} diff --git a/packages/core/src/gitea/types.ts b/packages/core/src/gitea/types.ts new file mode 100644 index 0000000..aaa323d --- /dev/null +++ b/packages/core/src/gitea/types.ts @@ -0,0 +1,79 @@ +/** + * Types for the gitea read client. Deliberately minimal: `@commitea/core` is + * pure (no DOM lib, no Electron, no network), so we don't reach for the global + * `fetch`/`Response`/`RequestInit` DOM types — we define the small structural + * surface we actually use. The real `globalThis.fetch` is structurally + * assignable to `FetchLike`, so the desktop main process passes it verbatim + * while tests pass a stub. + */ + +import type { LabelFacts } from '../labels/label-schema.js' + +/** Connection config. `baseUrl` is the instance root (no `/api/v1`). */ +export interface GiteaConfig { + /** e.g. `https://gitea.stephenmann.io` — trailing slash tolerated. */ + baseUrl: string + /** Personal access token (scopes: issue/repository/user). */ + token: string + owner: string + repo: string +} + +export interface GiteaRequestInit { + method?: string + headers?: Record + body?: string +} + +/** The slice of a `fetch` Response we consume. */ +export interface GiteaHttpResponse { + ok: boolean + status: number + json(): Promise + text(): Promise +} + +export type FetchLike = (url: string, init?: GiteaRequestInit) => Promise + +/** Milestone as referenced from an issue (not the full milestone resource). */ +export interface GiteaMilestoneRef { + id: number + title: string + /** ISO date the milestone is due, or null. */ + dueOn: string | null +} + +/** + * Normalized issue — camelCase, label names flattened, scheduler-facing + * `facts` precomputed via `extractLabelFacts`. This is the domain shape the + * rest of CommiTea works with; raw gitea JSON never escapes this module. + */ +export interface GiteaIssue { + number: number + title: string + body: string + state: 'open' | 'closed' + labels: string[] + facts: LabelFacts + milestone: GiteaMilestoneRef | null + /** Primary assignee username, or null. */ + assignee: string | null + /** All assignee usernames (includes the primary). */ + assignees: string[] + createdAt: string + updatedAt: string + closedAt: string | null + url: string +} + +/** Thrown on a non-2xx gitea response; carries the status + raw body. */ +export class GiteaApiError extends Error { + constructor( + readonly status: number, + message: string, + readonly body?: string, + ) { + super(message) + this.name = 'GiteaApiError' + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 82f0f7a..388ae80 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,3 +8,15 @@ export { parsePriorityLabel, } from './labels/label-schema.js' export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-schema.js' + +export { createGiteaClient, normalizeIssue } from './gitea/client.js' +export type { GiteaClient } from './gitea/client.js' +export { GiteaApiError } from './gitea/types.js' +export type { + FetchLike, + GiteaConfig, + GiteaHttpResponse, + GiteaIssue, + GiteaMilestoneRef, + GiteaRequestInit, +} from './gitea/types.js' From 96c2b8b1c2684b1c5bbd988ae6237bbc5ce92933 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 11:38:59 -0400 Subject: [PATCH 3/8] test(desktop): Playwright electron e2e harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launches the built app (out/main/index.js) via Playwright's _electron API — no browser project, no chromium download. Adds a launch fixture (electronApp/window/app), an AppPage page object with a screenshot helper for autonomous visual review, and a boot smoke suite (shell renders, @commitea/core label-parse runs in the renderer, preload API exposed). Scripts: e2e (build+run), e2e:only, e2e:report. Artifacts gitignored. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 6 ++++ README.md | 14 ++++++++ apps/desktop/e2e/fixtures.ts | 48 ++++++++++++++++++++++++++ apps/desktop/e2e/pages/app.page.ts | 40 ++++++++++++++++++++++ apps/desktop/e2e/smoke.spec.ts | 19 +++++++++++ apps/desktop/e2e/tsconfig.json | 10 ++++++ apps/desktop/package.json | 6 +++- apps/desktop/playwright.config.ts | 26 ++++++++++++++ yarn.lock | 55 ++++++++++++++++++++++++++++++ 9 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/e2e/fixtures.ts create mode 100644 apps/desktop/e2e/pages/app.page.ts create mode 100644 apps/desktop/e2e/smoke.spec.ts create mode 100644 apps/desktop/e2e/tsconfig.json create mode 100644 apps/desktop/playwright.config.ts diff --git a/.gitignore b/.gitignore index 79f0335..08c279b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,12 @@ dist/ out/ *.log .DS_Store + +# playwright e2e artifacts +.artifacts/ +test-results/ +playwright-report/ +.last-run.json .env .env.* !.env.example diff --git a/README.md b/README.md index 4ef66b1..c9cfa86 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,20 @@ yarn test # unit tests (vitest) yarn typecheck ``` +### End-to-end (Electron + Playwright) + +```sh +yarn workspace @commitea/desktop e2e # build, then drive the built app +yarn workspace @commitea/desktop e2e:only # reuse existing out/ build (tight loop) +yarn workspace @commitea/desktop e2e:report # open the last HTML report +``` + +Tests launch the built app (`out/main/index.js`) through Playwright's +`_electron` API — no browser project, no chromium download. Fixtures and page +objects live in `apps/desktop/e2e/`; screenshots land in +`e2e/.artifacts/screens/` for visual review. Page objects use user-facing +locators (`getByRole`/`getByText`), never CSS/DOM structure. + ## Conventions - Yarn 4 workspaces; ESM everywhere; `.js` extensions on relative imports diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts new file mode 100644 index 0000000..1c65e4f --- /dev/null +++ b/apps/desktop/e2e/fixtures.ts @@ -0,0 +1,48 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + _electron as electron, + expect, + test as base, + type ElectronApplication, + type Page, +} from '@playwright/test' + +import { AppPage } from './pages/app.page.js' + +const here = dirname(fileURLToPath(import.meta.url)) +/** The built main-process entry Playwright launches. `yarn e2e` builds it first. */ +const MAIN_ENTRY = join(here, '..', 'out', 'main', 'index.js') + +interface CommiteaFixtures { + /** The launched Electron application. */ + electronApp: ElectronApplication + /** The app's first (and only) BrowserWindow, as a Playwright Page. */ + window: Page + /** Page Object over the app shell. */ + app: AppPage +} + +export const test = base.extend({ + electronApp: async ({}, use) => { + const electronApp = await electron.launch({ + args: [MAIN_ENTRY], + env: { ...process.env, NODE_ENV: 'test', COMMITEA_E2E: '1' }, + }) + await use(electronApp) + await electronApp.close() + }, + + window: async ({ electronApp }, use) => { + const window = await electronApp.firstWindow() + await window.waitForLoadState('domcontentloaded') + await use(window) + }, + + app: async ({ window }, use) => { + await use(new AppPage(window)) + }, +}) + +export { expect } diff --git a/apps/desktop/e2e/pages/app.page.ts b/apps/desktop/e2e/pages/app.page.ts new file mode 100644 index 0000000..fadb0c4 --- /dev/null +++ b/apps/desktop/e2e/pages/app.page.ts @@ -0,0 +1,40 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { expect, type Locator, type Page } from '@playwright/test' + +const here = dirname(fileURLToPath(import.meta.url)) +/** Where `screenshot()` drops PNGs — gitignored, readable for visual review. */ +const SCREENS_DIR = join(here, '..', '.artifacts', 'screens') + +/** + * Page Object over the CommiTea app shell. As real screens land (P3), add + * per-screen page objects and accessor methods here; keep locators + * user-facing (getByRole/getByText), never CSS/DOM structure. + */ +export class AppPage { + constructor(readonly page: Page) {} + + get heading(): Locator { + return this.page.getByRole('heading', { name: 'CommiTea' }) + } + + /** Assert the shell has rendered. */ + async expectLoaded(): Promise { + await expect(this.heading).toBeVisible() + } + + /** Read the `commitea` preload API surface from the renderer. */ + async preloadApi(): Promise | undefined> { + return this.page.evaluate( + () => (globalThis as unknown as { commitea?: Record }).commitea, + ) + } + + /** Capture a full-page screenshot for visual review; returns the path. */ + async screenshot(name: string): Promise { + const path = join(SCREENS_DIR, `${name}.png`) + await this.page.screenshot({ path, fullPage: true }) + return path + } +} diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts new file mode 100644 index 0000000..5c22a72 --- /dev/null +++ b/apps/desktop/e2e/smoke.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from './fixtures.js' + +test('boots and renders the CommiTea shell', async ({ app }) => { + await app.expectLoaded() + await app.screenshot('boot') +}) + +test('renders the label-parse check from @commitea/core', async ({ window }) => { + // Proves the core package is bundled + running in the renderer, not just + // that the window opened. Updates as the scaffold gives way to real screens. + await expect(window.getByText(/label parse check/i)).toBeVisible() + await expect(window.getByText(/est\/3d · p\/2 · hard true/)).toBeVisible() +}) + +test('exposes the commitea preload API', async ({ app }) => { + const api = await app.preloadApi() + expect(api).toBeDefined() + expect(api).toHaveProperty('platform') +}) diff --git a/apps/desktop/e2e/tsconfig.json b/apps/desktop/e2e/tsconfig.json new file mode 100644 index 0000000..ffa38d1 --- /dev/null +++ b/apps/desktop/e2e/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"], + "moduleResolution": "Bundler", + "noEmit": true + }, + "include": ["."] +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5f128b0..f2e04af 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,7 +7,10 @@ "dev": "electron-vite dev 2>&1 | tee desktop.log", "build": "electron-vite build", "start": "electron-vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "e2e": "electron-vite build && playwright test", + "e2e:only": "playwright test", + "e2e:report": "playwright show-report e2e/.artifacts/report" }, "dependencies": { "@commitea/core": "workspace:*", @@ -15,6 +18,7 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@types/node": "^22.13.1", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts new file mode 100644 index 0000000..f6ec82a --- /dev/null +++ b/apps/desktop/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from '@playwright/test' + +/** + * E2E harness for the Electron app. Tests launch the *built* app + * (`out/main/index.js`) through Playwright's `_electron` API — see + * `e2e/fixtures.ts`. There is no browser project and no chromium download: + * `_electron` drives the app's own bundled electron. + * + * Run `yarn e2e` (builds first) for a fresh run, or `yarn e2e:only` to reuse + * the existing `out/` build during a tight iteration loop. + */ +export default defineConfig({ + testDir: './e2e', + outputDir: './e2e/.artifacts/test-results', + fullyParallel: false, + workers: 1, // one electron instance at a time — deterministic, avoids window races + forbidOnly: !!process.env.CI, + retries: 0, + timeout: 30_000, + expect: { timeout: 5_000 }, + reporter: [['list'], ['html', { outputFolder: './e2e/.artifacts/report', open: 'never' }]], + use: { + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, +}) diff --git a/yarn.lock b/yarn.lock index 67e76e1..d4cec25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -241,6 +241,7 @@ __metadata: resolution: "@commitea/desktop@workspace:apps/desktop" dependencies: "@commitea/core": "workspace:*" + "@playwright/test": "npm:^1.61.1" "@types/node": "npm:^22.13.1" "@types/react": "npm:^18.3.18" "@types/react-dom": "npm:^18.3.5" @@ -720,6 +721,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:^1.61.1": + version: 1.61.1 + resolution: "@playwright/test@npm:1.61.1" + dependencies: + playwright: "npm:1.61.1" + bin: + playwright: cli.js + checksum: 10c0/45004139eaa7c7ec8129e4ed77a9d734aa09ed40b69162b871e4123f1d70217b7b73651431a9343ed5090d5fed11c6df1bff2a56ff4b87dc7b0371b5da87cf9c + languageName: node + linkType: hard + "@rolldown/pluginutils@npm:1.0.0-beta.27": version: 1.0.0-beta.27 resolution: "@rolldown/pluginutils@npm:1.0.0-beta.27" @@ -1890,6 +1902,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -1900,6 +1922,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" @@ -2458,6 +2489,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.61.1": + version: 1.61.1 + resolution: "playwright-core@npm:1.61.1" + bin: + playwright-core: cli.js + checksum: 10c0/c28896ba82a602182e240ed4f9c467fb0dd9cb57d510f8aba9f25183fe1cde3a0105725a29baffa4157fe885bbb11e228032a2aad0424111584fde45303f07bd + languageName: node + linkType: hard + +"playwright@npm:1.61.1": + version: 1.61.1 + resolution: "playwright@npm:1.61.1" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.61.1" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10c0/cea1bc4d2a64ec3ef683891606774029da7b8a8036ed98332565cec2f8e89549ca1fab9a89fbfba4e08e27e0d7e7d148031e965af66d5ff97bb3c34058cfcad0 + languageName: node + linkType: hard + "postcss-import@npm:^15.1.0": version: 15.1.0 resolution: "postcss-import@npm:15.1.0" From 635025113cf081b181a2ca521a50489e5035be11 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 12:12:00 -0400 Subject: [PATCH 4/8] feat(desktop): port the 15 design-system primitives (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verbatim port of the handoff primitives into components/ui/ — Icon, Button, IconButton, Badge, Tag, Card, Tabs (core); Input, Select, Checkbox, Radio, Switch (forms); Dialog, Toast, Tooltip (feedback). Each keeps its injected token-referencing CSS byte-for-byte; the handoff .d.ts contracts become the exported prop interfaces. Barrel at components/ui/index.ts. Adds a PrimitivesGallery (app root for now; real shell is P3-2) that exercises every primitive with a light/dark toggle. Smoke suite asserts the gallery, section coverage, theme flip, and dialog open/Escape; screenshots captured for both themes. typecheck + 5 e2e green. Closes P3-1. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/e2e/smoke.spec.ts | 34 ++- apps/desktop/src/renderer/src/app.tsx | 25 +- .../src/renderer/src/components/gallery.tsx | 222 ++++++++++++++++++ .../src/renderer/src/components/ui/badge.tsx | 44 ++++ .../src/renderer/src/components/ui/button.tsx | 81 +++++++ .../src/renderer/src/components/ui/card.tsx | 79 +++++++ .../renderer/src/components/ui/checkbox.tsx | 54 +++++ .../src/renderer/src/components/ui/dialog.tsx | 82 +++++++ .../src/components/ui/icon-button.tsx | 68 ++++++ .../src/renderer/src/components/ui/icon.tsx | 47 ++++ .../src/renderer/src/components/ui/index.ts | 21 ++ .../src/renderer/src/components/ui/input.tsx | 75 ++++++ .../src/renderer/src/components/ui/radio.tsx | 63 +++++ .../src/renderer/src/components/ui/select.tsx | 63 +++++ .../src/renderer/src/components/ui/switch.tsx | 61 +++++ .../src/renderer/src/components/ui/tabs.tsx | 73 ++++++ .../src/renderer/src/components/ui/tag.tsx | 66 ++++++ .../src/renderer/src/components/ui/toast.tsx | 65 +++++ .../renderer/src/components/ui/tooltip.tsx | 55 +++++ 19 files changed, 1247 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/gallery.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/badge.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/button.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/card.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/checkbox.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/dialog.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/icon-button.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/icon.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/index.ts create mode 100644 apps/desktop/src/renderer/src/components/ui/input.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/radio.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/select.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/switch.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/tabs.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/tag.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/toast.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/tooltip.tsx diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index 5c22a72..5854c87 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -1,15 +1,33 @@ import { expect, test } from './fixtures.js' -test('boots and renders the CommiTea shell', async ({ app }) => { - await app.expectLoaded() - await app.screenshot('boot') +test('boots and renders the primitives gallery', async ({ app, window }) => { + await expect(window.getByRole('heading', { name: 'Primitives' })).toBeVisible() + await expect(window.getByRole('button', { name: 'Brew plan' })).toBeVisible() + await app.screenshot('primitives-light') }) -test('renders the label-parse check from @commitea/core', async ({ window }) => { - // Proves the core package is bundled + running in the renderer, not just - // that the window opened. Updates as the scaffold gives way to real screens. - await expect(window.getByText(/label parse check/i)).toBeVisible() - await expect(window.getByText(/est\/3d · p\/2 · hard true/)).toBeVisible() +test('primitives cover core, forms, and feedback', async ({ window }) => { + // section headings prove each cluster ported and rendered + for (const section of ['Button', 'Badge', 'Tag', 'Card', 'Input & Select', 'Feedback']) { + await expect(window.getByRole('heading', { name: section, exact: true })).toBeVisible() + } + // a mono label chip renders verbatim + await expect(window.getByText('deadline/hard')).toBeVisible() +}) + +test('dark theme toggle flips the document theme', async ({ app, window }) => { + await window.getByRole('button', { name: 'Switch to dark' }).click() + await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark') + await app.screenshot('primitives-dark') +}) + +test('dialog opens on demand and closes on Escape', async ({ window }) => { + await window.getByRole('button', { name: 'Open dialog' }).click() + const dialog = window.getByRole('dialog') + await expect(dialog).toBeVisible() + await expect(dialog.getByText('Withdraw directive?')).toBeVisible() + await window.keyboard.press('Escape') + await expect(dialog).toBeHidden() }) test('exposes the commitea preload API', async ({ app }) => { diff --git a/apps/desktop/src/renderer/src/app.tsx b/apps/desktop/src/renderer/src/app.tsx index ef35ec2..0434ef1 100644 --- a/apps/desktop/src/renderer/src/app.tsx +++ b/apps/desktop/src/renderer/src/app.tsx @@ -1,26 +1,5 @@ -import { extractLabelFacts } from '@commitea/core' - -const facts = extractLabelFacts(['est/3d', 'p/2', 'deadline/hard']) +import { PrimitivesGallery } from './components/gallery.js' export function App() { - return ( -
-
-
-

CommiTea

-

- scaffold · phase 0 · gitea connection pending -

-
-

- Good morning. The scaffold stands and the kettle is on, but I have nothing to manage - yet. Connect me to gitea and we shall put the pot to work. -

-

- label parse check: est/{facts.estimateDays}d · p/{facts.priority} · hard{' '} - {String(facts.hardDeadline)} -

-
-
- ) + return } diff --git a/apps/desktop/src/renderer/src/components/gallery.tsx b/apps/desktop/src/renderer/src/components/gallery.tsx new file mode 100644 index 0000000..bd5c814 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/gallery.tsx @@ -0,0 +1,222 @@ +import React, { useEffect, useState } from 'react' + +import { + Badge, + Button, + Card, + Checkbox, + Dialog, + Icon, + IconButton, + Input, + Radio, + Select, + Switch, + Tabs, + Tag, + Toast, + Tooltip, +} from './ui/index.js' + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ) +} + +/** + * Visual + behavioral proof for the ported primitives (P3-1). Not a product + * screen — the real shell lands in P3-2. Exercises every primitive in both + * themes via the toggle. + */ +export function PrimitivesGallery() { + const [dark, setDark] = useState(false) + const [tab, setTab] = useState('board') + const [dialogOpen, setDialogOpen] = useState(false) + const [checked, setChecked] = useState(true) + const [radio, setRadio] = useState('a') + const [on, setOn] = useState(true) + + useEffect(() => { + document.documentElement.dataset.theme = dark ? 'dark' : 'light' + }, [dark]) + + return ( +
+
+
+
+

Primitives

+

15 components · light + dark

+
+ setDark((d) => !d)} + /> +
+ +
+ + + + + + +
+ +
+ + + + +
+ +
+ + ahead + + + at risk + + + behind + + steeping + triage + on track +
+ +
+ + + + + + + + {}} /> +
+ +
+ } + footer={ + <> + + + + } + style={{ width: 340 }} + > +

+ The tap-root. Everything sinks into it — grab it first. +

+
+ +

Hairline border, whispered shadow.

+
+
+ +
+ +
+ +
+ + + + + +
+ +
+ + + + + + {label ? {label} : null} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/dialog.tsx b/apps/desktop/src/renderer/src/components/ui/dialog.tsx new file mode 100644 index 0000000..301e5d9 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/dialog.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import { IconButton } from './icon-button.js' + +/** + * Modal dialog — Caslon title over a double stationery rule; used for propose-approve + * moments and destructive confirms. + */ +export interface DialogProps { + open: boolean; + onClose?: () => void; + title: React.ReactNode; + /** Right-aligned action row (Buttons) */ + footer?: React.ReactNode; + children?: React.ReactNode; + style?: React.CSSProperties; +} + +const CSS = ` +.ct-dialog-scrim { + position: fixed; inset: 0; + background: rgba(32, 38, 29, 0.4); + display: flex; align-items: center; justify-content: center; + z-index: 100; + animation: ct-dialog-fade var(--duration-base) var(--ease-out); +} +.ct-dialog { + background: var(--surface-card); + border: 1px solid var(--border-hairline); + border-radius: var(--radius-3); + box-shadow: var(--shadow-3); + width: min(480px, calc(100vw - 48px)); + max-height: calc(100vh - 96px); + display: flex; flex-direction: column; + animation: ct-dialog-rise var(--duration-base) var(--ease-out); +} +.ct-dialog__header { + display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; + padding: 20px 20px 12px; + border-bottom: 3px double var(--border-strong); + margin: 0 20px; padding-left: 0; padding-right: 0; +} +.ct-dialog__title { font: var(--text-title); color: var(--ink-1); margin: 0; } +.ct-dialog__body { padding: 16px 20px; overflow-y: auto; font: var(--text-body); color: var(--ink-1); } +.ct-dialog__footer { + display: flex; justify-content: flex-end; gap: 8px; + padding: 12px 20px 20px; +} +@keyframes ct-dialog-fade { from { opacity: 0; } } +@keyframes ct-dialog-rise { from { opacity: 0; transform: translateY(8px); } } +@media (prefers-reduced-motion: reduce) { + .ct-dialog-scrim, .ct-dialog { animation: none; } +} +`; +(function inject() { + if (typeof document !== 'undefined' && !document.getElementById('ct-dialog-css')) { + const s = document.createElement('style'); s.id = 'ct-dialog-css'; s.textContent = CSS; + document.head.appendChild(s); + } +})(); + +export function Dialog({ open, onClose, title, footer, children, style }: DialogProps) { + React.useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && onClose) onClose(); }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [open, onClose]); + + if (!open) return null; + return ( +
{ if (e.target === e.currentTarget && onClose) onClose(); }}> +
+
+

{title}

+ {onClose ? : null} +
+
{children}
+ {footer ?
{footer}
: null} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/icon-button.tsx b/apps/desktop/src/renderer/src/components/ui/icon-button.tsx new file mode 100644 index 0000000..06b8a4c --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/icon-button.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Icon } from './icon.js'; + +/** + * 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; +} + +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 +}: IconButtonProps) { + return ( + + ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/icon.tsx b/apps/desktop/src/renderer/src/components/ui/icon.tsx new file mode 100644 index 0000000..3d53115 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/icon.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +/** + * 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, 18–20 in buttons/nav. @default 16 */ + size?: number; + /** @default 1.5 */ + strokeWidth?: number; + /** Accessible label; omit for decorative icons */ + title?: string; + style?: React.CSSProperties; +} + +/* Generated from assets/icons/*.svg (lucide-static v0.462.0, ISC). Do not edit paths by hand. */ +const ICONS: Record = {"activity":"","arrow-left":"\n ","arrow-right":"\n ","arrow-up-right":"\n ","bell":"\n ","calendar":"\n \n \n ","chart-line":"\n ","chart-no-axes-gantt":"\n \n ","check":"","chevron-down":"","chevron-left":"","chevron-right":"","chevron-up":"","circle-alert":"\n \n ","circle-check":"\n ","circle-dashed":"\n \n \n \n \n \n \n ","circle-dot":"\n ","circle":"","clock-3":"\n ","clock":"\n ","coffee":"\n \n \n ","copy":"\n ","ellipsis":"\n \n ","external-link":"\n \n ","eye":"\n ","filter":"","flag":"\n ","gauge":"\n ","git-branch":"\n \n \n ","git-commit-horizontal":"\n \n ","git-merge":"\n \n ","git-pull-request":"\n \n \n ","history":"\n \n ","inbox":"\n ","info":"\n \n ","keyboard":"\n \n \n \n \n \n \n \n ","layers":"\n \n ","leaf":"\n ","link":"\n ","list-filter":"\n \n ","list":"\n \n \n \n \n ","loader-circle":"","message-square":"","milestone":"\n \n ","minus":"","moon":"","network":"\n \n \n \n ","panel-left":"\n ","pause":"\n ","pencil":"\n ","play":"","plus":"\n ","refresh-cw":"\n \n \n ","search":"\n ","send":"\n ","settings-2":"\n \n \n ","settings":"\n ","sparkles":"\n \n \n \n ","square-kanban":"\n \n \n ","sun":"\n \n \n \n \n \n \n \n ","tag":"\n ","target":"\n \n ","trash-2":"\n \n \n \n ","trending-up":"\n ","triangle-alert":"\n \n ","user":"\n ","users":"\n \n \n ","x":"\n ","zap":""}; + +export function Icon({ name, size = 16, strokeWidth = 1.5, title, style, ...rest }: IconProps) { + const inner = ICONS[name]; + if (!inner) { + console.warn('[CommiTea Icon] unknown icon: ' + name); + return null; + } + return ( + + ); +} + +export const ICON_NAMES = Object.keys(ICONS); diff --git a/apps/desktop/src/renderer/src/components/ui/index.ts b/apps/desktop/src/renderer/src/components/ui/index.ts new file mode 100644 index 0000000..9d0eea1 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/index.ts @@ -0,0 +1,21 @@ +/** + * CommiTea design-system primitives, ported verbatim from the design handoff + * (docs/design/components/). Each keeps its injected token-referencing CSS; the + * contracts in the handoff's `.d.ts` files are reproduced here as the exported + * prop interfaces. Import from `#~/components/ui` — never reach into a file. + */ +export * from './icon.js' +export * from './button.js' +export * from './icon-button.js' +export * from './badge.js' +export * from './tag.js' +export * from './card.js' +export * from './tabs.js' +export * from './input.js' +export * from './checkbox.js' +export * from './radio.js' +export * from './switch.js' +export * from './select.js' +export * from './dialog.js' +export * from './toast.js' +export * from './tooltip.js' diff --git a/apps/desktop/src/renderer/src/components/ui/input.tsx b/apps/desktop/src/renderer/src/components/ui/input.tsx new file mode 100644 index 0000000..5f72dc1 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/input.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { Icon } from './icon.js' + +/** + * Single-line text input with optional label, hint/error and leading icon. + */ +export interface InputProps { + label?: string; + /** Small gray helper under the field */ + hint?: string; + /** Replaces hint; turns the border madder red */ + error?: string; + /** Lucide icon name, leading */ + icon?: string; + /** Mono text for machine values (urls, tokens) @default false */ + mono?: boolean; + placeholder?: string; + value?: string; + onChange?: (e: React.ChangeEvent) => void; + disabled?: boolean; + type?: string; + style?: React.CSSProperties; +} + +const CSS = ` +.ct-field { display: flex; flex-direction: column; gap: 6px; } +.ct-field__label { font: 600 13px/1.2 var(--font-sans); color: var(--ink-1); } +.ct-field__wrap { position: relative; display: flex; align-items: center; } +.ct-field__icon { position: absolute; left: 10px; color: var(--ink-3); pointer-events: none; display: flex; } +.ct-input { + width: 100%; height: 34px; + font: var(--text-body); + color: var(--ink-1); + background: var(--surface-card); + border: 1px solid var(--line-2); + border-radius: var(--radius-2); + padding: 0 12px; + transition: border-color var(--duration-fast) var(--ease-out); +} +.ct-input::placeholder { color: var(--ink-3); } +.ct-input:hover:not(:disabled):not(:focus) { border-color: var(--border-strong); background: var(--paper-1); } +.ct-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.ct-input:disabled { opacity: 0.5; background: var(--paper-2); cursor: not-allowed; } +.ct-input--icon { padding-left: 32px; } +.ct-input--error { border-color: var(--danger); } +.ct-input--error:focus { border-color: var(--danger); box-shadow: 0 0 0 1px var(--danger); } +.ct-input--mono { font: var(--text-data); } +.ct-field__hint { font: var(--text-caption); color: var(--ink-3); margin: 0; } +.ct-field__error { font: var(--text-caption); color: var(--danger); margin: 0; } +`; +(function inject() { + if (typeof document !== 'undefined' && !document.getElementById('ct-input-css')) { + const s = document.createElement('style'); s.id = 'ct-input-css'; s.textContent = CSS; + document.head.appendChild(s); + } +})(); + +export function Input({ label, hint, error, icon, mono = false, style, ...rest }: InputProps) { + const cls = [ + 'ct-input', + icon ? 'ct-input--icon' : '', + error ? 'ct-input--error' : '', + mono ? 'ct-input--mono' : '', + ].filter(Boolean).join(' '); + return ( +
+ {label ? : null} +
+ {icon ? : null} + +
+ {error ?

{error}

: hint ?

{hint}

: null} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/radio.tsx b/apps/desktop/src/renderer/src/components/ui/radio.tsx new file mode 100644 index 0000000..7ec3d3f --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/radio.tsx @@ -0,0 +1,63 @@ +import React from 'react'; + +/** + * Radio button with label; group by `name`. + */ +export interface RadioProps { + label?: React.ReactNode; + checked?: boolean; + onChange?: (e: React.ChangeEvent) => void; + name?: string; + value?: string; + disabled?: boolean; + style?: React.CSSProperties; +} + +const CSS = ` +.ct-radio { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; font: var(--text-body); color: var(--ink-1); } +.ct-radio--disabled { opacity: 0.5; cursor: not-allowed; } +.ct-radio__input { position: absolute; opacity: 0; width: 0; height: 0; } +.ct-radio__dot { + width: 16px; height: 16px; flex-shrink: 0; + border: 1px solid var(--line-2); + border-radius: 50%; + background: var(--surface-card); + display: inline-flex; align-items: center; justify-content: center; + transition: border-color var(--duration-fast) var(--ease-out); +} +.ct-radio__dot::after { + content: ''; + width: 8px; height: 8px; border-radius: 50%; + background: transparent; + transition: background var(--duration-fast) var(--ease-out); +} +.ct-radio:hover:not(.ct-radio--disabled) .ct-radio__dot { border-color: var(--border-strong); } +.ct-radio__input:checked + .ct-radio__dot { border-color: var(--accent); } +.ct-radio__input:checked + .ct-radio__dot::after { background: var(--accent); } +.ct-radio__input:focus-visible + .ct-radio__dot { outline: 2px solid var(--focus-ring); outline-offset: 2px; } +`; +(function inject() { + if (typeof document !== 'undefined' && !document.getElementById('ct-radio-css')) { + const s = document.createElement('style'); s.id = 'ct-radio-css'; s.textContent = CSS; + document.head.appendChild(s); + } +})(); + +export function Radio({ label, checked, onChange, name, value, disabled = false, style, ...rest }: RadioProps) { + return ( + + ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/select.tsx b/apps/desktop/src/renderer/src/components/ui/select.tsx new file mode 100644 index 0000000..3f66f74 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/select.tsx @@ -0,0 +1,63 @@ +import React from 'react' + +import { Icon } from './icon.js' + +/** + * Styled native select with Lucide chevron. + */ +export interface SelectOption { + value: string; + label: string; +} +export interface SelectProps { + label?: string; + options: SelectOption[]; + value?: string; + onChange?: (e: React.ChangeEvent) => void; + disabled?: boolean; + style?: React.CSSProperties; +} + +const CSS = ` +.ct-select-field { display: flex; flex-direction: column; gap: 6px; } +.ct-select-field__label { font: 600 13px/1.2 var(--font-sans); color: var(--ink-1); } +.ct-select__wrap { position: relative; display: flex; align-items: center; } +.ct-select { + width: 100%; height: 34px; + font: var(--text-body); + color: var(--ink-1); + background: var(--surface-card); + border: 1px solid var(--line-2); + border-radius: var(--radius-2); + padding: 0 30px 0 12px; + appearance: none; + cursor: pointer; + transition: border-color var(--duration-fast) var(--ease-out); +} +.ct-select:hover:not(:disabled):not(:focus) { border-color: var(--border-strong); } +.ct-select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.ct-select:disabled { opacity: 0.5; cursor: not-allowed; } +.ct-select__chevron { position: absolute; right: 10px; color: var(--ink-3); pointer-events: none; display: flex; } +`; +(function inject() { + if (typeof document !== 'undefined' && !document.getElementById('ct-select-css')) { + const s = document.createElement('style'); s.id = 'ct-select-css'; s.textContent = CSS; + document.head.appendChild(s); + } +})(); + +export function Select({ label, options, style, ...rest }: SelectProps) { + return ( +
+ {label ? : null} +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/switch.tsx b/apps/desktop/src/renderer/src/components/ui/switch.tsx new file mode 100644 index 0000000..7282025 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/switch.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +/** + * On/off switch; spruce when on. For live state, not form options. + */ +export interface SwitchProps { + label?: React.ReactNode; + checked?: boolean; + onChange?: (e: React.ChangeEvent) => void; + disabled?: boolean; + style?: React.CSSProperties; +} + +const CSS = ` +.ct-switch { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; font: var(--text-body); color: var(--ink-1); } +.ct-switch--disabled { opacity: 0.5; cursor: not-allowed; } +.ct-switch__input { position: absolute; opacity: 0; width: 0; height: 0; } +.ct-switch__track { + width: 34px; height: 20px; flex-shrink: 0; + border-radius: var(--radius-round); + background: var(--paper-3); + border: 1px solid var(--line-2); + position: relative; + transition: background var(--duration-base) var(--ease-out), border-color var(--duration-base) var(--ease-out); +} +.ct-switch__track::after { + content: ''; + position: absolute; top: 2px; left: 2px; + width: 14px; height: 14px; border-radius: 50%; + background: var(--surface-card); + box-shadow: var(--shadow-1); + transition: transform var(--duration-base) var(--ease-out); +} +.ct-switch__input:checked + .ct-switch__track { background: var(--accent); border-color: var(--accent); } +.ct-switch__input:checked + .ct-switch__track::after { transform: translateX(14px); } +.ct-switch__input:focus-visible + .ct-switch__track { outline: 2px solid var(--focus-ring); outline-offset: 2px; } +`; +(function inject() { + if (typeof document !== 'undefined' && !document.getElementById('ct-switch-css')) { + const s = document.createElement('style'); s.id = 'ct-switch-css'; s.textContent = CSS; + document.head.appendChild(s); + } +})(); + +export function Switch({ label, checked, onChange, disabled = false, style, ...rest }: SwitchProps) { + return ( + + ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/tabs.tsx b/apps/desktop/src/renderer/src/components/ui/tabs.tsx new file mode 100644 index 0000000..f14757e --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/tabs.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { Icon } from './icon.js'; + +/** + * 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; +} + +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 }: TabsProps) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/tag.tsx b/apps/desktop/src/renderer/src/components/ui/tag.tsx new file mode 100644 index 0000000..e0f6bbc --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/tag.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import { Icon } from './icon.js' + +/** + * 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; +} + +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: string) { + 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 }: TagProps) { + return ( + + {label} + {onRemove ? ( + + ) : null} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/toast.tsx b/apps/desktop/src/renderer/src/components/ui/toast.tsx new file mode 100644 index 0000000..be72874 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/toast.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Icon } from './icon.js'; +import { IconButton } from './icon-button.js'; + +/** + * Transient notification card. Presentational — the consumer owns positioning/stacking. + */ +export interface ToastProps { + /** @default 'info' */ + tone?: 'ok' | 'warn' | 'danger' | 'info'; + title?: React.ReactNode; + onDismiss?: () => void; + children?: React.ReactNode; + style?: React.CSSProperties; +} + +const CSS = ` +.ct-toast { + display: flex; align-items: flex-start; gap: 10px; + background: var(--surface-card); + border: 1px solid var(--border-hairline); + border-radius: var(--radius-2); + box-shadow: var(--shadow-2); + padding: 12px 14px; + max-width: 420px; + font: var(--text-body); + color: var(--ink-1); + animation: ct-toast-in var(--duration-slow) var(--ease-out); +} +.ct-toast__icon { display: flex; margin-top: 1px; } +.ct-toast--ok .ct-toast__icon { color: var(--ok); } +.ct-toast--warn .ct-toast__icon { color: var(--warn); } +.ct-toast--danger .ct-toast__icon { color: var(--danger); } +.ct-toast--info .ct-toast__icon { color: var(--info); } +.ct-toast__content { flex: 1; min-width: 0; } +.ct-toast__title { font: var(--text-body-strong); margin: 0 0 2px; } +@keyframes ct-toast-in { from { opacity: 0; transform: translateY(6px); } } +@media (prefers-reduced-motion: reduce) { .ct-toast { animation: none; } } +`; +(function inject() { + if (typeof document !== 'undefined' && !document.getElementById('ct-toast-css')) { + const s = document.createElement('style'); s.id = 'ct-toast-css'; s.textContent = CSS; + document.head.appendChild(s); + } +})(); + +const TOAST_ICONS: Record = { + ok: 'circle-check', + warn: 'triangle-alert', + danger: 'circle-alert', + info: 'info', +}; + +export function Toast({ tone = 'info', title, onDismiss, children, style, ...rest }: ToastProps) { + return ( +
+ +
+ {title ?

{title}

: null} + {children} +
+ {onDismiss ? : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/tooltip.tsx b/apps/desktop/src/renderer/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..2d9d3da --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/tooltip.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +/** + * Hover/focus tooltip — ink capsule, plain facts only. + */ +export interface TooltipProps { + content: React.ReactNode; + /** @default 'top' */ + side?: 'top' | 'bottom'; + children?: React.ReactNode; + style?: React.CSSProperties; +} + +const CSS = ` +.ct-tooltip-wrap { position: relative; display: inline-flex; } +.ct-tooltip { + position: absolute; bottom: calc(100% + 7px); left: 50%; + transform: translateX(-50%) translateY(2px); + background: var(--ink-1); + color: var(--ink-inverse); + font: 500 12px/1.4 var(--font-sans); + padding: 5px 9px; + border-radius: var(--radius-1); + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out); + z-index: 50; +} +.ct-tooltip--bottom { bottom: auto; top: calc(100% + 7px); transform: translateX(-50%) translateY(-2px); } +.ct-tooltip-wrap:hover .ct-tooltip, +.ct-tooltip-wrap:focus-within .ct-tooltip { + opacity: 1; + transform: translateX(-50%) translateY(0); +} +.ct-tooltip code { font: 500 11px var(--font-mono); } +`; + +(function inject() { + if (typeof document !== 'undefined' && !document.getElementById('ct-tooltip-css')) { + const s = document.createElement('style'); s.id = 'ct-tooltip-css'; s.textContent = CSS; + document.head.appendChild(s); + } +})(); + +export function Tooltip({ content, side = 'top', children, style }: TooltipProps) { + return ( + + {children} + + {content} + + + ); +} From f18c6d1078b1dd110d9a92ea57242a32ccd3e691 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 12:32:05 -0400 Subject: [PATCH 5/8] =?UTF-8?q?feat(desktop):=20app=20shell=20=E2=80=94=20?= =?UTF-8?q?rail,=20chat=20panel,=20routing,=20states=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reimplements the handoff Shell in React/TS: 208px left rail (nav + connection dot + Evening service theme switch), max-1120 main column, 330px Reginald chat panel. Routing across top-level views with an issue drill-in + back-stack of one; data-theme owned by the shell. - ChatPanel: fixture write-path — echoes a canned reply so layout + interactions are real; model router wiring lands in P4. - states.tsx: EmptyState / OfflineBanner / ModelAwayState + the States specimen gallery, ported from the handoff. - PlaceholderScreen stands in for not-yet-built views (P3-3+), keeping navigation live; it also exposes the issue drill-in for now. - Gallery loses its own theme toggle (shell owns data-theme); reachable via a Primitives rail entry as a living reference. Fixtures mirrored from the handoff's data.js. typecheck + 7 e2e green (nav, theme, offline banner + disabled composer, chat echo, drill-in back-stack); light/dark/states screenshots verified. Closes P3-2. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/e2e/pages/app.page.ts | 12 +- apps/desktop/e2e/smoke.spec.ts | 59 ++-- apps/desktop/src/renderer/src/app.tsx | 4 +- .../src/renderer/src/components/gallery.tsx | 27 +- .../src/components/shell/app-shell.tsx | 278 ++++++++++++++++++ .../src/components/shell/chat-panel.tsx | 167 +++++++++++ .../components/shell/placeholder-screen.tsx | 41 +++ .../renderer/src/components/shell/states.tsx | 232 +++++++++++++++ .../desktop/src/renderer/src/data/fixtures.ts | 26 ++ apps/desktop/src/renderer/src/vite-env.d.ts | 1 + 10 files changed, 802 insertions(+), 45 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/shell/app-shell.tsx create mode 100644 apps/desktop/src/renderer/src/components/shell/chat-panel.tsx create mode 100644 apps/desktop/src/renderer/src/components/shell/placeholder-screen.tsx create mode 100644 apps/desktop/src/renderer/src/components/shell/states.tsx create mode 100644 apps/desktop/src/renderer/src/data/fixtures.ts create mode 100644 apps/desktop/src/renderer/src/vite-env.d.ts diff --git a/apps/desktop/e2e/pages/app.page.ts b/apps/desktop/e2e/pages/app.page.ts index fadb0c4..8cc5ac9 100644 --- a/apps/desktop/e2e/pages/app.page.ts +++ b/apps/desktop/e2e/pages/app.page.ts @@ -15,13 +15,19 @@ const SCREENS_DIR = join(here, '..', '.artifacts', 'screens') export class AppPage { constructor(readonly page: Page) {} - get heading(): Locator { - return this.page.getByRole('heading', { name: 'CommiTea' }) + /** The rail wordmark — present on every in-app view. */ + get wordmark(): Locator { + return this.page.getByText('CommiTea', { exact: true }) + } + + /** Navigate via a left-rail entry by its label. */ + nav(label: string): Locator { + return this.page.getByRole('button', { name: label, exact: true }) } /** Assert the shell has rendered. */ async expectLoaded(): Promise { - await expect(this.heading).toBeVisible() + await expect(this.wordmark).toBeVisible() } /** Read the `commitea` preload API surface from the renderer. */ diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index 5854c87..7f38224 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -1,33 +1,50 @@ import { expect, test } from './fixtures.js' -test('boots and renders the primitives gallery', async ({ app, window }) => { - await expect(window.getByRole('heading', { name: 'Primitives' })).toBeVisible() - await expect(window.getByRole('button', { name: 'Brew plan' })).toBeVisible() - await app.screenshot('primitives-light') +test('boots into the shell: rail, main, and Reginald panel', async ({ app, window }) => { + await app.expectLoaded() + await expect(window.getByText('Reginald', { exact: true })).toBeVisible() + // default view is Morning service + await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible() + await app.screenshot('shell-light') }) -test('primitives cover core, forms, and feedback', async ({ window }) => { - // section headings prove each cluster ported and rendered - for (const section of ['Button', 'Badge', 'Tag', 'Card', 'Input & Select', 'Feedback']) { - await expect(window.getByRole('heading', { name: section, exact: true })).toBeVisible() - } - // a mono label chip renders verbatim - await expect(window.getByText('deadline/hard')).toBeVisible() +test('rail navigation switches the main view', async ({ app, window }) => { + await app.nav('The pot').click() + await expect(window.getByRole('heading', { name: 'The pot' })).toBeVisible() + + await app.nav('States').click() + await expect(window.getByRole('heading', { name: 'States' })).toBeVisible() + await expect(window.getByText('The pot is empty')).toBeVisible() + await app.screenshot('shell-states') }) -test('dark theme toggle flips the document theme', async ({ app, window }) => { - await window.getByRole('button', { name: 'Switch to dark' }).click() +test('Evening service toggle flips the document theme', async ({ app, window }) => { + // the switch's real input is visually hidden — click the label text to toggle + await window.getByText('Evening service', { exact: true }).click() await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark') - await app.screenshot('primitives-dark') + await expect(window.getByRole('switch', { name: 'Evening service' })).toBeChecked() + await app.screenshot('shell-dark') }) -test('dialog opens on demand and closes on Escape', async ({ window }) => { - await window.getByRole('button', { name: 'Open dialog' }).click() - const dialog = window.getByRole('dialog') - await expect(dialog).toBeVisible() - await expect(dialog.getByText('Withdraw directive?')).toBeVisible() - await window.keyboard.press('Escape') - await expect(dialog).toBeHidden() +test('offline sim shows the banner and disables the composer', async ({ window }) => { + await window.getByRole('button', { name: /Connection/ }).click() + await expect(window.getByText(/Gitea isn.t answering/)).toBeVisible() + await expect(window.getByRole('textbox')).toBeDisabled() +}) + +test('chat composer echoes a canned reply (fixture)', async ({ window }) => { + await window.getByRole('textbox').fill('Push pilots first') + await window.keyboard.press('Enter') + await expect(window.getByText('Push pilots first')).toBeVisible() + await expect(window.getByText(/Noted and logged as a directive/)).toBeVisible() +}) + +test('issue drill-in and back-stack of one', async ({ window }) => { + await window.getByRole('button', { name: 'Preview an issue page' }).click() + await expect(window.getByRole('heading', { name: 'Issue' })).toBeVisible() + await window.getByRole('button', { name: 'Back' }).click() + // returns to the view we drilled in from + await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible() }) test('exposes the commitea preload API', async ({ app }) => { diff --git a/apps/desktop/src/renderer/src/app.tsx b/apps/desktop/src/renderer/src/app.tsx index 0434ef1..64be805 100644 --- a/apps/desktop/src/renderer/src/app.tsx +++ b/apps/desktop/src/renderer/src/app.tsx @@ -1,5 +1,5 @@ -import { PrimitivesGallery } from './components/gallery.js' +import { AppShell } from './components/shell/app-shell.js' export function App() { - return + return } diff --git a/apps/desktop/src/renderer/src/components/gallery.tsx b/apps/desktop/src/renderer/src/components/gallery.tsx index bd5c814..ebb2761 100644 --- a/apps/desktop/src/renderer/src/components/gallery.tsx +++ b/apps/desktop/src/renderer/src/components/gallery.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { useState } from 'react' import { Badge, @@ -33,31 +33,20 @@ function Section({ title, children }: { title: string; children: React.ReactNode * themes via the toggle. */ export function PrimitivesGallery() { - const [dark, setDark] = useState(false) const [tab, setTab] = useState('board') const [dialogOpen, setDialogOpen] = useState(false) const [checked, setChecked] = useState(true) const [radio, setRadio] = useState('a') const [on, setOn] = useState(true) - useEffect(() => { - document.documentElement.dataset.theme = dark ? 'dark' : 'light' - }, [dark]) - return ( -
-
-
-
-

Primitives

-

15 components · light + dark

-
- setDark((d) => !d)} - /> +
+
+
+

Primitives

+

+ 15 components · toggle Evening service in the rail for dark +

diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx new file mode 100644 index 0000000..b798312 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx @@ -0,0 +1,278 @@ +import React, { useEffect, useState } from 'react' + +import logoIcon from '../../design/assets/logo-icon.png' +import { PrimitivesGallery } from '../gallery.js' +import { Icon, Switch } from '../ui/index.js' +import { ChatPanel } from './chat-panel.js' +import { PlaceholderScreen } from './placeholder-screen.js' +import { OfflineBanner, StatesScreen } from './states.js' + +type View = + | 'standup' + | 'focus' + | 'inbox' + | 'capture' + | 'board' + | 'runway' + | 'directives' + | 'settings' + | 'states' + | 'firstrun' + | 'primitives' + | 'issue' + | 'calibration' + | 'milestone' + +interface NavEntry { + id: View + label: string + icon: string + count?: number | null +} + +// which phase builds each not-yet-real view (shown on its placeholder) +const PHASE: Partial> = { + standup: 'P3-3', + focus: 'P3-3', + inbox: 'P3-6', + capture: 'P3-7', + board: 'P3-4', + runway: 'P3-5', + directives: 'P3-8', + settings: 'P3-8', + firstrun: 'P3-8', + issue: 'P3-6', + calibration: 'P3-5', + milestone: 'P3-5', +} + +const TITLE: Partial> = { + standup: 'Standup', + focus: 'Morning service', + inbox: 'Inbox', + capture: 'Capture', + board: 'The pot', + runway: 'Runway', + directives: 'Directives', + settings: 'Settings', + firstrun: 'First run', + issue: 'Issue', + calibration: 'Calibration', + milestone: 'Milestone', +} + +const INBOX_UNREAD = 3 + +export function AppShell() { + const [view, setView] = useState('focus') + const [prevView, setPrevView] = useState('focus') + const [dark, setDark] = useState(false) + const [offline, setOffline] = useState(false) + + useEffect(() => { + document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') + }, [dark]) + + const openIssue = (n: number) => { + if (view !== 'issue') setPrevView(view) + void n + setView('issue') + } + + const NAV: NavEntry[] = [ + { id: 'standup', label: 'Standup', icon: 'sun' }, + { id: 'focus', label: 'Morning service', icon: 'coffee' }, + { id: 'inbox', label: 'Inbox', icon: 'bell', count: INBOX_UNREAD || null }, + { id: 'capture', label: 'Capture', icon: 'plus' }, + { id: 'board', label: 'The pot', icon: 'square-kanban' }, + { id: 'runway', label: 'Runway', icon: 'chart-line' }, + { id: 'directives', label: 'Directives', icon: 'flag' }, + ] + + const NavItem = ({ item }: { item: NavEntry }) => { + const active = + view === item.id || + (view === 'issue' && prevView === item.id) || + ((view === 'calibration' || view === 'milestone') && item.id === 'runway') + return ( + + ) + } + + const renderScreen = () => { + switch (view) { + case 'states': + return setView('capture')} /> + case 'primitives': + return + case 'issue': + return ( +
+ + +
+ ) + default: + return ( + openIssue(1)} + /> + ) + } + } + + return ( +
+ {/* left rail */} + + + {/* main */} +
+
+ {offline ? : null} + {renderScreen()} +
+
+ + setView('directives')} offline={offline} /> +
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx new file mode 100644 index 0000000..fde4e09 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx @@ -0,0 +1,167 @@ +import React, { useEffect, useRef, useState } from 'react' + +import { CANNED_REPLY, CHAT, type ChatMessage } from '../../data/fixtures.js' +import { Icon, IconButton } from '../ui/index.js' + +/** + * Reginald's panel — chat is the write-path (decisions.md D1). This is the P3-2 + * fixture shell: it echoes a canned reply so the layout + interactions are real, + * but no model is wired. P4 replaces `send` with the model router + tools. + */ +export interface ChatPanelProps { + onOpenDirectives?: () => void + offline?: boolean +} + +export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) { + const [msgs, setMsgs] = useState(CHAT) + const [text, setText] = useState('') + const [thinking, setThinking] = useState(false) + const scrollRef = useRef(null) + + useEffect(() => { + const el = scrollRef.current + if (el) el.scrollTop = el.scrollHeight + }, [msgs, thinking]) + + const send = () => { + const t = text.trim() + if (!t) return + setMsgs((m) => [...m, { from: 'user', text: t }]) + setText('') + setThinking(true) + setTimeout(() => { + setThinking(false) + setMsgs((m) => [...m, { from: 'agent', text: CANNED_REPLY }]) + }, 900) + } + + return ( +