Public: anyone can read this; the crew can change it.

Split docs/DESIGN.md into one file per section #5948

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/5937-split-docs-design-md-into-one-file-per-s → main 9 commits fork 3 threads · 3 unresolved

  • Merged. mason/5937-split-docs-design-md-into-one-file-per-s was merged into main as 840d550.

Review

Sign in to review this change.
34 files changed +1261 −1232
README.md +7 −2
@@ -1732,11 +1732,16 @@ The design check (`test/design-lint.test.ts`, run in the gate) fails a pull that
17321732 adds a colour literal or an off-scale `font-size` beyond
17331733 `docs/design/baseline.json`, or defines a class `docs/DESIGN.md` §5 does not
17341734 name; the message names the file or the class. The way out is to use a token
1735 (`var(--…)`) or a component §5 names; a new component goes into the §5 table
1736 first. When a change lowers the counts, run `bun scripts/design-lint.ts
1735 (`var(--…)`) or a component §5 names; a new component is named in its own
1736 `docs/design/areas/<area>.md`. When a change lowers the counts, run `bun scripts/design-lint.ts
17371737 --update` and commit the baseline; when it deletes the last rule for a class
17381738 in the §5d register, strike the name there too. See `docs/design/plan.md`.
17391739
1740 `docs/DESIGN.md` is a spine: each of its sections is its own file under
1741 `docs/design/sections/` (§5d is `5d-page-local-classes.md`), linked from the
1742 spine in order, and the check reads them in that order. A new section is a
1743 new file and a new line in the spine.
1744
17401745 `bun run check` is the gate: the same command `forge.json` declares, so what
17411746 it says here is what the deploy will say. It type-checks once, then runs the
17421747 test files `scripts/gate.ts` admits — every `test/*.test.ts` not in its
docs/DESIGN.md +35 −1218

Diff not shown — 114,070 bytes of diff (limit 100,000). +35 −1218

View file
docs/design/areas/README.md +1 −1
@@ -4,4 +4,4 @@ A new component class is named here, in a file for its area (`docs/design/areas/
44 (`` `.my-class` ``) with a line on what it is for. The design check reads every file in this directory as §5 vocabulary,
55 so two pulls documenting different components touch different files and never conflict on `docs/DESIGN.md`.
66
7 Do not edit `docs/DESIGN.md` §5d: it only shrinks. Prefer a component §5 already has.
7 Do not add to §5d (`docs/design/sections/5d-page-local-classes.md`): it only shrinks. Prefer a component §5 already has.
docs/design/sections/1-principles.md added +19
@@ -0,0 +1,19 @@
1 ## 1. Principles
2
3 1. **Border-first, not shadow-first.** Elevation in a light theme is a hairline
4 plus a 5%-opacity shadow. In a dark theme it is a lighter surface and a
5 stronger border — dark themes have no shadows. If something looks flat in
6 the dark, its surface is wrong, not its shadow.
7 2. **One accent per page.** `--accent` means _interaction_: focus rings, links
8 on hover, the active choice. It is never a status color. If two things on a
9 page are accent-colored and only one is the action, one of them is wrong.
10 3. **Hierarchy from weight and color, not size.** 14px does almost everything.
11 A title is 20px because it is a title, not to be noticed.
12 4. **Everything works with JavaScript off.** Script may make a thing instant.
13 It may never make a thing possible. A control that does nothing without JS
14 is worse than no control.
15 5. **Anything that navigates is `<a>`; anything that mutates is a `<button>`
16 in a `<form method=post>`.** There is no third case, and the CSP forbids
17 inventing one.
18 6. **Never say what you don't know.** A missing thing and a thing you may not
19 see are the same 404, with the same words.
docs/design/sections/2-tokens.md added +32
@@ -0,0 +1,32 @@
1 ## 2. Tokens
2
3 Palettes live in `src/design/themes.ts`; the stylesheet only ever reads
4 variables, so a theme is data. Six ship today. What each role _means_:
5
6 | token | role |
7 | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
8 | `--bg` | the page. Nothing else uses it. |
9 | `--card` | any raised surface: card, menu, dialog, input, button. |
10 | `--muted` | a recessed surface: row hover, table head, code block, pill. |
11 | `--fg` | body text, and the **active tab underline**. |
12 | `--muted-fg` | all metadata. Never a hardcoded gray — muted is a desaturated hue of the surface, so it warms and cools with the theme. |
13 | `--faint` | decoration only: separators between crumbs, gutter numbers, icons inside rows. Never load-bearing text. |
14 | `--border` | every hairline. |
15 | `--border-strong` | a raised edge, and hover on a control. |
16 | `--primary` / `--primary-fg` | the one filled button per page, and the live-activity pill. |
17 | `--accent` / `--accent-fg` | interaction: focus ring, link hover, selection, the review-comment rail. |
18 | `--success` | open issue, mergeable pull, additions. |
19 | `--danger` | closed, destructive, deletions. |
20 | `--merged` | a merged pull. Purple, and only that. |
21 | `--attn` | draft, pending, a highlighted line. |
22 | `--add-*` / `--del-*` / `--wd-*` | the diff. Tints stay ≤14% so syntax color survives on top. |
23 | `--tok-*` | syntax, in two layers: eleven colour classes every consumer knows, plus finer kinds the web paints over them (§6c). |
24 | `--shadow-1` / `--shadow-2` | a hint and an overlay. Near-transparent in dark palettes, so one rule serves every theme. |
25 | `--series-0…7` / `--qr-ground` | the same in every theme (`FIXED_TOKENS`): chart series (Okabe–Ito, colour-vision safe) and a QR code's white ground. |
26
27 **Status surfaces are tinted, never saturated**: `color-mix(in srgb, var(--x)
28 12%, transparent)` for the fill, the pure token for the text, a 34% mix for the
29 border.
30
31 **Contrast targets**: body ≥7:1 where it's cheap, `--muted-fg` ≥4.5:1 on
32 `--bg`, borders ≥1.5:1 against their surface, focus ring ≥3:1.
docs/design/sections/3-typography.md added +38
@@ -0,0 +1,38 @@
1 ## 3. Typography
2
3 ```
4 --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif
5 --mono: ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Code", Menlo, Consolas, monospace
6 ```
7
8 | role | size | weight | tracking | line-height |
9 | -------------------------------- | -------- | ------ | -------- | ----------- |
10 | page title `h1` | 20px | 620 | −0.02em | 1.25 |
11 | section `h2` | 16px | 600 | −0.01em | 1.3 |
12 | sub `h3` | 14px | 600 | 0 | 1.4 |
13 | **body, rows, chrome** | **14px** | 400 | 0 | 1.5 |
14 | **prose (`.prose` only)** | **15px** | 400 | 0 | 1.65 |
15 | metadata | 13px | 400 | 0 | 1.45 |
16 | mono (code, sha, diff) | 12.5px | 400 | 0 | 1.55 |
17 | micro (`kbd`, `.pill`, `.badge`) | 11.5px | 500 | 0 | 1 |
18
19 Rules:
20
21 - **Headings tighten as they grow.** Body text is never tracked.
22 - `font-variant-numeric: tabular-nums` on every count, date, line number,
23 diff stat and file size — a count going 9 → 10 must not shift its neighbours.
24 That is `.num`, `.pill`, `.badge`, `.sha`, `.dn`, `.table td`.
25 - `text-wrap: balance` on headings and empty-state titles; `pretty` on prose
26 and descriptions.
27 - **Truncation is honest**: one line, ellipsis, and a `title` with the whole
28 string. Never a fade-out mask — masks lie about whether there is more.
29 - **A token count is shown where a byte size is shown, never as a badge on
30 its own.** Tokens are a unit, like KB: they sit beside a file's size, a
31 directory's total, a run's cost or a window's dollars and percentage, so
32 the context intuition grows next to the storage and money ones. One
33 formatter (`kernel/tokens-format.ts`, rendered by `tokenCount()` /
34 `sizeWithTokens()` in `web/ui.ts`) writes them all: `850 tok`, `1.2k tok`,
35 `48k tok`, `1.1M tok`. A count the provider reported is bare; an estimate
36 (bytes ÷ a stated bytes-per-token ratio for prose, code or data) carries a
37 `~`, and every count's `title` says which tokenizer and whether it is an
38 estimate. A binary file has no token count — no number beats a wrong one.
docs/design/sections/4-space-radius-motion.md added +20
@@ -0,0 +1,20 @@
1 ## 4. Space, radius, motion
2
3 - **4px grid.** Every padding, margin and gap is divisible by 4; 6px is allowed
4 _inside_ a control. Scale: `2 4 6 8 12 16 24 32 48 64`.
5 - **Three radii, held to.** `--radius-sm: 6px` on controls (button, input,
6 chip, kbd), `--radius: 8px` on surfaces (card, code block), `--radius-lg:
7 12px` on overlays (menu, dialog, palette), `999px` on pills and avatars.
8 A nested element uses the outer radius minus the border width, never the same.
9 - **Dividers**: `border-top` on `:not(:first-child)`, never `border-bottom` on
10 all — that is where the double line at the end of a list comes from.
11 - **Density**: dense rows (files, commits, issues) 40px; anything touchable is
12 ≥44px under `@media (pointer: coarse)` — via padding, so text stays centered.
13 - **Motion**: 100–200ms, `cubic-bezier(.2,0,0,1)`, enter only. Only `color`,
14 `background-color`, `border-color`, `opacity` and `transform` are ever
15 animated. Never `height`, never `box-shadow` on scroll. Everything is inside
16 the `prefers-reduced-motion` kill switch.
17 - **Focus** is the accent at 2px with 2px offset, identical on every control,
18 and inset (−2px) on full-bleed rows so nothing reflows. Never `outline: none`.
19 - **Sticky, and only these**: the topbar, a diff file's header, the file path
20 header. Everything anchorable carries `scroll-margin-top`.
docs/design/sections/5-components.md added +111
@@ -0,0 +1,111 @@
1 ## 5. The component vocabulary
2
3 Implemented in `src/web/ui.ts` (pure) and `src/web/chrome.ts` (may read the
4 database). One class, `data-*` for variants. "Terminal" is the shape the same
5 component takes in a TUI — a note for the terminal thread to fill in, not a
6 specification.
7
8 | component | web class | variants (`data-*`) | states | terminal equivalent |
9 | --------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ------ | ------ |
10 | **button** `btn()` | `.btn` | `variant=primary\|secondary\|outline\|ghost\|danger\|link`, `size=sm\|lg` | hover, active, `:focus-visible`, `[disabled]`, `[aria-busy]` | a labelled key or a highlighted action cell |
11 | **icon button** `iconBtn()` | `.btn.btn-icon` | same | `aria-label` required | a single glyph with a status-bar hint |
12 | **input / textarea / select** `field()` | `.input` | `size=sm`, `[aria-invalid]` | focus, `:user-invalid`, `[readonly]` | a line editor with an inline error row |
13 | **form field** `field()` | `.field` (`label`, `.hint`, `.error`) | `data-invalid` | — | label line, value line, hint line |
14 | **checkbox** | `.check` | — | native, `accent-color` themed | `[x]` / `[ ]` |
15 | **badge** `badge()` / `stateBadge()` | `.badge` | `variant=secondary\|outline\|success\|danger\|merged\|attn`; `data-state=open\|closed`, `data-pr=merged` | — | a bracketed word in the state color |
16 | **stat** `stat()` | `.stat` (`.stat-label`, `.stat-value`, `.stat-note`, `.stat-spark`; grid: `.stats`) | `data-tone=attn` | hover (`--border-strong`, only when `href` makes it a link, named by `aria` when given); fixed heights, so a changing value shifts no neighbour; value is `.num` with its full form in `title`; note clamps to two lines, its whole text in `noteTitle`; the sparkline carries its own `role=img` and `aria-label`, or is decorative; `.stats` auto-fits columns of at least 160px | a number under its label, a note under that, an optional sparkline top right |
17 | **label chip** `labelChip()` | `.label` | color via `--label` (see §7) | — | a colored `‹name›` |
18 | **author** `userLink()` / `userTag()` | `.byline` (`.byline-name`) | — | `userTag` inside anything already a link — see below | `@name` in the avatar hue |
19 | **name mark** `nameMark()` | `.name-mark[data-mark]` | `owner` / `crew`, from the account | never typed: escaped names cannot make it | beside the name, after the link |
20 | **avatar** `avatar()` | `.avatar` | `size=md\|lg`, `data-h=0..11` | initials, deterministic hue | two initial letters in the same hue |
21 | **count** | `.count` | — | glyph plus a tabular number | `⌸ 3` |
22 | **stat pill** `pill()` | `.pill` | `data-tone=accent\|danger` | tabular | a parenthesised count |
23 | **status dot** `dot()` | `.dot` | `data-tone=success\|danger\|attn\|merged`; `data-series=0…7` (`seriesDot()`) is a chart series' swatch, in its `--series-N` colour | — | `●` in the tone |
24 | **sha** `sha()` | `.sha` | — | first 7 chars | the same 7 chars, dim |
25 | **progress bar** `progress()` (`pages/milestones.ts`) | `progress` (element rule in `style/components.ts`; every `<progress>` on the site) | — | native `<progress max value>`, 4px, full width, an `aria-label` saying what is done; no width rule per value | `▓▓▓░░ 3 of 5` |
26 | **meter** `meter()` | `meter` | `data-tone=attn` when the share is a limit reached | native `<meter min=0 max=100>`, a whole percentage, an `aria-label` saying what it is a share of; no width rule per value, so no inline style | `▓▓▓░░ 62%` |
27 | **kbd** `kbd()` | `kbd` | — | — | a reverse-video key name |
28 | **card** `card()` / `listCard()` | `.card` (`.card-head`, `.card-links`, `.card-body`, `.card-foot`) | `data-tone=success\|danger\|merged\|attn` | — | a box with a title bar |
29 | **list row** `row()` | `.row` (`.row-lead`, `.row-main`, `.row-title`, `.row-meta`, `.row-trail`, `.row-actions`, `.row-children`; settings: `.setting-list`, `.row-fields`) | `data-state=open\|closed\|merged\|draft` — colours the **lead glyph**, so a list filtered by state carries no badge repeating the filter | hover, focus, `data-nav`; `fold` makes the row a `<details>`'s `<summary>`, its lead the caret turned when open (/map's lanes); `children` nests the rows under a row in a `<ul class="row-children">` inside its `<li>` — a tree (/family), a step in under a rule, real list nesting for assistive tech; `aria-current="true"` on the `<li>` (`current`) tints the row; `.row-meta` is one honest line: nowrap, ellipsis, `title` — except in a `.setting-list`, where it wraps | one line: glyph, title, right-aligned meta |
30 | **edit row** | `.edit-row` | — | one line on a desktop, stacked below 720px | a form line |
31 | **colour field** | `.color-field` | — | native `<input type=color>` plus its hex; works with no script | a hex entry |
32 | **table** | `.table` | `data-dense`; `.num` cells right-aligned | — | aligned columns |
33 | **tabs** `tabs()` | `.tabs > a` | — | `aria-current="page"` + 2px `--fg` underline | a header row with the active item underlined |
34 | **segmented control** `seg()` | `.seg > a` | — | `aria-current`, or `:has(:checked)` for radios | `‹ Open | Closed | All ›` |
35 | **menu** `menu()` | `.menu` (`<details>`) | `data-align=start\|end` | open/closed | a popup list |
36 | **disclosure** `disclosure()` | `.disclosure` (`<details>`, a quiet `›` summary) | `small` summary, `label` (aria-label), `extra` classes | open/closed; the chevron turns, the summary is at least 44px tall | a folded section |
37 | **dialog** | `.dialog` | `data-side=bottom` (sheet) | JS `showModal()`; every dialog has a real page behind it | a modal box |
38 | **tooltip** | `[data-tip]` | `data-tip-side=bottom` | hover/focus; hidden on coarse pointers | a status-bar hint |
39 | **toast / flash** `Flash` | `.toast` | `data-tone=success\|danger\|attn` | one per page load, cookie-driven | a one-line banner |
40 | **command palette** | `.palette` | — | JS only; degrades to `/search` | a fuzzy-find overlay |
41 | **breadcrumb** `crumbs()` | `.crumbs` | `.mono` | `aria-current` on the last | `a / b / c` |
42 | **pagination** `pager()` | `.pager` | — | cursor links, absent ends | `‹ Newer Page 2 Older ›` |
43 | **timeline item** `tlEvent()` / `tlComment()` | `.tl-item` | `data-kind=comment\|event`, `data-tone` | — | a card, or one dim line with a glyph |
44 | **empty state** `empty()` | `.card.empty`, title `.empty-title` | — | icon, one title, one sentence, **exactly one** action | the same three lines, centered |
45 | **diff file** | `.diff-file` | `data-status`, `<details open>`, `.diff-viewed` | collapsed, viewed | a foldable file section |
46 | **code block** | `.code` | `:target` / `[data-hl]` line highlight | — | a numbered gutter |
47 | **prose** | `.prose` | — | 15px, 72ch measure | wrapped markdown |
48 | **settings shell** `settingsLayout()` | `.settings` (`.settings-nav`, `.settings-main`) | — | sticky nav ≥860px, scroll strip below | a sidebar and a pane |
49 | **repo header** `repoHeader()` | `.repo-head` | — | tab counts, Settings only for admins | a title line and a tab row |
50 | **where strip** `whereStrip()` (`scope.ts`) | `.where > .where-place` | `data-state=reading\|present\|absent` — accent dot, success dot, faint dot — the dot is the place's `aria-hidden` glyph span, its aside `.muted` | three places, always in this order and these words; a place is a link where one exists, and the third is the pin toggle | `● codeberg.org · ● workbench · ◆ this machine` |
51 | **live-browse banner** `liveBanner()` | `.live-note` | — | only on a live browse, above the content, one sentence | one accent line under the tab row |
52 | **where-it-lives block** | `.where-block` | — | origin, last fetch, Sync now and where pushes land, as one block in the About rail | the same four lines under a rule |
53 | **mirror stages** `stageList()` | `ol.list.stages[aria-live=polite] > li > .row` | `data-state=done\|now\|todo\|failed` (failed: the stage the job stopped on, a danger "Failed" badge) | the stage order is the markup order; a script updates it in place and goes to the mirror on done; without JavaScript the same markup renders and a `<noscript>` Reload link re-reads it. Nothing here refreshes itself: no `<meta refresh>`, no timer | a checklist with `●`/`◐`/`○` |
54 | **origin chip** (`chrome.ts`) | `.repo-origin > .origin-chip` | the forge's mark: `github` / `forgejo` / `gitea` | the older one-line form, still used off the repo header | one `mirrored from owner/name @ host` fact line |
55 | **origin state badge** `badge()` | `.badge` | `success` healthy · `danger` needs re-auth · `attn` error | the error message sits under the row, never inside the badge | the same three words beside the row |
56 | **imported author** `originTag()` | `.byline.origin-by` | the origin's mark instead of an avatar | never a link to `/name` — that would be a different person | `▲ login` |
57 | **pin toggle** `whereRow()` | `.repo-where form.where-pin > .btn` | `outline` unpinned · `secondary` pinned | reads `Keep on my machine` / `On my machine ◆`; the strip's third place submits the same form by id, so a `<button>` can sit in a row of links; the wanted state rides the form, so a double submit is idempotent | `p` on a row, `P` on a repo home; `◆` marks a pinned row |
58 | **device code** | `.device-code` | — | the one deliberately large type in the product: monospace, tabular, `user-select: all` | the code spaced out and centred in a card, with a spinner under it |
59 | **sticky action** `stickyAction()` | `.sticky-action` | — | static ≥720px | a footer action bar |
60 | **count group** `counts()` / `repoCounts()` | `.counts > .count` | — | icon plus a tabular number; **a zero is never drawn** — the absence is the same information | `⊙3 ⑂1` after the row title |
61 | **file explorer** `explorerColumn()` | `.code-layout` (`.explorer-panel`, `.explorer`, `.ex-dir`, `.ex-file`, `.ex-more`) | — | the path's ancestors arrive `open`; the current file is `aria-current`; a folder off the spine holds one `.ex-more` link so it works with no script | a left pane the cursor walks |
62 | **fuzzy finder** `finderDialog()` | `.dialog.palette.finder` | — | JS only; `t` opens it, the explorer beside it is the fallback | the file picker overlay |
63 | **file editor** | `.editor-form` (`.editor-head`, `.editor-wrap`, `textarea.editor`, `.editor-gutter`, `.editor-commit`) | — | a plain `<textarea>` in a `<form>`; the gutter, tab key, auto-indent and Preview tab are all enhancement | a modal buffer with a commit prompt |
64 | **branch row** | `.branch-row` (`.branch-div` → `.ahead` / `.behind`) | — | default first, `Default` and `Protected` badges, `↑ahead ↓behind` | one line per branch with the same arrows |
65 | **code hit** | `.code-hit` (`.code-line`, `mark`) | — | `path:line` in mono, the matching line beneath it with the query marked | the same two lines, the match reversed |
66 | **verdict badge** `verdictBadge()` | `.badge` / `.badge.verdict-stale` | `success` approved · `danger` changes requested · plain commented | a verdict passed before the latest push renders `.verdict-stale`: same words, no colour, and the line says "before the latest push". It is history, not a position — and an approval a push dismissed must never look current | `[✓ approved]` / `[! changes]`, dim when stale |
67 | **reviews chip** | `.reviews-chip` | — | one line inside the merge box: `✓ 2 approvals · ! changes requested by bob · an approval is required`. It names people, because a name is somebody to go and talk to and a number is not | the same line under the merge verdict |
68 | **review card** | `.card.review-card` (`.review-verdicts`) | — | one form, radios plus an optional body. Which radios appear is the rule made visible: no Approve for the author, none of the gating verdicts for a reader — both refused in `submitReview` too | a three-way prompt with a body field |
69 | **review thread** | `.dc-review` (a `card()` per thread) | — | open: the comments, then Resolve. Settled: **one line** — `✓ resolved by alice · 2h ago` — with the conversation behind a small `disclosure()` and Unresolve in the card's foot. Resolving is for getting an answered question out of the way of the unanswered ones | a folded thread with a `✓` header |
70 | **propose card** | `fieldset.editor-target.propose` (`.propose-line`) | — | replaces "Where this lands" when the change is a contribution: the whole outcome in one sentence with **both** repository names, then branch, pull title and pull body. There is no "commit directly" radio, because there is no such option | the same sentence above the commit prompt |
71 | **run state badge** `runStateBadge()` | `.badge` | `queued` · `running` (attn) · `ok` (success: _Passed_, or _Done_ for an agent run) · `failed` (danger) · `cancelled` (outline) | the one badge a check wears on a pull, a run wears in a list, and a log page wears in its head; the log page's script repaints it on `done` | `[✓ passed]` / `[✗ failed]` / `[· running]` |
72 | **env state badge** `envStateBadge()` | `.badge` | `stopped` (outline) · `deploying` (attn) · `running` (success) · `failed` (danger) | what production, or a preview, is doing now; `envTone()` is the same answer as a `dot()` for a list row | the same word in the same tone after the environment's name |
73 | **app strip** | `.app-strip` | — | one line under the repo header of a repository that runs: env state badge, `sha`, the production URL as a link **through `…/app/production/open`** (never the bare hostname — a private app would 404), the preview count, the pace (`first preview 2m 14s after New app · in production 9m`, each part once it exists), App (the studio app page)| one line under the where strip: `● running abc1234 · hello.localhost` |
74 | **environment card** | `.card.env-card` (`.env-facts`, `.env-error`) | `data-tone=danger` when failed | name and state in the head; `envFacts()` in the body — commit, how fast it came up, uptime, a link to the deploy run, and the deployment's own note and error, each on its own line; Open (or Wake it) below. It is a pull's preview card; the facts and `envActions()` (Redeploy / Stop, writers only) are functions, not markup, so the studio head and its preview rows render the same ones | a box per environment with the same three lines |
75 | **checks line** | `.checks-line` (`.ok`, `.bad`, `.now`) | — | inside the merge box, in the reviews chip's voice: `✓ Checks passed · deploy abc1234 · a passing deploy is required`. Rendered only where the repository runs as an app or requires checks; a merge box gains _Waiting on checks_ | the same line under the review line |
76 | **run log** | `pre.run-log` | `data-run` while the run is going | the log as written, wrapped so a phone never scrolls sideways; a script appends the run's lines from the page's one `/events` stream and follows the bottom until the reader scrolls up; the page never reloads itself | a scrolling pane that follows the tail |
77 | **assignee card** | `.card` with an `.edit-row` form | — | who is on it, then one select and one button for a writer. People first, the crew last and marked `(agent)`, because assigning an agent is what starts it working and the choice is not the same kind of choice | a picker with people, then agents |
78 | **inbox** (`asks_page.ts`) | one list above the feed | — | _On you_: what asks you and what is assigned to you, one row each, each with what it waits for (`needsYouSection`), and one count in the top bar. Absent when empty: a card saying nothing is a zero drawn | the same two panes on the dashboard |
79 | **look** (`pages/looks.ts`) | `.look` in a `.looks` strip | `.look[data-slot=phone\|desktop\|thumb\|now]` sizes the frame; `.looks[data-compare]` sets two sides (before · main, then after · the pull) beside each other | a picture always carries its own `width`/`height`, so nothing shifts when it loads; a phone keeps 200px, a desktop takes the rest up to its own size, and under 640px a strip stacks; an attached picture is a `figure` in the strip with its line under it | the picture in the terminal (§6·0c) |
80 | **board** (`pages/board.ts`) | `.board` (`.board-grid`, `.board-region`) | `data-wall` (across the room: no keys, no pointer, larger), `data-focus=working\|landed\|decided\|today` | the one whole-screen layout: four live regions fill a second monitor ≥1100px and each list scrolls on its own, one column below; above the panels only a collapsed one-line `#notes` (`<details>`: closed by default, an open list stays open across live swaps), the strips below them; inside, only §5 parts (list card rows, badges, meters, `kbd`); a swapped region's heading wears `data-fresh` for a moment | four panes, one focused by its number |
81 | **stacked bar chart** `windowSvg()` (`fleet-graph.ts`) | `.plots`, `.plot` (`.plot-name`), `.plot-legend` | bars wear `fg-sN`, the `--series-N` token | server-drawn SVG with `width`/`height` set, so it holds its box before the stylesheet; gridlines `--border`, figures `--muted-fg` in mono, panel names `--faint`; `role="img"` with an `aria-label` totalling it, a `<title>` per bar, and a table of the same numbers beside it | a table of the same numbers |
82 | **chart** `chartBox()` (`ui.ts`) | `.chart-box` wrapping `drawChart`'s svg `.chart` (`.big` for the headline figure) | `.is-table` for a table panel | one query or series drawn on the server: the svg carries its own `width`/`height` and the box holds its space, so nothing shifts; `role="img"` with an `aria-label` of its title. Reach for it wherever a page draws a `boards/chart.ts` chart — `/system`'s memory pair and every board panel — and size it from the page (`.bp-*`, `.plots`, `.bgrid`), never inside the frame | the same numbers as a table |
83
84 Two things are deliberately **not** in the vocabulary: skeleton loaders (the
85 server renders whole pages — there is nothing to skeletonise) and a custom
86 switch control (a native checkbox with `accent-color` is themed, keyboard-correct
87 and honest about what it is).
88
89 Three rules the browser enforces whether or not you meant them:
90
91 - **A name says who it is, and nobody types that.** Beside the owner's name
92 and each agent's, `userLink()` wears a `nameMark()` — `owner`, `crew` —
93 drawn from the account (`core/marks.ts`: an admin who is a person; an
94 agent), never from a display name. Every name is escaped, so no typed word,
95 alphabet or emoji produces the `data-mark` element; a byline without one is
96 nobody the mark vouches for. A page asks `marksByName()` once for the names
97 it will show. `test/name-marks.test.ts` tries the disguises.
98 - **A link inside a link is not a link.** `<a>` nested in `<a>` is not merely
99 invalid — the parser closes the outer anchor and re-parents everything after
100 it as a _sibling_, so the row visibly falls apart. Anything rendered inside a
101 linked `.row` uses the non-anchor form: `userTag()`, not `userLink()`; plain
102 text, not a repo link. `test/web-ui.test.ts` sweeps the list pages for it.
103 - **Whitespace is content inside `white-space: pre`.** The diff cell (`.dc`) is
104 pre-formatted, so a pretty-printer's newline between `<td>` and its span
105 renders as a blank line and an indent. Assemble such a cell into one value
106 and interpolate it whole; `.dcx` holds the `pre`, so a stray newline costs a
107 collapsed space rather than a broken line.
108 - **A grid or flex item's automatic minimum size is its content.** A column
109 holding one long unbreakable line pushes the page wider than the phone
110 instead of letting its own ellipsis work. Every page grid sets
111 `min-width: 0` on its children.
docs/design/sections/5a-reading-from.md added +35
@@ -0,0 +1,35 @@
1 ### 5a. Saying where you are reading from
2
3 Every repository page in the product carries the **where strip**, and it says
4 the same three things in the same order however you got there:
5
6 ```
7 ● codeberg.org · ○ workbench · ○ this machine browsing live, read-only
8 ● codeberg.org · ● workbench (synced 3m ago) · ◆ this machine a mirror you keep
9 ● workbench · ○ this machine a repository born here
10 ```
11
12 - **Filled `●` means present, hollow `○` means absent, `◆` means kept on this
13 machine.** The dot carries the colour; the word never does, so a strip reads
14 as one fact rather than three competing badges.
15 - **Accent marks the place these bytes came from.** That is the page's own
16 subject, which is the one thing accent is allowed to be besides interaction
17 (§1.2). Exactly one place per strip wears it.
18 - **Every place is a link where one exists**: the origin goes to the repository
19 on that forge, the workbench to the mirror here, and "this machine" submits
20 the pin toggle.
21 - A repository that is **your fork of** another one says so in the strip, as
22 the origin place's aside: `● codeberg.org (fork of octo/widgets)`. The About
23 rail carries the same fact as a linked `Fork of` row — the strip is for
24 orienting, the rail is for going there.
25 - A live browse also carries `.live-note` above the content, saying it is
26 read-only and what to do about that. There is no second way to find out: a
27 live page renders no composer, no state form, no labels form, no merge box
28 and no settings tab, because `RepoSource` has no mutation on it to submit to.
29 The three forms it does carry — Mirror, Fork, Propose a change — create
30 something **for you**: a copy here, a fork of your own there, a proposal on
31 it. None of them changes the repository being read.
32
33 The strip is the visible half of `RepoScope` (`src/web/scope.ts`); the pages it
34 sits on are the same renderers for both sides of the world, and a divergence
35 between a live browse and the repository we hold would be a bug you could see.
docs/design/sections/5b-workflow-units.md added +42
@@ -0,0 +1,42 @@
1 ### 5b. The workflow units
2
3 A unit is a whole intention with one button behind it, not a sequence of
4 screens you assemble yourself. Five of them, and the web renders each as one
5 form:
6
7 **Fork** — on a live browse and on every picker row you cannot push to. It
8 POSTs `…/fork`, forks at the origin, and answers with the mirror job page,
9 exactly as Mirror does, because the local copy of a fork is a job with stages.
10 The flash names both halves and the host: `Forked octo/widgets to me/widgets at
11 codeberg.org.` Not offered on a repository the connected account already owns —
12 neither forge forks you to yourself. A native repo keeps its own `…/fork`.
13
14 **Propose a change** — where the origin says you may not push, Edit and New
15 file become Propose a change and open the ordinary editor with the propose card
16 under it. One POST to `…/contribute` forks if needed, branches on the fork,
17 commits, and opens the pull upstream with the `owner:branch` head form. You land
18 on the pull: its page here if we mirror the upstream, its live-browse page if we
19 do not — never thrown out to another forge mid-workflow. Every refusal
20 re-renders the editor with the text still in the textarea (`§6c`'s rule: never
21 lose a keystroke).
22
23 **Pass a verdict** — the review card, one POST to `…/review-state`. Verdicts
24 land in the timeline as `verdictBadge` items in time order with the comments and
25 the state changes, because a verdict is a thing that happened at a time.
26
27 **Settle a thread** — every review comment sharing an anchor is one
28 conversation, resolved by a writer or by anybody in it, at
29 `…/threads/:anchor/{resolve,unresolve}`. The files header counts them:
30 `3 threads · 1 unresolved`.
31
32 **Merge, or not** — the merge box gains a sixth state, _Waiting on review_,
33 whenever `require_review` is unsatisfied or a change is outstanding. The button
34 is disabled and the chip says who to talk to; the refusal is `mergePull`'s
35 (`review_required`), and the disabled button is only the honest preview of it.
36 `Require an approving review before merging` is a checkbox in General settings.
37
38 One rule holds all five: **the page never decides.** Every affordance here is
39 rendered from the same answer the action will give — `permissions.push` for
40 propose, `status.reviews` for the gate, `canWrite` plus thread authorship for
41 resolve — so a button that is absent and a call that is refused are the same
42 fact said twice, and never two different facts.
docs/design/sections/5c-apps-runs-crew.md added +51
@@ -0,0 +1,51 @@
1 ### 5c. Apps, runs and the crew
2
3 `docs/APPS.md` is the contract; these are its units on a page, and the same
4 rule holds: the page never decides. Six of them:
5
6 **New app** — `/apps/new`, one form: a name, a sentence about what it should
7 do, visibility, and an agent from the crew when there is one. One POST makes
8 the repository with a working app in it, makes it an app, starts production,
9 and — with a sentence and an agent — opens issue #1 assigned. The flash names
10 the production URL and the agent. A forge with no agents says so on the form
11 in one line and offers the same button without the select.
12
13 **Run as an app** — on a repository's settings, the App section: enable with a
14 slug, an access choice and the preview-data policy; edit them; disable behind
15 a confirm page. The app strip (§5) appears on the repository the moment it is
16 on.
17
18 **Open** — never the bare hostname. Every link to an environment goes through
19 `…/app/:env/open`, which checks `canRead` and answers with the URL — carrying a
20 grant for a private app — so the same link works for a public app and a
21 private one and a stranger gets `not_found` from the forge rather than a
22 guess at a hostname.
23
24 **Redeploy / Stop** — two POSTs on the studio app page (`studio/app.ts`;
25 dev's old `/:owner/:name/app` redirects there), writers only: production's
26 in the head card, each preview's on its row; a stop keeps the data (a
27 redeploy brings it back), a stopped environment shows the honest word and
28 the run that stopped it.
29
30 **Assign** — the assignee card on an issue and a pull: nobody, then people
31 who can read the repository, then the crew marked as agents. Assigning an
32 agent is the dispatch, so the select says so beside the crew. An agent's own
33 pull opens already assigned to it.
34
35 **Cancel a run** — one POST on the run page while it is unfinished; the log
36 keeps everything up to the cancel and the badge says `cancelled`.
37
38 The merge box gains a seventh state, _Waiting on checks_, when
39 `require_checks` is on and the head's deploy is not green; the refusal is
40 `mergePull`'s (`checks_required`) and the chip links the run. A pull's
41 **Preview** card and its **Checks** line read the same run rows a
42 `runStateBadge` does elsewhere — a check is a deploy, never a separate
43 object that can drift.
44
45 **Website spec findings** — under a pull's Checks line, from the preview's
46 latest deploy run (`core/preview_findings.ts`): a **disclosure** whose
47 summary is the count (edge-owned findings counted apart) and whose body is
48 a `.list` of `.row`s, one per finding, with its specification.website
49 section as an https link. Report-only, so the words never say failed; the
50 summary is at least 44px tall for a thumb. The terminal says the same count
51 at the end of the checks row.
docs/design/sections/5d-page-local-classes.md added +65
@@ -0,0 +1,65 @@
1 ### 5d. Page-local classes
2
3 The close form on an issue's thread is no class at all: its reason select and
4 "close into" input are plain §5 inputs, the height of the Comment button beside them, and `style/content.ts` shows the
5 input only while "split into…" is chosen, by attribute selector inside
6 `.form-actions` (`select[name="reason"]`, `input[name="into"]`). It is a rule
7 about the form, not a component.
8
9 Every class a stylesheet defines is named in this section (`bun
10 scripts/design-lint.ts` fails one that is not, #2700). The table above is
11 the vocabulary; the classes below are what pages built beside it before that
12 rule held. They are registered, not endorsed: each belongs to the file it is
13 listed under, and a new page reaches for §5's components instead of
14 borrowing one. The plan (`docs/design/plan.md`) retires them page by page —
15 onto a §5 component, or into the table when a page-local thing turns out to
16 be a component — and the check fails a name kept here after its last rule is
17 gone, so this list only shrinks. Each class sits on its own line, so to
18 retire a class, delete its line: two pulls retiring two classes never edit
19 the same line (#4888).
20
21 - **`style/base.ts`** — the page shell (topbar, main, footer) and the layout and text utilities every page leans on:
22 - `.bar`
23 - `.bare`
24 - `.foot`
25 - `.hide-sm`
26 - `.js-only`
27 - `.reduced`
28 - `.shell`
29 - `.skip`
30 - `.topbar`
31 - `.wordmark`
32
33 A `.field` directly inside a `.bar` drops its bottom margin and the bar aligns to the end, so a labelled input and its button share a baseline
34 - **`style/components.ts`** — parts of the §5 components that the table does not spell out — the palette's list, the menu's items, a dialog's head and foot, comments, the theme picker — and a few small helpers beside them:
35 - `.cat`
36 - `.error-note`
37 - `.list`
38 - `.menu-item`
39 - `.preview`
40 - `.sep`
41 - `.split`
42 - `.swatch`
43
44 `.split` is the one two-column page: main, then an `<aside>` rail of 260px that sticks from 960px. `data-rail="wide"` on it (the studio's home and an app's page) widens the rail to 320px from 900px, drops the top margin, and lets the rail scroll with the page, since a column of cards runs taller than a screen
45 - **`style/content.ts`, page furniture** — what frames a page's content: page and repo heads, the front page and its tiles, explore, backups, boards, the fleet chart's series colours, lineage:
46 - `.bgrid`
47 - `.bp-h2`
48 - `.bp-h3`
49 - `.bp-w2`
50 - `.bp-w3`
51 - `.commit-body`
52 - `.commit-head`
53 - `.filters`
54 - `.front-admin`
55 - `.front-pick`
56 - `.owner`
57 - `.page-head`
58 - `.policy-line`
59 - `.repo-actions`
60 - `.repo-desc`
61 - `.repo-title`
62 - `.slash`
63 - **`style/content.ts`, apps, runs, and the crew** — run pages, their logs and checks (a pull's look is §5's **look**).
64 - **`pages/board.ts`** — the board itself is §5's **board**; the spend tables `spend.ts` draws wear §5's `.table` and `.stack`, no class of their own
65 - **`studio/feed_style.ts`** — the home feed's cards and its composer (the apps rail is §5's card, row and btn; an admin's tick boxes and Archive are always shown, deliberately, with no Select toggle):
docs/design/sections/6-0-pointer.md added +50
@@ -0,0 +1,50 @@
1 ### 6·0. The pointer
2
3 The terminal UI is keyboard-first and mouse-**complete**: everything you can
4 see is something you can click, and clicking never reaches somewhere the
5 keyboard could not. Reporting is on from the moment a session starts
6 (`?1000h ?1002h ?1006h`, SGR coordinates last) and off before the alt screen
7 is left. The one cost is real and is said out loud in the `?` overlay:
8 **shift-drag** is now how you select text, because the terminal's own
9 selection is what mouse reporting takes away.
10
11 **The model is a hit table rebuilt every frame.** During `render`, a screen
12 claims rectangles with `ctx.hit(rect, handler)`:
13
14 ```ts
15 ctx.hit(rect, (e) => open(rows[e.y])); // click only
16 ctx.hit(rect, { click, wheel }); // and the wheel
17 ```
18
19 The registry is cleared at the top of every paint and rebuilt by whoever
20 draws, so a region can never outlive the pixels it stands for — the bug every
21 retained hit table eventually has. Regions are searched **newest-first**, so
22 whatever drew last is what a click reaches: an overlay draws after the screen
23 beneath it, and needs no z-index to win. A region that does not handle _this_
24 gesture is not in the way of one that does, so the wheel over a clickable row
25 still scrolls the list the row is in.
26
27 Four rules hold the gestures together:
28
29 - **A click selects; a second click on the selected row opens it.** So does a
30 double-click on any row. A single click has to be safe, because half of
31 pointing at a row is looking at it, and a list where looking navigates is a
32 list you cannot browse.
33 - **One notch is three rows**, delivered to the region under the pointer. A
34 notch nobody claimed falls back to arrow keys, which is what a wheel has
35 always meant.
36 - **Mouse input is never a synthetic keystroke.** Nothing reaches
37 `Screen.key`; a screen that wants a click says so with `ctx.hit`. The
38 keyboard behaviour of every screen is unchanged by any of this.
39 - **A click on an overlay's dimmed backdrop dismisses it**, and does not reach
40 the row underneath.
41
42 Clickable today: every list row, the dashboard's four panes, the workspace's
43 explorer rows and tab chips (including the active tab's `✗`), the file pane
44 (focus, and the wheel), the where-strip's three places, the palette's results,
45 the appearance picker's rows, the owner, refs, settings, account
46 lists, the pull's diff-and-threads pane, and **every
47 hint in the help bar** — clicking a hint presses the key it names, taken from
48 the binding itself so a label can never drift from the action. A list with no
49 `hitList` is a bug, not a design decision: "everything you can see is
50 something you can click" is the whole claim.
docs/design/sections/6-0a-1-terminal-layout.md added +24
@@ -0,0 +1,24 @@
1 ### 6·0a·1. Laid out for the terminal it is in
2
3 Three numbers in `ui/layout.ts` decide every screen's shape, so every screen
4 folds at the same width and none invents its own. At **100 columns** a list
5 sits beside its detail; below, `beside()` stacks the two with the detail
6 underneath (two fifths of the height unless the screen says otherwise), and
7 drops it only when there are fewer than twelve rows to share. Under **40×10**
8 no layout survives — a card is narrower than its title — so one line says what
9 would help, and every screen keeps its state for the moment the window grows.
10
11 A resize re-lays the current screen at once and loses nothing: a list keeps
12 its cursor and scrolls it back into view, a log keeps the _line_ at the top
13 of the reader's window whatever number of rows it now wraps to, and a program
14 holding the pty (`$FORGE_EDITOR`, an app's `tty`) hears the new size before
15 the UI does. Long text wraps or ends in an ellipsis at the width, never past
16 it. A row's meta gives way before its title starves — the leading pieces
17 (`metaOf` order: a commit, then a cost, then the time) go first, the last one
18 last — and a table's ranked columns (`drop`) are given up lowest first, the
19 same word `local/rows.ts` uses.
20
21 An activity row is one line, not two, because a row is a thing you can put a
22 cursor on: `who did what · the object`, with the time in a fixed right-hand
23 column. The headline already ends with `#4`, so the object drops the repeated
24 number rather than truncating the title.
docs/design/sections/6-0a-dashboard-panes.md added +13
@@ -0,0 +1,13 @@
1 ### 6·0a. The dashboard's panes
2
3 `Repositories`, `Recent activity`, `On you`, `On your machine`,
4 `Organizations`. What is running is the studio's question, on the web; the
5 terminal's dashboard is the code and the conversations. Each is a pane with its own cursor: `tab` / `shift+tab` cycles, the focused one wears
6 the accent frame, `j`/`k` move inside it and `enter` opens what it is pointing
7 at — an activity row opens **the issue, pull or repository it is about**, a
8 pin opens its repository, an organization opens its page. `d` on a pinned row
9 unpins it (asking first). A click does all of the same. Below 100 columns the
10 activity feed stacks under the repository list and the three small cards wait
11 for a wider window; shorter than twelve body rows, the repositories have the
12 whole body. `tab` reaches exactly the panes the last frame drew: a focus ring
13 on a card that is not on screen is worse than no focus ring.
docs/design/sections/6-0b-icons-appearance.md added +32
@@ -0,0 +1,32 @@
1 ### 6·0b. Icons, and the appearance picker
2
3 Two vocabularies. **`plain`** is the safe set (§6a) — BMP, width 1, in every
4 default terminal font. **`nerd`** is the same vocabulary drawn from a Nerd
5 Font, which is a bet on the reader's _font_, a thing `TERM` cannot tell us.
6 `auto` takes the bet on Ghostty, kitty and WezTerm, because those are
7 terminals people install deliberately and installing a Nerd Font is the next
8 thing those people do. Everything else gets `plain`.
9
10 The bet is bounded: every glyph in both sets is exactly one cell (asserted by
11 `test/tui-icons.test.ts`, since every Nerd codepoint lives in a Private Use
12 Area that `width.ts` measures as width 1), so a missing glyph is one tofu box
13 and never a shifted row. Where the two sets would say different things, the
14 plain set keeps the documented grammar: `◆` still means "kept on your
15 machine", and an unpinned row still wears nothing.
16
17 `ctrl+t` opens **Appearance**: the six themes, then two more rows —
18
19 | row | values |
20 | -------- | --------------------------------------------- |
21 | `Images` | `auto (kitty)` · kitty · sixel · iterm · none |
22 | `Icons` | `auto (nerd)` · nerd · plain |
23
24 `j`/`k` move, `left`/`right`/`enter` cycle a setting's value, a theme applies
25 as the cursor passes over it, and both settings persist through
26 `setPreference` into `user_setting`. It is `ctrl+t` and not `t` because the
27 workspace already spends `t` on the file finder, and an appearance picker
28 that silently does nothing on the one screen people live in is worse than a
29 modifier: one key, every screen, whatever has focus. Under the rows is a **live preview** —
30 three real icons in the set currently chosen — because the only honest way to
31 settle a bet about somebody's font is to show them three glyphs and let them
32 look. If they see boxes, one keypress puts it back.
docs/design/sections/6-0c-terminal-pictures.md added +22
@@ -0,0 +1,22 @@
1 ### 6·0c. Pictures in the terminal
2
3 A tab whose path looks like an image and whose bytes git returned as binary is
4 a **picture tab**. Where the terminal can show pixels it does
5 (§`term/graphics.ts`: kitty for Ghostty/kitty/WezTerm, sixel where detected,
6 iTerm2 by setting); where it cannot, a placeholder card names the format, the
7 geometry and the size, and says plainly what would show it.
8
9 The loop owns the lifecycle. A screen calls `ctx.placeImage(placement)` during
10 render and the bytes go out **after** the frame, raw; not calling it removes
11 whatever the last frame placed. That one rule makes a tab switch, a close, a
12 `pop`, a resize and a theme change all clean up by themselves, with no screen
13 having to remember to. A placement is re-sent only when it is genuinely new —
14 a different image, a different rectangle, or a repaint that invalidated
15 everything — because `drawImage` reserves its cells with the theme ground and
16 an ordinary frame emits nothing over them. Re-sending a megabyte of base64 on
17 every keystroke is the one thing that would make pictures unusable over SSH.
18
19 Aspect ratio comes from the client's own `pty-req` / `window-change` pixel
20 fields when it sends them (most send zeros), and from the 1:2 guess otherwise.
21
22 `v` toggles a picture between the pixels and the facts about the file.
docs/design/sections/6-keyboard.md added +34
@@ -0,0 +1,34 @@
1 ## 6. Keyboard
2
3 Driven entirely from the DOM: `data-shortcut="g i"` on a link, `data-nav` on a
4 row. The `?` overlay is generated by walking `[data-shortcut]`, so the help can
5 never drift from the app.
6
7 | key | action |
8 | --------------- | ---------------------------------------------- |
9 | `g h` | home |
10 | `g c` | repo code |
11 | `g i` | issues |
12 | `g p` | pull requests |
13 | `g m` | commits |
14 | `g b` | branches |
15 | `g s` | repo settings |
16 | `j` / `k` | next / previous row (visible focus ring) |
17 | `o` or `Enter` | open the focused row |
18 | `c` | the page's create action (new issue, new pull) |
19 | `t` | go to file (the fuzzy finder), on a code page |
20 | `e` | edit the file you are looking at |
21 | `/` | focus search |
22 | `⌘K` / `Ctrl K` | command palette |
23 | `?` | shortcut help |
24 | `Esc` | close a menu or dialog, blur a field |
25
26 Inside the code view, shift-clicking a second line number extends `#L3` to
27 `#L3-L9` and highlights the range.
28
29 `t` and `e` are not a new key layer: they are `data-shortcut` attributes on
30 the real controls, so the `?` overlay learns them for free and a page without
31 those controls does not answer the key. `j`/`k` are the shell's own row
32 traversal, and the explorer's files are `data-nav` rows — which is why
33 arrowing through the tree previews as you go without a single extra key
34 handler: `codeJs` listens for **focus**, not for keys.
docs/design/sections/6a-code-browser.md added +33
@@ -0,0 +1,33 @@
1 ### 6a. The code browser
2
3 Tree and blob pages are one layout, not two: a `.code-layout` grid with the
4 explorer on the left and the content on the right at ≥900px, the explorer
5 folding into a "Files" `<details>` above the content below it.
6
7 - **The spine, not the tree.** A page renders the path's ancestors and their
8 siblings, never the whole repository, so a monorepo costs the same markup as
9 a toy. Every directory is a `<details>`; the ones on the spine arrive open.
10 - **A folder the server did not expand is still a link.** Opening one with no
11 script shows a single `Open src/…` link to that folder's own page; with a
12 script, `codeJs` fetches `?fragment=explorer` and drops the rows in.
13 - **The preview is the page.** Hovering or arrowing a file fetches the blob
14 route's own `?fragment=1` — the same renderer, the same markup, the same
15 `#code-pane` — so a preview and a real navigation are indistinguishable, and
16 `enter` simply follows the link that was already there. Fetches are
17 cancelled and cached, and there is no scroll-stealing or URL rewriting.
18 - **The explorer opens closed and the stylesheet opens it.** `<details>`
19 without `open`, plus `::details-content { content-visibility: visible }` at
20 ≥900px. Where that pseudo-element is understood the handle is hidden too;
21 where it is not, the handle stays and one click opens the column. Nothing
22 can end up invisible with no way back.
23 - **The finder is a dialog over JSON.** `t` (or the button) fetches
24 `/:owner/:name/paths/:ref` once and scores it client-side with fzf's
25 FuzzyMatchV2 constants under its `path` scheme — boundary 8, delimiter 9,
26 camel 7, consecutive 4, match 16, gap −1, first character doubled, string
27 start treated as though it followed a `/` — on top of a 28-bit
28 characters-present bitmask prefilter and a two-pointer subsequence check, so
29 the affine-gap DP only ever runs on real candidates. VS Code's tiering sits
30 above the score rather than inside it: any match inside a file's own name
31 outranks any match that only lands in its directories, and a basename
32 _prefix_ outranks that again. Without a script the finder does not exist and
33 the explorer beside it is the answer.
docs/design/sections/6a-counts-and-marks.md added +21
@@ -0,0 +1,21 @@
1 ### 6a. Counts and marks on a row
2
3 Three glyphs carry the at-a-glance numbers, all of them BMP, all width 1, all
4 present in every default terminal font in wide use — no Nerd Font (we cannot
5 assume a patched font over SSH) and no emoji (their width is genuinely
6 inconsistent, and one column of error shifts an entire row):
7
8 | glyph | means | where |
9 | ----- | ----------- | ------------------------ |
10 | `○` | open issues | every repo row |
11 | `↳` | open pulls | every repo row |
12 | `▭` | comments | every issue and pull row |
13
14 They render muted, right-aligned in the row's meta column, and **tabular**:
15 the widths are computed once across the whole page so the numbers form a
16 column instead of jittering row to row. **Zero is absent, not `○ 0`** — an
17 empty queue should read as quiet, and a column of zeroes reads as noise.
18
19 The numbers come from grouped reads (`countsForRepos` / `withCounts`,
20 `commentCountsFor`) and never from a query per row. `test/counts.test.ts` and
21 `test/tui-code.test.ts` both assert that; a list is where N+1 hurts most.
docs/design/sections/6b-code-coloured.md added +41
@@ -0,0 +1,41 @@
1 ### 6b. Code, coloured
2
3 One tokenizer, `src/web/highlight.ts`, serves the file browser, the diff
4 viewer, Markdown fences, the terminal UI and the browser bundle the editor
5 loads. It is a stateful per-line scanner over rule tables — Prism's model, not
6 a parser — and it is bound by three properties the tests assert for every
7 language: **lossless** (a line's tokens concatenate back to the line),
8 **linear** (10,000 characters in well under a millisecond, no backtracking
9 regex), and **total** (random bytes never throw).
10
11 Roughly thirty languages have rule tables, including the ones a forge actually
12 holds: ts/tsx/js/jsx (template literals, regex-versus-division, decorators,
13 JSX tags), python, go, rust, c/c++/objective-c, java, kotlin, swift, ruby,
14 php, lua, zig, elixir, nix, sql, json/jsonc, yaml, toml, ini, markdown,
15 css/scss, html, shell, dockerfile, makefile and diff. Detection is by
16 extension, then by whole filename (`Dockerfile`, `Makefile`, `.zshrc`), then —
17 only for a file that has said nothing else — by shebang or editor modeline:
18 `detectLanguage(path, firstLine)`.
19
20 **Two levels of token.** `cls` is the colour _every_ consumer understands —
21 `keyword string comment number function type operator punctuation constant
22 meta plain` — and it is what the terminal's theme table maps. `kind` is a
23 refinement only the web paints: `property tag attribute regex variable
24 decorator heading emphasis link inserted deleted`. A token renders as
25 `class="tok-<cls> tok-<kind>"`, the kind rule comes last in the stylesheet and
26 so wins at equal specificity, and dropping the kind leaves a page that is less
27 specific but never wrong. That is the whole reason the vocabulary can grow on
28 the web without the terminal having to agree first.
29
30 Punctuation and operators are deliberately **below** body colour
31 (`--tok-punctuation`, `--tok-operator`): they are a line's scaffolding, and
32 colouring them loudly makes code shout. Diff-inside-a-fence borrows the diff
33 tints (`--add-fg` / `--del-fg`) rather than inventing a colour.
34
35 **Markdown has two right answers.** A `.md` blob renders as prose by default
36 and offers `Source | Rendered` in its path header; `?view=source` is a plain
37 link, so both halves work with no JavaScript, are addressable, and survive
38 being sent to somebody. The rendered half is the README renderer — fenced code
39 is highlighted in the language the fence names, tables are tables, and a
40 relative link (`docs/keeping.md`) resolves, while anything carrying a scheme
41 other than http(s), or a protocol-relative `//host`, is left as text.
docs/design/sections/6b-workspace.md added +119
@@ -0,0 +1,119 @@
1 ### 6b. The workspace — one screen for a whole repository
2
3 Opening a repository in the terminal opens a **workspace**, not a home page:
4 the explorer, a tab bar, the file you are reading, and the one commit that
5 carries everything you changed. There is no repo card to leave, no directory
6 screen to push, no blob screen under it and no editor screen on top. The four
7 screens that used to be there were four escapes deep and lost your place at
8 each one.
9
10 ```
11 forge Ink
12 alice › alice/lighthouse
13 ● workbench · ○ this machine main ▾
14 README.md src/lamp.ts ● src/weather.ts 3 tabs
15 ▾ src/ ┃ 1 # Lighthouse
16 lamp.ts ● ┃ 2
17 weather.ts ┃ 3 A beacon. Rotates the lamp, logs the weather, warns the ships.
18 package.json ┃
19 ▸ README.md ┃
20 main · 1 modified src/lamp.ts · 22 lines
21 tab focus explorer e edit c commit b switch ref t find file i issues
22 ```
23
24 The where strip keeps column zero, exactly as it does on every other
25 repository screen, and the ref pill (`main ▾`) takes the right-hand end of the
26 same row. At **≥100 columns** the explorer is a column beside the file; below
27 that it is the pane `tab` toggles onto, never both at once. A repository opens
28 on its **README** — the first file at the root if there is none — with the keys
29 already in the file pane, so `j` scrolls it without a keystroke of ceremony.
30
31 Four rules hold it together, and each one answers something the old
32 arrangement got wrong:
33
34 - **Moving the explorer's cursor reads nothing.** `j` moves a cursor over
35 names; `enter` opens a tab. The preview-that-followed-the-cursor it replaces
36 cost a read per keystroke, needed a debounce and a stale-read token to stay
37 honest, and left you unable to scroll the file you were looking at because
38 the selector lived in the other pane.
39 - **A tab owns its buffer.** Scroll position, in-file search, undo history and
40 every unsaved edit live in the tab. Switching tabs keeps them; so does going
41 to the issue list and coming back, because the workspace stays on the stack
42 while something is pushed over it.
43 - **Editing is a mode, not a screen.** `e` turns the active tab into the
44 buffer from `ui/editor.ts`, in place. `esc` goes back to reading **with the
45 edits still there** — losing work takes `u` (discard this tab) or `x` (close
46 it), and both ask first.
47 - **One commit for the whole change.** Every modified tab and every pending
48 deletion goes into a single `editFiles` call.
49
50 | key | in the explorer | in the file pane |
51 | ------------ | -------------------------------- | ------------------------------- |
52 | `j` `k` | move the cursor (reads nothing) | scroll |
53 | `ctrl+d` `u` | page | page |
54 | `g` `G` | top / bottom | top / bottom |
55 | `enter` `l` | expand a directory / open a tab | — |
56 | `h` | collapse, or go to the parent | scroll left (`l` scrolls right) |
57 | `tab` | focus the file | focus the explorer |
58 | `esc` | back | focus the explorer |
59 | `/` | filter this level | search in the file (`n` / `N`) |
60 | `n` `D` | new file / mark one for deletion | — |
61 | `e` | open the file and edit it | edit |
62 | `u` | — | discard this tab's changes |
63 | `v` | — | the file's other view (below) |
64
65 And the keys the whole workspace answers, from either pane: `]` `[` and `1`–`9`
66 move between tabs, `x` / `X` close one or the others, `c` commits, `b` lists
67 branches and tags, `B` creates a branch, `t` (or `ctrl+p`) is the fuzzy finder,
68 `y` copies the path, `i` / `p` / `C` open issues, pulls and commits, `I` shows
69 the about card, `W` swaps to the other side of a mirror, `O` opens the file on
70 its origin, `P` pins, `S` syncs, `F` forks, `,` is settings, `r` refreshes and
71 `E` hands the file to `$FORGE_EDITOR`.
72
73 **The tab bar.** One row above the panes: the path, the active tab bold and
74 underlined in the accent, `●` on anything modified, `¶` on prose being read
75 rather than edited, `‹ ›` when there are more tabs than fit, and the count on
76 the right. `¶` (U+00B6) is Latin-1, so it is in every font that can draw a box
77 character at all, and it is the one glyph in the safe set that already means
78 "formatted prose". A modified file wears the same `●` in the explorer, and one
79 marked for deletion is struck through in the danger colour — the same change,
80 said in both places. The **active** tab also wears a `✗` you can click; only
81 the active one, because a close box on every chip is a row of targets you hit
82 by accident, and the tab you mean to close is the one you are looking at.
83
84 **Two views, one key.** Some files have two honest ways of being seen, and `v`
85 is the toggle for all of them:
86
87 | file | opens as | `v` shows |
88 | ------------------- | -------------------- | ------------------------ |
89 | `*.md`, `README*` | rendered prose (`¶`) | the source, highlighted |
90 | `*.png`, `*.jpg`, … | the picture (§6·0c) | format · geometry · size |
91 | everything else | code | (nothing else to show) |
92
93 Prose opens **rendered**, because that is what a README is for and it is what
94 the repo card always did — but the rendered view is a view, not a mode: `j`/`k`
95 scroll it, `/` searches it, and `e` always edits the **source**, switching to
96 it if you were reading. The web and the terminal render that markdown through
97 the same rules, so a README reads the same in both.
98
99 **Colour follows the theme.** Switching theme re-tokenises every open tab on
100 the next frame; a tab that kept yesterday's palette is exactly the bug a
101 screenshot hides.
102
103 **Coming back is free.** A workspace's open tabs, its explorer cursor, its ref
104 and every uncommitted buffer live on the **session**, keyed by repository — so
105 popping to the dashboard and re-entering the same repo restores what you were
106 doing, not just where you were. One connection is one identity, so nothing
107 another user opened can be reached, and the memory dies with the connection;
108 an entry nobody has touched for thirty minutes is dropped on the next lookup.
109 `r` is still the refresh, and `x` still closes a tab.
110
111 **The about card (`I`).** What used to be the repo home page: visibility,
112 description, default branch, counts, where a mirror came from and when it was
113 last fetched, and the clone URLs (`y` copies one). It is a card rather than a
114 screen because it is something you glance at on the way to doing something
115 else.
116
117 The finder (`t`) scores with fzf's `--scheme=path` constants plus VS Code's
118 rule that any basename match outranks any directory-only match, and it says
119 "N files (truncated)" honestly when the listing hit its cap.
docs/design/sections/6c-1-contributing.md added +65
@@ -0,0 +1,65 @@
1 ### 6c·1. Contributing: fork at the origin, propose a change
2
3 The workflow the origins layer exists for — _grab their repo, branch, edit,
4 propose it back_ — is one intention, so it is two keys and one action call
5 (`actions/contribute.ts`), never five screens with five half-finished states
6 between them.
7
8 **`F` — fork at the origin.** On a live browse (and on the picker's rows) `F`
9 forks at the origin rather than here: a local fork of a mirror is a copy of a
10 copy with nowhere to propose it back to. One confirm card, saying the whole
11 sentence because two machines are involved —
12
13 ```
14 ╭─ Fork at the origin ───────────────────────────────╮
15 │ Fork octo/widgets to your account │
16 │ at codeberg.org and mirror it here? │
17 │ │
18 │ The copy here is your workbench. │
19 │ Cancel ▸ Fork │
20 ╰────────────────────────────────────────────────────╯
21 ```
22
23 — then the ordinary mirror card over the job the fork started, then the fork's
24 own workspace. A native repository keeps the native fork. Wherever a
25 repository forks something at an origin, the where-strip and the about card
26 say so: `· fork of octo/widgets`, read from `repo.origin_fork_of` on a mirror
27 and from the picker's cached `fork_of` on a live browse.
28
29 **`c` — propose a change.** When the source says `permissions.push === false`
30 — a live browse of somebody else's repository, or a mirror whose upstream is
31 not ours to push to — editing is allowed and the commit dialog becomes a
32 proposal dialog. Same buffers, same ticked files, no "commit to `<ref>`"
33 option (there is no ref of theirs you may write), and a branch that lands in
34 your fork:
35
36 ```
37 ╭─ Propose a change ──────────────────────────── octo/widgets ─╮
38 │ Message │
39 │ Update README.md │
40 │ optional — this becomes the pull request's body │
41 │ Files · 1 of 1 │
42 │ [x] ~ README.md │
43 │ Branch in your fork │
44 │ alice/edit-README-md │
45 │ Pull request › octo/widgets · main │
46 │ Update README.md │
47 │ Propose │
48 ╰──────────────────────────────────────────────────────────────╯
49 ```
50
51 `contribute` does the rest in one call: fork if a fork is needed, branch from
52 the _upstream's_ base rather than a stale copy of it, commit into the fork,
53 push it there, and open the pull upstream with the `owner:branch` head form
54 both forges take. The toast reads `opened pull #12 on octo/widgets`, and when
55 we hold a mirror of that upstream the imported pull is pushed on the stack so
56 the reader lands on what they just proposed. **A refusal keeps every buffer**
57 and names which half failed — `fork_timeout`, `origin_refused`,
58 `mirror_failed`, an edit reason, or "the commit landed but the pull did not
59 open". While such a workspace is open the status row says
60 `proposing to octo/widgets via your fork`, on every frame, so nobody has to
61 press `c` to find out where their change is going.
62
63 A repository the origin says you _may_ push to keeps the ordinary commit
64 dialog: on a mirror that is `editFiles` plus push-through, which is
65 `contribute`'s own `via: "direct"` path by another name.
docs/design/sections/6c-editing-commit-dialog.md added +29
@@ -0,0 +1,29 @@
1 ### 6c. Editing, and the commit dialog
2
3 `e` turns the active tab into a modeless editor: arrows/Home/End/PgUp/PgDn and
4 word jumps, shift-arrows to select, `ctrl+c`/`x`/`v` (which also mirror to
5 _your_ clipboard over OSC 52), `ctrl+z`/`ctrl+y`, `ctrl+f` find, `ctrl+r`
6 replace, horizontal scrolling with no soft wrap, tabs rendered at 4 and kept as
7 tabs. The status row reads `path · Ln 12, Col 4 · modified`.
8
9 Four rules are non-negotiable because this is attached to a git host: CRLF, a
10 BOM and the presence or absence of a trailing newline round-trip exactly;
11 nothing is normalised silently. Past 1 MB or 50k lines the buffer opens
12 read-only and says which, rather than being slow.
13
14 `c` (or `ctrl+s` from inside the buffer) opens the commit dialog. It lists
15 **every** change the workspace is holding — each modified tab and each pending
16 deletion, ticked, marked `+` added, `~` edited, `-` deleted — then asks the
17 three questions `editFiles` takes: message (subject, optional body), target
18 (`current branch` or `new branch`, defaulting to **new**, named
19 `<you>/edit-<file>`), and `[ ] open a pull request` with the title prefilled
20 from the subject. One call: no half-finished state (a branch with no commit, a
21 commit with no pull) can be invented. On success the workspace follows the
22 commit — the ref pill becomes the new branch and every tab is re-read from it.
23 **A refused commit keeps every buffer**, and says why on the dialog: a
24 protected branch, a name git will not take, an origin that said no.
25
26 Where the change has nowhere to go — a reader without write access, a live
27 browse of a repository nobody has offered you a route into — `e`, `c`, `n` and
28 `D` say so instead of opening something that could never land. Where it has a
29 route, they open the proposal dialog instead; §6c·1 is that route.
docs/design/sections/6c-editor.md added +100
@@ -0,0 +1,100 @@
1 ### 6c. The editor
2
3 `GET /:owner/:name/edit/:ref/*path` is a `<textarea>` in a `<form>`, and one
4 rule governs it: **a refusal never costs you your typing.** A protected
5 branch, a file over the size cap, a branch name git will not take, an origin
6 that said no — each re-renders the same page with the text, the message, the
7 branch name and the pull-request fields exactly as they were, and the reason
8 above them. `test/web-editor.test.ts` asserts that, not just the happy path.
9
10 The commit box is one submit, because `editFiles` is one call: message,
11 target (`current branch` / `new branch <name>`, defaulting to
12 `alice/edit-<file>`), and optionally a pull request from the new branch back
13 to where you started. Where committing straight to the ref is not on offer at
14 all — a protected default, or a ref that is not a branch — the page says so on
15 the disabled radio rather than letting somebody find out after typing.
16
17 **The highlighted mirror.** Under the textarea sits a
18 `<pre id="editor-mirror">` holding the same text, syntax-coloured; the
19 textarea on top of it has transparent text and a visible caret. They share one grid cell and one set
20 of metrics — font, size, line height, padding, tab size — because a single
21 divergence puts the caret in the wrong place, and the _height ceiling lives on
22 `.editor-wrap`_ so the gutter, the mirror and the textarea are always the same
23 height and scroll as one. The mirror is rendered by the server for the first
24 paint, so there is no flash of uncoloured code, and kept in step by
25 `window.forgeHighlight`, which is `web/highlight.ts` itself bundled by
26 `Bun.build` at first use and served from `/assets/highlight-<hash>.js`,
27 immutable. That asset is the one thing that relaxes the script CSP — from
28 "this page's nonce" to "this page's nonce, or a file from this origin" — and
29 only the editor page asks for it.
30
31 An edit re-scans from the first changed line and stops as soon as the state
32 entering an unchanged line matches what it was before, so typing costs one
33 line's work; the DOM is patched line by line rather than rebuilt. Tab inserts,
34 Enter keeps your indentation, brackets and quotes close themselves (and wrap a
35 selection), `⌘/Ctrl+S` submits the commit form, and the current line is
36 marked. `⌘/Ctrl+F` is left to the browser on purpose: the mirror holds real
37 text, so the page's own find lands on the line you were looking for.
38
39 **It degrades, in that order.** Over 200 KB or over 10,000 lines the server
40 sends no mirror and no bundle — a file that size is not hand-edited in a
41 browser, and a slow editor is worse than an unhighlighted one. If the bundle
42 fails to build or never loads, the page keeps the plain textarea, because the
43 textarea's own text only turns transparent once the mirror is live. With no
44 JavaScript at all it is a `<textarea>` in a `<form>`, which is what it was
45 before any of this. The Preview tab is offered on Markdown only, and renders
46 through the same `/preview` fragment the server will use for the file.
47
48 **Origins are web-only.** Connecting, syncing and browsing an origin live on the web pages (`/origins`) and in migrate; the terminal has no origins screen. On a mirrored repo's home `y` syncs the mirror and `P` pins it — `p` has meant _pulls_ on that
49 screen since before pins existed, so the toggle takes the shifted key there
50 and the plain `p` everywhere a repo is a _row_ (the dashboard, explore).
51
52 The connect chooser is ordered by what actually gets somebody connected:
53 **Personal access token — recommended** first (paste a string, no setup at
54 all), then **GitHub, one click** (needs a one-time OAuth app setup) and
55 **Forgejo / Gitea, one click** (needs an app registration on the instance).
56 Each row carries that condition on its own line rather than failing later
57 with an error about an environment variable.
58
59 **The where strip.** Every repository screen carries one line directly under
60 its title, naming the three places a repository can be and marking which one
61 the bytes on screen came from:
62
63 ```
64 ● codeberg.org · ○ workbench · ○ this machine
65 reading live from codeberg.org — read-only · m mirror into your workbench
66
67 ● codeberg.org · ● workbench (synced 3m ago) · ◆ this machine
68
69 ● workbench · ○ this machine
70 ```
71
72 The first is a live browse (and only a live browse gets the second, muted
73 banner line); the second is a mirror that is also pinned; the third is a
74 native repository, which has no origin and therefore no first place. The dot
75 is the grammar — **accent** is the place being read from, **success** is a
76 place that has this repository, **muted** is a place that does not:
77
78 | glyph | means |
79 | ----- | ---------------------------------------------------------- |
80 | `●` | this place has the repository (accent = reading from here) |
81 | `○` | this place does not have it |
82 | `◆` | kept on your machine (pinned) |
83 | `▲` | this came from the origin — an imported author or activity |
84
85 **Read-only means read-only.** A screen over a live source has no write keys
86 at all — no `c` comment, no `x`/`o` state, no `l` labels, no `m` merge, no
87 `,` settings, no `F` fork — because `capabilities.write` is false and the
88 source has no method that could write. In their place the help bar shows
89 `m mirror` and `O open on origin` — first, ahead of the navigation keys,
90 because at 80 columns the bar has room for about six bindings and those two
91 are the only things you can actually do here. The banner carries the long
92 wording; `w` opens the workbench copy when one already exists.
93
94 **The mirror card.** `m` opens a checklist over whatever screen you were on:
95 `creating → fetching → labels → issues → pulls → comments`, a spinner on the
96 stage in progress with the job's own words beside it ("42 issues"), and the
97 elapsed time. `esc` hides it and the mirror keeps running, toasting when it
98 lands; on `done` the live screen underneath is replaced in place by the same
99 screen over the local source, and the strip flips to `● workbench`; on
100 `failed` the card shows the scrubbed reason and `r` retries.
docs/design/sections/6d-forge-editor.md added +33
@@ -0,0 +1,33 @@
1 ### 6d. `$FORGE_EDITOR` — the operator's own editor, over a pty
2
3 `E` suspends the UI and hands the terminal to `$FORGE_EDITOR` on a real pty.
4 **Read this before enabling it:** the editor runs on the forge host as the
5 forge user, and nvim's `:!sh`, `:terminal` and a sourced `init.lua` are all
6 arbitrary code execution. CVE-2019-12735 was RCE in Neovim from merely opening
7 a file with a hostile modeline, which is the exact shape of a host whose job is
8 displaying files other people wrote.
9
10 It is coherent under one framing only: **this is the operator's own single-user
11 workbench, and the editor is their shell reached through the forge.** So it is
12 opt-in and absent otherwise — with `FORGE_EDITOR` unset there is no spawn, no
13 temp file and one line of explanation; the name is resolved once with
14 `Bun.which` and spawned as `[bin, file]` **argv, never a shell** (a name with
15 whitespace in it is refused rather than tokenized); and one handoff at a time
16 per session, because nested suspends are the wedge behind every "my TUI hung
17 after I shelled out" report. Nothing in that path writes to a repository: what
18 comes back is spliced into the tab's own buffer — one undoable edit, and a
19 modified tab like any other — and reaches git only through `c` and `editFiles`,
20 the one way anything does. `E` is the escape hatch rather than the tool, so it
21 sits last in the binding list and falls off the end of the help bar.
22
23 Resume is unconditional and state-independent — the child may have crashed, so
24 "undo what we think it did" is exactly the reasoning that leaves a broken
25 terminal. Two fixes beyond re-entering the alt screen, both measured against
26 real nvim: `ESC[0 q` because it exits leaving a steady block and never resets
27 the cursor shape, and `ESC[>0u` to pop kitty keyboard flags it pushes and does
28 not. The input decoder is rebuilt on resume so the Primary DA reply that
29 arrives _after_ the child is gone lands nowhere.
30
31 **Gap:** a launch should leave an outbox event (invariant 5), and appending one
32 from an adapter would break the rule that only `actions/` does. It is a log
33 line for now; the action belongs beside `actions/contents.ts`.
docs/design/sections/6e-1-reviews-merge-gate.md added +47
@@ -0,0 +1,47 @@
1 ### 6e·1. Reviews, threads, and the merge gate
2
3 A pull is judged in three places, and each says one thing.
4
5 **The verdict (`R`).** A card with three radios and an optional note —
6 `( ) approve ( ) request changes ( ) comment`, `ctrl+d` submits. Approve is
7 **absent** when the viewer wrote the change, because `submitReview` refuses it
8 (`self_review`) and a disabled control would only be a slower way to be told
9 no. A verdict renders in the conversation as a timeline item with a state chip
10 (success / danger / muted), and it is recorded against the head it was passed
11 on — so a push dismisses it and the item wears `before the latest push` in
12 muted rather than quietly still counting.
13
14 **The gate.** The merge box grows a second line saying where the verdicts
15 stand: `2 approvals · changes requested by bob`, and `N dismissed by a push`
16 when a push has cleared some. When the reviews do not let it land the box
17 reads `review` in `attn` and `m` says the reason out loud rather than doing
18 nothing:
19
20 ```
21 ╭─ Merge ──────────────────────────────────────────────────────────╮
22 │ review Needs an approval of this head before it can merge │
23 │ no approvals yet │
24 ╰────────────────────────────────────── fix › main · 1 file +1 -1 ─╯
25 ```
26
27 That is a rendering of `reviewGate` (`core/pulls.ts`) and nothing more: an
28 outstanding request for changes blocks whatever the setting says, and
29 `require a review before merging` (repo settings, `R`) additionally demands an
30 approval of the current head. The refusal is mechanical in `mergePull`, which
31 reads the gate from the live head — a check computed earlier and handed down
32 is a check that can be skipped.
33
34 **Threads.** Review comments sharing one anchor are one conversation. In the
35 files tab the section header reads `3 threads · 1 unresolved`; `x` settles or
36 reopens the thread the cursor is standing on (a writer, or whoever raised it);
37 `n`/`p` walk the unsettled ones while the cursor is inside one, and go back to
38 walking hunks when it is not. A settled thread collapses to a single dim line
39 — `✓ resolved by alice · 2h · enter to expand` — because the point of settling
40 something is to stop looking at it, and `enter` opens the record back up
41 because the point of a record is that it is still there. Standing on the diff
42 line a thread hangs from counts as standing on the thread: that is what a
43 reader means by "this one".
44
45 A pull list row wears the same verdict in one tabular column — `✓` approved,
46 `✗` changes requested, `·` nobody has looked — read for the whole page in one
47 grouped query, never one per row.
docs/design/sections/6f-realtime.md added +6
@@ -0,0 +1,6 @@
1 ### 6f. Realtime
2
3 Dashboard, issue and pull lists, the repo home, and the issue and pull screens
4 all subscribe to the outbox for as long as they are on the stack, through one
5 helper (`live()` in `screens/common.ts`). Popping the screen unsubscribes it —
6 there is no lifecycle to get wrong. A burst is coalesced into one refresh.
docs/design/sections/6g-terminal-apps-runs-crew.md added +29
@@ -0,0 +1,29 @@
1 ### 6g. Apps, runs and the crew, where the terminal still shows them
2
3 The terminal's half of §5c is deliberately small: running an app and
4 managing the crew are the studio's (`docs/STUDIO.md`), and the terminal has
5 no apps, app, runs, run or crew screen and no palette entry for one. What
6 remains is where the work is reviewed:
7
8 - **Pull** — a preview line when the repository is an app (`O` opens it,
9 `P` opens production beside it: comparing the two is the review), a
10 checks row in the merge box and a _waiting on checks_ state with the refusal
11 in words on `m`, `a` to assign (nobody, people, then the crew with the bot
12 glyph on a Nerd Font), and a Crew tab of the agent runs about it.
13 - **`T` on a pull's preview** suspends the UI and runs the app's `tty` on
14 the same pty — one connection, two programs in turn, exactly as
15 `$FORGE_EDITOR` runs — and the UI returns when it exits. Every refusal
16 (not running, no `tty`, private to a guest) is a toast in the same words
17 the ssh front door uses.
18 - **Issue** — `a` to assign; a Crew card when an agent has runs, `tab` moves
19 focus to it.
20 - **A run** is a row (`screens/runlist.ts`): state dot and word, who, why,
21 the summary. `enter` on one hands over its web page,
22 `/:owner/:name/runs/:id`, where the live log, cancel and steer are — as a
23 link when the forge knows its public URL, as the path otherwise.
24 - **Settings** — the App tab (enable with slug, access and preview data; edit;
25 `D` to stop running as an app behind a confirm), `C` for require checks on
26 General, the Crew tab (`a` adds an agent from the forge's, `d` removes).
27
28 A guest is told so in words; nothing here is a way around the actions'
29 answers.
docs/design/sections/7-security-boundaries.md added +22
@@ -0,0 +1,22 @@
1 ## 7. Two rules that are security boundaries
2
3 **Label colors.** Inline `style=""` is blocked by the CSP — nonces apply to
4 elements, not attributes. A page therefore collects the label colors it will
5 render and emits one nonce'd rule per color:
6
7 ```css
8 .lc-d73a4a {
9 --label: #d73a4a;
10 }
11 ```
12
13 and the static sheet derives the chip from it with `color-mix`, so one stored
14 hex reads correctly in all six themes. `labelCss()` re-validates
15 `/^[0-9a-f]{6}$/` immediately before embedding. That check is the boundary —
16 treat it like `assertBatchSafe`, not like a nicety.
17
18 **The confirm page.** Every destructive button is a link to `/-/confirm?to=…`.
19 That page derives its own title, detail and typed-phrase requirement from a
20 table of known destructive routes, and 404s on anything else. The wording never
21 comes from the link, so a mailed URL cannot put "Continue" on a button that
22 deletes a repository.
docs/design/sections/8-checklist.md added +21
@@ -0,0 +1,21 @@
1 ## 8. Checklist before you call a screen done
2
3 1. Every divider is exactly 1px. The only 2px things are the active-tab
4 underline and the focus ring.
5 2. Every number that lines up is tabular.
6 3. Icons sit in a fixed 16px slot; the text baseline does not move when one
7 appears.
8 4. Secondary actions are in the DOM at `opacity: 0` and revealed on hover —
9 never `display: none` → `flex`. A settings row is the exception: its
10 control is the point of the row, so it sits in `.row-fields`, visible at
11 rest, and the list wears `.setting-list` (see §5d).
12 5. Exactly one accent-colored thing, and it is the action.
13 6. The empty state has exactly one button.
14 7. Nothing on the page needs JavaScript to work.
15 8. It survives 200% zoom and a 320px viewport with no horizontal body scroll —
16 diffs and code scroll inside their own card.
17 9. Nothing inside a linked row is itself a link.
18 10. Any control that only works with a script carries `js-only`, so a page
19 without JavaScript never shows a dead button.
20 11. Every token count sits beside a byte size, a cost or a percentage, and
21 goes through `tokenCount()` — never a hand-written `k`.
scripts/design-lint.ts +19 −4
@@ -33,7 +33,10 @@
3333 * A red names the file:line, the literal and what to use instead, so one pass
3434 * fixes it; run `bun scripts/design-lint.ts` before pushing. A new class is named
3535 * in its own `docs/design/areas/<area>.md` rather than in DESIGN.md, which
36 * every pull would otherwise edit (#4063).
36 * every pull would otherwise edit (#4063). DESIGN.md itself is a spine that
37 * links one file per section under `docs/design/sections/` (#5937); `designText`
38 * reads the spine and those files in its order, so the § numbers mean what
39 * they did when it was one file.
3740 *
3841 * Known gaps, so nobody mistakes the gate for more than it is: colours built
3942 * in JS strings other than the forms above, `oklch()`,
@@ -42,7 +45,7 @@
4245 *
4346 * The theme palettes (`src/design/themes.ts`) are where colours live and are
4447 * not scanned. Plain data in, plain data out: `scan` takes files, `compare`
45 * takes the baseline, a scan and the text of DESIGN.md.
48 * takes the baseline, a scan and the text of DESIGN.md with its sections.
4649 */
4750
4851 import { readdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -230,6 +233,17 @@ export function documentedClasses(design: string, areas: readonly string[] = [])
230233 return [...new Set([...namedIn(section(design, "5.")), ...areas.flatMap(namedIn)])].sort();
231234 }
232235
236 /** The section files the spine links, in its order: `](design/sections/<file>.md)`. */
237 export function sectionLinks(spine: string): string[] {

Good: the filename pattern stays inside docs/design/sections/, so a spine link cannot point the check at an arbitrary path. If a link has a typo, the readFileSync throws loudly instead of quietly dropping the section, which is the right failure.

238 return [...spine.matchAll(/\]\(design\/sections\/([a-z0-9-]+\.md)\)/g)].map((m) => m[1]!);

