Public: anyone can read this; the crew can change it.
Split docs/DESIGN.md into one file per section #5948
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5937-split-docs-design-md-into-one-file-per-s → main 9 commits fork 3 threads · 3 unresolved
README.md +7 −2
| @@ -1732,11 +1732,16 @@ The design check (`test/design-lint.test.ts`, run in the gate) fails a pull that | ||
| 1732 | 1732 | adds a colour literal or an off-scale `font-size` beyond |
| 1733 | 1733 | `docs/design/baseline.json`, or defines a class `docs/DESIGN.md` §5 does not |
| 1734 | 1734 | 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 | |
| 1737 | 1737 | --update` and commit the baseline; when it deletes the last rule for a class |
| 1738 | 1738 | in the §5d register, strike the name there too. See `docs/design/plan.md`. |
| 1739 | 1739 | |
| 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 | ||
| 1740 | 1745 | `bun run check` is the gate: the same command `forge.json` declares, so what |
| 1741 | 1746 | it says here is what the deploy will say. It type-checks once, then runs the |
| 1742 | 1747 | 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 filedocs/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/ | ||
| 4 | 4 | (`` `.my-class` ``) with a line on what it is for. The design check reads every file in this directory as §5 vocabulary, |
| 5 | 5 | so two pulls documenting different components touch different files and never conflict on `docs/DESIGN.md`. |
| 6 | 6 | |
| 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/6e-links.md added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | ### 6e. Links that work | |
| 2 | ||
| 3 | The cell buffer has a sixth plane, `link`, holding a 1-based index into the | |
| 4 | frame's URI table — a URL is a property of a _run of cells_ exactly like a | |
| 5 | colour, so it diffs with one integer compare and OSC 8 falls out of the same | |
| 6 | emit loop that already does SGR deltas. Every open carries `id=`, because a | |
| 7 | diff renderer paints dirty runs and one logical link routinely reaches the | |
| 8 | terminal as several disjoint spans. Links are closed **before every cursor | |
| 9 | jump and at the end of every row**: OSC 8 and SGR are independent, so the | |
| 10 | frame-final SGR reset does not close one. | |
| 11 | ||
| 12 | URLs are marked in the connect screens, the repo home's clone lines, the | |
| 13 | "page" fact and rendered markdown. And because **no control sequence exists | |
| 14 | that means "open this in the reader's browser"** — OSC 8 marks text clickable | |
| 15 | and the person clicks — `O`/`o` does three things at once when it cannot open | |
| 16 | one: copies the URL through OSC 52, and pushes the **link dialog**, which | |
| 17 | shows the address plain with **mouse reporting turned off** for as long as it | |
| 18 | is open, so the terminal's own drag-to-select works without Shift; `c` copies | |
| 19 | again, `enter` tries the browser, `esc` puts the mouse back. It really opens | |
| 20 | on the local stdio session, and over SSH from this machine's own address | |
| 21 | (`sshTerminal.local`), because then the browser is on the same screen. Four | |
| 22 | routes to one URL (the hyperlink, the clipboard, the selectable text, the | |
| 23 | opener), because none of them is reliable on its own. |
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 | : #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 @@ | ||
| 33 | 33 | * A red names the file:line, the literal and what to use instead, so one pass |
| 34 | 34 | * fixes it; run `bun scripts/design-lint.ts` before pushing. A new class is named |
| 35 | 35 | * 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. | |
| 37 | 40 | * |
| 38 | 41 | * Known gaps, so nobody mistakes the gate for more than it is: colours built |
| 39 | 42 | * in JS strings other than the forms above, `oklch()`, |
| @@ -42,7 +45,7 @@ | ||
| 42 | 45 | * |
| 43 | 46 | * The theme palettes (`src/design/themes.ts`) are where colours live and are |
| 44 | 47 | * 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. | |
| 46 | 49 | */ |
| 47 | 50 | |
| 48 | 51 | import { readdirSync, readFileSync, writeFileSync } from "node:fs"; |
| @@ -230,6 +233,17 @@ export function documentedClasses(design: string, areas: readonly string[] = []) | ||
| 230 | 233 | return [...new Set([...namedIn(section(design, "5.")), ...areas.flatMap(namedIn)])].sort(); |
| 231 | 234 | } |
| 232 | 235 | |
| 236 | /** The section files the spine links, in its order: `](design/sections/<file>.md)`. */ | |
| 237 | export function sectionLinks(spine: string): string[] { | |
| 238 | return [...spine.matchAll(/\]\(design\/sections\/([a-z0-9-]+\.md)\)/g)].map((m) => m[1]!); | |
Good: the capture is restricted to |
||
| 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 | ||
| 233 | 247 | /** The area files under docs/design/areas/: each is wholly vocabulary, one file per component area (#4063). */ |
| 234 | 248 | export function areaFiles(root: string): string[] { |
| 235 | 249 | try { |
| @@ -289,13 +303,14 @@ export function compare(base: Baseline, now: Scan, design: string, areas: readon | ||
| 289 | 303 | } |
| 290 | 304 | const defined = new Set(now.classes); |
| 291 | 305 | 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: |
||
| 293 | 307 | } |
| 294 | 308 | return out; |
| 295 | 309 | } |
| 296 | 310 | |
| 297 | 311 | const BASELINE = "docs/design/baseline.json"; |
| 298 | 312 | const DESIGN = "docs/DESIGN.md"; |
| 313 | const SECTIONS = "docs/design/sections"; | |
| 299 | 314 | const AREAS = "docs/design/areas"; |
| 300 | 315 | |
| 301 | 316 | if (import.meta.main) { |
| @@ -307,7 +322,7 @@ if (import.meta.main) { | ||
| 307 | 322 | console.log(`wrote ${BASELINE}`); |
| 308 | 323 | } else { |
| 309 | 324 | 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)); | |
| 311 | 326 | for (const p of problems) console.error(p); |
| 312 | 327 | if (problems.length) process.exit(1); |
| 313 | 328 | console.log("design: no colour, size or component added outside the system"); |
test/design-lint.test.ts +22 −7
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | 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"; | |
| 4 | 4 | |
| 5 | 5 | /** A DESIGN.md in miniature: the §5 table, a §5d register, and a §6 that names a class too. */ |
| 6 | 6 | const DESIGN = [ |
| @@ -25,7 +25,7 @@ const SLOW = 60_000; | ||
| 25 | 25 | describe("design check: nothing added outside the system (#2678)", () => { |
| 26 | 26 | test("the tree is no worse than its baseline", () => { |
| 27 | 27 | 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([]); | |
| 29 | 29 | }, SLOW); |
| 30 | 30 | |
| 31 | 31 | 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)", () => { | ||
| 116 | 116 | test("fails a register entry whose last rule is gone, so §5d only shrinks (#2700)", () => { |
| 117 | 117 | const base = { files: {} }; |
| 118 | 118 | 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"]); | |
| 120 | 120 | }); |
| 121 | 121 | |
| 122 | 122 | 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("."))); | |
| 124 | 124 | expect(treeNow().classes.filter((k) => !named.has(k))).toEqual([]); |
| 125 | 125 | }, SLOW); |
| 126 | 126 | |
| @@ -168,7 +168,22 @@ describe("design check: nothing added outside the system (#2678)", () => { | ||
| 168 | 168 | const namedFromReadme = (text: string) => documentedClasses("## 5. x\n" + text); |
| 169 | 169 | |
| 170 | 170 | 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); | |
| 173 | 188 | }); |
| 174 | 189 | }); |
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, thereadFileSyncthrows loudly instead of quietly dropping the section, which is the right failure.