steid

@jamesgill /

fix: the commit column takes half an 800px window, so it narrows there

`w-56` below `lg`, `w-80` above it. The code beside the attribution is what
the reader came for, and at 800px twenty rems of sha, summary and date left
the file itself scrolled almost entirely out of view. The summary truncates
inside whichever width applies, so nothing else changes.

Also records the handover for the orchestrator: the blame page's decisions,
the holes it opens — a commit page it links to that does not exist yet, line
anchors it assumes — and what was and was not verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4XhTsWXrFyMeN1C12jqV9
JamesPatrickGill authored 15 hours agoparentf42a185Browse filese141fa656716003fd25758fa3da5e0492bf13a9c

2 files changed+150 −1

plans/handover-blame.md+145 −0View file
@@ -0,0 +1,145 @@
1+# Handover — `blame` (branch `feat/blame`)
2+
3+For the orchestrator to fold into `progress.md`, `current.md` and `ui.md`. Written
4+instead of editing those files directly, because five agents appending to them at once
5+would conflict every time.
6+
7+## What shipped
8+
9+`GET /{handle}/repos/{name}/blame/{rev}/-/{*path}` — the same file as the blob page,
10+read by who last changed each line. `Code · Blame · Raw` in the file header of **both**
11+views, so the blob page gained an entry point rather than blame being reachable only by
12+typing a URL. That is the `ui.md` "Where the next features go" row for Blame, filled in
13+as written: *a toggle in the blob's own header, beside Raw*.
14+
15+Files: `src/application/blame.rs` (new), `src/infrastructure/web/blame.rs` (new),
16+`GitQuery::blame` on `application/port.rs`, its two implementations, and three
17+visibility widenings plus a header swap in `web/browse.rs`.
18+
19+## For `progress.md` — decisions worth not rediscovering
20+
21+- **The porcelain parser is pure and lives in the application layer.** All the fiddly
22+ knowledge about `git blame --porcelain` is about the *format*, not about the binary:
23+ the commit header block is written **only the first time a commit appears**, so every
24+ later run of the same commit is a bare sha line and its details have to be looked up
25+ from an accumulated map. A line of code can look exactly like a header line — a file
26+ full of shas would reparse itself into nonsense — and **the leading tab is the only
27+ thing that tells them apart**. Both are pinned by tests.
28+- **`parse_blame` never fails.** A record git spells in a shape it does not recognise is
29+ skipped. Blame renders fine missing one line's attribution; refusing the page would
30+ turn a future git version into an outage.
31+- **`filename` is per commit, not per hunk.** git emits it inside the first header block
32+ for a commit and not again, so it is carried on `BlameCommit`. That is what makes the
33+ "moved from" marker work at all — measured against real output, not assumed.
34+- **The use case reads the blob before blaming.** Binary, too-large and not-there are
35+ then decided by exactly the rules the blob page uses, from the same `MAX_BLOB_BYTES`,
36+ rather than by a second set free to disagree. It costs one `cat-file`. The alternative
37+ — deriving those states from blame's own output — **cannot work**: the porcelain
38+ stream carries the bytes without saying what they are, so a binary file blames into
39+ garbage rather than reporting itself.
40+- **A timeout is a page, and it does not matter which read timed out.** The page catches
41+ `Error::GitQuery(e) if e.is_timeout()` around the whole use case, so a timeout in the
42+ blob read renders the same "Blame took too long for this file" as one in blame itself.
43+ Verified by temporarily setting `GIT_TIMEOUT` to 1ns (screenshotted, then restored).
44+- **The commit cell is one line tall, on purpose.** Two lines and the blame and the blob
45+ disagree about where line 400 is, which is the one thing that would stop them reading
46+ as two views of one file. Row height was measured at 19.5px in both, matching.
47+- **The age tint is linear in time, not in rank.** `AGE_STEPS = 5`, scaled between the
48+ file's own oldest and newest commit. Consequence worth knowing: **a file whose history
49+ is bunched shows only the end steps.** `src/infrastructure/git_query.rs` on this branch
50+ has four commits at two moments a week apart, so it renders as steps 0 and 4 only,
51+ and looks like a two-value marker rather than five. `plans/progress.md`, whose history
52+ is spread over five weeks, uses the middle steps and is the file to look at to judge
53+ the effect. Rank-based quintiles would always use all five and always look richer —
54+ and would lie about the gaps. Linear was kept; if it is ever changed, that is the
55+ trade being made.
56+- **A file written in one commit takes the *top* step**, not the bottom. There is
57+ nothing older to contrast it with, and rendering it as uniformly ancient would be a
58+ claim the data does not support.
59+- **The tint is plain CSS in `styles.css`**, five `.blame-age-N` rules built from
60+ `color-mix(in oklab, var(--primary) N%, transparent)`. Not a Tailwind utility, because
61+ the colour is a token at a fraction of an alpha; `color-mix` keeps it a token, so it
62+ follows a palette change and both colour schemes without a `.dark` variant. It rides
63+ on the leftmost `<td>` of **every** row in a run, so consecutive rows draw one
64+ unbroken edge under `border-collapse`.
65+- **Chrome headless writes screenshots to files; the puppeteer MCP tool does not.**
66+ `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless
67+ --hide-scrollbars --window-size=W,H --screenshot=path URL` is how the PNGs in this
68+ handover were made, with the MCP browser used for measuring computed styles.
69+- **`topcoat dev` does not always pick up a rebuild**, and its symptom is silent: it
70+ keeps printing `no changes; application up to date` while serving the previous binary.
71+ A `pkill -f "topcoat dev"`, wait for the port to free, then restart is the fix. Two
72+ restarts in a row failed with `AddrInUse` because the old process had not yet exited.
73+
74+## For `current.md` — holes opened, shortcuts taken
75+
76+- **`/{handle}/repos/{name}/commits/{sha}` is linked and does not exist on this branch.**
77+ Every sha in the blame's commit column points at it. This is the URL contract with the
78+ `commit-page` agent; if that branch does not land, every blame row has a dead link and
79+ the fix is one function (`commit_url` in `web/blame.rs`).
80+- **Line-number links point at `blob#L<n>`.** The anchors are another agent's work. Until
81+ that lands the link navigates to the blob and does not scroll.
82+- **The blame page has no revision switcher**, deliberately: it would cost a whole `git`
83+ process (`list_refs`, ~14ms) and would need a `Switch::Blame` variant in
84+ `web/browse.rs` — a shared file five agents were editing. Reconsider once the branches
85+ are merged; a `Switch::Blame(&path)` arm is about four lines.
86+- **`BlameCommit::boundary` is parsed and tested but only reaches the tooltip.** A
87+ boundary commit is where git stopped walking, so its lines may be older than it is.
88+ There is nowhere to say that on a row that must stay one line tall. If it matters
89+ later, the place for it is a marker beside the sha.
90+- **`BlameCommit::author_name` is shown only in the tooltip** for the same reason. The
91+ row is sha, summary, date; the author did not fit, and on a personal instance every
92+ commit has the same author anyway. On a multi-user instance this is the first thing to
93+ revisit.
94+- **The blame page makes 4 `git` processes** for a text file: `cat-file --batch-check` +
95+ `cat-file blob` for the blob read, then `cat-file --batch-check` + `git blame` for the
96+ blame. Two of those are the same existence question asked twice — once by the use case
97+ through `read_blob`, once inside the adapter's `blame` because the port must be
98+ self-contained. **A kept-alive `cat-file --batch` collapses both**, which is the same
99+ upgrade 0006 defers for the per-file last-commit column. Until then it is the price of
100+ the port not assuming its caller checked.
101+- **Blame is the most expensive read in `GitQuery`** and is the read most likely to meet
102+ the 20s timeout. Nothing but this page calls it.
103+
104+## For `ui.md`
105+
106+The Blame row in "Where the next features go" is done; it can move out of that table
107+into the page's own description. Suggested text for **The repository page**:
108+
109+- **A file has two views and one header.** `Code · Blame · Raw` sits where Raw alone
110+ used to, with the active view in the primary colour — the same rule the tab strip
111+ follows, so the primary colour still means position and nothing else. Raw is never
112+ "active": it downloads rather than displays.
113+- **Blame's rows are the blob's rows.** Same type, same leading, same height, so the two
114+ views of a file are two readings of one thing rather than two pages. The commit is
115+ shown once per run of lines and is held to a single line to keep that true.
116+- **The age tint is texture, not a heat map.** A hairline down the left of each run,
117+ five steps of the primary colour at 5–30% alpha, scaled between the file's own oldest
118+ and newest commit. It should tell you which end of a file is moving and nothing more
119+ precise; if it ever reads as a value to look up, it is too strong.
120+- The page's cost: **4 `git` processes**, up from the blob page's 4 (3 + the switcher).
121+
122+## Verified
123+
124+- `cargo test` — **494 passed, 0 failed** (21 of them new). `cargo clippy --all-targets`
125+ — zero warnings. `cargo fmt` clean.
126+- Run on port 3105 from this worktree against a claimed instance holding this
127+ repository's own history, `v0.1.0` and `v0.2.0` annotated tags, and a `fixtures`
128+ branch carrying a 1.7 MB text file, a binary file and a one-commit file.
129+- Looked at in a browser in **both colour modes** and at **800px**: no horizontal page
130+ overflow (`document.scrollWidth == innerWidth`), the table scrolls inside its own
131+ container, and the commit column narrows to `w-56` below `lg` so the code keeps the
132+ room.
133+- Every state screenshotted and looked at: many commits, one commit, a spread of tint
134+ steps, a renamed file (the `↳` marker beside the sha, verified end to end against a
135+ real `git mv`), an empty file, binary, too large, timeout, the blob header's new
136+ toggle, and 800px.
137+
138+## Not verified
139+
140+- **The commit link's destination**, because the page does not exist on this branch.
141+- **A real timeout.** The state was provoked by lowering `GIT_TIMEOUT` to 1ns, not by a
142+ repository large enough to take 20 seconds.
143+- **A file with a run of lines carried through a rename in *this repository's* own
144+ history.** The marker was verified against a `git mv` pushed to the fixture branch, and
145+ the parse against captured porcelain; no existing Steid source file has been renamed.
src/infrastructure/web/blame.rs+5 −1View file
@@ -247,6 +247,10 @@ async fn blame_rows(
247247 ///
248248 /// Repeating the same commit down forty adjacent rows is noise; saying it once is the
249249 /// whole reason blame is grouped.
250+///
251+/// A fixed width, narrower on a small viewport: the code beside it is what the reader
252+/// came for, and on an 800px window a 20rem column of attribution would take half of
253+/// it. The summary truncates inside whichever width applies.
250254 #[component]
251255 async fn commit_cell(handle: &str, name: &str, path: &RepoPath, group: &BlameGroup) -> Result {
252256 let commit = &group.commit;
@@ -269,7 +273,7 @@ async fn commit_cell(handle: &str, name: &str, path: &RepoPath, group: &BlameGro
269273 }
270274
271275 view! {
272 <div class="flex w-80 items-baseline gap-2 overflow-hidden">
276+ <div class="flex w-56 items-baseline gap-2 overflow-hidden lg:w-80">
273277 <a
274278 href=(commit_url(handle, name, &commit.id))
275279 title=(about.as_str())