steid

@jamesgill /

docs: fold the highlighting handover into plans

The wave's agents write plans/handover-<name>.md instead of editing the shared
docs, because five branches appending to current.md at once conflict every
time. Folding it in is the orchestrator's half of that bargain, done at merge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGBYVHV86DDvJCBKsDFHT6
JamesPatrickGill authored 15 hours agoparent8331d0cBrowse files6f0a60833ecd3d2ac8722944fb86535a56f4ece0

4 files changed+43 −125

plans/current.md+13 −0View file
@@ -135,6 +135,19 @@ instance, and Steid's own source is pushed to it and browsable there.
135135 `/jamesgill/repos/steid/releases`, but nothing has been rsynced there — so the
136136 one-command install a stranger would run does not work yet.
137137
138+### Opened by the section 1 wave
139+
140+- **Highlighting caches nothing.** The same file is re-highlighted on every view at
141+ ~87 ms per thousand lines in release. Cache per blob object id when it shows.
142+- **Diffs, READMEs and markdown code fences are unhighlighted.** The README goes through
143+ `web/markdown.rs`, which writes its own `<pre><code>`; wiring `highlight.rs` into it is
144+ a small follow-up.
145+- **`.jsx` is plain**: two-face's JavaScript grammar claims only `js`/`htc`; `tsx` is
146+ covered by TypeScriptReact. An alias is one line.
147+- **`\r\n` files keep the `\r` inside highlighted markup.** Invisible under
148+ `white-space: pre`; noted rather than fixed.
149+- **The blob header does not name the detected language.** It is only visible as colour.
150+
138151 ### Carried over — small, unblocked
139152
140153 - **A client that disappears mid-request leaves the body-copy task waiting.** The copy
plans/handover-highlight.md+0 −125
@@ -1,125 +0,0 @@
1# Handover — `highlight` (syntax highlighting in the blob view)
2
3Branch `feat/highlighting`. One commit of code, plus this file.
4
5## What shipped
6
7`/{handle}/repos/{name}/tree/{rev}/-/{path}` renders a text file with syntax
8highlighting. Nothing else changed: no new route, no new `GitQuery` method, **no extra
9`git` subprocess** — the bytes were already being read for the plain-text view.
10
11- `src/infrastructure/highlight.rs` — the whole adapter. `source_lines(file_name, text)`
12 returns `Source { lines: Vec<SourceLine>, too_large: bool }`, one entry per line of
13 `text.lines()`, each either `Plain(String)` or `Classed(String)` of HTML.
14- `src/infrastructure/web/browse.rs` — only the `source` component: it takes a
15 `file_name` as well as the text, and the code cell renders `Classed` through
16 `Unescaped`. The row, the line-number cell and the scroll container are untouched, so
17 the blob-header toggle and the `id="L<n>"` anchors being added by sibling agents do
18 not collide with this.
19- `styles.css` — a `--syntax-*` family on `:root` and `.dark`, and the mapping onto the
20 `hl-` classes at the bottom of the file.
21- `syntect 5.3` with `default-features = false, features = ["default-fancy"]`: the pure
22 Rust regex engine, so the Debian release build gains no C dependency.
23- `two-face 0.5` with `default-features = false, features = ["syntect-fancy"]`: the
24 grammars syntect's defaults lack. Its default feature is `syntect-onig`, so
25 `default-features = false` is not tidiness — it is what keeps `onig`, and therefore a
26 C dependency, out of the release build.
27
28## progress.md material — decisions worth not rediscovering
29
30- **Classes, not inline styles.** `syntect` will happily emit `style="color:#..."`.
31 That would hardcode one palette into generated markup and follow neither a palette
32 change nor the colour scheme, which is the one rule `styles.css` states outright. So
33 the output is `ClassStyle::SpacedPrefixed { prefix: "hl-" }` and the colours live in
34 `styles.css` with every other token.
35- **`ClassedHTMLGenerator` cannot be used here.** It produces a single string whose
36 `<span>`s cross line boundaries — its own docs say the output must go in one `<pre>` —
37 and the blob view is a table with one row per line, so those spans cannot be split
38 across cells. `highlight.rs` therefore drives `ParseState` + `ScopeStack` +
39 `line_tokens_to_classed_spans` itself: the parse state carries across lines (a block
40 comment opens on one and closes on another) but the *markup* does not — whatever is
41 open at the end of a line is closed there and reopened at the start of the next. That
42 is the whole reason the module is 150 lines rather than 20.
43- **The line ending is stripped by character, not trimmed.** The `_newlines` syntaxes
44 need the `\n` handed to the parser, but it comes back *escaped inside the output* and
45 can land before or after a closing `</span>` depending on where a scope pops. Left in,
46 `white-space: pre` renders a second, empty line inside every row and the whole file
47 reads double-spaced. It is dropped with a `filter`, not a `trim_end`.
48- **`hl-` classes are scope *atoms*, and atoms are not positional.** `keyword.control`
49 becomes `class="hl-keyword hl-control"`, and `meta.function` — which wraps a whole
50 function *body* — becomes `class="hl-meta hl-function"`. A bare `.hl-function` rule
51 would therefore colour every function body. Single-atom selectors are only safe for an
52 atom that leads its scope; the rest are spelled as compounds
53 (`.hl-entity.hl-name.hl-function`). **Rule order is load-bearing** for the same
54 reason: `punctuation.definition.string` carries both `hl-punctuation` and `hl-string`,
55 equal specificity, so `.hl-string` is written later and a quote reads as its string.
56 The comment rule is last so a comment is a comment whatever it contains.
57- **Plain text is not "highlighted with a plain-text syntax".** When the resolved syntax
58 is syntect's `Plain Text`, `source_lines` returns `Plain` lines, so a `LICENSE` is not
59 wrapped in spans that colour nothing.
60- **The caps are 512 KiB and 10,000 lines**, and they are two caps because the cost is
61 both per byte (one enormous minified line) and per line. The page states it —
62 otherwise a large Rust file silently looks like an unsupported language. Neither
63 number was derived from a measurement; see below.
64- **The syntax set is loaded once into a `OnceLock`** on the first blob viewed. It is a
65 few MB of resident memory for the life of the process.
66
67## current.md material — holes opened and shortcuts taken
68
69- **`.jsx` is the one extension still unhighlighted.** `two_face` closed the gap that
70 mattered — TOML, Dockerfile and TypeScript all highlight now, and the first two are in
71 this repository — but its JavaScript grammar claims only `js` and `htc`, while React
72 is covered on the TypeScript side by `TypeScriptReact` for `tsx`. So a `.jsx` file
73 renders plain. Nothing here uses one; it is recorded rather than fixed, and the fix if
74 it is ever wanted is to alias `jsx` onto the JavaScript syntax in `syntax_for`, not
75 another crate. `the_languages_this_repository_uses_are_all_highlighted` asserts the
76 sixteen that do work, so a future syntax-set swap cannot quietly reopen the hole.
77
78- **Highlighting costs ~87 ms per 1,255-line file in a release build** — measured on
79 `browse.rs` with `cargo test --release`, and it is paid on every view because nothing
80 caches. In the `topcoat dev` (debug) build the same page takes ~0.7 s to serve versus
81 ~0.06 s for a file past the cap, so debug is roughly eight times slower and is not the
82 number to plan against. 87 ms is still the most expensive thing on a blob page: it is
83 a Rust regex engine over every line, not a subprocess. If it needs to go, the answers
84 in order are cache per blob object id (the id is already in `FileView`), then lower the
85 caps.
86- **Nothing caches.** The same file is re-highlighted on every view.
87- **Diffs, READMEs and markdown code fences are unhighlighted**, as scoped. A README
88 rendered on the repository page goes through `web/markdown.rs`, which writes its own
89 `<pre><code>`; wiring this module into it is a small, obvious follow-up and was left
90 alone to keep the branches apart.
91- **`\r\n` files keep the `\r` inside the highlighted markup** (it is stripped from the
92 plain path by `str::lines`). Invisible in `white-space: pre`; noted rather than fixed.
93- **The blob view's own header has no "this is Rust" indicator.** The language is
94 detected and then only ever visible as colour.
95
96## The `two-face` decision, and what it cost
97
98Taken by the user after the first pass reported the gap.
99
100- **The syntax set is now `two_face::syntax::extra_newlines()`**, which is syntect's
101 defaults plus the grammars `bat` curates. It is a drop-in for
102 `SyntaxSet::load_defaults_newlines` — one line in the `OnceLock`. Nothing else moved:
103 the `hl-` class output and every rule in `styles.css` are unchanged, because the extra
104 grammars emit the same TextMate scopes the mapping was already written against.
105- **The release binary grew 0.59 MiB, from 13.65 MB to 14.27 MB** (`cargo build
106 --release`, measured on this branch with and without the dependency). That is the
107 embedded grammar dump; it is data, not code. Worth knowing because the artifact is
108 downloaded by `install.sh` over whatever connection an operator has, and because
109 5b's release notes quote a size.
110- **No new C dependency.** `syntect-fancy` resolves to `syntect/regex-fancy`, the same
111 pure-Rust engine already in use, so the musl/glibc reasoning recorded in `current.md`
112 is untouched.
113
114## ui.md material
115
116- The blob's code cell is the only place colour carries information rather than
117 position. The palette is deliberately narrow — comments below muted-foreground,
118 strings and keywords carrying the contrast, punctuation just under foreground,
119 everything else close to it — so a file reads as text with structure, not as a parade.
120 It is one more `--syntax-*` block in `styles.css`, so it follows the palette.
121- **The over-cap notice is a row of the blob panel, not a banner**: one line of muted
122 mono between the file header and the table, sharing the header's hairline. A page
123 state, not an error.
124- Entry point: none. Highlighting has no control and no URL of its own — it is what the
125 blob view now does. The "Where the next features go" table needs no new row.
plans/progress.md+23 −0View file
@@ -14,6 +14,29 @@ produced five. `GitQueryError::is_timeout()` is how a page tells "asked too much
1414 (render a state, offer less) from "git failed" (a 500). Tokio's `time` feature was
1515 enabled for it.
1616
17+**Syntax highlighting (merged 2026-09-05).** `syntect` with the pure-Rust `default-fancy`
18+engine plus `two-face` (`syntect-fancy` feature) for the grammars the default set lacks —
19+TOML, Dockerfile, TypeScript — at +0.59 MiB on the binary. Decisions worth keeping:
20+
21+- **Classes, never inline colours.** Output is `ClassStyle::SpacedPrefixed { prefix: "hl-" }`
22+ and every colour is a `--syntax-*` token in `styles.css`, so the palette follows the
23+ theme. `ClassedHTMLGenerator` cannot be used because its spans cross line boundaries
24+ and the blob view is one table row per line; `highlight.rs` drives `ParseState` +
25+ `ScopeStack` itself, carrying parse state across lines but closing and reopening the
26+ markup at each line end.
27+- **`hl-` classes are scope atoms, not positional**, so `.hl-function` alone would colour
28+ every `meta.function` body. Single-atom selectors only for an atom that leads its scope;
29+ the rest are compounds. Rule order in `styles.css` is load-bearing: `.hl-string` is
30+ written after `.hl-punctuation` so a quote reads as its string, and the comment rule is
31+ last.
32+- **The trailing newline is dropped by character, not `trim_end`** — the `_newlines`
33+ syntaxes need it fed in, and it comes back escaped inside the output, sometimes before a
34+ closing span. Left in, every row renders double-spaced.
35+- **Caps are 512 KiB and 10,000 lines**, both, because cost is per byte and per line.
36+ Plain text is returned as `Plain` lines, not wrapped in colourless spans.
37+- **~87 ms per 1,255-line file in release**, uncached, paid on every view; debug is ~8×
38+ slower and not the number to plan against. If it matters: cache per blob object id.
39+
1740 ### Milestone 0 — Skeleton · done
1841
1942 Topcoat 0.5 app serving pages, `AppConfig` from `STEID_*` env, SQLite pool in app
plans/ui.md+7 −0View file
@@ -123,6 +123,13 @@ to say, pushing the code below the fold.
123123 identify is linked to and labelled "Licence": naming the wrong one is a claim about
124124 somebody's legal terms.
125125
126+- **Syntax colour is the one place colour carries information rather than position.**
127+ The palette is deliberately narrow: comments below muted-foreground, strings and
128+ keywords carrying the contrast, punctuation just under foreground, everything else
129+ close to it — a file reads as text with structure, not a parade. It is one
130+ `--syntax-*` block in `styles.css`, so it follows the palette. The over-cap notice is a
131+ row of the blob panel sharing the header's hairline, a page state rather than a banner.
132+
126133 ### Where the next features go
127134
128135 The slots matter as much as what fills them today. Written down so the next feature