Skip to content

Latest commit

 

History

History
341 lines (241 loc) · 218 KB

File metadata and controls

341 lines (241 loc) · 218 KB

Tasks — the register, the board, and what is still owed

This file is the single source of truth for what is done and what is not. CLAUDE.md defines the rules (how ids are allocated, how a round lands); this file holds the state those rules operate on. Nothing else tracks status — if a round's status appears in two places, one of them is wrong, which is exactly how CLAUDE.md came to list M5 as open for months after it was built.

Three things live here:

  1. The board — every range, its status, and the document that owns it.
  2. What is owed — acceptance criteria that were not met, and gaps found but not fixed. These used to be buried in prose across eight documents with no list anywhere.
  3. The register — the allocation record, with the notes made when each range was planned.

How status is recorded

Every plan document carries a machine-readable marker on line 3:

<!-- status: built -->

Vocabulary: built, built-caveat (done, something owed — see §Owed), open, closed (decided against), superseded.

The marker on the document is the source; the board below is derived from it. test/docsStatus.test.ts asserts they agree, that every plan document has one, and that every built-caveat has at least one Owed entry. A prose header that says "Open" over a Results section saying "built" is what this replaces — R50 found eleven of those by hand, and they came back within one round.

Before writing a plan document: read the last allocated id below. When the plan lands: append its range, add the marker, and update the board — same commit.

Ids are allocated once and never reused — a task that is dropped stays listed, marked dropped, so a reader of an old commit message can still find out what R41 was.

Board

R ranges

Range Status Home
R1–R7 built docs/plans/M5d-PLAN.md
R8–R11 built ⚠ docs/plans/M5e-PLAN.md
R12 built docs/plans/M5f-PLAN.md
R13 built docs/plans/M5g-PLAN.md
R14–R17 built docs/plans/M6-PLAN.md
R18 built docs/plans/M5h-PLAN.md
R19–R20 built docs/plans/R19-document-props.md
R21–R23 built docs/plans/R21-notifications.md
R24–R30 built ⚠ docs/plans/R24-tabs.md
R31 built docs/plans/R31-csv-spike.md
R32 n/a — (this file, CLAUDE.md)
R33 built ⚠ docs/plans/R33-scrollbars-and-selection.md
R34 built docs/plans/R34-wide-grids.md
R35–R37 built docs/plans/R35-tab-overflow.md
R38 built docs/plans/R38-tab-strip-polish.md
R39–R40 built docs/plans/R39-grid-followups.md
R41 built docs/plans/R41-raw-editing.md
R42 built docs/plans/R42-stale-spans.md
R43–R46 built ⚠ docs/plans/R43-grid-sizing-and-scroll.md
R47–R50 built docs/plans/R47-repo-hygiene.md
R51–R52 built docs/plans/R51-main-process.md
R53 built ⚠ docs/plans/R53-interner-encoding.md
R54–R56 built docs/plans/R54-signal-followups.md
R57 built ⚠ docs/plans/R57-notification-emphasis.md
R58–R59 built docs/plans/R58-zoom.md
R60 built docs/plans/R60-dark-elevation.md
R61–R64 built docs/plans/R61-keyboard-workflow.md
R65 built docs/plans/R65-shortcuts-help.md
R66–R68 built docs/plans/R66-palette-polish.md
R69–R70 built docs/plans/R69-focus-and-find.md
R71 built docs/plans/R71-text-as-icons.md
R72–R75 built docs/plans/R72-path-query.md
R76 built docs/plans/R72-path-query.md
R77 built — (this file, CLAUDE.md, test/docsStatus.test.ts)
R78–R81 built docs/plans/R78-find-affordances.md
R82–R83 built docs/plans/R82-hover-and-glyphs.md
R84–R85 OPEN docs/plans/R84-settings-group.md
R86–R90 built ⚠ docs/plans/R86-find-as-query-surface.md
R91–R94, R112 built docs/plans/R91-focus-into-content.md
R95–R97 built docs/plans/R95-recent-files.md
R98–R99 built docs/plans/R98-glyph-font-per-glyph.md
R100–R101 built docs/plans/R100-raw-external-rewrite.md
R102–R103 built docs/plans/R102-find-single-line.md
R104–R105 built docs/plans/R104-start-pane-polish.md
R106–R107, R111 built docs/plans/R106-detail-focus-ring.md
R108–R110 built ⚠ docs/plans/R108-replace-all-quadratic.md
R113–R116 built ⚠ docs/plans/R113-inactive-selection.md
R120–R123 built docs/plans/R120-find-bar-keyboard.md
R124 built docs/plans/R124-go-to-position-hint.md
R125 built docs/plans/R125-legacy-encoding-edits.md
R126–R128 built docs/plans/R126-find-count-stability.md
R129–R131 built docs/plans/R129-query-predicates.md
R132 built docs/plans/R132-existence-predicates.md
R134–R137 built ⚠ docs/plans/R134-xml-namespaces.md
R138–R139 built ⚠ docs/plans/R138-boolean-predicates.md
R140–R144 built ⚠ docs/plans/R140-publication.md
R145–R150 built ⚠ docs/plans/R145-csv.md
R143 built ⚠ docs/plans/R143-yaml.md
R151–R154 built docs/plans/R151-ci-matrix.md
R155 built docs/plans/R155-spike-policy.md
R156 built docs/plans/R156-search-stale-flag.md
R157–R158 built docs/plans/R157-package-check.md
R159–R163 built docs/plans/R159-fixed-duration-waits.md
R164–R167 built ⚠ docs/plans/R164-release-security-hardening.md
R168 OPEN docs/plans/R168-crlf-edit-offset.md
R169 OPEN docs/plans/R169-external-change-reload.md
R170 OPEN docs/plans/R170-tree-horizontal-scroll.md
R171 OPEN docs/plans/R171-watcher-error-handling.md

Pre-R milestones

Home Status
docs/plans/M0-PLAN.md built
docs/plans/M0c-PLAN.md built
docs/plans/M1-PLAN.md built
docs/plans/M2-PLAN.md built
docs/plans/M3-PLAN.md built
docs/plans/M4-PLAN.md built
docs/plans/M5-PLAN.md built
docs/plans/M5c-PLAN.md built
docs/plans/UI-FEEDBACK.md superseded

Owed

Everything below is real work that was consciously not done, not a backlog of ideas. Each is disclosed in its own document too; this is the list that did not exist before.

Owed Where Why it is still open
R164: dropping a link on the title bar is not confirmed on a real build. The navigation guard is tested at the decision level and the drop guard at the event level; what is unverified is the OS gesture that produces the event. docs/plans/R164-release-security-hardening.md §9 Native drag-drop cannot be dispatched from the harness — flagged as manual by §2e before the work started, not discovered late.
R166: the full lifecycle under sandbox: true is not exercised. Automated coverage reaches the preload surface, an IPC round trip and the document read path (stat, mintReadToken); Save, Save As, file watching and an edit cycle are not. docs/plans/R164-release-security-hardening.md §9 Nothing suggests they are broken and the seam they share (the contextBridge) is proven — but §4 asked for the lifecycle, and this is not the whole of it.
R167's checksum job has never run for real. The hashing logic is dry-run; gh release download against a real draft release is not. docs/plans/R164-release-security-hardening.md §9 By R141/R151's own argument the first genuine exercise is a v* tag, and a draft release cannot be rehearsed without making one.
R46's p90 thumb-drag frame time was never measured. The fix stands on the read-count mechanism (test/scrollbarCoalescing.test.tsx), not a re-confirmed frame time. docs/plans/R43-grid-sizing-and-scroll.md No display in this environment. Flagged rather than silently substituted.
Per-tab view state (scroll, layout) is not restored by session restore; paths and active-tab index are. docs/plans/R24-tabs.md (R29) Scoped out deliberately.
The scrollbar thumb's corner radius renders wrong at top: 0 — a fractional-pixel artifact. docs/plans/R33-scrollbars-and-selection.md (addendum 1) Found, not fixed.
core/path/parse.ts's NAME_CHAR is ASCII-only, so the grammar blocks the non-ASCII query R53's own fix resolves — one layer before it is reached. docs/plans/R53-interner-encoding.md Found and reported by R53, out of its scope.
info notifications' hairline clears 3:1 contrast in neither theme (exactly 1:1 in dark — same token both sides). docs/plans/R57-notification-emphasis.md Disclosed and tested as a known case rather than fixed.
One R11 item: the XML formatter does not report what it skipped. docs/plans/M5e-PLAN.md §5 Does not block R9, which is complete.
The full Transform pipeline's renderer-side peak was never measured end-to-end; only format() in isolation (1.47×). H2d's ~23 ms block floor was never re-measured in a real Electron renderer either. docs/plans/M5-RESULTS.md Both were blocked on a real Electron harness. R51's test/mainElectron.test.ts now provides one — newly tractable.
The title-bar mark's 1px optical nudge is calibrated for one font, so on a platform whose --font-ui resolves to neither Segoe UI nor the Apple system font, the mark sits about a pixel off the title text. src/renderer/components/TitleBar/TitleBar.css, test/titleBarInkAlignment.test.tsx Found by the first CI run: the assertion measured 1px against a 0.5px tolerance on a Linux runner. The test now skips where the calibrated font is absent rather than widening the tolerance, which would make it pass everywhere while no longer detecting the misalignment it exists for. A per-font nudge is a design decision, not a test change. Cosmetic.
npm run test:large fails (worker timeout; a full run is ~21 minutes). docs/FINDINGS.md Known, not scheduled.
Activating a minified 10 MB document's tab takes ~2.5 s. docs/FINDINGS.md The fix is a live EditorView per tab, which multiplies the ~1 MB UTF-16 window by tab count against R28's budget. A larger change than R41 was scoped for.
The multi-line current-match ring (a left edge, not a closed box; kept by R102, Raw.css) was reasoned from the decoration code, not screenshot-verified. docs/plans/R86-find-as-query-surface.md / docs/plans/R102-find-single-line.md No running Electron instance in this environment to render a real multi-line match and look at it. R89's own multi-line find field is gone (R102) — this is about the decoration, which the revert kept.
R90's confirm-above-50,000-matches gate was exercised via the undo-budget branch of the same notify() call, not the literal match-count threshold — constructing 50,001 real matches was judged not worth the fixture cost. docs/plans/R86-find-as-query-surface.md The two conditions are one || apart in handleReplaceAllClick; recorded as untested-at-threshold rather than assumed equivalent.
R109's fast undo/redo path is not "the same primitive for all three callers" the plan asked for — it's used only when UndoEntry.independent is true (a Replace All or a Transform); an ordinary typing burst still uses the original per-patch loop. docs/plans/R108-replace-all-quadratic.md §8 An overlapping same-burst edit (correcting one character repeatedly) makes the single-pass primitive produce wrong bytes, not just run slower — found by an existing test failing, not by inspection, when the unconditional version was tried first.
R143's own §3–§10 (the actual YAML parser, anchors/aliases, multi-document streams, detection, formatting) were not started. Only §2 — the subtreeSplice.ts graft's multi-root guard — landed; that section was ready to build and did not depend on any YAML decision. §9's open questions (anchors/tags contract, multi-document display, canFormat, the parsed subset, comment attachment) are still awaiting a yes/no before implementation ids for the rest of the round get allocated. docs/plans/R143-yaml.md §9 Deliberate scope: §2 was explicitly "ready to build and does not depend on any YAML decision," while §3–§10 each end in an open question for the reader, not a decision this round could make unilaterally.
R137 (path queries resolving a prefix against document declarations, //inv:price matching //i:price) was not built. R134–R136 (interner prefix/local split, per-name resolution, the R135 rebinding fallback, and grid grouping on the resolved name) landed; R137 needs PathStep/NameResolver (core/path/parse.ts) to carry a resolved key a candidate can match against several raw spellings, plus evaluate.ts's per-candidate loop to consult it — a real query-engine change, not an extension of what R134–R136 already built. docs/plans/R134-xml-namespaces.md §5 Stopped and reported rather than rushed, per CLAUDE.md's own rule: a half-built query-engine change would ship less carefully reviewed than R132/R138–R139's own expression-parser rewrite was.
R134–R135's namespace state does not survive subtreeSplice.ts's incremental-reparse graft. The real worker round trip (opening a document) now correctly threads NodeStore.exportNamespaceState()/importNamespaceState() through (found in review — every namespace test up to that point constructed its NodeStore directly, never through the round trip, so the gap was invisible to them). An incremental splice (editing, not opening) still constructs its post-graft store via NodeStore.fromBuffers with no namespace state argument, so resolvedNameIdOf silently reverts to the raw nameIdOf for that document until it is next fully reopened. docs/plans/R134-xml-namespaces.md §3 (subtreeSplice.ts) A full merge (old-tree state plus whatever the newly-reparsed fragment adds, including a graft that introduces a fresh rebinding) is a genuinely separate piece of work from the worker round trip, and was out of budget for this round.
TitleBar.css's title-ink alignment comment now says "K" glyph (Klados) but the pixel measurements it cites (9px cap-height ink, top 14.5/bottom 23.5) are carried over from the old "N" glyph (NodePad) measurement, not re-taken. docs/plans/R140-publication.md §9 A sans-serif capital K and capital N are both flat-topped strokes with no overshoot, so the values are very likely still correct, but this was not verified against the running app in this session.
R139's acceptance 10 (and costing measurably less than the sum of its two terms) was not wall-clock measured. Short-circuiting is verified structurally instead: evaluatePredicatePlan's and/or use native &&/` (so a failing left term never evaluates the right one), andplanPredicateconstant-folds an unresolved subject throughand/or/notat build time — asserted intest/pathEvaluate.test.ts` by a query whose second term names an element absent from the whole document, which the loop never touches the store to check.
A CSV row with two or more unheadered extra fields collapses them to one shared empty-name column, keeping only the first. docs/plans/R145-csv.md §9 (parseRow) The single-extra-field case — already a Warning-flagged malformed row — is unaffected; a second extra field on the same row is the narrow residual of the §3 probe's finding (empty attribute name spans all share one interned id).
A columns × rows-derived pre-open memory projection for CSV was not built. docs/plans/R145-csv.md §8/§10 Requires peeking at the file's header and estimating row count before document:openDialog's existing stat()-only check — a real pre-open content-read feature, not a measurement-pass task. The existing generic file-size-based confirmSize gate (2.5× multiplier) already covers CSV today; measured real multiplier is 2.85×–3.36×, close to it.

Allocated

Range Plan Milestone at allocation Notes
R1–R7 docs/plans/M5d-PLAN.md M5d Klados draws its own title bar: frameless shell, pane toggles, retire the command bar, open-file affordance, window title, platform pass, document-scope controls (R7, from D-055).
R8–R11 docs/plans/M5e-PLAN.md M5e R8: six fixes from the first session with the drawn title bar. R9: the pretty-print button (placement settled by D-057). R10: renderer test tooling — Vitest browser mode + Playwright/Electron, proven on R8. R11: the XML formatter, reopening D-045. Order: R10 → R8 → R11 → R9.

| R12 | docs/plans/M5f-PLAN.md | M5f | The status bar: VS Code-style left/right split, diagnostics and caret position clickable, statistics behind an info icon (amends CONCEPT.md §8). |

| R13 | docs/plans/M5g-PLAN.md | M5g | Transform performance: the no-op skip, the formatter's per-element Map, indent allocation, the Raw remount — plus the benchmark and fixtures to measure them. |

| R14–R17 | docs/plans/M6-PLAN.md | M6 | TOML: the parser (dotted keys/implicit tables, all value forms), incremental reparse, the conservative formatter, fixtures and the architecture-validation check. |

| R18 | docs/plans/M5h-PLAN.md | M5h | The XML formatter's emitChild/formatElement recursion overflows the stack at nesting depth ~5 000 — input the parser accepts completely. Invariants 4 and 5. Found reviewing docs/plans/M5g-RESULTS.md. |

| R19–R20 | docs/plans/R19-document-props.md | — | R19 (built): React's DEV performance-track logging deep-walks changed props, enumerating the NodeStore's typed arrays with for...in — any in-place reparse wedged npm run dev and grew it past 12.6 GB. Disabled via devPerformanceTracks.ts; production was never affected (D-061). R20 (closed, not worth doing): §5a's measurement found the extra re-renders cost ~3.3ms summed across all five panes for a 10-keystroke burst — the slice split (§5b) was not built (D-063). R20 does not make R19 revertable. |

| R21–R23 | docs/plans/R21-notifications.md | — | Built. Notifications replace the alert strip, which is deleted (D-062). R21: the system — derived choice notifications vs pushed transient ones, document-scoped from day one, bottom-right and never in layout flow. R22: migrate the existing messages, delete DocumentStatus. R23: CONCEPT.md §11.3's external-change prompt, closed — keepMine now has a caller via the derived external-change notification. |

| R24–R30 | docs/plans/R24-tabs.md | — | Tabs. CONCEPT.md §11.4 already designs it; the cost is the twelve module-level singletons that assume one open document. R24 (built): de-singleton the renderer spine — session/tabs.ts's registry, four modules turned into active-tab lookups, commands/context gated-write-plus-resync (D-064), layoutStore kept global (D-064). R25 (built): the tab strip — components/TabStrip/, format-glyph map, same-name disambiguation, klados.tabs.new. R26 (mostly built): dirty-close prompts (a derived notification, R21-notifications.md §3a), keyboard switching (Ctrl+Tab/Ctrl+Shift+Tab/Ctrl+W), and "open into a new tab" for klados.document.open/drag-drop — klados.tabs.new removed as a duplicate. The consolidated quit-time list landed as its own addendum after R30 — Notepad++'s shape (one dirty tab at a time, "Discard All"), not one list-view modal; session/quitFlow.ts plus main-process close interception (main/index.ts) and a new app IPC namespace (preload/api.ts). R27 (built): the shared worker pool — core/workerPool.ts, POOL_SIZE = 3, parseClient.ts/transformClient.ts acquire/release/replace instead of spawning and terminating a Worker per call. R28 (built): the cross-tab memory budget — settings.ts's configurable total, tabs.ts's getCrossTabMemoryBytes, a second confirmSize reason ('budget'), and the statistics panel's "All tabs" row. R29 (built): session restore — session/sessionRestore.ts, paths and active-tab index persisted to localStorage, restored before React's first render (main.tsx), degrading per tab with one application-scoped notification on partial failure. Per-tab view state (scroll, layout) is not restored, disclosed in docs/plans/R24-tabs.md's own R29 Results section. R30 (built): the measurement pass — no production code changed; tab-switch remount (~60-75ms wall, ~29ms profiled React render, mostly DetailContent), worker-pool queueing, and context-switch cost (~0.0024ms) all measured, real OS-level process RSS left for later (JS-heap proxy only). Found and fixed a real devPerformanceTracks gap in the measuring test itself, now in docs/FINDINGS.md. R27: the 2–3 worker pool. R28: cross-tab memory budget. R29: session restore. R30: measurement pass. |

| R31 | docs/plans/R31-csv-spike.md | — | CSV spike (built) — the quoted-newline question. Answer: Option 4 holds, validated by an exhaustive edit-and-compare check (1,757 edits against the real subtreeSplice.ts, 0 mismatches) — the existing generic bytesConsumed !== newSpanEnd safety net already covers it, so no record index, no incremental-reparse opt-out, and no core/types.ts change are needed. Option 1 (backward scan) was measured and rejected: a purely-local version is unsound (found by the spike's own first draft), and a corrected version costs O(document position), the same order as a full reparse. Recommendation for CSV proper: parser/incremental-reparse layer is now de-risked; the real cost is dialect sniffing, header detection, Tree-view design, and wide-table grid rendering — a milestone-sized UI/UX task, not a parser-risk one. |

| R32 | — (this file, CLAUDE.md) | — | Documentation restructure. CLAUDE.md's "Current state" had grown to 458 of its 655 lines — read in full every session, never once pruned. Split by lifecycle: docs/LOG.md (what was built, newest first, append-only), docs/FINDINGS.md (curated traps and measured facts, pruned not appended, read before implementing), and a status table in CLAUDE.md (248 lines). Doc map tiered always / active / historical; DECISIONS.md gained a numeric index over its 66 entries. Working agreement reordered to review → fix → commit. |

| R33 | docs/plans/R33-scrollbars-and-selection.md | — | Two corrections from using the app. Scrollbars: D-051 was only half-applied — Detail's outer pane still has a native scrollbar, and the thumb uses --elev-2-bg (a surface token) so it's near-invisible in light mode; the track is transparent and should not be. Selection: the initial selection is node 0 while every other pane shows the wrapper-descent destination, so the Tree highlights Document and the panes show elements (D-065). Stopping the descent at commented nodes was considered and rejected. |

| R34 | docs/plans/R34-wide-grids.md | — | The grid on wide tables — not CSV-specific; every defect is live today past 60 columns. Rendering is already bounded (column virtualization + GRID_COLUMN_CAP). Behind it: collectColumns is O(rows × columns²) from a findField linear scan justified by a "~6 fields" assumption tables break; numericColumns re-runs over every column on every picker tick; pinned columns render outside the virtualizer with no bound; and the quick filter is column-major, making it both silently incomplete and ~60× slower than a row-major scan (D-066). |

| R35–R37 | docs/plans/R35-tab-overflow.md | — | The tab strip once the tabs stop fitting (D-067). Measured: 16 tabs in a 1200 px window give scrollWidth 1920 against clientWidth 928, every tab already at its 120 px floor — about eight fit. R35: the defect — nothing ever scrolls the active tab into view, so Ctrl+Shift+Tab onto tab 16 leaves scrollLeft at 0 with the tab off-screen; independent of the rest and lands first. R36: Firefox-style [<] [tabs] [>] [⌄] [+] — chevrons scroll the viewport without changing the active tab, disabled at the ends, all three overflow controls hidden when everything fits, plus a real onWheel (overflow-x: auto alone does not give the strip wheel scrolling). R37: Alt+1Alt+9 direct switching — not Ctrl+1Ctrl+9, which is pane focus; a searchable "Go to Open Document…" is rejected and reported, since registerCommand has no unregister. |

| R38 | docs/plans/R38-tab-strip-polish.md | — | The tab strip's icons and scroll feel, following R35–R37. The ///+ buttons are Unicode text where the rest of the chrome uses Fluent (icons.ts's ICONS map); neither scrollBy nor R35's scrollIntoView passes a behavior, so activation jumps the strip across its whole range in one frame. Smaller steps rejected — one-tab steps are D-067's Firefox behaviour and a sub-tab step leaves a clipped tab at rest; smoothness is what's missing. Also found unreported: readOverflowState returns a fresh object per scroll event, so the whole strip re-renders on every scroll frame — which smooth scrolling would make ~30× worse. |

| R39–R40 | docs/plans/R39-grid-followups.md | — | Two grid follow-ups. R39 (D-068): exportGrid collapses Absent and "present but empty" into the same blank field, so a presence-marker column like sunroof — drawn as / in the grid — exports as nothing at all. Fixed per cell (marker exports a token) plus per column (an all-marker column exports true/false). The column classification is exact, not sampled like isNumericColumn, because export already walks every row — and must be one pass, since R34 measured cellOf as O(row fan-out) per lookup, which makes it the dominant cost of a wide export. R40: virtualized body cells are position: absolute with no height so they shrink to content (18.4 px, or 20 px when italic) while the sticky row-header and pinned cells stretch to ROW_HEIGHT (23 px). |

| R41 | docs/plans/R41-raw-editing.md | — | Typing in Raw loses the caret (D-069). Measured: at +75 ms the pane scrolls away with the editor still alive and focused; at +358 ms the .cm-content element is replaced — the debounced reparse produces a new NodeStore and Raw.tsx's mount effect is keyed on [store]. This is M5g-PLAN.md's O4 Raw half, deferred with its fix shape fully specified; the reason it was deferred ("no live GUI in this session to drive the result") no longer holds. Scoped to same-document reparse — a live EditorView per tab is a separate, larger change against R28's budget. A Raw "edit mode" was proposed and rejected: the debounce already is one, and the problem is that its end is destructive. |

| R42 | docs/plans/R42-stale-spans.md | — | The ~270 ms window after every keystroke where the source buffer is current and every stored span is not (D-070). Measured: typing XXX into Focus shows FocXX (truncated to the old byte length) and — not in the report, and worse — every following sibling's preview slides into the markup, <year>2016</year> rendering as ar>2. core/deltaList.ts was built for exactly this and its shiftedOffset was never wired into any UI read path; documentSession.ts's own comment parks the gap on the premise that "this has not been reported as a visible problem in practice", which is the premise that just failed. Distinct from R41, which fixed the remount. |

| R43–R46 | docs/plans/R43-grid-sizing-and-scroll.md | — | The grid's sizing and scrollbars. R43 (D-071): every column is CELL_WIDTH = 160 regardless of content, so year wastes most of its width — content-derived defaults from the sample isNumericColumn already takes, plus drag-resize as the escape hatch any estimate needs; and .detail-grid-container's fixed height: 480px becomes flexible with a floor, which also removes the two-nested-scrollbars problem on small windows. R44: the horizontal track spans the full viewport, so pinned columns paint over it, and .grid-scroll reserves a right gutter but no bottom one, so it covers the last row — the vertical axis is already correct (R33 addendum 1). R45: Scrollbar's onWheel reads deltaX on the horizontal axis, which is 0 for a mouse wheel, so pointing at it does nothing. R46: thumb-drag frame times measured at p90 33 ms — cause not measured; the lead is Scrollbar's MutationObserver forcing a synchronous layout read on every virtualization mutation. |

| R47–R50 | docs/plans/R47-repo-hygiene.md | — | Making the repo's own signals trustworthy, from a pre-publication audit. R47: npm run lint exits non-zero on 4 real errors buried under 5,363 CRLF warnings — no .gitattributes, core.autocrlf=true, Prettier on LF; the recent results docs' "clean, CRLF only, the existing baseline" is how the tool got switched off. Must be its own commit — renormalizing rewrites every tracked text file. R48: npm test runs only the node project, so 94 browser tests never run by default; and tabStrip.test.tsx passes in the suite and fails alone (order-dependent viewport width, plus a ! assertion that turns "found 1 of 2" into a TypeError). Also the 82.7 s invariants.test.ts. R49: no CI, no hooks — one workflow, with R47 as a prerequisite. R50: eleven plan docs still say "Open" for shipped work (a gap in the working agreement, not eleven oversights), and docs/plans/UI-FEEDBACK.md is advertised as active but dead since 2026-08-07 — retire, never delete — it is referenced from 29 files, four DECISIONS.md entries among them. |

| R51–R52 | docs/plans/R51-main-process.md | — | The main process. R51: src/main + src/preload are 666 lines with no test file — file access, IPC, window lifecycle, the R26 quit flow. The token registry's logic is already extracted and tested (core/readTokenRegistry.ts); what is untested is the Electron wiring that can undo its guarantees. Extract-and-test for most of it, Playwright _electron only for what is genuinely about Electron — and rebuild out/ first, per docs/FINDINGS.md. R52: document:watch is single-document module state while tabs exist, so an external change to any tab but the last-watched one silently never notifies; a keyed map, keeping the existing late-callback guards, refcounting the same path opened twice. |

| R53 | docs/plans/R53-interner-encoding.md | — | Interner.lookup encodes queries with TextEncoder (UTF-8 only, by spec) while stored names are raw bytes in the document's encoding (invariant 7). So a non-ASCII name in a windows-1252 document is unfindable, and lookup's own correct contract — absent means empty, no diagnostic — makes it indistinguishable from a typo. The only silently-wrong entry on docs/FINDINGS.md's known-wrong list; promoted for publication. Encode in the document's encoding; a third "unrepresentable in this encoding" state with a diagnostic, since that is a different thing from absent. |

| R54–R56 | docs/plans/R54-signal-followups.md | — | Three things R47–R50 left half-closed, found reviewing it (that round's own results verified independently and accurate). R54 (D-072): npm run lint has no --max-warnings and prettier/prettier is warn, so the command — and therefore R49's CI — exits 0 at 37 warnings and would at 370; R47 cleared the noise without closing the mechanism that produced it. Clear the 34 fixable, ratchet at --max-warnings 3 (the survivors are react-hooks/incompatible-library from useVirtualizer). R55: npm run format would rewrite 66 files, 51 of them markdown — Prettier normalizes *emphasis* to _emphasis_, 59 lines in LOG.md alone — and it became more likely to be run once R47 made the code nearly clean; needs a .prettierignore for *.md, and must land before R54's step 1. R56: two findings sit only in R47's results — npm run test:large fails (8 tests, ~21 min, worker RPC timeouts, pre-existing) and grep -c $'\r' returns false zeroes in this environment — both meet FINDINGS.md's own bar and belong there as pointers. |

| R57 | docs/plans/R57-notification-emphasis.md | — | The amber fill on a warning notification is too much of it. Two corrections first: it is --diagnostic-warning-bg, not the brand --mark-fg the report assumed (palette.css says the two amber families are deliberately separate); and the shadow it asks for is already declared — what is missing is a step underneath it, since in the light theme --surface-bg and --elev-2-bg are both --gray-0, the same "lightest surface" trap R33/D-051 §1b hit with the scrollbar thumb. So the fill has been hiding a missing boundary. Fix: neutral --elev-2-bg for every severity, severity moves to a 3px left edge, plus a 1px --surface-border hairline that is not optional. Not a 1px amber border all round (too quiet for the app's only prompt mechanism, D-062), no severity icon, and --elev-2-shadow deliberately untouched — a stronger shadow is a whole-tier change in themes/light.css, not a local override. |

| R58–R59 | docs/plans/R58-zoom.md | — | Content zoom. R58: the built app starts at zoom factor 1.25 and dev starts at 1.0 — measured on fresh never-navigated windows in one launch: file:// 1.25, http://localhost 1.0, about:blank 1.0, data: 1.0. Ruled out: display scale (1), --force-device-scale-factor (unset), the Playwright harness (a second unattached window shows it too), our own code (nothing in src/ touches zoom), and a persisted user zoom (Preferences has no zoom map). The cause of the 1.25 is not identified and is written up as unexplained rather than guessed. It only exists in the shipped artefact, since packaged builds load file://. Fix: set the zoom explicitly on first load. R59: zoom as a real command — invariant 10 puts it in the palette, persisted like the theme, applied through preload/api.ts, clamped; and it must re-check the three places that measure CSS geometry once (gridColumnWidth.ts, Grid's row height, Raw.tsx:755's character width, whose comment already claims zoom-safety untested). Electron's default menu is present with resetzoom/zoomin/zoomout; whether the accelerators fire is unverified — Playwright cannot reach a native menu. |

| R60 | docs/plans/R60-dark-elevation.md | — | Dark mode's notification panel is "very gray" where light mode already looks right. Two measured causes in the running app: --surface-border and dark's --elev-2-bg are the same token (--gray-700), so R57's hairline is drawn at exactly 1:1 and the frame does not exist in dark; and the panel sits four ramp steps above the pane (1.836:1). Light is right by accident — its --elev-2-bg is --surface-bg, so R57's hairline is the only boundary, which is the VS Code shape. Amends CONCEPT.md §9.3, which allows elevation only two mechanisms (shadow in light, a lighter background step in dark) and never considers a border, the one thing that reads on both. Also found: only 1 of the 7 --elev-2-bg surfaces has a border — the other six are white-on-white in light today. Fix, in order: give the tier the hairline (6 files), then --elev-2-bg: --gray-850 in dark only. Side effect worth the round on its own: .notification-action (--elev-1-bg) is currently darker than the panel it sits on. --gray-800 rejected — it equals --elev-1-bg, so the buttons lose their fill. |

| R61–R64 | docs/plans/R61-keyboard-workflow.md | — | The keyboard workflow, from four reports. Most of it already exists and is invisible: F6/Shift+F6 cycle panes, Ctrl+1/2/3 jump to one, Tree and Grid already navigate by arrow — all measured in the built app. R62: Tab from Tree never reaches Raw — 23 stops and still inside the grid header, because the header spends two tab stops per column, so the count is O(columns) and past 120 on an R34 wide table. Fix is WAI-ARIA roving tabindex (one stop per composite widget), not rebinding Tab, which F6 already covers conventionally and which R61 needs inside Raw. R61: Tab moves focus out of Raw because Raw.tsx registers no CodeMirror keymap at all; add one (Tab/Shift+Tab + Escape-then-Tab escape hatch), ~30 lines, no dependency (@codemirror/commands is not installed) and no new plumbing (rawEdit is an updateListener on docChanged). R64: Enter does not carry indentation (measured, new line at column 0) — buildable format-agnostically in the same keymap; auto-close tags is reported, not builtlang-xml means a Lezer grammar, a second parser of the same document, and doing it properly is a FormatCapabilities change, i.e. src/core/types.ts, i.e. stop and report. R63: the palette is advertised only on the empty screen you leave immediately — add titleBar to its surfaces plus an icon (the tooltip already renders "Show All Commands (Ctrl+Shift+P)" via D-055, which is what actually teaches it). Adjacent finding, reported not fixed: DEFAULT_FORMAT_INDENT/DEFAULT_FORMAT_NEWLINE are hardcoded, so Format Document silently rewrites a tab-indented file to spaces and CRLF to LF. Order: R63, then R61+R64 (one keymap), then R62. Refined after the plan landed (R62 §3): F-keys are impractical on a modern keyboard, but F6 is the actual convention on Windows, GTK/GNOME and KDE, so it stays; the proposed Ctrl+←/↑/↓ is spatially correct (CONCEPT.md §4.1 stacks Raw below Detail) but homeless — Ctrl+↑/↓ is drillUp/drillDown, Alt+←/→ is back/forward, Ctrl+Alt+arrow is screen rotation on Intel/AMD drivers, and Ctrl+←/→ is word-jump, which exposes that keybindings.ts's stated safety rule ("always require a modifier") protects typing but not modifier-carrying editing keys. Recommendation: add no binding — Ctrl+1/2/3 already reaches any of three panes in one press; put the chord in each pane header's tooltip (D-055's shape) so it is discoverable. Ctrl+K + arrow is the fallback if one is wanted. Focus-follows-geometry (five layouts) is named as a separate later task. |

| R65 | docs/plans/R65-shortcuts-help.md | — | A "Help: Shortcuts" palette command opening a modal listing the keyboard. Supersedes R62's pane-header-tooltip suggestion — checked, and Layout.tsx:66 renders the pane label as a bare <span> with no title, so that would have added a hover-only affordance to a non-interactive element. The finding: the app displays chords it cannot know are correct. Bindings are user-overridable, but loadKeybindings()'s result lives in a let inside useKeymap's effect closure (App.tsx:22), unreachable, so every displayed chord comes from defaultChordForDEFAULT_KEYBINDINGS. Fine as a tooltip hint; not fine for a panel whose purpose is to be authoritative, and whose reader is exactly the user who overrode something. So R65 first makes the loaded keymap a subscribable module-level store (theme.ts's shape) and moves the palette/tooltips onto it. Content is derived from the registry for commands (category already gives File/Edit/Navigate/View) and curated for in-pane keys that are not commands (Tree/Grid arrows, R61's Raw keys) — the latter unavoidably, stated as such. F1 (universal, and F3/F6/F8 are taken); not ?, which is the web convention but is a bare printable key, and keybindings.ts requires a modifier because the listener is capture-phase. New Help category. Reuses Palette.tsx's modal machinery; first new elevated surface since R60, so the first real test of its acceptance criterion 3. No keybinding editor. |

| R66–R68 | docs/plans/R66-palette-polish.md | — | Palette polish. R66: categories are shown but not matchedpaletteLogic.ts:98 fuzzy-matches command.title alone, so typing "view" finds nothing in View. Render and match ${category}: ${title} (VS Code's shape); traps are that highlight() indexes into the matched string so both must change together, the separate category span must go, and "Toggle: Format Minified Files on Open" would read "Edit: Toggle: …". R67: the active option never scrolls into view — measured after 14 ArrowDowns, scrollTop 0, scrollHeight 1227, clientHeight 498, selection off-screen; scrollIntoView({block:"nearest"}), both lists, no smooth. R68: six toggle commands, none shows its state, and every one already has a getter; optional state?: () => boolean on Command (renderer registry, not core/types.ts). Enter keeps closing the palette — recorded as a real fork, with a settings panel as the answer if toggles outgrow it. Refined after the plan landed (R66 §1): drop Toggle from all six titles, not just the one with the double colon — but four of the six render as icon-only buttons where tooltipFor renders the title verbatim and the verb is the only text there is, so the verb moves to tooltipFor (prefix Toggle when state is defined) rather than disappearing — the same composition D-055 already uses for the chord. One rename is semantic, not cosmetic: Light/Dark Theme · On is meaningless, so theme.toggle becomes Dark Theme with state: () => getTheme() === 'dark'. Accepted cost: typing "toggle" stops matching, and the mitigation (match a string you do not display) is the exact highlight-index trap §1 warns about. |

| R69–R70 | docs/plans/R69-focus-and-find.md | — | Where focus lands, and where Find lives. R69: focusPane is called from exactly one place (the klados.focus.* commands) — never after opening a file, and never after a palette jump, which selects the node and then restores focus to wherever it was. Focus a pane after both, with a first-visible-pane fallback since Tree can be hidden; : go-to-position arguably belongs to Raw rather than Tree. R70: <FindBar /> is rendered inside Raw.tsx:670, so with the Raw pane hidden Ctrl+F produces nothing at all (measured: 0 find elements, focus unchanged). The scope is already right (D-038 made Find document-wide); only the mount point is wrong — hoist it to the app shell. The open/close-tag match is text search working correctly; the structural search wanted already exists as the palette's @ and / modes — the third "already built, undiscovered" in three rounds, and neither is a command, so R65's panel must list them by hand. |

| R71 | docs/plans/R71-text-as-icons.md | — | The glyphs that are still text. Revised: the axis is codepoints, not text-versus-icon. The tab marker and the tree element glyph are the same string (<>); measured, .tab-icon is ui-monospace 600 and .tree-row-glyph is the UI font at 400 in a fixed 20px box, and monospace draws </> wide and shallow — the reported "wrong angle". The monospace is not buying alignment either: <>/{} are two characters and [ ] is three, so a fixed advance never equalised them; the tree's width + text-align: center is the correct in-repo precedent. So all three format markers stay text (which also dissolves the missing TOML bracket icon), and the rule becomes: ASCII/Latin-1/General Punctuation may be a mark, symbol-block codepoints may not — which is why <> reads fine and (U+24D8, a letterform in a circle) does not. Six sites to iconify, all bounded chrome: (status bar), (Find bar). Unchanged exclusions: is data (R39/D-068, an SVG cannot be copied as a value), and the geometric-shape glyphs render per row/column where Icon's per-instance SVG is a different cost class — mask-image if ever wanted. |

| R72–R75 | docs/plans/R72-path-query.md | — | The palette's / path query and its seams. Verified working in the built app: /garage/cars/elements/car[2]/name → 1, /garage//name → 1,202, /garage/*/elements/car → 1,202, /garage//car[@color="red"] → 145 — so *, //, [n] and [@a="v"] all work, and multiple matches are already the normal case (the palette pre-announces the count, Enter publishes them as a SearchResult and Find becomes the navigator). R73 is the real defect: parsePaletteInput strips exactly one leading /, so //name becomes /name and the descendant axis is unreachable at the top level — a silent zero on the most natural query anyone would type. R74: reusing Find as the result navigator is right, but it renders an empty Find… input over a live result, so nothing says where 145 matches came from; searchStore already tracks the state, it is just not rendered. Find does not accept path syntax (bytes vs node model) — worth saying, since the two surfaces look identical at that moment. R75: the copy-path→paste round trip works on XML (/garage/cars/elements/car[3] → 1 match, toXPath's leading slash doubling as the mode prefix) and silently fails on JSON, where copyPathFor emits a JSON Pointer whose bare numeric array segments resolve as names; detailModel.ts:146's own comment already predicted this. R72: the / empty state shows one example and leads with the most complex form — show the five-line grammar instead, and render the parse error as a caret rather than Error at 7. The "technical explanation string" is identified (§6): FindBar.tsx:247's .find-footnote about ASCII folding vs platform Unicode casing, rendered whenever regex and case-sensitive are both off — i.e. always by default, and even when there is no needle at all, confirmed showing under a path result with an empty input. Its condition is not the condition under which the two paths can disagree (that is a non-ASCII needle): measured, 154 divergent single-character pairs, zero of them with an ASCII needle, and neither path is a superset (84 regex-only, 70 plain-only) — so plain search does not merely add hits, it also misses some. The two diverge in opposite directions because the plain path folds down (toLowerCase) and JS regex i folds up (Canonicalize is defined via toUpperCase), and Unicode case mappings are not symmetric. The obvious fix is measured and rejected: the regex u flag removes 58 of the 154 but leaves 96, and turns Annex B patterns (-, [a-d], a{) into syntax errors that decodedTextMatches swallows into zero matches. Decision: the behaviour is accepted as-is (the ASCII-needle result is what makes it tolerable, not the size or direction of the divergence) and the task is scoped to the note's condition and wording; R76 accepted on the same terms. Also upgrades R70: with Raw hidden a path query reports 1,202 matches to a Find bar that does not render, so the result set is live and invisible. |

| R76 | docs/plans/R72-path-query.md (§6b) | — | indexOfCaseInsensitive compares text.slice(i, i + needle.length).toLowerCase() against needle.toLowerCase(), so the slice is always exactly needle.length code units and any case mapping that changes length can never match (İ U+0130 → , 1 → 2). Not a disagreement between the two find paths — a case neither expresses. Found while checking the footnote's claim; recommendation revised: still document it as a third entry in textFind.ts's existing "two limits, written down rather than discovered", and record that it gates a better foldtoUpperCase().toLowerCase() round-trip folding measures at 6 wrong pairs against toLowerCase's 94, but 91 characters change length under it, so a length-tolerant comparison is its prerequisite rather than an independent tidy-up. It also only fixes the plain path: a regex pattern is syntax and cannot be pre-folded. Normalization measured and rejected as a fold — NFKC disagrees with case folding on 2112 pairs because it equates compatibility variants (/fi, ²/2, fullwidth forms), which is a different and broader equivalence, not a bug fix, and a real behaviour change for a byte-faithful inspection tool. The note wording is settled in R72 §6 ("some look-alike characters count as the same here and others do not — the .* button changes which"), shown only for a non-ASCII needle; the proposed "find is exact" wording is rejected as inaccurate, since plain find does match Å/Å and Ω/Ω. Confusable detection in the document (VS Code's approach, UTS #39) recorded as the better direction, with a zero-dependency 80% version (flag a non-ASCII character inside an otherwise-ASCII run) to cost first. |

| R77 | — (this file, CLAUDE.md) | — | Documentation restructure, second pass. R32 split CLAUDE.md by lifecycle but left status recorded in four places — CLAUDE.md's status table, its doc map, this file, and each plan document's prose header — so a landing round had four updates and one was always missed (M5 read "partly open" for months after shipping; R60 was stale one commit after landing). Measured before changing anything: CLAUDE.md was 47% round bookkeeping, this file's register rows ran to 3,083 characters, and an attempt to check status automatically returned 80% false positives because status was prose. Changes: all 53 plan and results documents moved to docs/plans/; docs/ICONS.md moved to assets/README.md (an asset and build spec, not decisions — and DECISIONS.md referenced it 11 times as the place the detail lives); docs/issues.md retired into docs/plans/M5c-PLAN.md's appendix, verbatim, since four DECISIONS.md entries quote it by name; UI-FEEDBACK.md moved rather than deleted — 33 inbound references, ~20 of them source comments citing it as rationale. Every plan document gained a <!-- status: … --> marker; this file gained the board (derived from those markers) and an Owed table, which is new capability rather than reorganization — the outstanding acceptance criteria had never been listed anywhere. test/docsStatus.test.ts enforces marker/board agreement and that every built-caveat names what it owes; verified to fail on a deliberately drifted marker. 663 path references rewritten across 192 files, with a new rule: source comments cite a document by name, not path, so the next move costs nothing in src/. |

| R78–R81 | docs/plans/R78-find-affordances.md | — | Three reports against R72–R76 as built, all confirmed. R78: R74 landed half of its own option 1 — the origin text goes into the Find input as ordinary component state (FindBar.tsx:74-81), read-write, unstyled, with the query's own leading slash standing in for the "prefixed to show it is a path" the plan asked for. It is indistinguishable from a needle. And the toggles destroy it: handleCaseSensitiveChange/handleRegexChange call search(), whose first two statements clear hasDirectResult and directResultOrigin — one click on Aa or .* turns 1,202 structural matches into a text search for the literal string /garage//name, i.e. No matches, with no warning. Both toggles are meaningless for a node-model result anyway. Fix: the origin becomes a non-editable chip beside an empty focused input (which dissolves the plan's own read-only-but-typeable contradiction), and the toggles are disabled while a direct result is live. R79: runPathQuery jumps to the first match (navigateToNode) but never calls setCurrentMatchIndex(0), so the bar reads 0 of N, Raw marks no current match at all (matchDecorations.ts:73 keys on i === currentIndex), and the first F3 stands stillnextMatchIndex(starts, null, caret) resolves to index 0, which is where the caret already is. One line. R74 saw this and narrowed its own test from "1 of 2" to "of 2" rather than treat it as a defect; the assertion is tightened back. Widened on request to plain text search too, which has the same three symptoms and is not the same one-line fix: (a) the result is asynchronous, so the selection has to happen where the result lands, not where the search is requested; (b) searchStore re-runs the active query on every reparse, so a naive "select on any complete result" would yank the caret out from under someone editing in Raw — gated on a pendingAutoSelectRef that only FindBar-initiated searches set; (c) the caret is not a stable anchor, since selecting a match moves it, so an explicit anchor offset is captured on open and on empty-query and matchIndexAtOrAfter is applied to that; (d) the existing clamp effect keys on the same result and must be folded in rather than raced. Decided: caret-anchored, so 1 of 145 from a fresh document (caret at 0) and 37 of 145 from a caret deep in a file — making visible, without a keypress, what nextMatchIndex already resolves to from a null index. Consequence to test deliberately: 1 of N is not guaranteed, so the guard must assert both the caret-at-0 case and a correct non-1 index, or the anchor is untested. Built, and measured: a synthetic 30 MB/1,000,000-match document put moveTo's own cost at p90 0.019 ms, p90 max 0.290 ms — three orders of magnitude under the 150 ms debounce. Folding the auto-select into the existing clamp effect also surfaced a real pre-existing bug, caught by the edit-driven-re-run guard test before it shipped: a re-run publishes a transient incomplete, empty result the instant it starts, and reacting to that (rather than waiting for result.complete) would null the current match on every single edit, with no edit-driven path to restore it. R80: --find-match-current-bg (--amber-600) measured against every syntax foreground in dark: 1.04:1 for comments, 1.28–2.32 for the rest, 3.53 for plain text — nothing clears the 3:1 this codebase already holds itself to (R33/D-051, R57). The dim fill is fine (3.14–11.56), which is why only the difference got reported. Not fixable by picking a better yellow: interpolating back toward the dim fill drops below 3:1 at 10% of the way, because the dim fill itself has only 0.14 of headroom. So the current-match signal cannot be a fill — it becomes an inset ring, docs/plans/R60-dark-elevation.md's conclusion reached from the other direction. Tracing the four uses also found the token means three different things: a background under text in Raw.css and a marker bar in Tree.css/Grid.css/Scrubber.css, where it marks any match rather than the current one. Split into --find-match-bg (unchanged), --find-match-current-border (new, the ring) and --find-match-marker (new, today's exact values, so a pure rename). R81: test/findMatchContrast.test.tsx, modelled on test/scrollbarContrast.test.tsx, at 3:1 — not 4.5, because --syntax-comment on the plain surface is already 4.20/4.10 and a 4.5 bar would fail the unhighlighted editor. To be verified failing against the old value before it is trusted. |

| R82–R83 | docs/plans/R82-hover-and-glyphs.md | — | Two reports, one shape: a round fixed the symptom it was given and moved the cost somewhere nobody was looking. R82: the palette selection jumps back to the top while arrowing down. Cause is exact — every option row has onMouseEnter={() => setActiveIndex(index)} (Palette.tsx:466, :515), and R67 added scrollIntoView to the same list. Once the selection reaches the bottom, the list scrolls, the rows move under a stationary pointer, mouseenter fires on whichever row slid beneath the cursor, and the selection snaps to it. The hover handler predates R67; R67 is what made it reachable, since nothing scrolled the list from the keyboard before. test/paletteRender.test.tsx:87 fires one ArrowDown per option and passes, and always will — it places no pointer. Fix: hover-activation requires a genuine pointer move (compare clientX/clientY, do not rely on scroll not emitting mousemove), disabled by arrow-key nav; one shared helper for both lists; a real-Chromium guard that parks a pointer first. R83: the tab format markers. R71 invalidated its own argument inside the same commit — it removed --font-mono from .tab-icon because "the three markers are not the same length: <> and {} are two characters, [ ] is three", and then §5a changed [ ] to [], making all three two characters and the fixed advance exact. Measured at 11px/600 in real Chromium: in --font-ui <> is 15.28 and {}/[] are 7.30 — XML more than twice JSON, 76% of the 20px box against 37%, which is the reported "JSON looked better before" and is a size problem as much as a shape one. In --font-mono all three are 12.89. Restore the monospace; keep R71's width: 20px; text-align: center. Both proposed angle brackets fail on measurement: ⟨⟩ U+27E8/9 and 〈〉 U+2329/A are in neither Segoe UI nor Cascadia (identical 8.35 in both stacks — the same fallback serves both), so ⟨⟩ would draw 8.35 against the others' 12.89 and undo the alignment being restored, and it is exactly the "shape varies with whatever font resolves" failure R71's codepoint rule exists to prevent — that rule was right even though its alignment argument was not. 〈〉 U+2329/A is worse and permanently disqualified: it has a canonical decomposition to U+3008/9 CJK, verified via normalize('NFC'), which measures 22.00px against a 20px box — a FINDINGS.md entry. Decided: <> stays and only the font changes. ‹› (7.23/12.89, the one candidate present in both stacks) rejected on sight — the guillemets sit low and small, reading as quotation marks rather than a tag. ⟨⟩ was preferred visually and rejected on the font evidence: probed per family, no family named by --font-ui or --font-mono has U+27E8, and the fallback serving it is not even the system font that does — it measures 16.69px identically under both app stacks, bare serif and bare monospace (one last-resort), while 'Segoe UI Symbol' named explicitly gives 13.28px. The shape is chosen by a chain the app neither names nor controls, measured on Windows only; bundling a font for one 11px marker is not worth proposing. Second half, easy to miss: R71 removed the monospace in order to make the tab marker match the tree glyph, so restoring it to .tab-icon alone re-splits them (12.89 tab vs 15.28 tree). .tree-row-glyph gets --font-mono too — its other glyphs (D : " ¶ # ? !, {} []) are all Basic Latin or Latin-1 and present in Cascadia — with that half independently revertible if the tree looks worse. |

| R84–R85 | docs/plans/R84-settings-group.md | — | A Settings category, and session restore becomes a choice. R84: there are six toggle commands, not three — but "is it a toggle" is the wrong axis. The layout (Tree/Detail/Raw) and Soft Wrap toggles are working controls reached mid-task: each has a surface outside the palette (titleBar, paneHeader) and a chord, and filing them under Settings would mean typing settings tree pane to hide a pane. The axis that separates cleanly is what you see now vs what the app does next time. So Format Minified Files on Open (palette-only, no chord) moves, and Dark Theme moves by explicit decision — a preference about the application rather than a property of the document — keeping Ctrl+Shift+L, since a category is how a command is found, not whether it has a shortcut. Everything else stays in View; whether layout/wrap follow is left open as taste. Two silent traps found reading the code. (a) Shortcuts.tsx:100's CATEGORY_ORDER is a hard-coded array sorted by indexOf, which returns -1 for an unknown category — Settings would sort above File, first in the panel, with no error. It bites only because of Theme: the panel skips chordless commands (if (chord === null) continue), so the other two would never create a section and the trap would stay dormant. Fix plus a test that fails when any command declares a category CATEGORY_ORDER does not know. (b) R66 made the category part of the searchable string, so view dark stops matching — intended, and the reason the move helps, but a muscle-memory change that belongs in the LOG. R85: Settings: Restore Session on Launch, default off. The gate must be read synchronously before the first React rendermain.tsx:19 calls beginSessionRestore() there precisely because an effect fires after the first commit, by which point getActiveSession() has already lazily minted a stray empty tab; settings.ts's existing sync-localStorage-with-a-guard shape fits. Decision: when off, stop writing and clear klados.sessionRestore rather than keeping the list dormant — a feature that is off should not still be recording your recent file paths; the cost is that turning it on does nothing until you next open something. Named for the moment it applies, because the effect is asymmetric: off acts immediately (clears), on does nothing until relaunch. Also records the rejection of rendering the path origin as the Find input's placeholder instead of a chip: placeholder styling means "nothing here yet", so using it for live state says the opposite of what is true — and the auto-clear it was reaching for is already delivered (test/findBarOrigin.test.tsx:83). It would also lose the ✕, selectability, and the accessible text (placeholder is neither name nor value; aria-label is "Find"). Bar width is the one real argument for it; the answer there is to middle-elide the chip, not to move it into the placeholder. |

| R86–R90 | docs/plans/R86-find-as-query-surface.md | — | Five asks that are one topic: the Find bar stops being a one-line needle box. R86–R88, path as a third Find mode. Framed as a convenience, but the reason to do it is in searchStore.ts's own comment — a direct result has "no SearchQuery to re-run when the document changes underneath it", so editing with a path result live marks it stale permanently while a text search re-runs on every reparse. A mode makes the query re-runnable and that gap closes as a side effect. Mechanically cheap: evaluatePathChunked already returns the same SearchJob type findJobFor does, both on runChunkedJob. It also removes the cause of R74/R78 rather than another symptom — every defect there came from the query living in the palette while the result lived in Find. Supersedes R78's chip, four commits after it shipped; stated plainly rather than pretended to compose. Design point: SearchQuery holds the path text, not a ParsedPathparsePath binds to store.interner, so a parsed path goes stale on the very reparse this round exists to survive. SearchQuery lives in the renderer, so src/core/types.ts is untouched. R87 the mode control: rejected a three-state cycle (hides two of three states), a third independent-looking toggle (Aa and .* compose; path is exclusive with .* and makes Aa meaningless — three identical buttons would lie about the structure) and a typed / prefix (R78: a slash is not a marker). Chosen: .* unpressed already is a one-member radio group with an off state, so / joins it as the second member — one segment, Aa separated by a gap. Path mode runs on Enter, not as-you-type, since /garage/ is a parse error halfway through typing; the diagnostic goes in the footnote row that already exists. R88 the palette hands off prefilled and keeps its live preview (decided) — accept knowingly that evaluatePathChunked then has two call sites, and keep the palette's strictly a preview that counts and never publishes. R89 sizing, measured in the built app. The height cap and the drag are in tension and the obvious implementation gets it wrong: CSS resize respects max-height, so a max-height: 6lh would cap the drag too and leave manual resize able only to shrink. Cap the JS auto-grow at 6 rows instead and leave the CSS bound generous, so paste-40-lines gives a scrolling 6-row box while a deliberate drag can still go past it. Rows must come from scrollHeight, not a newline count — a single long pasted line soft-wraps to many visual rows (wrap is visual only; the needle is unaffected). Auto-grow must yield once the user has dragged, or the next keystroke snaps their box back. Width is already bounded correctly: .find-bar has max-width: calc(100% - var(--space-2) * 2) where 100% is .layout, not the viewport (D-080). Measured at shrinking window widths, the bar clamps (514→504→404) and its left edge stops at 8px rather than going negative. No horizontal resize — a textarea soft-wraps so width is not the axis a long query needs, and browser resize sets an inline width that would push the non-wrapping .find-bar-row past the bar's own max-width. And that uncovered a defect that exists today: below ~530px of layout width the row's 488px of content no longer fits the clamped bar, .find-bar sets no overflow, and the controls spill out of the window — screenshotted at 420px with ↓ and the close button gone entirely, so Find cannot be dismissed with the mouse. Cause is .find-input's min-width: 200px, measured at 200 from 1400px down to 420px. Pre-existing, and R89/R90 both add content to the same row, so it is fixed here: the textarea becomes the only shrinkable item (flex: 1 1 auto, min-width: 0) and every control is flex: none, which keeps them all inside the bar down to ~314px of layout width (the 288px of non-shrinkable controls plus 26px padding); below that the row scrolls inside its own surface, the degradation R8e already sanctioned ("scrolls or truncates"). Noted out of scope: main/index.ts sets width: 900 with no minWidth, so that floor is reachable by dragging at all. R89, the input becomes a growing textarea. Verified rather than assumed: the engine already handles multi-line needles. The byte path looked unsafe (1 MiB chunks, no overlap) but is not — findAsciiInRange's to bounds only the match start, while bytes[i + j] reads past it to bytes.length, so chunks partition starts with no gap; and decodedWindowsFor gives windows a deliberate one-row overlap for exactly this. So it is a pure UI change, blocked only by <input type="text"> being unable to hold a newline. The Enter conflict resolves by noticing the request is paste, not typing — pasting inserts newlines with no keystroke, so Enter stays find-next and there is no way to type a newline at all (decided) — paste only, as in VS Code, which removes a chord nobody would discover rather than adding one. Knock-on to check: R80's inset ring fragments per line, so a two-line match draws two closed rings. R90, Replace. Invariant 6 answered rather than flagged. What it protects is a direction of information flow, not a location — §5.1: "the text is the single source of truth; the model is a derived projection… no serialization path from model back to text exists." So the test is does the operation generate document text from the model? Typing in Raw: no. Grid edit-a-cell: yes, refused by design. Format/Minify: yesM5-PLAN.md is blunt that "a Transform is the generating path", accepted because it is quarantined so Save stays a dumb byte write. Replace: no — offsets come from a search over the bytes, the replacement is bytes the user typed, the model is never consulted. It is more conservative than Transform, which already ships; the only thing it strains is the word "only" in "only in the Raw view", which is geography. But that geography is load-bearing in exactly one place, found by checking: DECISIONS.md's "no read-only badge" entry declines a status-bar indicator because Raw is "the one pane where an edit could be attempted at all" — Replace makes that false, and since R70 hoisted the Find bar out of Raw.tsx it is reachable with Raw hidden, where the read-only banner is not shown. Decided: learning it on the attempt is acceptable — no pre-emptive indicator, but the refusal must be a clear notification rather than a silent no-op. The decision entry is still amended, and the distinction matters: its outcome survives (no status-bar badge) while its stated reason is falsified, and that sentence is what a future reader would rely on. Separately — the risk I had conflated with the invariant is that Replace All edits regions the user cannot see, at scale, possibly skipping the undo entry above the threshold. That is a safety question for the confirmation dialog, not an architecture one. The machinery exists: UndoEntry already holds patches[] + inverses[] undone back-to-front, which is a replace-all; apply descending so match offsets stay valid, batched into one entry and one reparse. Gates: encodeForRoundTrip refuses everything but UTF-8/UTF-16, so Replace is unavailable on a windows-1252 document (pre-existing, same as typing in Raw, but must refuse clearly); read-only already exists; and a soft-cap confirmation above 50,000 matches, mirroring GRID_EXPORT_CONFIRM_ROWS = 50_000 so the number needs no separate defence. No second undo threshold is inventedcomputeUndoStats already tracks the stack against the memory budget; if the entry would exceed it, drop it and say so in the confirmation ("this cannot be undone"), which is the one thing applyTransform's own 50 MB skip does not do. Replace is hidden in path mode — replacing a set of node spans is a different feature. Capture groups ($1) deferred. Ordering constraint: R87/R89/R90 all rewrite .find-bar-row; do the grid once in R89 so R90 fills a row that already exists. R86 → R88 is forced, since setDirectResult cannot retire until the palette stops calling it. |

| R91–R94 | docs/plans/R91-focus-into-content.md | — | F6 selects a pane but hands the keyboard nothing to drive. Cause is exact and short: PaneShell registers the .pane-body wrapper (Layout.tsx:85, tabIndex={-1}, no key handling) and focusPane focuses that (focus.ts:52) — while every pane's arrow handling is an onKeyDown on an element inside it, and a keydown on the wrapper bubbles up, never down. The interactive element already exists in three of four cases, one Tab away: .tree (role="tree", tabIndex={0}, aria-activedescendant), .grid-scroll (role="grid", tabIndex={0}), .cm-content; only Detail's list mode has no keyboard model at all (ChildrenList has no tabIndex, no onKeyDown, rows are onMouseDown only). R61/R62 built the in-pane navigation, R69 built the pane-to-pane navigation, and nobody joined them. Three things checked in source that delete most of the work: the Tree's active row is the selection (activeIndex derived at Tree.tsx:180, scrolled into view at :191), so focus alone is the whole fix; Raw's caret is already at the selected node's span start (setSelectedNode sets caretOffset = spanOf(node).start, documentSession.ts:1343-1346jumpTo dispatches selection: {anchor}, Raw.tsx:793), so the requested |<car> needs only view.focus(); and the grid's active cell already starts {row: 0, col: 0}. Pane tracking does not moveregisterPane listens for focusin, which bubbles, so lastFocusedPane, the focus context key and :focus-within all keep working; Layout.css:64's comment is the only prose the round falsifies. R91 adds registerPaneContent(pane, () => boolean) to focus.ts, tried between the shell-registered check (so a hidden pane still no-ops) and the shell focus (so nothing can leave F6 nowhere to land); a plain function, not an element, because test/focus.test.ts runs the module against plain objects with no DOM. Identity-guarded cleanup, rawController.ts's existing pattern. Rejected: routing through the three existing per-pane controllers — three lifetimes, and Detail's list mode has none. Registration follows ownership: Tree and Raw register directly, Detail delegates to a new focusGrid() on the existing GridController since the element lives in a child. R92 Raw: view.focus(), and decided that focusing also makes the caret visible without moving it — the naive scrollIntoView(main.head) is wrong here because scrolling can trigger a re-slice and CodeMirror will have clamped the selection to the new window, so main.head no longer names the caret; check caretOffset against [handle.start, handle.end) and fall to jumpTo outside it. Deliberately does not reposition on re-entry — the caret stays where the user left it. R93 is a defect found while checking R94 would work: Grid.tsx has no scroll-into-view of any kind (scrollToIndex/scrollIntoView/scrollTop appear nowhere; the only "scroll" hits are class names and R44b's overflow probe), and focus stays on the container rather than per-cell, so the browser's own scroll-into-view never fires either — ArrowDown past the last visible row moves the active cell to a row the virtualizer has not rendered, the highlight vanishes, and further presses look dead. Untested as well as unimplemented: test/gridKeyboardNav.test.tsx never arrows past the visible rows. Fix mirrors Tree.tsx:191; the column axis is scoped with care rather than assumed one-line, because pinned columns (stickyCount/stickyLefts) sit outside the virtual range. Must land before R94. R94 Detail: grid mode → .grid-scroll; list mode → tabIndex={-1} on .detail (already height: 100%; overflow: auto) so arrows/PageUp/PageDown/Home/End scroll natively, no new state or ARIA; no selection → no delegate, .pane-body as today. Rejected: a keyboard model for the children list — a second tree with a strictly worse model (no expand/collapse, no ancestors, no type-ahead) whose "Enter to descend" is the tree, one F6 away. Knock-on to record, not just to F6: focusPane is the single funnel, so Ctrl+1/2/3 improves free, and R69's focus-after-open / focus-after-palette-jump now land inside the Tree — where bare printable keys are type-ahead (Tree.tsx:402-404), so a stray keystroke after opening a file now navigates. Standard tree behaviour, but a LOG entry, because it reports as a bug later. Tab order also shifts by one stop from a pane-focus starting point. |

| R95–R97 | docs/plans/R95-recent-files.md | — | A recent-files list on the start pane, beside today's open-a-file content. There is nothing to reuse, and the thing that looks reusable is not: persistSessionState() writes the currently open tab set to klados.sessionRestore and overwrites it on every change, so closing a file removes it immediately — a snapshot of now, not a history. Four things do exist and delete most of the work. (a) phase: 'ready' appears as a transition at documentSession.ts:797 and nowhere else — every other setState spreads ...state and preserves the phase, so a reparse, a transform and a dirty-flag change do not re-enter it — with path, fileName and formatId all in scope; one line covers openPath, the dialog, drag-and-drop and session restore, with none of sessionRestore.ts's per-tab subscription machinery. (b) formatGlyphOf (tabDisplay.ts:56) already maps a format to the <>/{}/[] marker R83 just re-monospaced, so storing formatId means no re-detection at render. (c) splitForMiddleTruncation (textTruncate.ts) already does head-ellipsis-with-visible-tail and is already used by TabStrip and TitleBar — a path row is exactly its case. (d) theme.ts is the subscribable-store shape and sessionRestore.ts's isPersistedSession the validator shape (corrupt JSON degrades to empty, never a launch crash). R95 the store: klados.recentFiles, cap 6, {path, fileName, formatId}, paths only, never content (sessionRestore.ts's own stated rule). The guard that matters is already-at-index-0 returns before writing, so no localStorage write and no listener notification for a no-op — belt-and-braces given (a), which is exactly why it belongs there: it makes the recorder safe to call from anywhere. Two call sites, both named: the ready transition, and documentSession.ts:1952 where Save As sets filePathdecided to include, because after a Save As the document you are editing is that path and a recent list missing the most recent file of all is wrong. Session restore reordering the list on launch is considered and accepted (same entries, order only, and "recently opened" stays literally true). Invariant 10, stated rather than assumed: the six rows are data, like tree rows and tab-strip entries — clicking one is a selection, not a command; only Clear is an action, registered as klados.document.clearRecentFiles (surfaces: ['palette'], invoked from the button via runCommand like Layout.tsx's pane-header buttons), hidden not disabled via a new hasRecentFiles key, the reasoning session/commands.ts already gives for undo/redo. No confirmation — §11.2 is for operations whose size is the risk. Clicking a recent file uses activeSession.openPath, not openPathInNewTab, and the two existing paths settle it: the palette's Open File… calls openNewTab() (R26: Ctrl+O must not discard the active tab), while the start pane's button calls activeSession.openFileDialog() — because you are already looking at an empty tab. A new tab per click would strand a blank tab every time. R96 the layout: No document open. hoists above both columns (it describes the whole state, not the left half, and that is what lets both columns carry a heading); Open left, Recent right, because a leading empty column reads as broken on a fresh install and the primary action belongs first in reading and tab order. flex-wrap with flex: 1 1 260px, not a media query — no breakpoint to get wrong, no dependence on how zoom interacts with media queries (R59's trap), and it is R89's lesson applied deliberately: a fixed min-width floor is what let the Find bar spill out of the window, while wrapping degrades by stacking. Rows reuse --row-hover-bg (invariant 9, no new colour); the directory is always shown, which distinguishes same-named files without importing tabLabelsOf's disambiguation rule. Six plain buttons, not a roving group — R62's roving tabindex existed because the grid header's stop count was O(columns); six is not a problem, and a roving group would make Tab skip five files the user can see. No autofocus. R97 the stale entry, and the dead end it lands in. openPath never throws (failure degrades to 'error', which is how sessionRestore.ts reads back success), so a stale click already reports honestly — but the entry stays, and nothing returns a session from error to empty: cancel() handles only confirmSize/parsing (documentSession.ts:1328-1337), so the error phase replaces the start pane and the list is gone. Decided: a failed open removes that entry, and the two-column layout also renders in the error phase, retiring a dead end DocumentArea.tsx's own comment already frets about. No pre-validation on render — six stat round-trips per empty tab to grey out a row nobody was going to click is the wrong trade. parsing/confirmSize keep their single-purpose layouts. The R85 collision, resolved not noticed: R84-settings-group.md §R85 (still open) justifies clearing klados.sessionRestore on the grounds that off "means the app is not keeping a list of your recent file paths at all" — which this round makes false. Decided: two independent stores, R85's conclusion stands, and its reasoning is amended in R95's own first commit (the R90 move, and for the same reason: the sentence a future reader relies on has to be true). What makes it defensible is visibility — a dormant key records invisibly after you switched it off; a recent list is on screen with a Clear beside it. No Settings: Remember Recent Files toggle this round; if one is ever wanted, R84's new Settings category is where it goes. |

| R98–R99 | docs/plans/R98-glyph-font-per-glyph.md | — | The XML marker renders as a filled diamond, not two angle brackets — a report against R83 as built. Cause measured, and it is one font's letterform, not monospace's: --font-mono resolves to Cascadia (<> 12.89 at 11px, identical to 'Cascadia Code'/'Cascadia Mono' named directly and distinct from Consolas's 12.10), whose < and > carry almost no side bearing inside a 6.45px advance, so the two chevrons meet at a point. The same string in Consolas renders open — verified by rendering, not inferred from advances, which is the mistake R83 made: its ⟨⟩ rejection was decisive because it was a coverage question, but its <> decision was blind, and shape cannot be read off a width. font-weight: 600 makes it worse, which is why .tab-icon is the worst instance. The app was already split, which is what makes the fix cheap: .detail sets font-family: var(--font-ui) (Detail.css:38) and neither .detail-node-glyph nor .detail-child-glyph overrides it, so Detail has been drawing an open <> in Segoe UI all along — R83 only touched .tab-icon and .tree-row-glyph. Decided from a rendered twelve-candidate comparison in both themes: the font is a property of the glyph, not of the surface. <> in --font-ui, every other marker in --font-mono exactly where R83 measured it. Keyed on the glyph string, since KIND_GLYPHS (by NodeKind) and FORMAT_GLYPHS (by format id) are two independent maps both emitting <> — and keying on the string is also the option that adds no second format-id switch in the renderer, keeping invariant 8's spirit rather than eroding it further (the existing switch in formatGlyphOf is noted, not touched). Two classes in base.css, "the one non-theme, non-token stylesheet". R98 applies it at .tab-icon and .tree-row-glyph; R99 at Detail's two glyph sites — scoped as its own id because it is not required by the report, is the half most likely to look wrong (.detail-node-glyph runs at 13px, not 11px), and R83's own separately-revertible tree half set that precedent correctly. Four alternatives rendered and rejected. </> as text was genuinely attractive — no CSS, one font, still ASCII so R71's codepoint rule holds — but is 19.34px of glyph in a 20px box at 11px, visibly wider and busier than the 12.89px {} beside it. The Fluent code (</>) icon looked best in isolation and code_16_regular is already in the installed @fluentui/svg-icons, but Fluent has no [] icon (mention_brackets is [@], tag is a physical price tag), so the column stays permanently mixed — an icon directly above a text marker, which the comparison shows reading as a mistake — and it would grow glyphOf's string return into a union across three call sites. letter-spacing is one line but pries apart a letterform that is wrong for the use, plus a compensating text-indent that will rot. Naming Consolas first renders correctly on Windows and silently reverts to the diamond on macOS/Linux — the exact "shape chosen by a chain the app neither names nor controls" failure R83's rule exists to prevent. Reverting R83 wholesale fails on R83's own measurement, now visible: {} drops to 7.30 against <>'s 15.28 and nearly disappears in a tree row — R83 was right about {}/[] and wrong only about <>. Correction to the record: R71 rejected per-row icons partly as "a different cost class"; that is overstated, since Tree, ChildrenList and Grid are all virtualized at overscan: 12, so ~40 rows are ever mounted, not 31,655 — the missing [] is the objection, not cost. Two FINDINGS.md entries: ui-monospace, the first entry in --font-mono, is not monospace in Chromium on Windows ({} 8.67 vs <> 12.54) — the stack works because Cascadia matches second; and Cascadia closes <> into a diamond at UI sizes, not fixable by size or weight. The guard is a measurement, not a snapshot: assert the marker's rendered advance matches the UI font (15.28) rather than the mono one (12.89), since shape cannot be asserted but the font that draws it can — plus a test that every entry in both glyph maps resolves to a font class, so a later kind or format cannot fall through. |

| R100–R101 | docs/plans/R100-raw-external-rewrite.md | — | "Replace works but the Raw pane does not update" — investigated, and it is not an R90 defect. Measured in real Chromium against a real session, comparing sourceBuffer to the live EditorView: Replace All, Format and Undo all leave the editor showing the old text while the buffer, the model, the dirty flag and every other pane are correct. Confirmed in the built app, not only the harness — opened a JSON file, ran Format Document from the palette, and the title bar shows the dirty dot and Detail shows the reparsed tree while Raw still reads the unformatted original. The regression point is R41, and it is five days old — checked rather than assumed. Format shipped in M5 and Undo in M3, so the obvious conclusion is months of breakage; but before a50ceff (R41, 2026-08-16) the mount effect was keyed on store, so every reparse tore the EditorView down and rebuilt it from the current buffer, and Format/Minify/Undo/Redo/Reload all displayed correctly — not by design, as a side effect of the rebuild. R41 removed that rebuild for good reasons (it was destroying the caret on every keystroke) without noticing it was the only mechanism by which an externally rewritten buffer ever reached the view. So: one regression in a change that was right about its own goal, not four features independently broken; R90 made it visible because Replace is the first feature added since. It survived because nothing about it looks like data loss and the other panes always agreed with what was asked for. Cause is one sentence in Raw.tsx:530's own comment — "Nothing here dispatches a content change — the view's own document is already the live, correct text (CodeMirror is what the user has been typing into all along)" — which is true for exactly one caller and false for every other. R41 was right to stop tearing the EditorView down per reparse (that was the caret-loss bug) and to key the mount effect on documentIdentity rather than store; what it left is that a same-document reparse became the only path an externally rewritten buffer could reach the view by, and that path deliberately refuses to touch content. Second consequence, worse than the visible one: handle.map is rebuilt from the new bytes while view.state.doc still holds the old ones, so every byte↔UTF-16 conversion through that map describes text that is not on screen. No self-repair: applyReslice does dispatch content, but only from the scroll handler and from jumpTo, and jumpTo reslices only when the target is outside the window — never, in a document smaller than one window. Measured: moving the caret afterwards left it stale. R100 decided: an explicit signal, not a text comparison. The self-correcting alternative (compare view.state.doc against the freshly-derived handle.text) cannot miss a caller, which is attractive given that missing callers is the bug — rejected on cost, since it would run on every debounced reparse while typing and allocate a ~1 MB string (D-031's window bound) every ~200ms to answer "no"; a length pre-check does not save it, because a same-length replace (xyab) is the case that must not be missed. So OpenDocument gains externalRewrites, incremented by applyReplaceAll/applyTransform/undo/redo/reload and not by applyEdit (the editor's own path, the one case where the view is already right). The live-update effect branches: unchanged counter → today's in-place refresh exactly, so typing and R41's caret work are untouched; changed → a full reslice around caretOffset via computeWindowBounds + applyReslice, which already rebuilds bounds/text/map together and annotates programmaticChange so rawEditExtension does not record it as a user edit. A reslice, not a patch, because after a Format the document's length changes by a large factor and clamping handle.end to the new length keeps a window describing nothing; the caret anchor is correct without new policy, since the reparse has already re-resolved the selection by structural path. R101 is the durable half: per-path tests cannot fail for a caller added later, so a test asserts that every DocumentSession method replacing sourceBuffer either increments the counter or is the one documented exception — invariant 10's own "enforced by test, not discipline" precedent. Acceptance includes that test/rawEditCaretSurvival.test.tsx still passes unmodified: it is the record of what R41 cost to get right, and needing to change it means the fix is wrong. FINDINGS.md gains the general form — a buffer rewritten outside CodeMirror does not reach the Raw view on its own, and Tree/Detail agreeing is not evidence that it did. |

| R102–R103 | docs/plans/R102-find-single-line.md | — | The find field goes back to a single line — reverting the multi-line half of R89, deliberately deferred until R100 lands. Two concrete reasons rather than taste. (a) It made Replace inconsistent structurally: the find field is a <textarea> and the replace field an <input type="text">, both wearing .find-input — a class that now carries textarea-only properties (resize, overflow-y, scrollbar-width, ::-webkit-scrollbar, a max-height backstop), so one of the two elements ignores half its own class; and when the find field grows the replace field cannot follow, so a pair that must read as a pair stops lining up exactly when the feature is in use. (b) It was under-specified and the follow-ups show it — the manual drag had to be removed after landing (6c65686) because a dragged height and autoGrow's measured height disagreed the moment the next keystroke arrived, a conflict the plan did not anticipate; second correction to a feature whose requirement was "like VS Code". The important half of the task is what stays, since R89 was two changes in one commit and only one is being reverted: the narrow-width fix (width: 200px; flex: 1 1 auto; min-width: 0 plus .find-bar-row > *:not(.find-input) { flex: none }) is what keeps every control inside the bar down to ~314px — before it the close button left the window and Find could not be dismissed with the mouse — so test/r8Layout.test.tsx's three R89 width tests must still pass with only a selector change; and Raw.css's 3px left-edge current-match mark stays, because reverting the needle does not make it wrong (it is also R57's established severity language, and a multi-line match can still arise from a needle typed elsewhere) — said explicitly so a future reader does not "finish" the revert by undoing it. Replace, path mode, the mode control and auto-select are untouched, and Replace gets better: with both fields the same element type the asymmetry in (a) disappears rather than needing its own fix. R103 is the one thing the revert genuinely loses, measured not assumed: assigning 'a\nb' to an <input type="text"> yields 'ab' — the newline is dropped and the lines are concatenated with no separator, so a needle copied across two lines becomes a string that appears in no document and Find says No matches with nothing explaining why. Newly introduced by the revert, and exactly the silent-wrong-answer class this project keeps finding. Decided: strip explicitly and say so — on a paste containing a line break, remove the breaks and render one line in the .find-footnote row that already exists for the ASCII-folding note and R87's path diagnostic. Rejected: joining with a space (invents a character the user did not copy, equally wrong and less visible) and refusing the paste (a search box that discards what you paste is worse than one that tells you what it kept). Sequencing is a dependency, not a preference: R100 and R102 do not collide in the source, but Replace's correctness is currently unknown to the eye because the Raw pane has been lying about the result — judging the bar through a display that does not update is not judging it. Fix the display, look at Find again, then revert. |

| R104–R105 | docs/plans/R104-start-pane-polish.md | — | Four reports against R96/R97 as built, and three of them are downstream of one cause: the columns are too narrow. Measured with the app's own stylesheet at the ~341px column R96's 720px two-column block produces: the file name box gets 72.2px while wanting 84px (clipped) and the directory gets 216.8px — the less important half has three times the space. Stacked at 560px nothing truncates at all, asserted with a 48-character filename in the list (name clipped? false dir clipped? false), and the Open hint stops wrapping, which removes the condition under which the key caps collide. R104: .document-area-columns becomes a column, max-width drops 720→560. The trap, found by rendering the first attempt rather than reasoning about it: .document-area-column is flex: 1 1 260px, and in a column flex container flex-basis is a height — with flex-grow: 1 both sections stretch and the first render put Open and Recent at opposite ends of a tall empty box. They must become flex: none; this is why it is not a one-line flex-direction swap. The heading is removed but the slot stays — R97 reuses StartColumns({heading}) for the error banner, so deleting the prop would delete R97's dead-end fix. Two accessibility consequences to finish rather than leave: the empty case's <div role="status"> must go (with the text gone it announces the whole Open/Recent block, and a status region announces changes, so opening a file would re-announce the start screen — correct around one sentence, wrong around a file list), and role="alert" in the error case wraps StartColumns entirely rather than the banner that is actually the alert. .document-area-start-heading becomes dead CSS. The kbd overlap, measured: the hint's line box is 17.00px (line-height: normal) and the kbd border box is 19.00pxan inline element's padding and border never grow the line box, so the caps stand 2px proud and consecutive wrapped lines collide. Fix is line-height on the paragraph, not padding on the kbd, which would make the caps taller and the overlap worse; it must land even though stacking stops the wrap at 560px, since a narrow window wraps again. Noted: .document-area-hint is shared with the parsing phase's percentage line, which gains harmless leading. R105 the row. .document-area-recent-list has no gap at all (computed row-gap: normal). And the name/path inversion is a flex-shrink problem: both are flex: 1 1 auto, so shrink is proportional to content width and the long directory keeps more absolute width while the short filename is squeezed — fix is flex-shrink: 1 on the name and flex-shrink: 100 on the directory, plus overflow: hidden on the row, because middle truncation gives each field an incompressible floor (.document-area-recent-tail is white-space: nowrap and guaranteed never truncated). This is a contract violation, not bad luck: what is being cut is the tail, which textTruncate.ts documents as "Never truncated — always fully visible" — the parent is overflow: hidden, so for a short filename, where splitForMiddleTruncation yields a 1-character head and a 12-character tail, there is nothing for the head's ellipsis to give up and the parent clips the tail instead. Middle truncation only works when the head has room to disappear into, and that goes in the code comment. Buttons stay, the name takes --accent — a <button> is the correct semantics (opening a file is an action, not navigation), the full-width row is a larger hit target than a text link, and --row-hover-bg's row highlight is an affordance links would lose; accent-as-text measures 7.21:1 dark and 4.98:1 light on the surface, and is already used as text in CommandPalette.css:75 and Detail.css:100. But the row has a hover background, and that is where it fails — against --row-hover-bg the numbers are 5.70 dark and 4.29 light, below AA, on exactly the state the user is looking at when about to click. Decided: the name reverts to --surface-fg on hover — one rule, and it reads as correct rather than as a workaround, since --accent exists to say "actionable" and the row highlight is already saying it. Rejected: accepting 4.29 because hover is transient (WCAG exempts no state, and that is the moment that matters), bolding (13px bold misses WCAG's large-text threshold, so the requirement is unchanged) and a new darker accent token (a whole token for one label when an existing one solves it). |

| R106–R107 | docs/plans/R106-detail-focus-ring.md | — | A ~1px white line across the top of the Detail pane after F6 in list mode — reproduced and measured, not guessed. Rebuilt the real DOM (.pane.pane-body.detail-viewport.detail) against the app's own stylesheets in real Chromium, dispatched a key event and called .focus() — the sequence F6 produces — and the line appears where reported. .detail matches :focus-visible, outline-style is auto (the user-agent default), outline-color rgb(16,16,16) — the dark half of Chromium's double ring, the light half being what shows. Cause: R94 made .detail focusable and never gave it a focus style — it added tabIndex={-1} so arrows would scroll the pane, and Detail.css has no outline rule at all. Why only the top edge: .detail is height: 100% and its box is exactly .pane-body's (measured identical, top 24.0 / height 396.0 for both); an outline-style: auto ring straddles the border box, so everything outside falls beyond .pane-body's padding box and is clipped by its overflow: auto, leaving the inner sliver — which survives on all four edges but sits against the divider, the scrollbar gutter and the pane's bottom chrome on three of them, and against flat background under the pane header on the fourth. Why the grid route does not show it: Enter on a grid row selects a node with no repeating children, useGrid flips false, Grid unmounts taking the focused .grid-scroll with it, and .detail never receives focus at all. Two precedents exist and .detail matches neither.tree is outline: none with nothing on focus (it has a selected row and aria-activedescendant), .grid-scroll is outline: none plus :focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px }. R106 decided: follow .grid-scroll — same pane (ringing grid mode but not list mode would be an inconsistency produced by which node happens to be selected); list mode has no active item at all, so a container ring is the only indication of where the arrows go, making it more useful here than in either of the others; and outline-offset: -2px draws inside the border box, avoiding exactly the straddle-then-clip the measurement found. :focus-visible not :focus (a mouse click should not ring it), --focus-ring not --pane-focus-ring (the latter is documented as the deliberately quieter whole-pane overlay token). Invariant 9 is worth naming even though no literal colour is added: the present state is worse than a literal, since the colour comes from the user agent and cannot be themed. R107, found while explaining the second scenario: Detail.tsx registers the pane-content delegate and nothing else — the delegate is only ever called by focusPane — so when useGrid flips true→false the grid unmounts with focus inside it and focus falls to <body>. R94's own acceptance criterion is true for F6 and false for this path. Not visibly broken (Chromium keeps scrolling the nearest scroll container after focus loss, which is why the report says arrows still work), but document.activeElement is <body>, so Tab restarts from the top of the document and any future focus == detail gate reads a key no live focus supports. Fix is an effect keyed on useGrid that re-focuses only if focus was inside this pane — the conditional is the whole task, since an unconditional focus would steal the keyboard from Tree or Raw on every selection change to a non-grid node, the behaviour R69 was careful not to introduce. FINDINGS.md gains the general trap: a scroller made focusable with tabIndex={-1} needs an explicit outline rule, because the UA auto ring straddles the border box, the parent's overflow: auto clips all but a sliver, and what survives reads as a stray 1px line rather than a focus ring — so it gets reported as a rendering artefact rather than a missing style. R91 made three scrollers focusable; this is the one that had no rule. |

| R108–R110 | docs/plans/R108-replace-all-quadratic.md | — | A ~65,000-match Replace All on a 10 MB file froze the app — and the app was not hung, it was working. applyReplaceAll ends for (const patch of patchesDescending) bytes = applyPatch(bytes, patch) (documentSession.ts:1814), and applyPatch (documentEdits.ts:89) allocates a whole new Uint8Array of the entire document per call. The loop is therefore O(document × matches). Measured, replicating it exactly: 10 MB at 250/1,000/2,000/4,000/8,000 matches takes 0.73/2.97/5.93/12.95/24.08 s — dead linear at ~3.0 ms per match, so the reported case extrapolates to ~196 seconds, in bare Node without the renderer's GC pressure from 65,000 live 10 MB allocations. Nobody waits three minutes at a frozen window, so nobody found out. Every detail of the report follows from that one loop, which is how we know nothing else is involved: the notification stayed because Notifications.tsx:162's runAction calls runCommand to completion before dismissNotification, and the dismissal then only schedules a render that cannot paint; the mouse wheel still scrolled because Chromium scrolls on the compositor thread — the signature of a synchronous main-thread stall specifically, which is what rules out a crash or a deadlock. Checked and cleared: the reparse is on a worker (~45 MB/s, ~220 ms for 10 MB, off this thread) and Find's re-run is a chunked job that yields (searchStore.ts:157); inversePatchOf allocates 65,000 six-byte slices, nothing. The confirmation is working as designed and does not change — it fires above REPLACE_ALL_CONFIRM_MATCHES = 50,000 (FindBar.tsx:53, deliberately GRID_EXPORT_CONFIRM_ROWS's figure) or when the undo stack would exceed the memory budget; the reported case hit the first and not the second (65,000 × (6+3) ≈ 585 KB), which is why it said "Continue?" rather than warning about undo. It survives an ~8,000× speed-up because it is about how many places in the document are about to change, not how long that takes — recorded at the constant so it does not later read as vestigial. R108: one allocation, not one per match. Every patch's offsets are already in the input buffer's coordinates and none overlap, so the rewrite is a single ascending pass — total the delta, allocate once, alternate gap and replacement. 5 MB / 20,000 matches: 28.9 s → 7.5 ms (~3,850×); 10 MB / 65,000: ~196 s → 24.4 ms. 200 MB with 500,000 matches is 175 ms and with 5,000,000 is 765 ms, so the feature stops having a size story at all. Lives in documentEdits.ts beside applyPatch (the module whose header already promises the splice strategy can change invisibly), not inside applyReplaceAll, because R109 needs the same primitive; applyPatch stays for the single-patch case. R109: applyUndoEntry (documentSession.ts:1468) has the identical loop, so undoing the replace freezes exactly as long as making it did — and undo is what you reach for after a large replace you regret, so fixing only R108 leaves an edit that takes 24 ms to make and three minutes to take back. But it is not the identical problem, and that is the whole risk: applyReplaceAll's patches and redo's are all in input-buffer coordinates and directly usable, while undo's inverses are sequentially valid — patch i's offsets only become correct once 0…i−1 have been applied, which works today precisely because they are applied one at a time. Verified rather than reasoned about: feeding them to the single-pass primitive unchanged threw RangeError: offset is out of bounds on the first trial input — loud there, but the same mistake with a replacement longer than what it replaces writes wrong bytes silently. Undo therefore rebases first, subtracting a running total of replacement.length - (end - start), confirmed round-tripping a real replace-then-undo byte-for-byte; a named function with the invariant in its comment, not three inline lines, or the next reader will see two ascending lists treated differently and "simplify" it. The progress bar, the dimmed UI and the background job are all rejected — they were the right instinct from the symptom but solve the wrong problem: a progress bar over 24 ms is a flash nobody can read, and it would cost cancellation semantics mid-splice (a half-rewritten buffer is a state this app has nowhere else), a fourth JobSlot, and a document that is neither old nor new while it runs. The general form: a synchronous main-thread stall does not by itself say whether the work is slow or merely quadratic, and the two want opposite fixes — the instinct is to make the slow thing async and show progress, which is right often enough that this project has already chosen it twice (chunked search, D-041; the splice graft, where chunking cut the block from ~400-960 ms to ~23 ms per D-036s addendum). What separates this case from those is the measurement, not the symptom: there the work was genuinely that large, here 10 MB of output was being copied 65,000 times. R110 is a wall-clock test, which is normally flaky and here is not: at 5 MB / 20,000 matches the gap is 28.9 s versus 7.5 ms, so a 2-second bound fails the current implementation by 14× and passes the fix by 260× — no plausible CI machine sits in that band. It asserts the output bytes too, since a performance test that does not check the answer is how a fast wrong implementation ships. FINDINGS.md gains the trap: applyPatch allocates the whole document per call, so calling it in a loop is O(document × patches) — the right primitive for one patch, the wrong one for a batch, both batch call sites had it, and the symptom is a frozen window with a working scroll wheel, which reads as a crash. |

| R106 addendum | docs/plans/R106-detail-focus-ring.md | — | R106 §2 reversed after looking at it in the running app: F6 into Detail drew a blue box around the pane's content, which reads as a sub-region being highlighted rather than as "this pane has focus". §2's reasoning — list mode has no active item, so the container ring is the only signal of where the arrows go — was true in isolation and answering a question the app had already answered elsewhere. .pane:focus-within::after (Layout.css) exists to say which pane has focus, for every pane, and F6 is a pane-level operation; the second ring added no precision, competed with the existing indication, and pulled the apparent boundary of the focused region inward past the pane header. Chosen from a rendered screenshot both times — the second look overruled the first, which is the point worth keeping: the argument for the ring was sound on paper and obviously wrong on screen. .detail:focus-visible deleted; .detail keeps outline: none, which is what actually fixed the original defect (the UA outline: auto ring whose clipped sliver was the ~1px white line), and .tree has had exactly this shape all along. Cost stated rather than glossed: list mode now has no indication of which element within the pane takes the arrow keys — the same position Tree and Raw are in, acceptable because the arrows scroll the pane as a whole there, so a ring would mark a target that does not exist. Test rewritten to the new contract: outlineStyle is none (neither auto nor a replacement ring) and .pane::after's border colour becomes --pane-focus-ring on focus in both modes; the token must be resolved through a probe element, since getPropertyValue('--pane-focus-ring') returns the literal var(--gray-300) it is declared as rather than the colour, and the comparison would never match. The test file now renders Detail inside a real .pane, because the rule under test lives there. Left open deliberately: .grid-scroll:focus-visible (Grid.css:36-39) draws the identical inset box, so grid mode still shows the sub-pane highlight this removed from list mode — not reported and predating R106, so reported rather than quietly changed, with the recommendation to drop .grid-scroll from that selector and keep .grid-quick-filter (a text input needs its own ring; a scroll container whose active cell is already ringed does not). |

| R111 | docs/plans/R106-detail-focus-ring.md | — | Closes the item R106's addendum left open, plus the window edge. .grid-scroll is out of Grid.css's :focus-visible selector — it drew the same inset box the addendum removed from list mode, and grid mode already has a within-pane indication the list has not (.grid-cell-active), which made the container ring the redundant one of the two. .grid-quick-filter keeps its ring: a text input has nothing else saying where typing will go. .grid-scroll keeps outline: none, so the user-agent ring does not return in its place. The window edge: the pane overlay is a 1px border at inset: 0, so on the outermost panes it occupies the window's own edge pixel, which Windows' frame and rounded-corner mask cover — a focused Tree loses its left edge, a focused Detail/Raw its right. Only left and right are ever at risk (TitleBar above .layout, StatusBar below .layout-body). Insetting the overlay cannot work, and the measurement is why: the scrollbar track is pinned to right: 0 at 15px with its thumb inset left: 1px; right: 1px, so the thumb spans [W-14, W-1) and the overlay currently occupies exactly the one free pixel column the thumb leaves — moving it inward by 1px would put it on the thumb. So .layout-body gets padding: 0 1px and the panes move instead. Cost, measured and accepted: the track background is deliberately opaque (R33/1c) and differs from --surface-bg, so Detail and Raw get a 1px seam between scrollbar and window edge at all times, focused or not — rendered both ways, indistinguishable at 1x and obvious at 6x; invisible on the Tree's left edge, which has no track. What it is not: the gutter is --surface-bg, so it draws no visible window boundary and does not help tell overlapping windows apart — that would be border-left/border-right on .layout in --surface-border, matching its existing border-top. Recorded because the two are easy to confuse. |

| R112 | docs/plans/R91-focus-into-content.md | — | F6 was dead on the first press after opening a document — and never recovered until the user clicked a pane by hand. Reproduced exactly by mounting the real Layout under StrictMode (which main.tsx wraps the whole app in): document.activeElement is .tree, but wasLastFocusedPane is false for all three panes, and F6 three times running leaves focus on .tree. Two mechanisms, and the bug needs both. (1) focusin is an event, not a state: StrictMode's mount → cleanup → mount replay runs registerPane's cleanup, which clears lastFocusedPane, while the DOM element never unmounts and so keeps focus — no second focusin fires for the listener that replaces it, and Layout's open-a-document effect does not re-run to repair it (its previousPhaseRef already reads ready, correctly declining to fire twice). (2) moveFocus's null case makes it permanent: with lastFocusedPane === null the index is -1, so next targets PANE_ORDER[0] — the Tree — which is already focused, so .focus() is a no-op, no focusin fires, and the state is still null on the next press. The state that breaks F6 is also the state that prevents F6 from repairing it, which is why it never recovers and why a click (which does fire focusin) fixes it for good. Fix: registerPane adopts focus already inside the shell it is given. FocusablePane gains an optional contains? — real HTMLElements have it, so PaneShell gets the behaviour free, while the plain objects the tests register skip it and the module stays DOM-free by contract. document.body is excluded deliberately: it contains everything and is where focus sits when nothing is focused, so counting it would make every registration claim the focus. Rejected: having focusPane set lastFocusedPane itself — it would leave the first press still doing nothing and only unstick the second, treating the symptom one press later rather than the wrong state. Dev-only in practice, not fixed as a dev-only problem: StrictMode makes it happen every time, but a shell registering while focus is already inside it is reachable by any remount, and nothing in the fix is conditional on StrictMode. test/focusAfterOpen.test.tsx (3 tests) asserts both halves — the model agrees focus is in the Tree, and the first moveFocus actually leaves it — checked to fail without the fix, plus the two cases that must not claim focus. FINDINGS.md gains the general trap. |

| R113–R116 | docs/plans/R113-inactive-selection.md | — | Each pane's selection says whether the keyboard is in that pane: blue when it is, grey when it is not — a second layer, with the whole-pane border kept. Replacing the border with content-level cues was considered and rejected, and the reasons are recorded next to the work: Detail in list mode has no active item at all (checked — .detail-child-row is a plain role="row" with a hover background, no selection, no keyboard traversal), so a content-only scheme leaves one pane silent; a caret is only visible where the viewport happens to be, and R92 exists precisely because Raw can be scrolled away from it; and three cues in three vocabularies do not compose into one glance. The border answers which pane, this answers which item the keyboard will move — different information, so both earn their place. The Raw caret already behaves as wanted and needs no change — verified in CodeMirror's base theme rather than assumed (.cm-cursor { display: none }, with display and blink both gated on &.cm-focused), and nothing in Raw.css overrides it; it stays in the plan as a characterization test so a future CodeMirror upgrade cannot drop it silently. Mechanism: one variable swap per pane at the pane's own root, not a :not(:focus-within) override per rule — there are five drawing sites across three files and a sixth will exist as soon as anyone adds a selectable thing, so participation should be automatic rather than something to remember. Which element carries it was checked against the real DOM: .tree-viewport, .detail-viewport (the common ancestor of both Detail modes), .raw-pane. Not .pane:focus-within, which would couple three component stylesheets to Layout.css for no gain; and specifically not the inner focus target.detail-viewport rather than .grid-scroll is what keeps the selection blue while the user types in the grid's quick-filter, which sits in .grid-toolbar, outside the scroller but inside the pane. Scoping to the scroller would grey the rows out the instant someone started filtering them. Focus in the Find bar greys all three panes, which is correct (the keyboard is in Find) and harmless (the current-match highlight is --find-match-bg, untouched). Two new tokens, measured rather than picked: --row-selected-inactive-bg (--gray-700 dark / --gray-300 light) and --focus-ring-inactive (--gray-400 dark / --gray-600 light); --row-selected-fg is reused for text on the inactive band, measured safe at 9.19:1 dark and 10.77:1 light, so a second foreground token would be dead weight. A selection band is inherently low-contrast — the focused one is only 1.15 dark / 1.24 light against the surface — so the bar is not 3:1 but "at least as visible as the focused band, and not confusable with hover": the chosen band measures 1.84/1.66 vs surface and 1.45/1.43 vs hover. Rejected --gray-200 for light: 1.31 vs surface but only 1.13 vs hover, so a selected-but-unfocused row would have been near-indistinguishable from whatever row the mouse was over. The ring does carry the 3:1 non-text bar (D-051/R33) and clears it on every background an active cell can sit on, worst case 3.84 dark / 3.98 light on the inactive band. Rejected reusing --pane-focus-ring: on the inactive band it measures 1.47 dark / 1.53 light, far under — it is the deliberately quieter whole-pane token and is quiet for a reason. A trap that never fires, recorded so nobody creates it: in light the focused blue ring on the inactive grey band measures 2.99, just under the bar — unreachable because ring and band always change together (blue-on-blue 4.00, grey-on-grey 3.98), but anyone who later drives them from separate conditions lands on the one failing combination. R115 also restores a distinction Raw.css flattened: .cm-selectionBackground and .cm-focused .cm-selectionBackground are currently set to the same value, when CodeMirror ships that pair precisely so an unfocused editor's selection can be dimmed. §8 enumerates what deliberately does not participate — the quick-filter's own focus ring (if it is focused, the pane is focused), the filter toggle's aria-pressed on-state, the column-has-a-filter indicator, the resize handle's hover, and everything outside the three panes — because each reads one of the two tokens today and would otherwise look like an oversight. R116 is the durable half: the mechanism makes a new element participate automatically, but a new pane could still never set the variables, and a per-pane test cannot fail for a pane added later — so an enumeration test over the panes, R101's externalRewrites precedent and invariant 10's "enforced by test, not discipline", plus per-pane value assertions since "they differ" would pass on two wrong colours. |

| R117 | docs/plans/R113-inactive-selection.md | — | R115's built-caveat re-checked independently: every measurement in it holds, the conclusion drawn from them does not. Confirmed absent — Raw.tsx's extensions is six custom extensions with no basicSetup and no drawSelection(), and a real mounted Raw with a real selection counts 0 .cm-selectionBackground, 0 .cm-cursorLayer, 0 .cm-cursor, focused and blurred alike, so Raw.css's two !important rules are dead exactly as reported. But the caveat says native rendering is something this app's CSS "does not — and currently cannot — touch", and ::selection touches it. Measured in the exact shipping form, reading the same --selection-bg R113 already drives from :focus-within: rgb(23,35,63) (--row-selected-bg) focused, rgb(61,68,83) (--row-selected-inactive-bg) blurred — custom properties resolve inside ::selection, and an ancestor :focus-within re-evaluates and repaints, confirmed by screenshot rather than by getComputedStyle alone, since a computed value that never reaches the screen is the exact failure this caveat is about. So R115's goal needs no drawSelection(), and the trade-off §11 correctly declined (secondary-range rendering, DOM-layout cost) does not have to be taken. The impact is also larger than "inert": .cm-content's computed ::selection background is rgba(0,0,0,0) — no author rule at all — so Chromium paints its own default, leaving Raw's selection unthemed in both themes (a saturated blue on near-black in dark, matching no palette token), and it does not dim on blur (the DOM selection survives at full strength: rangeCount: 1, collapsed: false, same text after focus moves away). R115 is therefore not "landed but inert" but the one row of §2's table still wrong on screen, in the direction the round existed to fix. The caret row is right for the wrong reason: CodeMirror's .cm-cursor rules never apply, but a caret paints only in the focused editing host (.cm-content is contenteditable="true"; verified across six samples either side of a blur that it is document.activeElement only while focused), and it is already themedRaw.css:99 sets caret-color: var(--accent), computed rgb(122,162,255), so §11's "this codebase's CSS has no rule for it at all" is wrong by one line in the same file. R117: delete the dead .cm-selectionBackground pair rather than leave it (it carries !important and reads as load-bearing), add ::selection on both .cm-content ::selection and .cm-content::selection (the text sits in descendant .cm-line elements, so the bare form alone misses it), correct §2's and §7's reasoning, and keep the characterization test asserting drawSelection()'s layers are absent — it still fires the day someone adds it and makes both mechanisms live at once. ::selection accepts only a restricted property set; background-color is in it, outline/box-shadow are not. |

| R118 | — (no plan document; recorded here and in docs/LOG.md) | — | The modal scrim is removed — it could never cover Windows' caption buttons, and in dark it was not dimming anyway. Reported from a screenshot: the palette dims the app behind it but the minimize/maximize/close buttons stay bright. A hard boundary, not a bug, and the codebase already said sosrc/shared/titleBar.ts: "Windows' titleBarOverlay paints outside CSS's reach." With titleBarStyle: 'hidden' + titleBarOverlay the caption strip is OS-drawn and composited over the web contents, not DOM; .title-bar-win32 { padding-right: 138px } exists to reserve space for pixels the renderer cannot draw into, so an inset: 0 scrim covers everything except that rectangle by construction. Recolouring them is possible and was rejected: setTitleBarOverlay is already wired as titleBar:setOverlayColors, but it is an async IPC round-trip that would lag the scrim by a frame on something opened as often as Ctrl+Shift+P, it cannot touch Windows' own hover feedback (the close button would still flash full red over a "dimmed" palette), and it puts a second writer on OS state the theme sync already owns, needing restore-correctness on every exit path — Escape, click-away, command run, theme change while open. The check also found the scrim was not dimming in dark at all: color-mix(in srgb, var(--surface-fg) 30%, transparent) with --surface-fg = #eceef2 composited #14171e up to roughly #55585e — a pale fog, not a dim, measured and rendered. That is why the untouched buttons read as mismatched: everything else shifted several steps lighter and they did not move. Removed from .palette-overlay and .shortcuts-overlay, which carried the identical rule — one modal dimming while the other did not would be a worse inconsistency than either choice. Modality still reads, and the measurements show each theme leans on the half of D-051's elevation pair that works in it: dark separates by border (1.84 vs a 1.10 background step whose shadow is near-invisible on dark), light by shadow (panel-vs-app is 1.00 — --elev-2-bg is --surface-bg, both #ffffff, so the two-layer shadow is load-bearing and anyone flattening it would remove the only thing separating the palette from the page). Rendered both themes before and after rather than reasoned about; no test asserted the scrim. |

| R119 | — (this file, CLAUDE.md, docs/PLANNING.md) | — | Working-approach change, from an attribution check rather than an impression. The run of follow-up rounds (R102, R104, R106, R108, R115/R117) looked like the implementing agent being given too much latitude; checking each one against its own plan showed the opposite. In almost every case the agent implemented what the plan said and the plan was wrong. Two are not close calls: R90 §6 specified "Apply back to front. Each splice shifts every later offset. Descending order keeps the match offsets valid without recomputing a single one" — which is the O(document × matches) algorithm that froze the app for an extrapolated ~196 s (R108); and R91 §5 specified "Give .detail a tabIndex={-1} … the attribute is the only new thing", which is exactly how a focusable element ends up with the user agent's ring (R106). So the fix is aimed at the planner, and only two narrow clauses at the implementer. CLAUDE.md's "Report rather than work around" gains two edges of the same rule. Never ship code you have already discovered is inert — R115 wrote the .cm-selectionBackground rules the plan asked for after establishing nothing renders that class, and shipped them with a note, so the round reported a caveat while the defect it existed to fix stayed on screen for another round; a faithful implementation of a wrong plan is not a faithful implementation. And do not report blocked until you have looked for another route to the same goal — R117's case, where the stated mechanism really was absent but ::selection reached the same result with no behaviour change, one probe away; "the stated mechanism is absent" and "the goal is unreachable" are different findings. docs/PLANNING.md is new, carrying the three planner-side rules with the evidence for each, and joins the doc map as a before writing any plan document read alongside this file — TASKS.md owns a plan's bookkeeping, PLANNING.md owns its content. (1) Render a visual decision before writing it down as settled: every visual decision rendered first survived contact (R104's stacked layout, where rendering caught that flex-basis is a height in a column container; R118's scrim; R117's paint check), and every one reasoned about came back (R105's whole-row button, R106 §2's inset ring) — both reversals had sound written arguments, which is the point. (2) Verify a mechanism before asserting it as fact: a rule existing in a library's stylesheet is not evidence the rule applies, and a computed CSS value is not evidence anything is painted. (3) State the non-functional expectation wherever the obvious implementation is wrong: R108 needed one clause, "N matches, one allocation — not one splice per match". The doc closes by saying what it does not ask for: not longer plans (R90 §6 was detailed, confident and quadratic), and not fewer R numbers — R108 found a three-minute freeze, R112 found F6 completely dead, R117 found Raw's selection unthemed in both themes, and a process producing fewer follow-ups would most likely mean those were still shipped and unnoticed. |

| R120–R123 | docs/plans/R120-find-bar-keyboard.md | — | Four Find-bar keyboard defects, reported together; three of them are one missing mechanism. Every claim about current behaviour was measured in real Chromium against the real <FindBar> before the plan was written, and §1 is that probe's output: there is no tabindex anywhere in the bar (tab order is pure DOM order, which is why the replace input comes after every first-row control), Escape dispatched on the Replace All button leaves the bar open (the handler is on the find <input> alone, FindBar.tsx:544, and no Escape binding exists in DEFAULT_KEYBINDINGS), Enter in the replace input does nothing (no handler, and the bar is not a <form>), and real userEvent.tab() walks out of the bar into the status bar and on to BODY. R120 — the tab order becomes find → replace → the rest of row 1 → the two replace buttons, the order VS Code's find widget uses (stated as familiarity, not evidence — VS Code was not probeable here and nothing depends on it). Positive tabIndex rejected on a fact, not taste: it does not reorder within a group, it promotes the element ahead of every tabindex="0" element in the document, so a fresh Tab from the top of the app would reach the replace field before the Tree. Restructuring the two flex rows into one CSS grid rejected on risk: R8e's no-flex-wrap rule, R89's overflow-x: auto and flex: none rules are all per row, .find-replace-spacer exists only to align the two, and test/findReplace.test.tsx asserts the rows' geometry to the pixel. Chosen: one keydown handler on .find-bar — decisive because R123 needs that handler regardless (wrapping Tab is not expressible in DOM order or tabindex). The order is derived from querySelectorAll, filtered to !disabled (↑/↓/⟳ are disabled with no matches), with exactly one deviation encoded — move .find-replace-input after .find-input — so a control added later participates with no edit. R121 — Enter = Replace, Ctrl+Alt+Enter = Replace All. Enter maps onto the existing handleReplace() unchanged. The modifier is not invented: VS Code (editor.action.replaceAll) and Sublime Text both bind Ctrl+Alt+Enter; the other tradition is Alt+A, a Win32 dialog access key, and this app has no access-key underline on any control and already uses Alt+<digit> for tabs (R37/D-067), so adopting it would be a vocabulary of one. Handled locally, not registered as a command, for the reason Find/commands.ts already gives about close — the replacement text lives in component state, and a global binding would need a new findOpen context key to gate an invocation path that makes no sense. The discoverability cost is paid the way the app already pays it: a "Find bar" group in Shortcuts.tsx's curated IN_PANE_KEYS (which exists for exactly this class of non-command key) plus the chords in the two buttons' titles, following the disclosure button's own title="Replace (Ctrl+H)". R122 — Escape moves to the container handler, and closing now returns focus to the last focused pane when focus was inside the bar. That half is past the literal report and is included because R122+R123 create the gap: Escape from a button would otherwise drop focus to <body> with Tab no longer able to leave. lastFocusedPane is still correct at that moment because the bar sits outside all three pane shells, so focusing a find control never fired their focusin (R112's mechanism, helping here). That needs one new export in focus.ts, checked rather than assumed: lastFocusedPane is a module-private let with no getter and PANE_ORDER is private too, so an outside caller can only recover the answer by trying all three panes in an order it would have to hardcode — focusLastPane() goes in the module that owns the state, built on focusPaneOrFirstAvailable (the remembered pane may have been collapsed since, R69's own trap) with a 'raw' fallback matching paneForPaletteJump's default. A guard the probe found: the global keymap preventDefault()s Ctrl+Tab but does not stopPropagation(), so the event still reaches the bar — the handler must ignore any Tab with Ctrl/Alt/Meta held or Ctrl+Tab would switch tabs and move focus. R123 — Tab wraps inside the bar. The bar is role="search" and deliberately does not become aria-modal (it does not block the document behind it), so trapping Tab in a non-modal widget is a real hazard when Tab is the only way out — it is not: Escape from any control, ✕, F6/Shift+F6 and Ctrl+1/2/3 all leave, the last two with the bar still open. What is genuinely lost is Tab-into-the-editor-with-Find-open, and the Shortcuts group says so in a row of its own. |

| R124 | docs/plans/R124-go-to-position-hint.md | — | The : go-to hint promises a choice that does not exist. Reported as a question — how do you enter a byte offset? You cannot, and you never need to: the document decides. parseGoToPosition takes a bare integer and branches once on hasMeaningfulLines, so a normal file takes a 1-based line number with no syntax for bytes, and a minified one takes a 0-based byte offset with no syntax for lines. The app is already self-consistent — a lines document never shows a byte offset anywhere to paste back in (caretPositionLabel gives Ln 42, Col 7, sourceRangeLabel gives line 42), so there is nothing you could want to type. The defect is the copy, in three places: the palette hint (Type a line number or byte offset), the status-bar button title (Go to Line/Byte), and Shortcuts.tsx's :line or :offset row — read together they say "type one or the other." Six wordings were rendered inside a real .palette at its real min(560px, …) width in both themes before one was chosen, which is what settled the shape: the lines form fits on one line and every byte form wraps to two, so the byte form's second clause has to earn its line rather than hang off an em dash. Chosen: Type a line number (1–41,038) and Type a byte offset (0–10,485,760). This document has no lines to number. — the second sentence is the point of the round, since without it the hint only swaps one unexplained unit for another. Rejected: leading with the negative (No line structure in this document — …), which puts a fact about the document ahead of the instruction; and the em-dash join, whose trailing clause wrapped as a fragment. A byte-offset syntax for lines documents (:b1024, :0x400) is rejected, not deferred: it would consume a number the UI never produces — reopen it only if the status bar or node header ever start showing byte offsets for such documents. |

| R125 | docs/plans/R125-legacy-encoding-edits.md | — | Legacy single-byte documents become editable, and the encoder for it already shipped. documentEdits.ts's encodeForRoundTrip refuses every non-UTF-8/UTF-16 edit under a comment claiming real codec tables would be "a separate, sizable piece of scope" — but R53/D-074 built exactly that in src/core/textEncode.ts (probe TextDecoder with all 256 byte values once, invert the map, no dependency) for Interner.lookup, and nothing connected them. encodeText has the same signature and the same null semantics; encodeForRoundTrip is a strict subset of it. Not a new decision either: D-009 already settled "where re-encoding an edit into a legacy code page would be lossy, the edit is refused with an explanation rather than substituting characters" — a per-edit, per-character rule, while what shipped is a blanket per-encoding one. Measured against the real encodeText: windows-1252, iso-8859-1 and iso-8859-15 round-trip identically (Grüße — Preis 20€ für Müller → 28 B → back unchanged); 日本語 into windows-1252 and any text into shift_jis correctly return null. The one part that is not a one-line delegation: null now means two unrelated things — the encoding is not invertible at all (multi-byte: shift_jis, gb18030, big5), or the encoding is fine and this text has an unrepresentable character. Conflating them makes the app worse than today, because "editing isn't supported for windows-1252 documents" becomes an outright lie the moment someone pastes one CJK character into a file they have been editing all along. So EditRefusal splits, unrepresentable-character carries the offending character, and textEncode.ts gains a canEncode(encoding) export to tell the two apart (explicitly not by probing encodeText('', enc), which works today only by accident of evaluation order). Invariant 7 is better served, not strained — nothing is converted; a Windows-1252 edit is encoded as Windows-1252 and saved in the document's own encoding, which is what the invariant asks for, where before the case was simply unreachable. The UTF-16 branch can never fire (the worker refuses UTF-16 as Fatal before a document is editable) and is kept deliberately, recorded so its presence is not read as evidence UTF-16 editing works. Two existing tests must fliptest/documentEdits.test.ts:232–234 and :268–272 assert the behaviour being removed — inverted rather than deleted, since a removed test is indistinguishable from one never written. The convert-to-UTF-8 offer is deliberately not here: it hooks onto the new unrepresentable-character refusal, and building it first would wire it to a refusal that should not have been firing. |

| R126–R128 | docs/plans/R126-find-count-stability.md | — | Three defects around the Find bar's match count. R126 — the bar stops moving. .find-bar is width: max-content anchored right:, so the count drives its width and growth translates every control left (a translation, not a resize — .find-input measured 218 px in every case, matching the report). Measured: (stale) costs +23.6 px at 1 of 65,432 and +37.9 px at 12,345 of 65,432; min-width: 6em (78 px) absorbs all of it on a small document — even 1 of 3 (stale) — which is why it only showed on the 10 MB file. A second cause nobody reported: 1 of 65,43212,345 of 65,432 is +16.6 px with no staleness, so F3 through a large result set creeps the bar too. Dimming the count was rejected on a measurement: it is already --surface-fg-secondary at 6.40:1 dark / 6.63:1 light, so opacity: .55 measures 2.90 / 2.45 and even .7 measures 3.87 / 3.30 — all under the 4.5:1 text bar, and .55 under 3:1. And this plan's own first answer was rejected by rendering it: reserving width for the whole {total} of {total} (stale) string leaves a ~54 px void between the count and Aa that plainly reads as a layout bug, where the ~16 px of a {total} of {total} reservation reads as ordinary spacing. So (stale) is removed outright. Its life is the whole editing burst plus REPARSE_DEBOUNCE_MS (200 ms) — corrected from a first-draft claim of a 200 ms flash — but the moment the number is genuinely unknown the label already reads Searching…, which costs no width; (stale) only ever covered the window where the count is frozen and approximately right, and the user is looking at the text, not the bar. SearchResult.stale stays in the store, just unrendered. Rejected alternatives, recorded so they need not be rediscovered: showing Searching… in place of the count while stale (blanks it for the entire burst — a longer-lived misstatement than the one removed), and a reserved trailing * with a tooltip (~7 px, invisible reservation, but a glyph needing a tooltip is a worse trade than the word or nothing). Fix: right-align the count inside a box reserved by a hidden sizer stacked in one CSS grid cell — right-aligned so the constant of 65,432 lands at the same x at both ordinals with no sub-spans, and a sizer rather than a min-width in ch because ch is the width of 0 and the label is mostly letters, spaces and commas. R127 — the count is not vertically centred, visible in the report's screenshot. .find-bar-row sets align-items: flex-start, which R89 chose deliberately because the find field was a textarea that could grow past one lineR102 reverted that field, so the justification is gone and only the side effect remains: .find-count is a bare span with no padding among siblings that all carry padding: var(--space-1) var(--space-2) and a border. align-items: center, rendered and confirmed. The mirror image of R102's own note about the multi-line match ring, which outlived its feature for a good independent reason where this did not. R128 — reopening Find keeps the query but loses the results. Both halves are individually deliberate: handleClose() calls activeSearchStore.clear() (correct — the result also drives Raw's match decorations), while text is component state and FindBar never unmounts between opens (isOpen gates the render, not the mount), so the query survives. The open effect then re-runs a search only for a palette prefill and returns early otherwise, so a plain Ctrl+F focuses the field and leaves surviving text describing a result set that was thrown away. Fix: on open with no prefill and non-empty text, re-run — restoring rather than preserving, which keeps clear() on close honest about the decorations. Must use the component's current options, since caseSensitive/matchMode survived the close too and a reopen that reset Aa would be a new bug for an old one. Noted but not fixed: the prefill path hardcodes { caseSensitive: false, regex: false }, so a palette hand-off resets those toggles. Consequence stated rather than discovered: the re-run takes the same path as typing the query, so it resolves a current match at or after the caret and scrolls Raw to it; suppressing auto-select on this path only is the one-line tuning point if that proves annoying. |

| R129–R131 | docs/plans/R129-query-predicates.md | M7 | CONCEPT §12's M7 — comparison predicates — and the finding that the expensive part is not the one the milestone is named after. Measured on two generated corpora through the real store and evaluator: car[price>50000] over 400,000 candidates costs 17.5 ms on a 75 MB document, while the facet predicate this app already ships costs 239–255 ms on the same corpus. Decoding is the whole difference — decode-then-Number() measures 155.3 ms against 15.5 ms for parsing the number straight out of the bytes (22× on the numeric work alone), and string equality measures 89.3 ms decoded against 11.6 ms compared as bytes. M7 needs no type system: no per-node type tag, no inferred column types, no coercion table, no parse-time work, and invariants 2 and 3 untouched — just one ~30-line function that reads a number out of a byte range without allocating. And no runtime type inference either, because XPath 1.0's rules need none: relational operators convert both sides to numbers always, and =/!= pick string or numeric from the literal's syntactic form, known when the query is parsed. So §6.3's worry ("what > means when values are strings, or absent on some rows") is a four-entry rule table with no state. Adopt XPath 1.0's rules verbatim, and note that its number() is not JavaScript's — measured, five of nine cases diverge: 1e3, 0x10, "", +5 and Infinity are all NaN under XPath and all finite under Number(). Reaching for Number() would be slower and produce different answers from every other XPath engine; implementing XPath's grammar is required for correctness and is also the fast path. Known gap recorded rather than discovered: exponent notation (1.5e-7) is NaN, the one case users will notice, deliberate because accepting more syntax later is a compatible relaxation and accepting less is not. R129 adds the grammar (car[price>100], car[@year<2000], six operators, element or attribute subject) and folds the existing facet predicate in as the op: '=' case — not tidying, but what makes R131 a retrofit of one code path instead of a second implementation beside it. Names resolve at parse time through the NameResolver parsePath already takes; string needles encode once per query at evaluation time, from SourceBuffer.encoding, so parsePath does not grow an encoding parameter. R130 is the real risk, and it is not arithmetic: pathQueryJob.ts already discloses that "a single expensive step is not itself preemptible mid-step", and a predicate makes one step linear in candidate count — 200 MB crosses hard rule 3's ~50 ms bar inside one uninterruptible unit. applyPredicate becomes a resumable state machine, which also retires G10's unfixed finding (M4-RESULTS.md §4, a facet predicate at 163–314 ms). Batch the deadline check — measured, because the obvious implementation is worse than the work it guards: none 17.5 ms, every 256 candidates 19.1 ms, every 1,024 candidates 20.5 ms, every candidate 45.0 ms — a clock read per node costs more than the entire numeric evaluation. R131 retrofits the facet predicate, and corrects an estimate this plan's own investigation got wrong: "~11×" came from a corpus with one attribute per record; on the realistic five-attribute corpus, replacing the decode with a byte comparison is 239.2 ms → 126.6 ms, i.e. 1.9×. The remaining ~113 ms is allocation — attributesOf is a generator that per node visit allocates a [number, number] tuple (attrRangeOf returns an array), the generator object, and one AttributeRef per attribute: 400k + 400k + 2M objects for one query, which is also why the first-attribute and last-attribute cases measure the same. So R131 is byte comparison plus an allocation-free indexed attribute accessor on NodeStore — not a types.ts change (AttributeRef and attributesOf live in nodeStore.ts and the format contract never mentions them), and a move toward invariant 2, since an object per attribute visit is the shape that invariant forbids one level down. Target stated as a target: near the 17.5 ms the allocation-free child-element predicate already measures — measure it, and if it lands materially above ~40 ms that is a finding, not a thing to ship past. The grid keeps Number() deliberately (gridSort.ts's isNumericColumn): its question is whether a column right-aligns — cosmetic, already sampled and therefore already approximate — where the query engine's question is whether a row matches. Recorded in DECISIONS.md rather than unified, because the two look like one function and the next person will try to share them. Out of scope, flagged now: XPath itself, boolean operators in predicates (and/or — the first thing anyone will ask for after > works, and its own grammar question), and functions. |

| R132 | docs/plans/R132-existence-predicates.md | M7 | [@id] and [not(@id)] — the one missing predicate, and it came out of an investigation into something else. Investigating attribute nodes for XPath settled that Klados path should only ever return element nodes: an attribute is a way of selecting a node, never a thing you select, consistently across Tree, Detail and Raw. Under that rule //car/@id means "cars that have an id", which is //car[@id] — and R129's grammar (positional | comparison, every comparison carrying an operator and a literal) has no form with a subject and nothing else. So the entire user-visible value of the attribute-node work is one predicate, reachable with no data-model change, no ref encoding, and no added memory. R132 — existence: [@id], [price], [@*], [*]; the wildcard forms come free because isWildcard is already a concept on PathStep. The semantic to get right, and the one the existing code gets wrong if reused as-is: existence is about the node, not its value — XPath's [price] means the selected node-set is non-empty, so <price></price> and <price><a/></price> both satisfy it, and id="" satisfies [@id]. childSubjectMatches currently walks to a value and continues when there is none (correct for a comparison, wrong here), so existence must stop at the name match before any value lookup. Otherwise it is R129's two candidate loops with the valueMatches call removed — allocation-free through R131's indexed accessors, inheriting R130's resumable loop unchanged. R133 was planned here and is dropped before implementationnot moved to R138 (see that row), because R133's own text said the day a second function is wanted the right move is a grammar that subsumes not rather than a second special form, and and/or are that day; nothing had been built, so the special form is never written rather than written and deleted. Its argument stands and moves with it: not(...) is not sugar, and it is spelled XPath's way rather than [!@id]: XPath 1.0 has no bare !, so ! is syntactically free and is rejected because it would work — two spellings for one concept is the drift R129 §4 adopted XPath's rules to avoid. It is not sugar, and this is the argument for including it now rather than with the rest of the boolean work: under XPath's rules a comparison against an empty node-set is false, so price>100 and price<=100 are both false for a record with no price, and only not(price>100) includes it. The difference is exactly the missing-data case — which is the case this round exists for, making negation half the feature rather than an extra. What R132 keeps from that argument: car[price<=100] excluding a car with no price at all is asserted here, in the round that has no negation — it is the baseline R138's not(...) criterion is measured against, and the behaviour someone would otherwise report as a bug. Out of scope: attribute nodes and the ref encoding (XPath prerequisites, nothing user-visible waits on them), not/and/or (all R138's expression parser now), and any display change — a match is an element node and highlights exactly as today. |

| R133 | — (dropped before implementation; not is built by R138) | M7 | Allocated, planned, and dropped without ever being built — the id stays listed so an old reference resolves. R133 was not(...) as a special form in docs/plans/R132-existence-predicates.md: one keyword, one nesting level, not(not(…)) rejected. Its own closing line was the reason it should not last — "the day a second function is wanted, the right move is a function-call grammar that subsumes not, not a second special form" — and docs/plans/R138-boolean-predicates.md is that day. Because nothing had been built when this was noticed, the special form is never written rather than written and then deleted; not is a unary operator in R138's expression parser, where it needs no depth limit and composes with parentheses for free. The argument for negation is not dropped, only its home: price>100 and price<=100 are both false for a record with no price (a comparison against an empty node-set is false under XPath), so only not(price>100) reaches it — which is why R138 is worth scheduling near R132 rather than much later. |

| R134–R137 | docs/plans/R134-xml-namespaces.md | — | XML namespace resolution, and the memory result that inverts the attribute-node conclusion. Because §6.5 interns every name, resolution is a function of the distinct name count, not the node count: R129's two corpora hold 8 and 10 interned names against 2,400,002 and 400,002 nodes, so a per-name resolution table is kilobytes. There is no version of this that costs megabytes. Namespaces are currently declared in three places and implemented in none: CONCEPT §2 says the prefix/local split is recorded on the intern table entry (Interner has only intern and text), §6.5 says there is a second interned id for the resolved (URI, localName) pair (there is not), and FormatCapabilities.hasNamespaces is true for XML with nothing reading it. The XML parser does build a prefix → URI map — in resumeContextFor, reconstructed from ancestor attributes for the incremental-reparse resume context only, never consulted during a full parse and never reaching the store. gridDetection.ts already documents the gap as blocking a shipped feature, so this has a customer before XPath arrives. Use cases are an educated guess and are labelled as one — no namespace-heavy corpus was available, so they are reasoned from SOAP/XBRL/SVG/OOXML/Atom/XSD/POM shapes rather than observed, and should be cut rather than worked around if wrong: grid grouping on the resolved pair (CONCEPT §2's "must group into one table, or the feature fails on exactly the documents that need it most"), path queries matching on the resolved pair, prefix-as-written display with the URI in a tooltip, and XPath NameTests as a downstream prerequisite. Explicitly not in scope: namespace-aware editing (invariant 6 keeps editing byte-level) and XPath's namespace axis. R134 — resolution per name: the interner records the prefix/local split (a colon scan once per distinct name, never per node), the sink accumulates prefix → URI from xmlns/xmlns:* as it descends — the same walk resumeContextFor already does backwards, done forward during parse — and fills resolvedId[nameId], an Int32Array sized by interner.size. R135 — the rebinding fallback, pay-per-use: a prefix bound to a second URI makes resolution position-dependent, and noticing it is a Map check the sink is already positioned to make. Only then does the document get a scope table of (startRef, endRef, prefixId, uriId) rows — and the flat store pays off again, since contiguous document-order refs plus O(depth) subtreeEndRef mean a namespace scope is a ref range, so the table is naturally sorted with one row per declaration and lookup is a binary search. Documents without rebinding never build or search it. R136 — grid grouping keys on the resolved id, closing gridDetection.ts's own documented gap; headers keep rendering the prefix as written (they read the node's nameId, not the group key), and the header tooltip gains the resolved URI — the only way a user can tell why two differently-prefixed sections merged, without which the feature works and looks like a bug. R137 — path queries resolve the prefix against the document, since a standalone viewer has no expression context the way XSLT does; an undeclared prefix gets its own diagnostic as a fourth NameResolver answer beside 'unrepresentable' (R53's reasoning), a prefix rebound under R135 matches either URI with the diagnostic saying so, and an unprefixed query name deliberately does not pick up the default xmlns binding because XPath 1.0 itself does not — following XPath over being helpful, per R129 §4's no-drift rule. Non-functional: nothing is per node except R135's rebinding path, which is — once a prefix is bound to two URIs a name's resolution depends on the node's position, so the candidate loop does a range lookup per candidate instead of reading resolvedId[nameId]. That is the one number in the plan that is a guess and it is written down as one: estimated single-digit milliseconds over 400,000 candidates with ~50 declarations, but unmeasured, and an unmeasured "should be fine" on a per-node path is the shape R108 came from — so it gets a wall-clock budget (under 40 ms, R110's shape) that must report its actual number, not an assurance. Two mitigations are recorded but not to be built speculatively: resolve once per (nameId, scope region), or resolve the query's whole target set up front when a document rebinds at all. Otherwise: a namespace-free document must parse measurably unchanged (the regression test), the scope table must not be allocated when unused (asserted on the store, not inferred from timing), and common-case resolution stays an array read on the query hot path. The colon scan must be capability-gatedInterner lives in core/ and every format's names go through it, so an unconditional split would read the JSON key "12:30" as prefix 12, local 30, plus cost a scan on every distinct key in formats that can never have namespaces. Gate on FormatCapabilities.hasNamespaces, never on a format id (invariant 8); R134 is the first round to give that capability a purpose. Found by asking whether JSON has namespaces, not by reviewing the plan. §7 carries the deferred ref-encoding question forward with a better prior: the namespace axis is now the only remaining candidate for a third ref kind and nothing in Klados's own feature set wants it — it exists for XPath conformance alone. Keep the encoding behind isAttrRef/attrIndexOf/attrRef, and do not use bare ~i, since ~0 === -1 collides with NodeStore's NO_REF. |

| R138–R139 | docs/plans/R138-boolean-predicates.md | M7 | and/or in predicates — mostly routine, and not entirely. The third of the three items the XPath sizing turned up, and R129 §11's "first thing anyone will ask for after > works". Three subtleties, each of the kind that ships wrong rather than failing loudly. (1) and is a legal element name, and not hypothetically: NAME_CHAR is /[A-Za-z0-9_.:-]/, and MathML content markup defines <and/>, <or/> and <not/> as elements — precisely the kind of document that sends someone reaching for boolean predicates. So [and] is existence on an element named and while [price and year] has and as an operator, and XPath 1.0's own rule is copied verbatim rather than reinvented: a name is an operator only when the preceding token is not @, ::, (, [, , or another operator — operator position versus operand position, decided by what came before. (2) parsePredicateBody is not a parser: it takes the whole bracket content as a string, trims it, regex-tests /^\d+$/ for a positional and otherwise hands a cursor to one comparison parse, because until now a predicate has always been exactly one thing. So this round is not "add two operators" — it is replacing a pattern-match with a real expression parser, which is the bulk of the work; the operators are a two-row precedence table. (3) Parentheses collide with a claim parse.ts makes about itself"No recursion: the grammar has no nesting… there is no call-stack risk this needs guarding against". Forbidding parens keeps the claim and costs [(a or b) and c], rejected because "you can't group" is a limitation every user rediscovers; chosen instead is precedence climbing with an explicit operand/operator stack, iterative by construction so nesting costs heap not call stack, the claim restated as "no recursion, via an explicit stack", and a depth cap with a diagnostic for a pathological paste. It is also the shape XPath's own parser needs later, so it is built once. Positional operands are rejected: [3 and @id] in XPath is boolean(3) and …, so the positional meaning silently evaporates — a diagnostic, exactly as R133 specifies for not(3). This round owns not, and R133 is dropped before implementation — R133's own text said "the day a second function is wanted, the right move is a function-call grammar that subsumes not, not a second special form", and and/or are that day; nothing had been built when this was noticed, so the special form is never written rather than written and then deleted. not becomes a unary operator in the expression parser, where it needs no depth limit, composes with parentheses for free, and shares §2's position rule (not is a legal element name too — MathML has <not/> — so [not] is existence and [not(@id)] is negation, decided by whether a ( follows). R133 stays allocated and dropped, visibly. R132's existence predicate is a dependency, not a competitor — it is the operand the operators combine — so the round carries an explicit build order: the expression parser replaces parsePredicateBody's pattern-match first, with R129's comparison and positional as its only operands and every existing path-query test passing unmodified as the proof the replacement is faithful; then R132's existence operand; then not/and/or/parens as a precedence table and a unary case. Doing R132 first against the current flat body also works and costs one small rewrite — what must not happen is building R133's special form, which survives neither order. R139 — evaluation: the predicate becomes a small expression tree, which needs one word changed in parse.ts's own comment about producing "not an AST of objects" — it now is one, but per query, a handful of objects built once, leaving §6.6's per-node argument untouched. planComparison generalises to a plan tree with R129's monomorphic ComparisonPlan at the leaves. Short-circuit per candidate, left to right as written — do not reorder operands by estimated cost, recorded as a deliberate non-optimization so it is not "fixed" later: a query whose performance depends on an invisible reordering is one nobody can reason about, and the user can move the term themselves. R130's resumable loop is unchanged — the state machine is over the candidate array and the tree evaluates within one candidate. Non-functional: and short-circuits work away, so the worst case (or, both terms) is 2× R129's 17.5 ms over 400,000 candidates and the common case is less than the sum; and and must not be implemented as set intersection over two full evaluations, which allocates two candidate arrays to answer a per-candidate question — the R131 mistake one level up. |

| R140–R142 | docs/plans/R140-publication.md | — | Publication — the round ci.yml's own header comment defers to by name ("release/packaging automation, signing or notarization… belong to the publication round"). The gap between today and a first release is mechanics, not features: the board covers CONCEPT.md §12's M0–M7 in full once R132/R134–R137/R138–R139 land, and what remains of the concept is M8 (YAML) and the "Later" bucket. R140 — publish from a fresh history, with commit identity scoped to a GitHub @users.noreply.github.com alias. Measured: several hundred commits from a single author, under 10 MB of .git, no remote, LICENSE naming no individual — but a contact address in a test fixture (test/fixtures/toml/cargo-style.toml, an authors = [...] line that is part of its Cargo-shaped content) survives every rewrite, because it is file content rather than commit metadata. Generalised: grep the working tree, not just the log — addresses live in fixtures, changelogs, mailmaps and package.json author fields, and none of them care what happens to the commit graph. The cost is quantified rather than guessed: 22 short commit hashes are referenced across docs/ (DECISIONS.md, LOG.md, TASKS.md, five plan documents), all 22 resolve today and all 22 dangle afterwards — an argument for a git bundle archive, not against resetting. Mechanism is an orphan commit, not rm -rf .git: same published result, but reversible until gc and incapable of losing local config. Identity must be set (--local) before the commit exists, plus GitHub's "Block command line pushes that expose my email", which makes a future forgotten --local fail loudly instead of publishing quietly. filter-repo rejected — rewriting the author field leaves the commit messages, which are a dated development diary written for an audience of one; publishing a fresh history is a different decision from rewriting an old one, and this is the first. R141 — tagged releases. electron-builder.yml is already complete, so only the workflow is new: v* tag, three-OS matrix, --publish always, and electron-builder's draft default kept so all three runners accumulate onto one release you publish by hand. Four non-obvious failure modes, each of which quietly produces a bad first release: the tag and package.json version are not connected and must be asserted; snap is dropped (a .snap outside the Store needs --dangerous and is worse than none); macos-latest is arm64 so Intel Macs get nothing without explicit --x64 --arm64; and everything is unsigned (notarize: false), which is a README instruction, not a footnote. R142 — the README, for users first. Six defects found against the tree, two of them false claims: it still says "Pre-alpha — not yet usable", [M0-PLAN.md](M0-PLAN.md) is a broken link, and "Third-party notices are generated at build time" is false — no script anywhere generates it, in scripts/, package.json or electron-builder.yml. It also never says which formats work today (XML/JSON/TOML built, YAML not; package.json's own description is stale on the TOML half), has no screenshot of the three panes, and puts git clone fourth on the page. The AI-authorship note has no standard clause — no SPDX identifier, no OSI text, no adopted AI-DISCLOSURE.md convention; the nearest real conventions are the Co-Authored-By:/Generated with commit trailers this project already uses, which R140 reduces to a single commit. Wording proposed in §5, with two properties to preserve if it is changed: it names what the human did rather than saying "with human oversight", and it does not apologize. CONCEPT.md §13's first open question is now closed: GitHub, npm and the trademark registers were all checked before the push — DPMA and EUIPO by hand in classes 9 and 42, since every register refuses programmatic queries — and no conflicting mark was found. Checking cost minutes; a collision found after a tagged release would have cost the repository URL, the release URLs, appId, productName, every screenshot and every external link. Built. Published as mincowski/klados at v1.0.0 with binaries for Windows, both Macs and Linux. All four non-obvious failure modes above were real: three of them were hit, plus a fourth the plan did not predict. Four release attempts, and not one failure was in product code — an npm ci lockfile out of sync invisibly because npm ci had never run; three test timeouts on Windows and macOS, the first platforms other than Ubuntu ever to run the suite; a dmg name collision that shipped both architectures under one filename, caught only because the asset list looked wrong, and fixed by splitting macOS into two jobs each overriding -c.dmg.artifactName (an artifactName pattern has no conditional, so one job cannot name two architectures); and a fixed 40 ms sleep in documentSession.test.ts losing its race on a Windows runner. The last two are the direct cause of R151–R153. Assets went from nine to five (zips, blockmaps and snap dropped). The prehistory bundle verified restorable — 22 of 22 doc-referenced hashes — before anything destructive ran, and is now the only copy. Criterion 14, the manual DPMA/EUIPO search in classes 9 and 42, was run by a person and found nothing. One item still owed: TitleBar.css's ink-alignment measurements, carried over from the "N" glyph and never re-taken against the running app. |

| R144 | docs/plans/R140-publication.md §6–§7 | — | The rename: NodePad becomes Klados (D-086), with a new mark (D-087). CONCEPT.md §13's first open question was finally checked and NodePad failed it on three independent counts: Google corrects the query to "Notepad" (not an SEO problem with a fix — the name can never be won in a search); mskayyali/nodepad is an active 1.1k-star MIT project with a commercial product behind it at node-pad.com, paid plans and a company, in the same channel and audience; and the npm name has been taken since 2011. Every node-formative alternative was checked and every one landed on a different established meaning — Node.js (nodist 1.7k★, nodish, nodash, nodos), blockchain nodes (Nodesmith, a Seattle company owning the .io and the GitHub org), Blender/DCC node graphs (147 nodetree repos), network nodes (Nodus, a payments company owning nodus.com, plus four more businesses) — and the maintainer's own objection was independent and decisive: node appears nowhere in the UI, so the meaning never reaches a user. Klados is Greek κλάδος, branch, the root of clade — an ancestor together with all its descendants, which is what a subtree is here given contiguous refs and O(depth) subtreeEndRef. GitHub effectively empty (2★ top hits); klados.io and klados.app both unregistered. Runner-up Leafsmith returned zero GitHub results and lost only on being English-only wordplay where Klados is pronounceable unchanged in German, English and the Romance languages. The mark stops being a letterform. The retired N carried node dots on its vertices; the obvious move was the same trick with a K, and the K is a better letter for it (three strokes, one junction — a branch point). Chosen instead: 3b-ii — stem, arm, and a leg branching off the arm, giving two branch points, because it stops reading as a letter and starts reading as a mark. The cost is taken knowingly: it may not read as a K at first sight, and it puts four strokes and two junctions inside a 16px box. Trap for the implementer: the exploration folder does not contain the chosen variant. Its four top-level SVGs are 3b-i (leg splitting lower on the arm) and alternates/ holds only 3a-i and 3a-ii, so 3b-ii exists only inside the untracked canvas HTML — its geometry is recorded in §7a and D-087, which are the authority. Surfaces counted, not estimated: 57 NodePad in src/ plus 77 nodepad.* command ids and 8 nodepad.* localStorage keys, 78 in test/, 133 in docs/, 15 in assets/, 7 in tools/, 4 in electron-builder.yml; package.json has no NodePad but its name is nodepad and its description is independently stale (still "and later TOML and YAML" after TOML shipped at R14–R17). The command-id rename is guarded by invariant 10's palette-reachability test, which is exactly what fails on a half-renamed id. The localStorage keys reset — and cost nothing, for a reason worth writing down: changing appId/productName relocates Electron's userData directory anyway, moving the Chromium profile that holds localStorage and orphaning keybindings.json with it, so the state is lost whatever the key names say; no migration is written for state the profile move has already stranded. One judgment call, recorded: product surfaces change, historical records do notdocs/LOG.md, pre-D-086 DECISIONS.md entries and plan Results sections keep the old name, because they describe a product that was called NodePad at the time, the same argument CLAUDE.md already makes for never renaming an R id. The counter (R140 wipes the git history, so there is no "before") loses precisely because docs/ then becomes the only history there is. Still owed and not silently dropped: the trademark registers. TMview, EUIPO, WIPO and USPTO all refuse programmatic queries and DPMAregister is a session-bound form, so DPMA and EUIPO in classes 9 and 42 are a manual check carried as acceptance criterion 14. |

| R145–R150 | docs/plans/R145-csv.md | — | CSV as an input formatCONCEPT.md §2's "array of records", promoted out of the "later candidates" list. docs/plans/R31-csv-spike.md already removed the only risk that could have made it impossible: a quoted newline cannot desynchronise incremental reparse, because subtreeSplice.ts's generic bytesConsumed !== newSpanEnd net already covers it — 1,757 edits, 0 mismatches — so no record index, no reparse opt-out and no core/types.ts change. This document is R31's own closing list: dialect sniffing, header detection, Tree-view design, wide-table rendering. The model maps onto existing NodeKind values exactly: Document → one Array root → an Object per row → each cell as an attribute() scalar facet, not a child node. That is arithmetic before it is taste — a facet is 16 bytes against a node's 38, and the obvious alternative (a Property node with a Scalar child per cell) costs 76 bytes per cell, nearly 5×, and would force gridDetection to descend a level it does not. It is also what CONCEPT.md §2 already means by "scalar facets", and it makes the grid work with no new code — a CSV is the canonical instance of the repeating-siblings shape the Detail view was built for, arriving last. One question is deliberately unsettled and gets a probe before any code (§3). NodeSink.attribute names a facet with a byte range, which for a headered CSV is better than it looks — every row's field names point at the same header bytes, so a 2,000,000-row file interns 10 names rather than 20,000,000. A headerless file has no bytes to point at, and "1"/"2" are not in the buffer. Recommended: an empty name span with the Detail view falling back to positional labels, keeping the contract exactly as written — but R147 must probe whether Interner, gridDetection and the grid renderer tolerate it, and report rather than work around if they do not, per CLAUDE.md's rule about types.ts. Rejected: always treating row 1 as a header (silently eats a row of data — the worst failure mode available, because nothing looks broken), and naming columns from the first data row (names become values). Dialect sniffing picks by consistency, not frequency — the delimiter whose per-row field count varies least over the head — because frequency alone chooses , for a semicolon-delimited file full of decimal commas, which is the German-Excel export this will meet on day one. Detection is by file extension only — no content sniffing at all (.csv/.tsv/.tab → 0.9, everything else → 0). That looks like a special case and is the opposite: every existing detector is already extension → 0.9, one cheap unambiguous content check → 0.7, else 0, and each of those checks is "the first meaningful byte tells you" (< for XML, {/[ for JSON, structure for TOML). CSV has no such byte, so applying the house rule faithfully yields 0 — a tabular-looking heuristic would be the deviation. The argument is the asymmetry of the failures: a miss (a .dat that will not open) is loud and self-explaining, while a hijack (an XML file opening as a grid of garbage) is silent, looks like corruption, and selectFormat takes the highest confidence over 0.5, so a greedy detector competes on every file and only has to win once. It also deletes work rather than adding it — no threshold to tune, no "strongly tabular" definition to argue about. R148 keeps the every-existing-fixture test, with its purpose changed: it no longer guards a live heuristic but a future one, since "extension-only" is a decision someone will eventually try to improve. The cost is recorded and deliberately not fixed here: a CSV named export.txt cannot be opened — selectFormat returns null and parse.worker.ts says "Could not detect a format" — and there is no manual override anywhere in the app. That gap is not CSV's (it already exists for any extensionless file whose first byte does not identify it); CSV only makes it visible, and the real fix is a general palette-reachable "Open As…", which is its own task. R148 only improves the message to name the supported formats. The Tree is degenerate and that is the design work: a 2M-row file is a root with 2M children at depth 2 — virtualized, so not slow, just pointless; the initial selection lands on the root so the grid is populated immediately (D-015's wrapper descent and D-065's initial selection may already cover it, to be checked rather than assumed). hasNamespaces: false is load-bearing, not boilerplate — R134 gates the interner's colon scan on it, which is what stops a header like time:start splitting into prefix and local; CSV is that gate's second customer. canFormat: false (normalising quoting would rewrite bytes nobody asked to change), and rowBreakBytes is static while the delimiter is per-document — a real mismatch with the contract and explicitly not a reason to change it, since the field is a preference and a slightly worse row boundary costs nothing. The number that decides the feature is width, not size — 1 node + F facets per row means a 200 MB file costs ~2.0× at 10 columns, ~3.6× at 20, and fails at 50 (~1.68 GB); the ceiling is a column limit, the opposite of every other format and the opposite of what anyone will assume. R28's cross-tab budget and confirmSize's 'budget' reason are the mechanism, and CSV can project the cost after one row where no other format can. R150 replaces the estimates with measurements and runs the incremental-reparse case R31 de-risked with a stand-in but never exercised against a real CSV parser. Built. §3's probe was run for real and the empty-name-span recommendation failed exactly as feared — a real Interner/collectColumns round trip collapsed 3 headerless columns to 1 — so option (a) (always treat row 1 as header) ships instead, gated by a has-header heuristic that discloses via diagnostic when that assumption is doing real work; reported rather than routed around, per the plan's own instruction. Running the real parser through detectGrid also found that every CSV row failed the pre-existing "has children" grid-eligibility test (fields are facets, R145 §2, never children) — no CSV file could ever have produced a grid — fixed as D-088, isGridEligible widening that one test to "has children or attributes" without touching hasChildren/"composite" itself. R150's measurements: 10/20/50 columns at 2,000,000 rows measure 3.36×/3.04×/2.85× store-to-file, confirming the 50-column "fails" prediction at real numbers (1.6 GB); 1,756 edits against the real parser via subtreeSplice.ts, 0 mismatches. Two items owed — a double-ragged-row edge case in the extra-field fallback, and a columns × rows-derived pre-open memory projection (the existing generic file-size gate covers it today) — see the Owed table and the plan document's own Results section. |

| R143 | docs/plans/R143-yaml.md | M8 | YAML, turn one — an investigation, and one defect ready to build. The document is deliberately incomplete: implementation ids are not allocated yet and get allocated when its §9 questions are answered, so this range grows rather than being guessed at. M6 proved "adding a format is only writing a parser" for TOML; for YAML the claim holds for the tree, grid, search, query language, Raw view and row index, and fails in exactly two places, both found by reading code rather than reasoning about YAML. (1) R143 itself — subtreeSplice.ts's graft accepts a parseRange result with more than one root and silently orphans nodes. decideSplice validates canIncrementalReparse, a containing node, result.complete and bytesConsumed === newSpanEnd, and never checks the fresh parse produced one root; the graft then writes fresh ref 0 into spliceNode's slot and remaps everything else relative to it. Demonstrated with a synthetic FormatModule through the real spliceSubtree, not inferred: a two-root result left ref=3 with parent = -1, a real span, and unreachable from the root — 4 of 5 refs reachable, no diagnostic, no fallback. The bytesConsumed net does not cover it (it catches a parse stopping short; a two-root parse consumes the range exactly), and a TOML attempt to reach the shape was refused as malformed, which is why it has never been seen. Fix is one check in decideSplice before the graft — every fresh ref but 0 must have a parent — returning the existing malformed fallback. Kept out of the YAML round on purpose: it hardens three shipped formats against silent tree loss today, and YAML is what turns it from exotic into routine, since outdenting a line reparents it. (2) A parser cannot mark a node as an alias. NodeFlags.IsAlias exists and says "reserved for TOML/YAML anchors", but every flag in that enum is sink-inferred and NodeSink has no method a parser could use to set one — while CONCEPT.md §4.2 promises alias nodes "jump to their anchor on activation". Three options evaluated, recommendation B: emit &anchor/*alias/!!tag through the existing attribute() call as pseudo-facets — zero types.ts change, the sink already interns attribute names and R131's accessors already read them allocation-free, jump-to-anchor becomes a one-off scan of the attribute side table, and Interner.splitsNamespaces is precedent for a capability-gated core/ behaviour only one format uses. A (plain scalars, no jump) is the honest zero-cost fallback; C (extend the contract) is reported rather than done, per CLAUDE.md. Aliases and merge keys are never resolved under any option — expanding <<: *base would show keys that are not in the file. What maps with no work at all: mappings/sequences/scalars (§3.2's folding rule already names YAML), and multi-document streams, which become the root Document's N children with --- inside the following child's span — no contract change. Block scalars keep the source text as their value span, following the precedent that JSON's scalar spans already include their quotes and never decode escapes; the one place it reads worse than elsewhere is a display concern to be rendered before it is settled (PLANNING.md §1), not a model change. Incremental reparse is viableResumeContext carries column (scanned backward from start, O(line)), flow-vs-block and block-scalar state — but its fallback rate will be worse than TOML's, because every indentation edit is a structural change; measure the rate before building an indentation-aware splice. Detection: a .json file is valid YAML and must stay JSON.yaml/.yml → 0.9, %YAML/--- a moderate signal, everything else 0; no key: value sniffing, since JSON already returns 0.7 from content alone. canFormat: false for the first round — in YAML re-indenting is semantic, not cosmetic, and the XML formatter's own history (R11's silent skips, R18's stack overflow, M5g's performance round) is the argument. Unmeasured and flagged as such: node density, parse throughput, the fallback rate, and parser size — estimated at 2,500–4,000 lines against TOML's 1,757 / XML's 1,234 / JSON's 756, from those three and the grammar's shape, not from a measurement. |

| R151–R154 | docs/plans/R151-ci-matrix.md | — | CI on every platform it ships to, plus the two test defects that would otherwise make a merge gate worthless. The case is measured, not argued: ci.yml runs ubuntu-only and release.yml runs four jobs but only on a v* tag, and over the published history the two disagreed on all four commits where both ran — in both directions. Green CI said nothing about whether a release would build; red CI said nothing about whether the code was broken. Both Release failures had one cause, the one the matrix closes: a test failing on a platform CI never runs — three timeouts on Windows/macOS at 69aadc2 (the first time the suite ran anywhere but Ubuntu) and a fixed 40 ms sleep losing its race at 87012f9, each costing a deleted draft release and a moved tag. R151 adds the matrix over ubuntu/windows/macos with fail-fast: false, gates only xvfb and the test invocation on Linux, adds a concurrency group (nine racing jobs per branch otherwise, which starts mattering the moment branches exist), and bumps actions/checkout/actions/setup-node off @v4, which targets the deprecated Node 20 and warns on every run. Everything else runs on all three with no if:, deliberately — an asymmetric matrix leaves steps that only ever execute on one OS, which is the shape of the problem being closed; the narrow-but-real value on Windows is guarding .gitattributes' eol=lf normalisation rather than re-finding R47. macOS is one job: macos-latest is arm64 and Release's Intel job exists to produce a second binary, not to exercise different code. Risk is low and measured: the three-platform run has already happened and passed — Release's Test step is unconditional, so run #4 ran the full suite green on Windows, macOS twice and Ubuntu. Cost is free (public repo, so no 2×/10× multipliers) and wall clock is the slowest job, not the sum. R152 — tabStrip.test.tsx:327 is a missing wait, not a flake, and failed 2 of the last 4 CI runs: the file defines waitForOverflowButtons() and every .tab-strip-scroll-btn query in it is preceded by that helper except the one that fails, because its describe block (R38) was added after the helper and never picked it up. expected +0 to be 3 is zero buttons — the layout pass and ResizeObserver had not run — the same class as R140's flushReparse fix, twice in two rounds. R153 — the namespace budget is a 2× ratio and CI measured 2.024×, over by 1.2%; R141 raised the timeout around that test and never revisited the ratio inside it. Raised to 3×, justified by what a regression looks like rather than by the run being close: per-node namespace work on a 150,000-node document costs a multiple, not 2%. retry rejected — it would green both in one line and would have hidden R152's missing wait entirely. Not in scope: branch protection (a repository setting, not a file), caching the Playwright download (nothing to compare against until the matrix has baselines), Intel macOS in CI, and Release's platform list, which is already right. R154 — allocated after the fact, because the matrix failed on two of its three platforms on its very first run, for causes latent since before the project had CI. macOS: mainElectron.test.ts timed out cold-starting Electron, and the plan's "already passed on three platforms" claim turned out true of the suite and false of that test — it guards on out/main/index.js and skips when absent, and release.yml tests before it packages, so that describe block had never executed off Linux in its life. Fixed with a 120 s timeout on that hook alone, not by raising the global hookTimeout for 152 other files. Windows: searchStore.test.ts failed on a fixed sleep — the third instance of that defect in three rounds, 17 call sites of a helper that only called setTimeout. The fix took three attempts and the wrong two are the valuable part: quiescence (R140's approach) fails because after an edit the store goes stale synchronously and then nothing moves until the debounce elapses, so stable and not-yet-started are indistinguishable from outside; quiescence-plus-!stale fails too, and instrumenting that surfaced a product defect — the flag clears at +48 ms when the reparse lands and is re-marked at +205 ms by a notification with the store unchanged and dirty still true, permanently, because searchStore.ts uses dirty (unsaved) as a proxy for the buffer moved since the search ran. Reported and carried in the Owed table rather than fixed, per CLAUDE.md: pre-existing, and picking the right signal is a product decision a CI round should not make. The correct wait needs both flags, since stale clears when the re-run starts and complete only when it finishes. macOS failed twice, in two hooks, for unrelated reasons, and the second is the one worth keeping: with the launch timeout fixed the run failed again at 30 s in afterAll, where the comment already sitting there had named the cause without anyone noticing it applied — "which quits the process on its own (non-macOS)". main/index.ts calls app.quit() on window-all-closed only when the platform is not darwin, because a Mac application is meant to stay running when its last window closes, so Playwright’s close() waits for a process exit that is deliberately designed never to happen. Correct product behaviour and a gap in the harness, not the reverse: raising the timeout would only have lengthened the hang. Teardown now bounds close() at 10 s and kills the process. The first macOS fix was right and still left the platform red — the round’s own argument turned on itself. A fourth instance of the fixed-sleep defect surfaced locally, in the file the release round had already fixed. documentSession.test.ts failed under full-suite load only (ten isolated runs pass — the signature exactly) and contained five more such helpers, one per describe block, because the pattern is copy-paste per describe rather than one shared utility: R140 fixed one wait out of six in a file it had opened for this very reason. All five now delegate to the single quiescence-based flushReparse, across 44 call sites. The conversion reproduced R140’s own mistake and the compiler caught it — a global rewrite of await flush() hit sites belonging to helpers still taking no arguments, and tsc reported ten wrong-arity calls rather than letting them sit in tests nobody reruns. The generalisation worth keeping: a per-describe private helper is where this defect hides, since fixing "the" helper in a file fixes one of however many copies it has. |

| R155 | docs/plans/R155-spike-policy.md | — | Spikes leave a document, not a directory. Prompted by Dependabot: 19 of 25 open alerts came from one completed M0a spike, whose package-lock.json pinned electron ^38.2.0 while the application's own lockfile already resolved 39.8.10 — higher than any advisory asked for, so none of it affected the shipped binaries, and the six real alerts are all build tooling (extract-zip, fast-uri, @xmldom/xmldom) with the eight runtime dependencies untouched. This is R47's finding in a new place: four real lint errors under 5,363 CRLF warnings is how a tool gets switched off, and unlike R47 there is no fix waiting — nobody updates a dead spike's lockfile, so the noise is permanent by construction. The rule (CLAUDE.md § Spikes): a spike runs on its own branch and only its document merges, into docs/spikes/, written for someone who cannot run the code — question, environment, method and why that method, every number, and the mistakes made getting there. That last clause is what makes deleting the code defensible rather than merely tidy: the code contains the fix, only the document can contain the error. Sufficiency was checked before anything was deleted, document against apparatus: M0a's 905 lines carry environment, the vsync floor that makes 16.7 ms a floor rather than a latency, the editor configuration, A6's windowing primitives by name, and three root-caused harness bugs; H11 names every scheme privilege flag and the run command; R31 names the seeded PRNG and seed behind its 1,757-edit result; and D15's family is fully tabulated in M1-RESULTS.md, which is titled for it. One real gap was closed rather than argued away — the raw JSON held min/max, readMs, docChars, lines and RSS counters no table carried, so all 24 files are transcribed verbatim into docs/spikes/raw-measurements.md: 2,691 scalar values in, 2,691 rows out, checked by count and spot value. Kept: generate-fixtures.ts and five per-milestone benches — re-runnable tooling, not spikes, with a spike/README.md saying what each measures; the directory keeps its name because renaming invalidates every historical path for no gain. spike/.gitignore now excludes package*.json at both levels, so a stray npm install cannot reintroduce the problem. References follow R144's precedent and the measurement made it cheap: of 28 mentions of deleted paths, only five are in live documents (DECISIONS.md ×2, docs/README.md, the board's historical-ids row, .gitignore's rationale); the other 23 stay pointing at deleted paths because they describe what was true when written. Review found one thing: the sweep would have deleted d15-*.json, which belongs to M1 and D-030, not M0a — covered by M1-RESULTS.md, but checked rather than assumed, and grouped under its own heading in the transcription. | | R156 | docs/plans/R156-search-stale-flag.md | — | A Find result is marked stale after it has already been recomputed — found at R151–R154 §4a while fixing a test wait, reported rather than fixed there because it is a product change and a CI round should not make one. searchStore.ts's subscription tests document.dirty where its own comment says "the buffer has [moved]"; dirty means unsaved, true from the first keystroke until the next save, so any later notification with the store unchanged re-stales a correct result permanently. Measured through the real store and session: stale at +1 ms, cleared at +48 ms when the re-run landed, re-marked at +205 ms, and it stays. Not user-visible, and the plan was wrong to say it was — corrected on implementation by grepping for consumers: nothing in src/ reads SearchResult.stale, because R126 removed the (stale) suffix from the Find bar and left no other reader. The real cost is a public field holding the wrong answer under a correct name, plus one spurious setResult/notify per edit cycle re-rendering every Find subscriber with identical data. The fix is one word and the field already exists: reparsePending is defined as "true from the moment sourceBuffer changes until applyReparseResult commits a store built from it" — the comment's intent verbatim — and it exists precisely because M5's H8 banner and H9 budget hit the same trap; searchStore is the third such consumer and reached for the wrong flag. Not yet verified: read from the code, not run, and R156 must also settle whether transformInProgress needs including, since reparsePending by its own definition does not cover the window before a Transform swaps the buffer. It owes a test that fails first — the missing half of R154 — asserting the flag is still clear after the window the old fixed sleeps read in, since a check at +48 ms cannot see this defect, which is how it survived. Built. One word, dirtyreparsePending, with the reasoning at the call site; searchStore.ts's module header corrected too, since it opened by describing the bug ("the moment the document goes dirty"). transformInProgress deliberately excluded — it covers the window before a Transform swaps the buffer, where the result still does match, so marking stale there would be the same defect briefer. The test asserts the mechanism, not a duration, which is the whole lesson: watching for a particular moment is what hid this, so after the re-run it issues a notification that provably cannot have moved a byte (setCaretOffset) and asserts snapshot identity. Verified to fail first on the real defect. |

| R157–R158 | docs/plans/R157-package-check.md | — | CI packages the app instead of only bundling it. ci.yml ran electron-vite build — a bundler, which stops at out/ — so nothing on a pull request had ever executed a line of the packaging toolchain. Found the hard way: two Dependabot security PRs bumping fast-uri (under ajv) and @xmldom/xmldom (under plist), both beneath app-builder-lib, went green on all three platforms while the 1,834-test suite could not reach either package. Green meant "the dependency tree still installs" and was read as more. The only thing exercising them was release.yml's npm run package, on a tag — i.e. after a version was committed to, which is the ordering R151 exists to correct and how R141 spent four attempts and two deleted drafts. --dir is the deliberate boundary: it covers config validation through ajv, asar and file-copy, the Windows executable rename and macOS Info.plist through plist — both prompting packages verified reachable in node_modules, not inferred (schemaValidator.js via validateConfiguration, which runs on every invocation; electronMac.js on a Mac) — while skipping NSIS, dmg, AppImage and fpm. Full packaging rejected for now, with the reason stated rather than assumed: installer config changes only in deliberate release rounds that run the real thing anyway, dependency bumps never touch it, and the failure modes differ in kind — a toolchain regression breaks every target and --dir sees it, an installer-naming defect breaks one filename and only the real target does. It would not have caught R141's dmg collision, which the asset-list check already did. Measured: 123 s locally, exit 0, producing Klados.exe — so the executable rename is real rather than merely configured. Caching ~/.cache/electron deliberately not done: it would cut most of the download, but a cache key is a correctness surface and there is no point tuning a step before its true cost has been observed once. One thing the local run could not settle and is flagged rather than assumed: Windows logged signing with signtool.exe and exited 0, but a local machine and a runner need not share a signing environment, and CSC_IDENTITY_AUTO_DISCOVERY: false is documented for macOS keychain discovery. If Windows fails there the fix is to disable signing for the check, not to configure a certificate — unsigned is what this project ships. Built, and the run answered every open question. Measured on CI: the step costs 26 s (ubuntu) / 32 s (macOS) / 51 s (Windows) — far below the 123 s measured on the 2013 desktop, so §4's "read it as an upper bound" held. Wall clock rises ~6 → ~7 minutes, set by Windows either way. That converts §4's deferral into a decision: 26–51 s does not justify a cache key's correctness surface, so the Electron download stays uncached. §5's signing uncertainty is resolved — signtool with no certificate is a no-op, Windows packaged in 51 s and passed, and no suppression is needed. R158, allocated after the fact for the same reason R154 was: the step passed on all three platforms but the macOS job failed, in the test step, on documentPropsRenderCost.test.tsxexpected 11 to be 10. Not caused by R157, which touches no product or test code, but inserting 32 s before the tests perturbs timing and a latent race surfaced. Fourth instance of the same class: mountAndDrain drained the mount's own commits by waiting two requestAnimationFrames — a duration wearing a frame's clothing — while its own comment already named the reason that cannot work, that CodeMirror's setup is effect-driven, not frame-driven, and need not land inside them. When it lands late its commit arrives after resetStats and is charged to the scenario. Now drains until the total commit count stops moving. The recurring tell, worth stating once: a helper whose comment describes something asynchronous while its body waits a fixed amount of time — R140's flushReparse, R152's missing waitForOverflowButtons, R154's two, and now this. |

| R159–R163 | docs/plans/R159-fixed-duration-waits.md | — | The fixed-duration wait, reviewed across the whole codebase after R158 made it four instances in four rounds — R140 (failed the v1.0.0 release build), R152, R154 (two), R158. The tell was already written down: a helper whose comment describes something asynchronous while its body waits a fixed amount of time. src/ is clean and that is a finding, not an omission: all 13 production timer call sites are deliberate — debounces, chord and type-ahead timeouts, toast auto-dismiss, searchJob’s setTimeout(0) yield — and src/main, src/core and src/preload contain no timers at all. The population is entirely in test/: 58 fixed sleeps across 24 files, plus 41 files carrying a two-rAF paint(), against just 12 correct waits (7 vi.waitFor, one predicate loop, four quiescence loops left by the four fix rounds). Three experiments, all reverted. Halving every sleep fails 7 tests in 4 files — under 2× headroom, the margin R153 rejected at 2.024×. Zeroing every sleep still passes 1,817 of 1,834, so ~41 of the 58 hold nothing up, including waits whose comments say they exist to clear a 150–200 ms product debounce. The third experiment is the round’s reason to exist. focusIntoContent.test.tsx:250 waits 250 ms “past rawCaretSync’s debounce” and passes at 125 ms, below it, and at 0 ms — so rawCaretSync.ts’s DEBOUNCE_MS was raised 200 → 200_000, disabling the feature, and the browser project ran 43 files / 239 tests, all green. The suite has no coverage of rawCaretSync; the one test naming it would pass if the extension were deleted. That is the second harm, and it is invisible by construction: a sleep that is too short does not only flake — where the assertion is negative or the state is unchanged either way, it passes vacuously and never turns red, which is why every previously found instance was one that went red. R159 adds test/support/wait.ts — quiescence only, since vi.waitFor already covers predicates and must not be reinvented — and collapses into it the four hand-rolled loops plus five character-identical copies of a 60 ms openTab (rawEditCaretSurvival, rawExternalRewrite, rawZoomRemeasure, rawDecorationTiming, inactiveSelection), two of which justify the number by citing the helper R154 replaced — so the fix reached two files while the rationale had already reached five. R160 converts only the waits that gate a real assertion: the seven sub-2× failures, documentSession.test.ts:769 (a positive assertion on a bare 60 ms sleep in the file R154 opened for this), and two name/body mismatches — flushMicrotasks() containing two macrotask hops, and a setTimeout(0) under “wait for it to actually land rather than racing”. R161 fixes tabSwitchMeasurement.test.tsx, documentPropsRenderCost’s twin, which R158 did not look at: same harness, same CodeMirror mount, same comment, still resetting after two rAFs. It will not flake (wallMs < 500 is generous) — it silently inflates the R30 number it prints as its own deliverable, which is CLAUDE.md’s “a component measured cleanly while the pipeline around it was not” for the fifth time. R162 names and exports FindBar’s and Palette’s debounces (bare }, 150) literals that twelve test sleeps in four files are coupled to by nothing but a comment) and closes the §2d hole, with acceptance defined as the mutation: the new test must go red under DEBOUNCE_MS = 200_000, demonstrated rather than argued — a test that passes under it is precisely what this round exists to find. R163 adds the eslint gate last, banning non-zero setTimeout(fn, N) under test/** with a narrow inline escape for the legitimate case: a negative assertion, where no condition exists to wait for and a generous duration is the right tool. Deliberately not done: the 25 paint() tails (safety margin, measured as load-bearing nowhere, and three quiet frames per call across 153 files is a real cost), and retry, rejected for R153’s reason — it would green everything in one line and hide exactly this class. Built, all five ids, every criterion met, and the numbers moved the right way. 58 call-site durations to zero; what remains are named constants, which is the distinction the rule draws. Re-running the review's own experiments: halving every remaining duration now fails 0 tests, where it failed 7, and zeroing them fails 0, where it failed 17 — so the survivors provably gate nothing, which §10 asserted when it declined to convert them and can now show. Suite 41.56 s → 41.41 s with three more tests (test time 108.56 → 104.50 s): §9's expectation confirmed rather than hoped, since a 5 ms poll replacing a 250 ms sleep should also be faster. The naming half paid off in a way the plan did not predict — the 24-file scripted rewrite that produced §2c is now a one-line edit to SETTLE_MS, because the value has a name. §7's acceptance was the mutation and the mutation was run: with CARET_SYNC_DEBOUNCE_MS at 200_000 the positive test dies in 5.2 s on the caret has not resolved to a node yet while both negative tests correctly survive — and the caps on their waits exist because the first mutation run slept 100 and 400 seconds, burying the one failure that mattered under two timeouts. The entire product diff is three constants gaining names and export (CARET_SYNC_DEBOUNCE_MS, FIND_DEBOUNCE_MS, PATH_QUERY_DEBOUNCE_MS); no behaviour changed. The review pass found things, and the useful ones were wrong conditions — §12's named risk arriving where it said it would. Waiting for the Find bar to stop reading "Searching…" is not waiting for the search: before the debounce fires the store still holds the previous result, so a fresh bar already reads "No matches" and the wait returns having waited for nothing — ten tests failed on it, R154's stable and not-yet-started are indistinguishable from outside in a new place. Re-searching text already in the box produces no store change at all, so one test had nothing to wait for. A malformed path query never reaches the search store, so two assertions one line apart want different waits — the shared duration is what made them look interchangeable. And repainting inside a poll remounts the component, destroying the pending debounce and the typed value, so a wait can prevent the thing it waits for. Each is recorded at the site where it was wrong, not only in the results. Beyond the plan: findAutoSelect's waitForSearch and findPathMode's Enter sites converted too (same named-helper tell), and documentSession.test.ts's 20 ms straggler. Rule verified three ways — a literal errors, 0 and an identifier pass, src/ is out of scope — with lint still at its 3-warning ratchet. Full suite 1,837 passed. |

| R164–R167 | docs/plans/R164-release-security-hardening.md | — | Security hardening before the first public release, from a review against the malicious-input-file and Electron threat models. The parser layer is already sound and that is a finding, not a task (§1): XXE not resolved (DOCTYPE is opaque, &xxe; stays literal), no entity expansion (billion-laughs inert), depth capped at DEFAULT_MAX_DEPTH = 10_000 so 200k-deep XML/JSON stop with a diagnostic not a stack overflow, pathological CSV bounded — all run through npm run inspect, not read. No XSS path (document content renders as escaped React text; the one dangerouslySetInnerHTML, Icon.tsx:19, is fed static build-time SVG only), and no OS-shell entry point (no fileAssociations/protocol client/argv — a malicious file enters only via dialog or drop). R164 (HIGH) is the real exposure: there is no will-navigate/web-contents-created guard anywhere, the drag-drop preventDefault is scoped to the .layout div while TitleBar/TabStrip sit outside it (App.tsx), and preload/index.ts exposes window.api on every origin with no CSP surviving a navigation — so a renderer navigated to hostile content (a dropped link on the unguarded chrome, per Electron's documented default drop behaviour, read not reproduced since the harness cannot drive native DnD) inherits document:mintReadToken (arbitrary-path token → klados-file:// fetch = arbitrary read) and document:write (overwrites any path unconditionally = arbitrary write → code execution). Fix: a main-process navigation guard denying any non-app-origin top-frame navigation (load-bearing, closes it whatever the trigger) plus a window-level drop guard so no region is unguarded. R165 (LOW): setWindowOpenHandlershell.openExternal forwards any scheme; currently unreachable (no external links exist), allowlist http:/https:/mailto:. R166 (LOW): sandbox: false — verify-then-enable sandbox: true (preload uses only sandbox-safe contextBridge/ipcRenderer/webUtils) to shrink the blast radius; the principled answer preferred over path-validating mint/write (§6, rejected with reasons — it fights Save-As/restore and R164 closes the root). R167: publish a workflow-computed SHA256SUMS.txt per release + a README "Verifying your download" section — the proportionate integrity signal for builds that are unsigned by design (notarize: false, CSC_IDENTITY_AUTO_DISCOVERY: false) with no auto-updater. Open — planned, not built. Built, all four, and the round is worth reading for the three plan claims that turned out to be false. sandbox: true broke the application outright — the plan said @electron-toolkit/preload was "bundled by electron-vite at build time, not a runtime require"; electron-vite externalizes declared dependencies, so the built preload still carried that require, a sandboxed preload resolves only built-in Electron modules, the script failed to load, and window.api was undefined. Exactly what verify-then-enable was written to catch. Fixed by removing electronAPI rather than bundling it: window.electron has no reader anywhere in src/renderer. klados-file:// is not "fetchable from any script in the renderer"index.html's own CSP (default-src 'self', no connect-src) refuses it from page context, while the worker's bundled script has no CSP; a second control neither the plan nor the review had noticed, now pinned by a test, and it weakens nothing because a page the renderer is navigated to carries its own CSP or none. And the permission deny was not tidying: with the handlers removed from a real build, Notification.requestPermission() returns granted. Every guard was verified by breaking itisAppUrl loosened to "protocol is file:" reddens the local-file case; setAppUrl pointed at a wrong URL reddens a new real-app IPC round trip with Refused keybindings:read from an untrusted frame while both pre-existing real-app tests still pass, which is why that test had to be added: the sender guard is the one change here that can brick the app, and no unit test of a correct predicate could see it. 13 ipcMain registrations, not the plan's 14 (its count included a comment line). §1's parser findings were re-run before anything was scoped: XXE stays literal, a six-level entity chain yields 3 nodes and 122 B, 200 000 nested elements stop at 10 001 nodes in 13.9 ms. R167 dry-run rather than left to the tag — three assets give three lines, a stale sums file is excluded, sha256sum -c --ignore-missing round-trips, an empty asset list fails the job. Suite 1,837 → 1,857. Three things owed, none of them a failure — see the Owed table. A fourth, @electron-toolkit/preload left declared after R166 removed its only import, was resolved inside the round on the user's call: removed outright, one dependency line and one lockfile entry, no transitive fallout. R155 applied rather than deferred. |

| R168 | docs/plans/R168-crlf-edit-offset.md | — | A Raw edit on a CRLF document lands at the wrong byte — silent data corruption, and it breaks invariant 6. Found by a user running R164–R167's owed manual pass against a real build, while looking for something else. Typing t after Jante on line 2 produced Jantte: one byte early, and the drift is the number of line breaks before the edit point. Reproduced in the real editor harness before this document was written. The mechanism is an argument between two texts, each side individually correct: Raw.tsx:346 builds the editor with no lineSeparator facet, so CodeMirror's default /\r\n?|\n/ split discards the carriage return — measured, 25 units against 27 bytes — while rawEdit.ts:163 builds its byte map from that CR-free text and applies the resulting offsets to a buffer that still has the CRs. Severe because it is silent: CodeMirror renders its own document, so the character appears where it was typed and the buffer holds something else until a reparse makes it visible. CRLF is the Windows default. The suite could not have caught it: every Raw edit fixture is LF-only, so units and bytes have always agreed and the conversion has never met the line ending most of the target platform uses — R151's finding in a new place, a whole dimension of the input space no test ever varied. Acceptance therefore includes CRLF variants of the existing edit tests, not just a test for the fix. Blast radius is stated as verified-versus-expected: the insertion is demonstrated; deletion, replacement, the recorded caret offset and rawCaretSync's separate map (built from the decoded window text, which does carry the CRs, so the two maps may disagree with each other) are reasoned but unproven, and the round should establish them because the shape of the fix depends on how many maps are wrong. Candidates: lineSeparator.of('\n'), which leaves the CR in the line text and realigns everything at a stroke — pending checks on rendering, caret placement past a CR, and mixed endings; or a byte-built map for rawEdit. Normalising the buffer to LF on open is rejected outright — it breaks invariants 6 and 7 and turns opening a file into rewriting it. Unrelated to R164–R167, verified by diffing that branch against main by filename: it touches no edit-path file. |

| R169 | docs/plans/R169-external-change-reload.md | — | "Reload and Discard" looks like a dead button, found in the same manual pass as R168. Three facts make the reported sequence the expected one: the command is fire-and-forget (void ctx.session.reloadAndDiscard(), so nothing observes completion or failure); reloadFromDisk never leaves phase: 'ready', aborting the in-flight work and swapping the document in one setState at the end, so there is nothing for a progress indicator to hang off — the user's own "I would expect the loading bar, similar to opening a file" is unmet by construction, and DocumentArea.tsx:203 renders exactly that indicator for a phase a reload never enters; and the banner is derived, so runAction's own dismissNotification is a no-op for it and it clears only when the reload lands. Between click and completion every visible signal says nothing happened, which reads as failure. The hypothesis, flagged as one: the user's "maybe a temporal coincidence" is probably the mechanism — Keep Mine flips the flag synchronously, so the banner vanishes at that instant while the in-flight reload lands moments later, and two unrelated events look like one. R169's first job is to confirm or kill that, with the real-app watcher test R164's manual pass produced as the foothold. A second-order defect falls out if it holds: keepMine() does not touch reloadAbort, so a user who clicks Reload, sees nothing, and clicks Keep Mine to back out still loses their edits — the banner offers two outcomes and the first is not revocable. Constraint: do not make the reload slower to make it visible — no artificial minimum display time, since a small file reloads faster than a frame. PLANNING.md §1 applies to the indicator itself: render it before settling it. |

| R170 | docs/plans/R170-tree-horizontal-scroll.md | — | A deeply nested tree has nowhere to scroll, and cannot as built. .tree sets overflow: auto, but a row can never be wider than the container: every virtualized row is position: absolute; left: 0; right: 0, so its width is the viewport's. The indent is depth * 16 px and unbounded, and .tree-row-label carries overflow: hidden; text-overflow: ellipsis — so on deep-10k.json the indent reaches 160,000 px inside a row pinned to the viewport width, nothing overflows .tree, and the label ellipsises away to nothing. The content is not clipped by a scroll region; it is compressed out of existence inside each row. Not a one-line fix: right: 0 is what R33 relied on so a row's background sits beside the overlay <Scrollbar> rather than under it, the full-width background is the selection affordance, and the virtualizer tracks no horizontal extent. A solution already exists in this codebase and must be read firstGrid.tsx runs a second horizontal: true virtualizer, owns scrollLeft by hand with clamping, and lets the scrollbar decide whether to draw a horizontal track; those decisions are the same decisions, already taken once. The design question is deliberately left open: a scrollbar that travels 160,000 px is correct and unusable, so capping the indent or eliding the middle are live alternatives — and PLANNING.md §1 applies with full force, so all three get rendered against the real fixture and put in front of the user before one is chosen. Constraint: the horizontal extent must not be measured by walking every row, or it reintroduces exactly the cost the virtualizer exists to avoid. |

| R171 | docs/plans/R171-watcher-error-handling.md | — | A file watcher error crashes the main process. A user hit Electron's "A JavaScript error occurred in the main process" dialog by hand — EPERM: operation not permitted, watch at FSWatcher._handle.onchange. The certain part needs no reproduction: fs.watch has no 'error' listener anywhere in src/ and there is no uncaughtException handler either, both confirmed by grep, and fs.FSWatcher is an EventEmitteran error event with no listener throws, which in main is exactly that dialog. The stack places it in the watcher's own change callback, so the error arose while the watch was live. R164's argument applies unchanged: a guard's value is covering the triggers nobody enumerated, so the fix should not wait on enumerating them. The trigger is deliberately recorded as unknown — a probe that launched the real app, watched a file, then deleted the file and its directory out from under the watcher produced no error at all, so the obvious hypothesis is wrong and the document says so rather than guessing. The behaviour on error is a real decision, not a formality: an empty listener stops the crash and silently drops the watch, trading a loud failure for a quiet one — release and tell the renderer, release silently, or retry with backoff all have a case, and a vanished file and a transiently locked one deserve different answers. R171 also settles whether main should have a global uncaughtException handler at all, which cuts against this project's own culture (R47's buried lint signal, R155's buried Dependabot alerts) and needs a DECISIONS.md entry either way. Constraint: a failing watcher must not spin — release or back off, never re-arm unconditionally. Found by a person, not a test: the suite covers that watching works and never what happens when the platform says no, which is FINDINGS.md's blind-spot entry in yet another place. |

Next free id: R172.

First entry with no milestone, and the first under the per-topic document convention (CLAUDE.md § "Plan documents"). The "Milestone at allocation" column stays for the historical rows; new rows may leave it when the work doesn't belong to a numbered round.

Historical ids — per-milestone letters, not renamed

Milestones M0 through M5c numbered tasks with a per-milestone letter. These are not migrated into the R sequence: renaming settled history would invalidate every cross-reference in docs/, in commit messages and in DECISIONS.md for no gain.

Letter Milestone Plan
A M0a (spike) docs/spikes/M0a-codemirror-and-parsers.md
B M0, M0c docs/plans/M0-PLAN.md, docs/plans/M0c-PLAN.md
D M1 docs/plans/M1-PLAN.md
E M2 docs/plans/M2-PLAN.md
F M3 docs/plans/M3-PLAN.md
G M4 docs/plans/M4-PLAN.md
H M5 docs/plans/M5-PLAN.md
J M5c docs/plans/M5c-PLAN.md

M2b and M5b had no plan document of their own — both were tracked as entries in docs/plans/UI-FEEDBACK.md rather than numbered tasks. Under the R scheme they would each have got a range; that is the practical reason the scheme exists.

C and I were never used: C was skipped when M0c reused B, and I is skipped everywhere in this project because it reads as 1 in a monospaced task id.