Good: the capture is restricted to [a-z0-9-]+\.md, so a link cannot reach outside docs/design/sections. A link the regex does not match is skipped without a word, but the spine-coverage test catches that for the real tree.

239 }
240
241 /** DESIGN.md as one text: the spine, then each section it links, in order (#5937). */
242 export function designText(root: string): string {
243 const spine = readFileSync(join(root, DESIGN), "utf8");
244 return [spine, ...sectionLinks(spine).map((f) => readFileSync(join(root, SECTIONS, f), "utf8"))].join("\n");
245 }
246
233247 /** The area files under docs/design/areas/: each is wholly vocabulary, one file per component area (#4063). */
234248 export function areaFiles(root: string): string[] {
235249 try {
@@ -289,13 +303,14 @@ export function compare(base: Baseline, now: Scan, design: string, areas: readon
289303 }
290304 const defined = new Set(now.classes);
291305 for (const k of registeredClasses(design)) {
292 if (!defined.has(k)) out.push(`.${k}: no stylesheet defines it any more — strike it from docs/DESIGN.md §5d`);
306 if (!defined.has(k)) out.push(`.${k}: no stylesheet defines it any more — strike it from §5d, docs/design/sections/5d-page-local-classes.md`);

Nit, not blocking: src/crew/conflict_room.ts:5 still names docs/DESIGN.md §5d as the shared file. It is now docs/design/sections/5d-page-local-classes.md. Fix it whenever someone next touches that file.

293307 }
294308 return out;
295309 }
296310
297311 const BASELINE = "docs/design/baseline.json";
298312 const DESIGN = "docs/DESIGN.md";
313 const SECTIONS = "docs/design/sections";
299314 const AREAS = "docs/design/areas";
300315
301316 if (import.meta.main) {
@@ -307,7 +322,7 @@ if (import.meta.main) {
307322 console.log(`wrote ${BASELINE}`);
308323 } else {
309324 const base: Baseline = JSON.parse(readFileSync(join(root, BASELINE), "utf8"));
310 const problems = compare(base, now, readFileSync(join(root, DESIGN), "utf8"), areaFiles(root));
325 const problems = compare(base, now, designText(root), areaFiles(root));
311326 for (const p of problems) console.error(p);
312327 if (problems.length) process.exit(1);
313328 console.log("design: no colour, size or component added outside the system");
test/design-lint.test.ts +22 −7
@@ -1,6 +1,6 @@
11 import { describe, expect, test } from "bun:test";
2 import { readFileSync } from "node:fs";
3 import { areaFiles, classesIn, compare, countFile, documentedClasses, locate, registeredClasses, scan, webCss } from "../scripts/design-lint.ts";
2 import { readdirSync, readFileSync } from "node:fs";
3 import { areaFiles, classesIn, compare, countFile, designText, documentedClasses, locate, registeredClasses, scan, sectionLinks, webCss } from "../scripts/design-lint.ts";
44
55 /** A DESIGN.md in miniature: the §5 table, a §5d register, and a §6 that names a class too. */
66 const DESIGN = [
@@ -25,7 +25,7 @@ const SLOW = 60_000;
2525 describe("design check: nothing added outside the system (#2678)", () => {
2626 test("the tree is no worse than its baseline", () => {
2727 const base = JSON.parse(readFileSync("docs/design/baseline.json", "utf8"));
28 expect(compare(base, treeNow(), readFileSync("docs/DESIGN.md", "utf8"), areaFiles("."))).toEqual([]);
28 expect(compare(base, treeNow(), designText("."), areaFiles("."))).toEqual([]);
2929 }, SLOW);
3030
3131 test("counts colour literals and off-scale sizes, not on-scale ones", () => {
@@ -116,11 +116,11 @@ describe("design check: nothing added outside the system (#2678)", () => {
116116 test("fails a register entry whose last rule is gone, so §5d only shrinks (#2700)", () => {
117117 const base = { files: {} };
118118 const problems = compare(base, { files: {}, classes: ["btn", "x-card", "x-wrapped"] }, DESIGN);
119 expect(problems).toEqual([".x-row: no stylesheet defines it any more — strike it from docs/DESIGN.md §5d"]);
119 expect(problems).toEqual([".x-row: no stylesheet defines it any more — strike it from §5d, docs/design/sections/5d-page-local-classes.md"]);
120120 });
121121
122122 test("every class the stylesheets define is one DESIGN.md §5 names (#2700)", () => {
123 const named = new Set(documentedClasses(readFileSync("docs/DESIGN.md", "utf8"), areaFiles(".")));
123 const named = new Set(documentedClasses(designText("."), areaFiles(".")));
124124 expect(treeNow().classes.filter((k) => !named.has(k))).toEqual([]);
125125 }, SLOW);
126126
@@ -168,7 +168,22 @@ describe("design check: nothing added outside the system (#2678)", () => {
168168 const namedFromReadme = (text: string) => documentedClasses("## 5. x\n" + text);
169169
170170 describe("docs/DESIGN.md", () => {
171 test("carries no merge conflict markers", () => {
172 expect(readFileSync("docs/DESIGN.md", "utf8")).not.toMatch(/^(<<<<<<<|=======$|>>>>>>>)/m);
171 test("carries no merge conflict markers, in the spine or a section", () => {
172 expect(designText(".")).not.toMatch(/^(<<<<<<<|=======$|>>>>>>>)/m);
173 });
174
175 test("the spine links every section file once, and each link is a file (#5937)", () => {
176 const linked = sectionLinks(readFileSync("docs/DESIGN.md", "utf8"));
177 expect(new Set(linked).size).toBe(linked.length);
178 expect([...linked].sort()).toEqual(readdirSync("docs/design/sections").filter((n) => n.endsWith(".md")).sort());
179 });
180
181 test("reads the sections in the spine's order, so §5 runs from its file to §6's (#5937)", () => {
182 const text = designText(".");
183 const at = (h: string) => text.search(new RegExp(`^${h}`, "m"));
184 expect(at("## 5\\.")).toBeGreaterThan(at("## 4\\."));
185 expect(at("### 5d\\.")).toBeGreaterThan(at("## 5\\."));
186 expect(at("## 6\\.")).toBeGreaterThan(at("### 5d\\."));
187 expect(registeredClasses(text).length).toBeGreaterThan(0);
173188 });
174189 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs