Ctrl K
ComunidadGuías…

CLAUDE.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# BoardRipper — Project Configuration

## Project Overview
BoardRipper — web-based PCB boardview file viewer and inspector. Hosted via Docker on NAS.

## License
AGPL-3.0. See [LICENSE](LICENSE) and [THIRD_PARTY.md](THIRD_PARTY.md). AGPL was
chosen because the Allegro parser (`src/frontend/src/parsers/allegro/`) is a
TypeScript re-implementation derived from KiCad (GPL-3.0), which forces the
whole project to be GPL-3.0-compatible. The Altium parser
(`src/frontend/src/parsers/altium/`) is also transliterated from KiCad
(GPL-2.0+ with one GPL-3.0+ file) — same lineage as Allegro. AGPL additionally
closes the SaaS loophole. BVR/BRD/BDV/FZ/CAD/XZZ draw from OpenBoardView (MIT);
TVW draws from eagleview (MIT). KiCad, EAGLE and Mentor Neutral are original
work: KiCad and EAGLE from their public format docs, Mentor Neutral
reverse-engineered from sample exports. All runtime dependencies are
MIT/Apache-2.0/BSD.

## Tech Stack
- **Rendering:** PixiJS v8 (WebGL) + pixi-viewport v6 (pan/zoom/culling/deceleration)
- **Frontend:** React 19 + TypeScript + Vite 7
- **Panels:** Dockview v5 (dockable, detachable, floating, popout-to-window)
- **Backend:** Go (net/http stdlib) — serves SPA, file management, board database, self-update
- **Container:** Docker (multi-stage build, scratch-based, ~28 MB unpacked / ~14 MB compressed)
- **Tests:** Playwright (Chromium headless)

## Supported Formats
- **BVR1** — tab-delimited, absolute coords ×1000. Spec: `docs/formats/BVR_FORMAT.md`
- **BVR3** — keyword-value, relative pin coords. Spec: `docs/formats/BVR_FORMAT.md`
- **BRD** — binary obfuscated boardview (Apple/Mac repair). Spec: `docs/formats/BRD_FORMAT.md`
- **BDV** — plain-text boardview (BRDOUT/NETS/PARTS/PINS/NAILS sections). Spec: `docs/formats/BDV_FORMAT.md`
- **BDV ASC** — Honhan / Tebo-ICT multi-section ASC. Two deliveries of one document: obfuscated single-file `.bdv` (line-key cipher, three sections) and plain `.asc` section files, of which the older deliveries ship **five** — `format`/`nails`/`pins` plus `parts` (rotation + package name) and `nets` (net listing). Clicking any one of them in the Library opens the whole board: `asc-siblings.ts` groups same-folder files by the stem left after stripping the section keyword, and `asc-open.ts` merges them through the one code path the `.bdv` already uses. The OS picker and drag-drop can't see the folder, so a lone section opens as-is plus a toast whose "Add sections…" button takes the rest. Spec: `docs/formats/BDV_ASC_FORMAT.md`, design: `docs/specs/2026-08-09-asc-multi-file-open-design.md`
- **FZ** — ASUS boardview (RC6-encrypted, zlib-compressed). Spec: `docs/formats/FZ_FORMAT.md`
- **CAE** — ASRock PCBRepair Pro. Same container and cipher as FZ, different key; shares `parseFZ`, which probes a list of candidate keys. Unverified against real data (no sample). Spec: `docs/formats/FZ_FORMAT.md`
- **CAD** — GenCAD 1.4 text-based PCB interchange. `$ROUTES` arcs are CCW from `(x1,y1)` to `(x2,y2)`; the sweep is lifted into `[0, 2π)`, never clamped to ±π (issue #33 — the shortest-arc clamp drew major arcs as their complement and lost half of every two-record circle). Spec: `docs/formats/CAD_FORMAT.md`
- **XZZ** — XZZ PCB (DES-encrypted boardview). Arc block `0x01` is CCW from `angStart` to `angEnd`; the sweep is lifted into `[0, 360)`, never swapped-and-clamped to ≤180° (issue #33 — that shortest-arc rule turned every outline notch into an outward lobe). The plaintext tail past the `v6v6555v6v6` marker has **two encodings** and `parseXzzTailAnnotations` picks by content, not by file name: legacy `=<mV>=PART(pin)` records (diode values only, `… Middle layer diode value….pcb` companions), or — current iPhone-era deliveries — a JSON document after a **GB2312** banner (`{"part":[{"reference","alias","pad":[{"name","diode"}]}],"net":[…]}`) carrying diode readings **plus** the part- and net-rename tables XZZ's own viewer displays. JSON pad names are not `\d+`-only — BGA pads like `M7` are unrepresentable in the legacy grammar. Which of `reference`/`alias` the *binary* blocks use varies between deliveries of one board, so the diode join tries both keys and the rename only touches parts the binary named by `reference`; both renames refuse to merge two entities into one name (collision tested against existing names − names being vacated) and net renames run before `buildNets`, since net identity here **is** the string. A JSON tail can carry renames and no `pad[]` at all — that file genuinely has no diode data (iPhone16_16Plus ships exactly this pair: `AP+BB Boardview.pcb` 4750 readings, `AP+BB YiDianTong.pcb` none). **Board packs (v0.37.3+):** the file draws every board twice (top and bottom as separate layer-28 loops, side by side) and iPhone deliveries pack two or three boards into one file. `foldBoardPack` (`xzz-parser.ts`) + `parsers/xzz-boards.ts` classify the loops (board / cutout nested inside a board / fragment / frame), pair halves by a score on dimensions, gap and alignment (never an exact `segCount` key — a one-segment difference broke iPhone 5, a notch broke XS Max), fold **every** geometry kind of the bottom half onto the top (pins, part silk, silk, traces, vias, test pads, outline), slide the boards next to each other, and emit `BoardData.boards` + `Part.boardIndex`. **Which half is the top** comes from copper when the file has traces (a half's routed pins all sit on the first copper layer or all on the last, never mixed — the `PCB layer` deliveries) and otherwise from the exporter's layout rule: the left half (lower x) is the top, or the upper half for vertically stacked pairs. Verified on 78/78 trace-carrying corpus files, MacBook and iPhone alike; the old CPU rule (most-pinned part = top) is right on MacBooks and a coin flip on iPhone sandwich boards, so it is only logged (`CPU RULE DISAGREES`), never obeyed. The part header carries **no side field** (unk1 = flag, x, y, rotation; unk2 = a silkscreen label element) and OpenBoardView hard-codes Top, so there is nothing to decode. The file-wide mirror flip stays with the pin-direction detector, which now votes on folded boards with real sides (its false flip on the iPhone X Qualcomm pack came from mixed sides). **Touching halves** (2008–2015 Apple laptops/iMacs, one loop): `splitSymmetricLoop` cuts a loop that is its own mirror image at the seam; same-direction pin winding in both halves ⇒ mirror fold, opposite winding ⇒ **translate** fold (`fold.mode`/`fold.offset`, one half was drawn through the board), mixed ⇒ a real single board, left alone; side by the CPU rule there. The store's `deriveBoardView` only selects a board, undoes slide+fold for "Show all sides" (parts and outline only on packs) and applies the sidebar's per-board **Swap sides** override (persisted in file-view-prefs). Corpus, evidence and the phased plan: `docs/specs/2026-09-05-xzz-multi-board-unfold-review.md`. **Text, tails and test points (2026-10-04, issues #37–#39, after Sean Johnson's fork, cherry-picked):** names, labels and board text decode as **GB18030 unless the strict-UTF-8 reading is made of plausible characters** (`decodeXzzText` — short GB2312 runs are often valid UTF-8 too, so validity alone cannot decide). The tail is a run of `===<name>` sections, not one encoding: legacy records are always read, the JSON is found by its `===PCB` banner and bounded by the next `\n===` (it used to be sought only in the first 4 KB, so on files with 62 KB of legacy records before it its renames never applied), and legacy pin names may be BGA pads (`N485(D9)`). Once the JSON's part renames apply, legacy records are still keyed by the binary's **original** name, so the diode join tries that as a third key; a purely numeric net alias is not applied (one would turn `90_PCIE_GP1_AP_FROM_BB_RX_P` into `380`). Measured on the 350-file local corpus: diode readings 73,753 → 144,707, no file loses one. Top-level `0x06` text records are read and `parsers/annotation-tables.ts` rebuilds the "Common problems" repair tables from text positions (the ruled lines do not delimit cells; 17 tables on 11 files, flowcharts rejected) into `BoardData.annotations`; `===信号` is the net glossary, `BoardData.netDescriptions` (34 files). Both are shown in the board sidebar's **Notes** tab (`BoardNotesTab.tsx`, book icon, `data-board-tab="notes"`, present only when `store/board-knowledge.ts` has content — the source-tagged board-knowledge model OBD, local notes and a wiki are meant to join; review `docs/specs/2026-09-29-board-knowledge-layer-review.md`), and both must be listed in `board-cache.ts`'s `serialize`/`deserialize` (they were dropped on a cache hit). Top-level `0x09` test points have the pin sub-block's layout (net index at `24 + nameLen + 32`, not the last 4 bytes) and become one-pin parts `TP<pad number>` (repeats `-2`, `-3`…; Xbox 360 Trinity is `TP1`…`TP1-492`) with real pad size, shape and drill; `nails` is still built for the status bar; the legacy fold mirrors them. A record inside a part pin's pad (≤ half its pad, ≥ 1.5 mil) that shares that pin's net or has none is a **copy of the pin**, not a test point, and is not emitted (iPhone13ProMax BB Boardview writes 5,401 of them; as parts they won every click by smallest area); test points are judged for placeholder pad geometry separately from part pins, and their optional trailing reading (`OL`, `375`) becomes the pin's diode value. Corpus: 16,302 records on 49 files, 8,761 drawn. Known, pre-existing, not the TP code: on files with the file-wide mirror flip (A2442 820-02098, 820-02382-16, A2115) the bottom half is X-mirrored against the top, and the K20 "translate fold" applies a mirror when it finds no seam — test points inherit both. Roadmap: `docs/plans/2026-09-28-xzz-issues-roadmap.md`. Spec: `docs/formats/XZZ_FORMAT.md`
- **TVW** — Teboview binary (multi-layer, traces, drill data). Spec: `docs/formats/TVW_FORMAT.md`
- **MENTOR_NEUTRAL** — Mentor Graphics Boardstation/Expedition neutral export (text, `.cad` extension; not GenCAD). Spec: `docs/formats/MENTOR_NEUTRAL_FORMAT.md`
- **ALLEGRO_BRD** — Cadence Allegro binary PCB. Two parser families share `parsers/allegro/`:
  - v16.x / v17.x / v18.x (magic `0x0013xxxx` / `0x0014xxxx` / `0x0015xxxx`) — original target. Spec: `docs/formats/ALLEGRO_BRD_FORMAT.md`
  - v15.x (magic `0x0012xxxx`) — added in v0.17.0 via blind RE; ~99% net coverage on 15.5.7 corpus, partial on 15.5.2. A third sub-variant (`0x0012050a`, Jasper_Kronos, 2026-09-28) drove a rewrite of the pad walk: pads come from the `BLK_0x2D.+0x24 → BLK_0xC8.+0x10` **ring** with real pin numbers from the `[0x34]` record (the old `BLK_0x48` chain invented `1..n`, swapping SOT-23 1↔3 and diode 1↔2), pointers resolve through a type-aware candidate index, C8 header byte 0 is a flag, drawing symbols no longer become `UNK` parts. Oracle = Allegro's own v17 re-save of the same board: 7982/7982 pins, 7910 nets agree, 0 wrong (`tests/allegro-v15-oracle.spec.ts` ▸ Kronos). The 15.5.7/15.5.2 samples are not on the build machine — re-measure there before release. Spec: `docs/formats/ALLEGRO_V15_FORMAT.md`
- **ALTIUM_PCB** — Altium Designer / Circuit Maker / Circuit Studio binary `.PcbDoc` + PCB ASCII Version 5.0 text variant. CFB container (Designer 6.0+, 2005+). Phase 1: parts/pins/nets/outline. Spec: `docs/formats/ALTIUM_PCB_FORMAT.md`
- **KICAD** — KiCad `.kicad_pcb` (S-expression, KiCad 4–9). The only *openly specified* format we support, so the parser is an original implementation from KiCad's public docs, not a transliteration. Self-contained ~70-line tokenizer (`parseSExpr`, no deps, Node + browser); footprints→parts, pads→pins+pads, Edge.Cuts→outline, `(segment)`/`(arc)`→traces, `(via)`→vias. **Y is already down** so `flipY` stays false — unlike GenCAD/Allegro/XZZ/TVW. Angles are CCW-*as-displayed* over a Y-down frame, i.e. negative in raw coords; both the rotation sign and the "back-side footprints are already mirrored in the file" rule were settled empirically (place each pad, then require a track endpoint at that spot to carry that pad's net id — a position-only check can't tell the conventions apart on symmetric 2-pin chips). `Part.angleDeg`/`Pad.angleDeg` carry the raw-frame angle the renderer consumes; `meta.angleDeg` keeps the as-authored value for the Info panel. `gr_arc` stores `(start)(mid)(end)` — a real point ON the arc — so it is unambiguous and there is deliberately **no** shortest-arc normalisation (that idiom is issue #33, which bit `xzzArcSweepDeg` and `gencadArcSweepRad`). `(zone)`→`surfaces` (copper pours): KiCad stores the **computed** fill, one `(filled_polygon)` per island, so there's no fill algorithm — but three traps. (a) A zone with a `(keepout …)` child is a DRC rule area, **not copper**, and is skipped. An **unfilled** zone (no `(filled_polygon)`) is not drawn either: its `(polygon)` boundary is the pre-clearance outline and would paint solid copper over every foreign-net via — it is counted into `parserNotes` instead, as is a KiCad 4/5 `fill_segments` pour. (b) Never emit `(polygon)` alongside `(filled_polygon)` — double-draws the pour un-clipped. (c) The layer comes from the **island's** own `(layer …)`, not the zone's, which may be the layer-*set* shorthand `F&B.Cu` that names no single layer; an island whose layer is not copper (KiCad 6+ allows zones on mask/silk/user layers) is skipped, never defaulted onto F.Cu. The surface net follows the zone's `(net N)` id — `(net_name)` is a label KiCad refreshes only on re-fill and can be stale. **`Surface.voids` is never populated, and that is correct, not a shortcut:** KiCad stores fills *fractured* (`SHAPE_POLY_SET::Fracture()`) — cutouts are stitched into the outer ring by zero-width slits, so there is no hole list in the file. Measured, not assumed: 0 of 155 rings wind clockwise (no reversed inner rings), every duplicated vertex belongs to a backtracking pair, and — the decisive behavioural check — of 996 foreign-net vias that reach a fill's layer, **zero** test as inside the copper under the even-odd rule. The fractured ring is also what `drawSurface` wants (slit edges cancel, holes appear free), and `board-scene` explicitly refuses to punch `voids` at all on perf grounds. Edge.Cuts primitives are chained into contours by `chainPolylines` (10 mil join tolerance, NaN pen-up between contours; chaining is per primitive, not per sampled point) and `drawOutline` punches inner closed contours as holes with PixiJS `cut()` — v8 has no even-odd fill, so a cutout drawn as a plain second sub-path would be painted. Skipped: silkscreen, text, dimensions, custom-pad primitives, zones nested in footprints. Spec: `docs/formats/KICAD_PCB_FORMAT.md`
- **EAGLE_BRD** — Autodesk/CadSoft EAGLE 6.0+ XML `.brd` (third `.brd` claimant; XML sniff is disjoint from Apple BRD's and Allegro's binary magics, and it registers **after** Allegro so the extension-only fallback is unchanged). Pre-6.0 binary `.brd` is out of scope and rejected with a "re-save from EAGLE 6+" message. The format's whole difficulty is **indirection**: pin positions are not stored — `<element>` places a `<package>` by name and each pin is `package pad coords → R(a)·M → +(x,y)`, where `rot="[S][M]R<deg>"` mirrors across the local Y axis (→ bottom side) *before* rotating CCW; nets join by `(element, pad)` **name** via `<contactref>`, never by coordinate. Coordinates are always millimetres regardless of grid unit. Board outline is layer 20, searched in `<plain>` **and** inside placed packages (carrier/shield footprints often own the whole edge). `<wire curve="…">` is a signed CCW sweep and is used verbatim — never normalised to a shortest arc (issue #33). Verified by a routing oracle: on the fully-routed fixtures 100% of pins land exactly on a trace endpoint of their own net. Spec: `docs/formats/EAGLE_BRD_FORMAT.md`

## Project Structure
```
BoardRipper/
├── CLAUDE.md                    # This file
├── README.md
├── Dockerfile                   # Multi-stage build (node → golang → scratch)
├── docker-compose.yml
├── desktop/                     # Electron desktop app (Mac + Windows builds)
├── Board Database/              # Reference board database (SQLite)
├── docs/
│   ├── formats/                  # Format specifications (one per format)
│   │   ├── BVR_FORMAT.md         # BVR1/BVR3
│   │   ├── BRD_FORMAT.md         # BRD (Apple/Mac obfuscated)
│   │   ├── BDV_FORMAT.md         # BDV (plain-text boardview)
│   │   ├── BDV_ASC_FORMAT.md     # BDV ASC (Honhan / Tebo-ICT obfuscated)
│   │   ├── FZ_FORMAT.md          # FZ (ASUS RC6-encrypted)
│   │   ├── CAD_FORMAT.md         # GenCAD 1.4
│   │   ├── XZZ_FORMAT.md         # XZZ PCB (DES-encrypted)
│   │   ├── TVW_FORMAT.md         # Teboview (multi-layer binary)
│   │   ├── MENTOR_NEUTRAL_FORMAT.md # Mentor Boardstation Neutral (.cad text)
│   │   ├── ALLEGRO_BRD_FORMAT.md # Cadence Allegro v16/v17 BRD
│   │   ├── ALLEGRO_V15_FORMAT.md # Cadence Allegro v15.x BRD (RE'd in v0.17.0)
│   │   ├── ALTIUM_PCB_FORMAT.md  # Altium Designer .PcbDoc (binary CFB + ASCII v5.0)
│   │   ├── KICAD_PCB_FORMAT.md   # KiCad .kicad_pcb (S-expression, KiCad 4–9)
│   │   ├── EAGLE_BRD_FORMAT.md   # Autodesk/CadSoft EAGLE 6.0+ XML .brd
│   │   ├── TVW_PDF_CLEARANCE.md  # TVW ↔ PDF schematic mapping notes
│   │   └── tvw-reference/        # eagleview / inflex / mmuman reference sources (eagleview MIT licence)
│   ├── PDF_VIEWER.md             # PDF render-pipeline architecture
│   └── RELEASE_RUNBOOK.md        # Maintainer release-cutting procedure
├── samples/                     # Local-only board fixtures (not redistributed)
├── landing/                     # Static landing page deployed to ripperdoc.de
├── scripts/                     # release.sh, packaging, NAS deploy helpers
└── src/
    ├── frontend/                # React + PixiJS SPA
    │   ├── tests/               # Playwright E2E specs
    │   └── src/
    │       ├── parsers/         # Format parsers (pure TS functions, 15 registered formats)
    │       │   ├── allegro/     # Allegro v15.x + v16/v17 (split families share types)
    │       │   └── altium/      # Altium .PcbDoc (CFB) + ASCII (transliterated from KiCad)
    │       ├── renderer/        # BoardRenderer, board-scene (shared), mockup-data
    │       ├── pdf/             # PDF glyph extraction & overlay utilities
    │       ├── components/      # Toolbar, StatusBar, ContextMenu, BindLink, BoardSidebar, UpdateProgressOverlay, overlay/ (board overlay slots), …
    │       ├── panels/          # BoardViewer, PDF, Settings, SettingsMockup, Debug, Library, History, Tools, Worklist, DatabaseEditor, tools/ (calculators, Part comparison)
    │       ├── hooks/           # useBoardStore, usePdfStore, useDatabank, useKeyboardShortcuts, createStoreHook (useObdForBoard lives in store/obd-store.ts)
    │       └── store/           # board-store, render-settings, board-cache, pdf-store, databank-store, update-store, themes, overlay-layout, obd-store, …
    └── backend/                 # Go net/http server
        ├── handlers/            # HTTP handlers (auth, files, boards, databank, sync, obd, update, health)
        ├── boarddb/             # Board reference database (v2 entity hierarchy, ODM matcher, resolver)
        ├── databank/            # File scanner, search, PDF text extraction
        ├── librarysync/         # WebDAV pull + scheduler for shared library mirror
        ├── obd/                 # OpenBoardData parser, cache, scraper
        └── updater/             # Self-update via Docker socket — signed-manifest pipeline
```

## Key Architectural Decisions
- PixiJS v8 chosen over Canvas2D/Konva for GPU-accelerated rendering of 10,000+ components at 60fps
- `buildBoardScene()` in `renderer/board-scene.ts` is a shared pure function used by both `BoardRenderer` and `SettingsMockup` — visual changes propagate to both automatically
- BitmapText atlases for part/pin labels: dramatically fewer GPU draw calls vs per-label canvas Text objects
- Dockview chosen for IDE-like panel system with floating/popout window support
- Go backend chosen for minimal Docker footprint and single-binary deployment
- All format parsing happens client-side in TypeScript (no server dependency for rendering)
- `useSyncExternalStore` for reactive stores — getSnapshot must return a stable cached reference
- **NEVER call `app.destroy()` on PixiJS v8 Applications** — `destroy()` triggers `GlobalResourceRegistry.clear()` which corrupts the module-level `batchPool` in `Batcher.mjs`, permanently breaking ALL other Application instances with `_DefaultBatcher2.break: Cannot read properties of null`. Instead, just remove the canvas from DOM and let GC reclaim the Application + WebGL context. See `BoardRenderer.teardownForReinit()`.
- **NEVER `BitmapFont.uninstall()` on per-tab teardown.** `BitmapFont.install()` registers atlases in PixiJS's **global** registry — one atlas is shared by every open tab's `BitmapText`. Uninstalling on tab close / scene rebuild destroys the `TextureStyle` still referenced by other tabs, which later crashes in `GlTextureSystem.updateStyle → applyStyleParams` with `Cannot read properties of null (reading 'addressModeU')`. Atlases are content-keyed (`board-shadow-N-v3`, `board-pin-N`), idempotent, and safe to keep for the app lifetime.
- PDF panels use per-document state via `usePdfDoc(fileName)` hook, allowing multiple PDFs to render side-by-side. The singleton `pdfStore` tracks an "active" doc for mutations but each panel reads its own doc's state independently.
- **PDF render pipeline:** see [docs/PDF_VIEWER.md](docs/PDF_VIEWER.md) for the full architecture. Short summary: at zoom ≤ 1.05 a full-page path renders the current page to an `ImageBitmap` cache keyed by `(file, page, tier, cleanMode)`; above that a per-tile DOM-canvas path (`tile-manager.ts`) renders 1024×1024 tiles with an LRU cache keyed by `(file, page, col, row, scale)`. Both paths flow through `mainTierFromZoom → quantiseTier → hysteresisFilter → clampCanvasScale` before calling `page.render()`. A separate tier-1 preview cache provides instant backdrop on zoom/page transitions. Adjacent pages use `renderPageToBitmap` (capped at `maxAdjTier`). Quality presets in `QUALITY_CONFIGS` control tier caps, cache budgets, and settle delays.
- **PDF watermark filter (v0.30.3+):** parse-time string-matching filter implemented as a pdf.js patch in `src/frontend/patches/pdfjs-dist+<version>.patch`, applied by `patch-package` via the `postinstall` script. The patched worker examines each `showText` op's reconstructed glyph string against terms forwarded through a custom `watermarkFilter` render option and skips matching ops **before they enter the operator list**. Net effect: 5–10k watermark glyph runs per page never tokenised, streamed, or allocated; zero client-side callback overhead. Matching rule (NFKC-normalise → strip whitespace → lowercase → substring; NFKC decomposes ligatures like `fi` so `"Vinafix.com"` matches `"Vinafix"`) is kept in lock-step with `isPdfWatermarkText`/`normalizeForWatermark` in `render-settings.ts` (and `IsWatermark` in `pdfindex/watermark.go`, which imports `golang.org/x/text/unicode/norm`) so click-test and render-skip stay aligned by construction. Worker URL in `pdf-store.ts` points at the **unminified** `pdf.worker.mjs` because the patch targets readable source (Vite still minifies for prod). The earlier `operationsFilter`-callback approach (v0.4.2 – v0.30.2) was retired because pdf.js's `QueueOptimizer` reorders/merges ops between `getOperatorList` and `render`, so pre-computed indices didn't line up with what `render` actually executed on PDFs heavy enough to trigger the optimizer. See `src/frontend/patches/README.md` for the five-site edit recipe and the procedure to re-port the patch on `pdfjs-dist` version bumps.
- **PDF filter menu (2026-10-03):** the PDF toolbar's only filtering control is one dropdown (`components/PdfFilterMenu.tsx`, `data-testid="pdf-filter-btn"` / `pdf-filter-menu`) holding the watermark text filter + term chips (live: the panel flushes operator lists in place), *Strip small watermark images* (the old per-doc `toggleClean`, which had no UI) and *Skip invisible content* (the worker's `brPruneInvisible`, see `patches/README.md`). All four are **global** render settings (`pdfWatermarkFilter`, `pdfWatermarkFilterEnabled`, `pdfStripWatermarkImages`, `pdfPruneInvisible` in `globalSettings`; board overrides and themes never apply to PDFs) and every write goes through `store/pdf-filter-actions.ts`, which also pushes the term list to the backend indexer — the right-click "add as watermark" used to skip that push. The two parse-time options are decided when pdf.js opens a document: each `PdfDocument` records `parsedWith`, `pdfStore.needsReparse`/`staleDocuments` compare it with the settings, the menu's *Reload & re-parse* row (and a dot on the button) lights up only then, and `pdfStore.reparse` swaps in fresh proxies (old ones destroyed 2 s later so an in-flight render finishes) and bumps `parseGen`, on which the panel drops its bitmap/tile caches. Page, search, rotation and extracted text survive a reparse. **Review fixes (2026-10-03):** `toggleClean` and `reparse` are serialised per file (`_serial`) — two at once raced on the proxies and leaked a stripped document; `reparse` with only the strip changed delegates to `toggleClean` (one proxy added/dropped, not a reopen), and a queued second reload finds nothing stale and returns. **Strip-on-open** sets `cleanMode` before the stripped proxy exists, so the panel's first pages were drawn from the unstripped document and cached under the clean key for good — `toggleClean` now bumps `parseGen` whenever the effective document changes, and `loadFile` records `stripImages` as applied from the start (the strip is part of the open; a reload offered meanwhile only raced it). A failed strip records the attempt in `parsedWith` and toasts, instead of a permanent "reload needed" that fails the same way again. `_extractText` re-reads a page from the new proxy when a read rejects because the old one was destroyed mid-stream (otherwise extraction stopped there and the progress bar stayed up for ever). The menu commits a typed term on close (an outside press unmounts the input before any blur) and says when a term is already listed. E2E `tests/pdf-filter-menu.spec.ts` (public fixture).
- **PDF canvas rules:** Only **pooled offscreen** canvases (freshly acquired, used once, released) use `getContext('2d', { alpha: false })`. Persistent canvases (main visible canvas, adjacent page canvases, tile canvases) must NOT use `alpha: false` — resetting `canvas.width` doesn't reliably reset the alpha attribute across browsers, causing mirroring artifacts on reused canvases. Canvas pool (8 entries) shrinks backing store synchronously before pooling — never defer shrinking (race condition with reuse). Highlight, glyph, and tile canvases retain alpha.
- **Never pool pdf.js-rendered canvases.** After `page.render()` completes, the pdf.js Worker thread may still queue stale draw operations. Abandon the offscreen canvas to GC by setting `width = 1; height = 1` — do NOT return it to the canvas pool, or subsequent reuses will be corrupted with mirrored/flipped content.
- **Scroll slots are independent, and Shift is the browser's horizontal scroll (issue #40, 2026-09-27):** every browser turns Shift+wheel into a horizontal `deltaX` *before* the page sees it — macOS's own gesture, and Chrome/Firefox do the same on Windows — so a viewer that intercepts Shift for zoom takes the user's horizontal scroll away, which is what both viewers did whenever bare scroll panned: the bindings were a *permutation* (each action in exactly one slot), and Pan on bare forced Zoom onto Shift. `store/scroll-bindings.ts` is now the one module for both surfaces: three slots (`bare`/`shift`/`meta`), each holding any action, two of them may agree. The board's slots live in three render-settings keys — `twoFingerPan` is the bare slot (it predates the rest and has many readers), `wheelShiftAction` and `wheelMetaAction` the other two; `installShiftWheelHandler` resolves ⌘ then Shift from them and a modifier pan consumes **both** deltas, so the axis is whatever the OS decided (no axis rule of ours; Mac Mouse Fix and friends just work). Ctrl is never touched — it is the synthesised trackpad pinch and the fast mouse zoom, and it passes through to pixi-viewport as before; the board's ⌘ slot is shown on macOS only, since `metaKey` is the Windows key elsewhere. **Defaults for a fresh install: Shift = Pan, ⌘ = Zoom** (Preview's convention), board and PDF alike; the PDF's old `meta: 'switch'` (page flip) stays selectable. **An existing install keeps the layout it had:** `loadFromStorage` writes the legacy layout out explicitly once, gated by `boardripper-scroll-slots-v2` — Shift = the opposite of bare on the board, `{pan, zoom, switch}` on the PDF — so nobody's Shift+Scroll changes meaning under them; a fresh install sets the marker and gets the new defaults. The editors (Settings ▸ Input ▸ Navigation and the start-page matrix) are per-slot click-to-cycle pills, no longer drag-to-swap (a swap cannot express two slots holding Pan), and a Shift slot bound to anything but Pan shows a note explaining the trade (`scroll-note-<surface>`). The ribbon / PDF-toolbar button (`invertScrollBindings`) flips only the bare slot (`toggleBareAction`), moving the displaced action to Shift only when no slot holds it any more — a pure toggle on the default layout. Zoom speeds are unchanged: Shift+wheel and ⌘+wheel go through `zoomAtScreen` at the calibrated `/500`; the coarse Ctrl+wheel and the pinch stay pixi-viewport's. Unit `store/scroll-bindings.test.ts`; E2E `tests/scroll-bindings.spec.ts` (public KiCad sample: fresh-install defaults, both legacy migrations, both-axis modifier pan, the Settings pills and note) + `tests/scroll-mode.spec.ts`.
- **PDF rotation + mirror + page modes:** Per-document `rotation` (0/90/180/270 CW), `mirror` (horizontal flip), and `pageMode` (`single` | `continuous`, default continuous) live in `pdf-store` (`PdfDocument`). **Rotation** is applied by passing `rotation: page.rotate + userRotation` to **every** `page.getViewport()` call in `PdfViewerPanel` (full-page, tiled, and adjacent paths) — pdf.js then swaps page dims for 90/270 so fit-width, `cssH`, the highlight/click transform (`viewportTransformRef`) and tile grid all adapt for free. Rotation does NOT key the bitmap/tile caches; instead a rotation-change effect clears `invalidatePageCache`/`invalidateTileCache` + adjacent canvases and re-fits (rotation is rare and deliberate). **Mirror** is a CSS `scaleX(-1)` baked into the wrapper transform by `applyTransform` (innermost, flipping around the committed-zoom content width via the `mirrorWidth()` helper) so the whole wrapper — page, tiles, highlight, overlays — flips together in all modes (full-page/tiled/continuous) with no cache or render changes; the only X-coordinate consumers that compensate are `hitTestWord` (`clickX = clientW - clickX`) and the two match-centering pans (`pan.x = clientW*(1-zoom) - pan.x`). Mirror is independent of rotation/page-mode and composes with both. **Effective single-page** (`pdfStore.isDocSinglePage`) = explicit single mode OR `rotation !== 0` — rotation forces single but **preserves** the user's `pageMode` so un-rotating restores continuous. Single-page = no adjacent-page rendering, no scroll/zoom page-flip (gated by `noFlip` in `handleWheel`), pan clamped to the current page (the `singlePage` branch in `clampPan`). Continuous = the original stacked-adjacent-pages + boundary-flip behaviour. Toolbar: the `pdf-rotate` (cycles CW) + `pdf-mirror` (toggle) + `pdf-page-mode` (toggle, disabled while rotated) group sits directly **right of the page-switching controls**. Shortcuts (PDF panel active): `rotateBoardCW/CCW` (Q/E) and `rotateCW/CCW` (⌘←/→) rotate; `mirrorBoard` (⌘↑) mirrors — all previously no-op on PDF. E2E: `tests/pdf-rotation.spec.ts`.
- **Secure update pipeline (v0.19.0+):** Releases are signed offline by the maintainer (Ed25519/minisign); the running container's compiled-in trusted key set verifies every manifest before any I/O on its body. `PubKey` (build-arg `PUBKEY`) is the primary signer; `PubKeys` (build-arg `PUBKEYS`, comma-separated) holds additional keys for a **rotation overlap window** — `TrustedKeys()` merges them and `VerifyManifestAny` accepts a signature verifying under any one, which is what lets a key rotation ship through the normal update path instead of stranding every install. Both are ldflags with **no env/file override**; an empty set fails closed. Rotation procedure: `docs/RELEASE_RUNBOOK.md` ▸ "Rotating the signing key". Two **manifest** sources walked in order — `https://www.ripperdoc.de/boardripper/` (canonical) and `https://github.com/AlexeyInwerp/BoardRipper/releases/latest/download` (fallback; the release carries `manifest.json` + `.minisig` + tarball as assets) — first whose signature verifies wins. GHCR is *not* a manifest source: it is an OCI registry and cannot serve `/manifest.json`; it remains where the image itself is pulled from, by digest. Serving the manifest from GitHub grants GitHub no trust — a tampered copy fails `VerifyManifestAny` exactly as a tampered mirror would. The tarball additionally lists the GitHub release asset in `url_mirrors`, which `Apply` already walks after `url_primary`. Manifest schema includes monotonic `counter` (replay defence), `released_at` + `not_after` (30-day freshness window + 90-day expiry — see freshness invariants below), `min_supported_version` (downgrade defence), `important` flag (red-banner UX) + `important_reason` + `notes_url`, image registry/tag/digest, tarball sha256 + `size_bytes`, and `orchestrator_image_digest` (the multi-arch INDEX digest of the alpine container that swaps the running image). `/api/update/*` is gated by a per-install 32-byte secret persisted at `/data/.update-secret` (mode 0600); the install's monotonic counter persists alongside at `/data/.update-counter`. Cookie attributes: HttpOnly + SameSite=Strict + conditional Secure based on request scheme. Healthcheck-based rollback: the orchestrator polls `/api/health` for 120 s and reverts on failure. **It probes a list of endpoints, not one** (`buildSwapScript`): the new container's IP, the host gateway at the published host port (or at the app port when nothing is published, which is the `--network host` case), and the container name. No single endpoint works everywhere — Docker DROPs traffic between two bridge networks, so an orchestrator on the default bridge cannot reach a container on a Compose network, and the orchestrator therefore also **joins the app's own networks** before starting (it keeps its default-bridge attachment for `apk add curl`). That was the 2026-09-20 report — a DSM Container Manager *project* is a Compose install, so its probe timed out for the full budget and rolled back on **every** update, while our `docker run` NAS install shared the default bridge and updated fine. The port comes from the container's own `PORT` env (`appListenPort`), never a hardcoded 8080. Because such an install can never self-update onto the fix — the drop-to-update bundle hands off to the same orchestrator — the only way out is one manual image pull. **Two records survive a failed swap**, both added with the fix because the reporting NAS was unreachable: the orchestrator tees its console into `<dataDir>/update-orchestrator.log` (the container itself auto-removes), and a rollback renames the failed container `<name>-failed` instead of deleting it, so its boot log can still be read. The previous image is tagged `boardripper:previous` before swap so a manual rollback is one `docker run` away. Drop-to-update bundle (`POST /api/update/apply-bundle`) is the recovery escape-hatch — a signed-manifest+tarball single file the user can drag onto the window when GHCR + ripperdoc.de are both unreachable. Same trust envelope as the network path. **Operational invariants:** download timeout 10 min, body cap = manifest's `size_bytes` (or 1 GiB fallback), `released_at` rejection bounds [now − 30d, now + 24h], single-flight gate prevents concurrent `Apply` / `ApplyBundle`. See `docs/RELEASE_RUNBOOK.md`.
- **Desktop self-update (issue #42, 2026-11-10):** the Electron app updates itself without code signing. `desktop/updater.js` checks `desktop.json` + `.minisig` at `ripperdoc.de/boardripper/desktop/` then `github.com/…/releases/latest/download/` (first that verifies wins; launch + 10 s, every 6 h, on focus after an hour, *Check for Updates…* in the app menu / Help), offers only a newer version, downloads this build's zip with a size cap and sha256 against the signed feed, extracts it **next to the running app** (`<parent>/.boardripper-update/`, same volume so the swap is a rename; `ditto` / `tar`), checks the extracted `CFBundleShortVersionString`, and on quit hands the swap to a detached `/bin/sh` / `cmd` helper in the temp dir (waits for our pid, running → `previous`, staged → running, restores on failure, relaunches only after *Restart Now*; log `userData/logs/update-swap.log`). Dev runs, translocated apps (`/AppTranslocation/`), unwritable folders and Windows paths with `% " ! ^` get the verified zip revealed instead. `update-verify.js` is minisign in pure JS: **BoringSSL has Ed25519 but no BLAKE2b-512**, so BLAKE2b is implemented there. `build-all.mjs` writes `build-info.json` into each bundle — `build` (universal vs legacy are both `darwin`), `version` (**`app.getVersion()` returns desktop/package.json's 1.0.0 in a packaged app**), `pubKeys` (release.sh's `PUBKEY`/`PUBKEYS`, else `~/.config/boardripper/release.pub`; none ⇒ no checks) and, for local tests only, `feeds` from `BOARDRIPPER_UPDATE_FEEDS`. A packaged app takes no runtime override; a dev run reads `BOARDRIPPER_UPDATE_{PUBKEY,FEEDS,BUILD,NO_REVEAL}` + `BOARDRIPPER_APP_VERSION`. Renderer: `components/DesktopUpdateBadge.tsx` over `electronAPI.updates` (same CSS as the Docker badge, hidden when the build trusts no key). Tests: `desktop/updater.test.js` (verifier vs RFC vector, flow, real swap of temp bundles), `tests/electron-update.spec.ts` (dev run against a local signed feed). Verified by hand with packaged bundles on macOS; Windows and legacy unmeasured. Plan: `docs/plans/2026-10-29-desktop-update-path.md`.
- **NAS dev container = the touchscreen test target (rd-nas:1234):** `scripts/devdeploy.sh` builds the working tree for **linux/amd64** (the NAS is Synology apollolake; the Dockerfile builds the frontend on `$BUILDPLATFORM` and cross-compiles Go by `GOARCH`, so nothing runs under QEMU), ships it, and runs `scripts/devdeploy-remote.sh` there — which until now had no local caller and had to be driven by hand. `--test` then points the WebKit iPad suite at the deployed container (`BASE_URL=… WEBKIT=1`); two `pdf-touch` specs skip there and that is correct, they read `__pdfStore`, a DEV-only global a production bundle does not carry. It never touches the live container on :1336. Reachable over Tailscale at `http://rd-nas.taila1bfc9.ts.net:1234/` — which is the point: neither this Mac nor WebKit's Mac port can reproduce a tablet, so a real iPad pointed at a real server is the only place the touch work can actually be judged. **Two DSM facts that cost time:** `scp` fails with "subsystem request failed on channel 0" because that user has no working SFTP subsystem — every copy needs `scp -O` (legacy protocol); and `docker` is not on the login PATH and needs sudo, so it is `/usr/local/bin/docker` with the password on stdin (`sudo -S`), never a tty. Credentials: `deploy.conf` (local-only), the same password for ssh and sudo.
- **Release network path:** the FTP credential lives in two files — `~/.config/boardripper/release.env` (release.sh) and `RipperDocWeb/ftp.env` (deploy.sh) — rotate both. `lftp` retried a stale password until fail2ban blocked the releasing Mac's IP for ~2 h (2026-09-03); both scripts now ping/TCP-probe `ftp.ripperdoc.de:21` before sending a credential and cap the login at one retry. Verify a credential from another IP with `scripts/ftp-check.sh --via rd-nas` (runs lftp on the NAS over Tailscale, password on stdin). If this machine is blocked, `release.sh … --via-tailscale [rd-nas]` routes the run through a Tailscale exit node and clears it on exit. Never probe FTP anonymously while diagnosing — a ban vs. a bad password is told apart by whether port **443** on the same host connects. Runbook: "FTP: credentials, bans, and the Tailscale route".
- **Every fixed popup divides by the body zoom (2026-10-22):** the interface scale is `body { zoom: var(--ui-scale) }`, so a `position: fixed` element inside the body has its `left`/`top` multiplied by the scale while `clientX`, `pageX` and `getBoundingClientRect()` stay in unzoomed viewport pixels. A popup placed from pointer or anchor coordinates therefore reads `uiZoom()` (`store/ui-zoom.ts`) and divides every coordinate and `window.inner*` bound by it — the ribbon's suggestion list sat a quarter of the screen right of its field at 125 % and left-and-above at 80 %. Sites: `dropdown-popover`, `ContextMenu`, `QuickMenu`, `ResizePopup`, `BindLink`, the toolbar tooltip. E2E `tests/ui-scale-popups.spec.ts` at 1.25× and 0.8×. Same day, three input rules on the board panel: a **mouse drag captures the pointer on the canvas** (`boundPointerCapture`) so a pan survives the pointer crossing the ribbon, the minimap or the panel edge (PixiJS listens for moves on the canvas only); a **wheel over an overlay sibling of the canvas host** (ribbon, minimap, slide sheet) is re-dispatched onto the canvas by `BoardViewerPanel`'s root capture listener → `BoardRenderer.forwardWheel` (the sidebar, text fields and any scrollable element keep their own); and the **minimap drags the view only for a press that began on it** (`dragging` ref) — it fades in under the pan being made and used to take any button-down move as its own. The browser's own chords (⌘F/G/E/S/D/P/O/J and the zoom keys) are default-prevented in the **capture phase on every window**, main and popout, ahead of any element that stops propagation. E2E `tests/board-wheel-overlays.spec.ts`, `tests/browser-chords-blocked.spec.ts`.
- **Theme stack (v0.18.0+):** themes split into board-side concerns (the `THEMES` registry — pin/part/background colours used by `buildBoardScene`) and three independent interface knobs persisted separately by `themeStore`: `accent`, `background`, `chrome`. Boards adopt the theme; UI chrome obeys the knobs. Auto-flip of accent text colour is computed against perceived brightness (don't hardcode contrast pairs). User-pickable accent presets (BoardRipper default + ATARI palette) live in `themes.ts`.
- **App bar vs. board ribbon (v0.39, proposal C):** the top toolbar (`Toolbar.tsx`) carries only app-level controls — sidebar cycle, Open, the centred global search, 2-window mode (labelled `2-Window`, still gated on `!isOfflineBuild() && !COARSE_POINTER`), About, version. The sidebar-cycle button is `.toolbar-railcell`: 45px wide (the activity rail's 46px **minus its 1px border**) with `margin-left: 0` at the window edge, at the rail's own 20px icon size, so its icon centres exactly on the rail's icon column below — measured, not eyeballed. The version badge alternates between the offered version and the word `Update` while one is pending (`.update-badge-swap`, two spans in one grid cell so the width never jumps; `aria-hidden` because the halves would read as one run-together string, with the button `title` carrying the sentence; reduced motion shows the word alone). Every per-board control is an overlay slot in the board ribbon: `sideSwitch` (segmented Top/Bottom with words, `data-testid="side-top|side-bottom"`, Shift-click = both), `butterfly` (every format; on a board with copper it hides traces/vias/pours while on, with a toast — see the Butterfly note below), `rotateCCW`/`rotateCW`, `transformMenu` (⋯ → 180°, mirror H/V, flip-axis check; `QuickMenu`), `history`, then `sep0` and the pre-existing slots; `traces` (only when the format has traces and no layers; keeps `traces-btn`) sits late, after `diodeValues` and before `sep2`. The order is `DEFAULT_OVERLAY_LAYOUT` in `store/overlay-layout.ts`. Slot renderers call the same `boardStore` actions the toolbar buttons called; keyboard shortcuts, MCP drive-UI tools and the store are untouched. Per-tab state reaches the slots through `SlotCtx.thisTab` (`showTop/showBottom/butterfly/showTraces/rotation/flipAxis/primarySide/hasLayers/hasTraces`), filled by `BoardViewerPanel`. Tests click the slots by test id, never toolbar text. Spec `docs/specs/2026-09-02-activity-rail-design.md` (toolbar section) + mockup `docs/specs/2026-09-08-toolbar-c-mockup.html`.
- **Board overlay slot system (v0.18.0+):** the floating in-canvas controls (top/bottom toggle, flip-axis, spotlight tri-state dim, parts/nets filters, separators, selection-name label) render via a slot registry under `components/overlay/`. Layout is persisted (`overlay-layout.ts`) and reconciled against `DEFAULT_OVERLAY_LAYOUT` on load — new slots auto-append with `def.visible`, removed slots drop. **Editable in two places sharing one set of store operations** (`setOverlaySlotVisible` / `moveOverlaySlot` / `moveOverlaySlotBefore` / `removeOverlaySeparator` / `resetOverlayLayout`, thin wrappers over pure helpers in `overlay-layout.ts`): **right-click the ribbon** (`QuickMenu`, `data-testid="board-bar-menu"`) to tick/untick each named slot, set row position, fold, reset, or jump to Settings; and **Settings ▸ Board ▸ Board overlay** (`OverlayCustomizer.tsx`), one list in ribbon order with the live chip, its name (`SLOT_LABELS`), eye, ↑↓ and × on separators, plus drag. `reconcileOverlayLayout` inserts unseen default slots **where the default puts them** (after the nearest earlier default the user still has, else at the front), not at the end — so a future leading slot lands in front for existing users. `QuickMenu` (`components/QuickMenu.tsx`) is the one right-click menu implementation, also used by the activity rail. Default position is left; the overlay is mounted as a `netDimGfx` sibling so highlight blending pattern works. **Collapsible (v0.38.1+):** a window-shade handle at the bar's **right** end — the Classic Mac collapse box — (`.overlay-collapse`, `data-testid="overlay-collapse"`) rolls the whole bar up toward its left anchor until only the handle is left; state is one persisted boolean for all tabs (`store/overlay-collapse-store.ts`, key `boardripper-overlay-collapsed`), and the bar carries `data-collapsed`. **Position and orientation** live in render settings: `overlayPosition: 'left'|'center'|'floating'` (floating = inline `left/top` from `overlayFloatX/Y` px within the panel; the shade handle is the drag grip — a press moving <4px is a fold click, more is a drag committed on release and clamped to the panel) and `overlayOrientation: 'horizontal'|'vertical'` (column via `.vertical`: separators become rules, the side switch stacks, the handle sits at the foot). Both are set from the ribbon's right-click menu and the Settings editor; `data-position` / `data-orientation` on the bar. The board sidebar ☰ (`.board-sidebar-toggle`) shares the ribbon button styling. `partsDropdown`/`netsDropdown` are one component (`slots/FilterDropdown.tsx`, `kind`): in the horizontal ribbon the plain inline `overlay-filter-input`; in the **vertical** ribbon only, a collapsed icon trigger (`parts-search-btn`/`nets-search-btn`, 20px magnifier + chip/net glyph) that mounts the field beside the column only while open (tests switch to vertical before clicking the trigger). `SuggestionList` (`dropdown-popover.tsx`) is portaled and viewport-clamped: max-height is the room under the field and it opens upward when there is more room above. **Board sidebar (`BoardSidebar.tsx`) tabs** use the same `library-tab icon-tab` cell as Library/Settings (icons pinned, only the open tab captioned, Revisions "!" badge in the cell corner) and are located by `data-board-tab="info|layers|search|revisions|notes|worklist"`. **Every `icon-tab` previews its name on hover** via `.icon-tab[data-title]:not(.active)::after` (same look and 300ms delay as the rail label); icon tabs carry `data-title`, never native `title`, so there is one tooltip — tests must not select them by `title`. The sidebar **opacity slider is gone** (v0.38.1) — the ☰ only toggles the panel.
- **Boards reference database v2 (v0.15.0+):** `Board Database/boards.db` is a SQLite reference DB shipped inside the container image (copied to `/boards.db`; the backend prefers `<DATA_DIR>/boards.db` when present). v2 schema replaces the old flat board table with an entity hierarchy (Brand → Family → Board), color cascade, and explicit family field. Resolver lives in `src/backend/boarddb/`. Imported sources include Wikidata Macs, XZZ Apple-laptop filenames, and an LLM-classified NAS dump (~3,977 boards in the shipped DB). The Database Editor panel surfaces it read-only.
- **A tap during the glide is a tap (2026-09-27):** with inertia on, pixi-viewport treats a touch that begins while the board is still decelerating as "stop the glide" and never emits `clicked` for it — by design, and right for a mouse. On a tablet, flick-then-tap is the everyday way to select: measured with real touches, a tap right after a pan selected **nothing**, and one 1.5 s later hit the wrong part because the board was still moving. That was "sometimes a tap can't select a component, only after panning". `boundShiftCapture` reads `decelerate.isActive()` in **capture phase on the container**, i.e. before pixi's own canvas `pointerdown` both stops the glide and decides no click may come of the touch; `boundPointerRelease` then raises `handleClick` itself when such a touch ends as a tap (same guards as always — multi-touch, cancel, the 12 px tolerance). Only touch pointers; a mouse mid-glide keeps pixi's behaviour. **Touch context menu:** the long-press (500 ms) works in Chromium but on iOS the same hold on a canvas is answered by the system callout / text selection, which cancels the pointer under it — `.board-panel-canvas` now carries `user-select: none` + `-webkit-touch-callout: none` like the PDF touch surface; the hold's slop is the tap tolerance (12 px, was 10); and because nothing on screen said any of this, the first board a touch device opens shows a one-time toast ("press and hold a part to open its menu", `boardripper-touch-menu-hint-shown`). E2E in `tests/touch-pinch-selection.spec.ts`: the glide case sweeps taps along the row the board glides on, because where the part is at tap time cannot be predicted from outside the renderer. **Known pre-existing:** `context-menu-quick-actions.spec.ts` opens the proprietary `820-02016.bvr` without a skip guard and fails with ENOENT on any machine without it.
- **A lookup that lights a big net must not stall the app (2026-09-23):** "double-tap or tap lookup blocks pan and zoom for 5–10 s" on the iPad. Not the PDF, not the touch path, and not one thing — three, all in the board renderer, all invisible on a desktop and found by profiling Chromium at **6× CPU throttle** as a stand-in for the A12Z (`Emulation.setCPUThrottlingRate`; the WebKit Mac port is too fast to show any of it). **(1) The selection blink re-ran the whole `renderSelection()`** — every member pin of the lit net, full geometry rebuild — thirteen times, 250 ms apart, for three seconds, to animate the colour of the *primary* outline; `selectionBlinkPhase` is read in exactly one place, `renderSelectionPrimary`, so that is all a phase ever changes. With a 4077-pin `GND` lit from a PDF tap that was a 200–300 ms stall every 250 ms until +3.9 s. The ticks redraw only the primary outline now. **(2) A net focus has no primary part**, so those twelve rebuilds — and the full scene render each one forced — drew nothing at all; the blink is skipped outright when `selection.partIndex === null`. **(3) Each blink phase still forces a full scene render** (pixi has no partial redraw), and on a 39k-trace board one render is ~35 ms on a desktop and several hundred on an A12Z, so twelve of them was the sluggish three seconds after tapping a *part* reference; `lastRenderMs` (always measured around `app.render()` in `onTick`) makes the blink adaptive — above 40 ms a render, it is a single red flash (two renders). Also fixed on the way: `renderCrossSideGhosts()` cleared and re-issued every hidden-side ghost's outline, fill, stroke and every pin fill **on every ticker frame** for as long as a net stayed lit, when the only thing a frame changes is two alphas — it is now baked once per (ghost set, settings) signature into two sibling Graphics (fill / outline, because one Graphics has one alpha) and the pulse sets `alpha`, the A5 net-line crossfade pattern; the outline layer is in every attach/detach/clear site of the fill layer (the `invalidateAllScenes` rule). And `resolvePinColor` is resolved once per (net, side) per pass instead of per pin — 4077 identical rule walks per redraw before. Measured after, PDF tap at 6×: a part tap stalls once (170 ms, the first draw); a net tap stalls through +1.5 s (first draw + focus animation) and is then quiet. **Method note:** the first two probes here were wrong — a one-page PDF clamps pan entirely (read as "blocked"), and a fast synthetic drag arms the inertia and flings the document off the words — and a symmetric assumption ("it's the label overlay") was refuted by inclusive time (`draw @ label-overlay` was 8 ms). Attribute by *caller* (roll native `fill`/`stroke`/`execute` up to the nearest JS frame) and by a settled time window, not by self time. E2E `tests/selection-blink-cost.spec.ts` (6× throttle, needs the proprietary Allegro sample; skips otherwise).
- **A lite page has to ask for updates itself (2026-09-23):** the hosted PWA uses `registerType: 'prompt'` and `UpdatePrompt` turns a *waiting* worker into the "new version — Reload" toast — but `useRegisterSW` registers once and never calls `registration.update()`, and browsers only re-fetch `sw.js` on navigation. A Safari tab on an iPad lives for weeks and an installed home-screen app is resumed, not relaunched, so such a page never navigates, never re-checks, and keeps the worker from its first visit — with the precached shell and hashed assets of that version — indefinitely. Found with an iPad still on **0.40** three releases later while ripperdoc.de served 0.43.2 with `sw.js` at `no-cache`: nothing on the server can reach a page that never asks. `store/sw-update-checks.ts` (`scheduleSwUpdateChecks`) asks on every return to visibility — that *is* the resume on iOS — on `online`, and hourly, never while hidden; `UpdatePrompt` wires it from `onRegisteredSW(url, registration)`. `update()` only fetches and installs; the prompt still owns the swap, so the reason for `'prompt'` (no 404s on pruned chunks mid-session) is untouched. A device already stranded on an old worker still needs one real navigation to pick this up — reload the tab, or fully quit and relaunch the home-screen app; from then on it stays current. Unit `store/sw-update-checks.test.ts`; wiring E2E in `tests/web-lite.spec.ts` (spies `ServiceWorkerRegistration.prototype.update`, flips `visibilityState`).
- **The lite/offline library is a folder, not a server (2026-09-20):** `src/frontend/src/store/folder-library.ts` is a **third producer** for `databankStore._files`, next to the Go backend and Electron IPC — everything downstream (Library panel, Board#/Folders trees, `openLibraryFileById`, the IndexedDB board cache) is source-agnostic and reused unchanged, so the feature is a producer plus a pick control, not a second library. Two APIs, feature-detected by `folderPickMode()`: Chromium's `showDirectoryPicker()` (live `FileSystemDirectoryHandle`, structured-cloneable so it survives a reload in IndexedDB, reads fresh bytes on every open, rescannable) and `<input type="file" webkitdirectory>` everywhere else — Firefox, Safari, **iOS Safari 18.4+**, and `file://`, which is also the fallback when the picker exists but the context is refused (`pickDirectory` throws on a refusal, returns null only on the user's own cancel — a `file://` page is exactly that case). **The index persists separately from the bytes:** both modes store the scanned `DatabankFile[]` + tree in IndexedDB (`boardripper-folder-library`), so the library lists on the next visit either way; an input-mode library comes back **`detached`** — browsable and searchable, opening asks for the folder — which is what keeps it usable on a tablet, where the page is discarded from memory constantly. Two rules inside: **file ids are a hash of the relative path**, never the position in the walk (recent files, worklists and session restore all persist ids; a file added at the front must not renumber the rest — collisions probe forward), and **the scan does not parse boards** (extension sniff + filename board number, like the Electron producer; Board# grouping already falls back to `apple-boards.ts` client-side) — with one exception measured on the corpus: an extension **more than one format claims** (`.brd` is Apple BRD, Allegro *and* EAGLE; also `.bdv`, `.cad`) gets an 8 KB header read through `detectFormat`, because `detectByExtension` answers with whichever format registered first and labelled all 29 `.brd` boards in `samples/` "BDV"; the read falls back to the extension when it fails, so a cloud placeholder loses its label and not its row. Cost: 11 ms of sniffs over 379 files. The Library tab is no longer filtered out of `TABS` in lite, and `LibraryPanel` gates its backend hydration on the new `localLibrary` getter (`electronMode || folderMode`) rather than `electronMode`. Related fix with real reach: **`apiFetch` now returns null without firing when `!hasBackend()`** — `fetchFileDetail` runs on every file click and had no guard, so the first folder-library board open broke the lite build's zero-`/api` rule; failing soft is not the same as not asking. **Permission is not the handle** (2026-09-20, after "I choose a folder and nothing happens"): the handle survives a reload in IndexedDB, the *grant* does not — Chromium revokes it when the last tab of the origin closes, so `queryPermission` → `prompt` on the next visit is the normal case. `requestPermission()` in a **user gesture** brings it back with no second pick, and `fetchFileBuffer` calls it on the first file open precisely because a click is that gesture (path-hashed ids are what let the clicked row survive the rescan). Permanent access is the user's to grant and exists in two forms, both Chromium's: **"Allow on every visit"** in the three-way prompt (shown only for a handle the origin held before, and withdrawn after three dismissals), and **installing the app** — an installed PWA persists permissions with no prompt at all, which the lite build already qualifies for. **What a click after a reload does:** the open itself resolves whatever access is missing — `requestPermission` when a handle is stored, the folder picker when the library came from the input and there is nothing to re-request (the store cannot open a dialog, so the chip lends it one via `setFolderRepickHandler`). The two dead ends must never be confused in the message: an input-mode library can never "reconnect", there is no handle. **Every path out of `pick` settles the pending promise** — a dismissed dialog used to leave the awaiting open hanging for ever, and the input's dismissal only arrives as the native `cancel` event, which React has no handler for. **Whatever the hidden `<input webkitdirectory>` needs goes in its ref callback, never a mount effect:** these components render `null` until they have something to show (the chip exists only once a folder does), so a `[]` effect fires with no element and never again — that left the chip's input without the attribute and opened a *file* dialog where a folder dialog belongs, on every browser with no directory picker (Firefox, Safari, iPad). **A file opened once needs no folder again** (`openFolderFileFromCache`, `store/file-actions.ts`): the parsed-board cache and the PDF byte cache are keyed `name:size:lastModified` and the folder index carries all three, so the open path tries them **before** asking for access — and only while the folder is unreadable, since with access in hand a fresh read cannot serve a stale copy. Rows therefore carry `mod_time_ms` (`mod_time` is seconds; the cache key is `File.lastModified` in ms, and rounding misses on every file whose mtime is not a whole second). This is what stops the folder dialog being a per-run ritual where no handle can be persisted — Safari and the iPad, which have no directory picker at all. **The "upload" wording is the browser's**, and only the `webkitdirectory` input produces it (the directory picker says "Open"), so the empty state pre-empts it in that mode only: nothing is uploaded, it is just the browser's only phrase for "let this page read these files". **Every way a pick can end is now named** (`PickResult`), because the silent ones were indistinguishable from a broken app: a **cancel and a folder the browser refuses outright are the same `AbortError`** (Chromium blocks the home folder, Desktop, Documents and the system tree), so that toast names those folders; a scan that found nothing supported says so instead of leaving an empty panel; a context that may not call the picker (`file://`) falls back to the input *and logs it*. E2E `tests/folder-library-handle.spec.ts` covers the handle path the `webkitdirectory` specs cannot reach, by substituting an **OPFS** directory handle for the picker's (same interface, same structured clone into IDB) and stubbing `queryPermission`/`requestPermission` — the revoked-grant and denied-re-request states cannot be produced on demand any other way. Its harness keeps state in `sessionStorage`, never on `window`: an init script re-runs on every navigation, and a counter held on `window` silently resets mid-test, which is exactly how a broken reload would look fine. **A dropped folder** becomes the library too (`captureDroppedFolder` in `App.tsx`'s `handleDrop`, before the first await — `DataTransferItem`s are neutered the moment the handler yields): `webkitGetAsEntry` is the only synchronous way to know a drop is a directory and the only reader Firefox/Safari have, while Chromium's `getAsFileSystemHandle` is what makes a *dropped* folder persist and rescan like a picked one, so both are taken; the entry walk calls `readEntries` until it answers empty, because it returns a batch of 100 rather than the directory. Before this, a dropped folder produced "Ignored unsupported file". Not ported: board reference DB, PDF FTS, OBD, dedup, sync. Docs: `src/frontend/LITE_BUILD.md` ▸ "The library: a folder on this machine"; origin `docs/research/2026-07-21-lite-capabilities-exploration.md` §1. E2E `tests/web-lite.spec.ts` (drives the `webkitdirectory` input — Chromium's own dialog is not drivable from Playwright), unit `src/store/folder-library.test.ts`.
- **Library sync (v0.16.11+):** `src/backend/librarysync/` pulls a remote WebDAV mirror into a local library mount on a schedule, with diff-then-fetch semantics, sync-error surfacing in the UI, and zero-byte-file skip. Configured via Settings ▸ Library. Scoped log: `log.scan.*`.
- **OpenBoardData (OBD) integration (v0.16.7+):** `src/backend/obd/` parses the public OBDATA_V002 corpus, caches per-board entries at `<dataDir>/obd/` (always writable across container updates; pre-v0.20.3 was rooted at the typically-read-only library mount and silently lost the cache on every restart), uses atomic writes + bpath sandbox, and exposes four HTTP handlers behind `/api/obd/*` with single-flight + drop-guard. Legacy caches at `<libRoot>/.boardripper/openboarddata/` are auto-migrated on first boot via `obd.MigrateLegacyCache` (skipped on cross-volume rename — user re-syncs once). Frontend renders a structured DIAGNOSIS section (collapsible blocks, clickable refs, multi-variant table) in LibraryPanel and surfaces readings in the canvas tooltip + Info pane. Scoped log: `log.obd.*`.
- **PDF text index (backend pdfium/wazero, FTS5):** PDF full-text search is powered by a backend pdfium/wazero indexer that writes a separate `pdfindex.db` (SQLite FTS5 external-content, porter+prefix tokenizer); donors are a `pdf_donors` membership list in `databank.db`. On PDF open, a pdf.js on-open fast-path in `pdf-index-client.ts` (`ensureIndexed`) uploads the client-extracted text so the active file is searchable immediately. The scratch image embeds a ~5 MB `pdfium.wasm` blob; container memory floor is raised to 1 GB. See `docs/PDF_VIEWER.md#pdf-text-index` for full API, state machine, watermark lock-step, and Ctrl-F invariant. **Reworked 2026-10-29** after a review on the maintainer's NAS (15 GB, 1.9 M pages: `10UF 25V` took 96 s, one-letter terms and `/stats` never answered, a row sat `indexing` for two days) — `docs/specs/2026-10-14-pdf-index-search-review.md`. **Search is unranked:** `SearchFiles` walks matches in rowid order in 2,000-row chunks on one connection, bounded by 500 files / 50,000 pages / 10 s (`partial` + reason), donor scope filtered after the FTS5 scan via `json_each` (the `rowid IN (subselect)` was 87 s for 2,000 donors), snippets built in Go for shown files only, terms under 2 letters/digits dropped (`ignored`) or refused (`too_short`), every query on the request context so an aborted search stops on the server. Never reintroduce `ORDER BY rank` — BM25 scores every match before the first row. **Indexer:** `attempts` column (additive ALTER, counted at Claim so an OOM kill counts) — sweeps skip a failed file at 3, an orphan becomes `failed`; deadline 2 min + 1 s/page (max 20 min) and a cut-off document is indexed for the pages read (`partial:` note), the page loop stops on a killed instance; heartbeat every minute; `RequestRun` (not `Run`, a no-op mid-sweep) for everything that makes rows pending, plus a follow-up when the lane is non-empty; a failed extractor refuses sweeps (503, `engine_error`); pool 1 under a 2 GiB cgroup; WAL `journal_size_limit` + checkpoint per sweep; `/stats` pages = `SUM(page_count)` of indexed rows (`COUNT(*)` over pdf_pages took >120 s on the NAS). **Client:** every index call has a timeout and a reason (`indexCall`), the poll is one request pair at a time and sets `pdfIndexState` ok/unavailable, the search stream resolves done/failed/aborted and "No results" is said only after a `done` line, `ensureIndexed` throws on the first failed step and releases its claim on `pagehide`. Never call `/api` without `hasHttpBackend()`. **Storage is the NAS stall's root cause:** on btrfs (Synology) copy-on-write shreds the SQLite file — the 13 GB dev index read at 4.3 MB/s vs 54.6 MB/s for a fresh file, so even stats timed out. New/recreated `pdfindex.db` files get `FS_NOCOW_FL` while empty (`nocow_linux.go`); existing ones are rewritten by **Compact** (`POST /api/pdfindex/compact`, `VACUUM INTO` a no-COW copy + swap, keeps all text, writes refused with `ErrCompacting` meanwhile, Settings ▸ Library). E2E `tests/pdf-search-states.spec.ts` (mocked server), Go `pdfindex/reliability_test.go`, `search_test.go`, measurement `BR_SEARCH_BENCH=1 … TestSearchScaleMeasure`.
- **Library content deduplication:** non-destructive layer that extracts each byte-identical file once and collapses copies in content-oriented views. Detection is size-bucket + sampled hash (`databank/dedup.go`): a unique byte size can't be a duplicate so it's never read (`files.content_hash` stays `NULL`); size-collision files get `sha256(size ‖ full-file if ≤192 KiB else head/mid/tail 64 KiB)`. A content group = files sharing a non-`NULL` `content_hash`; canonical = `MIN(id)`. The on-demand `DedupRunner` ("Find duplicates" in Settings ▸ Database info, `/api/databank/dedup/*`) hashes only size-collision candidates and is idempotent (the scanner clears `content_hash` on size/mod_time change). The PDF indexer marks non-canonical duplicates `status='duplicate'` + `canonical_file_id` and skips extraction — only the canonical is indexed. Folder views (DB + Live) show every file at its real path; Board#/Model + PDF Search collapse via `content_hash` (`contentCollapsePlan` in `databank-store.ts`, `copies[]`). See `docs/PDF_VIEWER.md#content-deduplication`. **Ingest-time dedup (drop-to-library):** `POST /api/upload` receives into a private temp file, then `Scanner.FindIdenticalFile` (`databank/ingest.go`) applies the same size-bucket + `ContentKey` rule against `files WHERE size = ?` (`idx_files_size`), hashing unhashed candidates on the way. A byte-identical library file (any name, any folder) → nothing is written, response `status:"exists"` with the original's `id`/`path`, and the client tags its open tab with that id. A same-name-different-bytes collision at the destination is saved as `name (n).ext` (`status:"renamed"`), never overwritten; same name + same bytes already on disk but unscanned → `exists` after indexing that path. New rows that collided on size get their hash stored at insert (`IndexFile(relPath, contentHash)`). Ingested PDFs go straight to `Indexer.Submit`; `IndexFile`'s update branch fires the pdf-modified hook like the scan worker does. Client side (`store/incoming-upload.ts`) merges only the new rows via `databankStore.mergeFilesById` instead of forcing a full re-stream.
- **Cloud-storage-aware file serving (v0.20.4+):** the two cloud-exposed file-serve handlers (`files.Get` and `files.GetByPath`; `databank.PreviewGet` continues to use `http.ServeFile` since previews live in the always-local `<dataDir>/.previews/`) read files fully into memory via `serveFileEager` (`src/backend/handlers/serve.go`) and verify byte count matches `stat().Size()` before writing the response. Truncated reads from cloud-sync placeholders (Google Drive on macOS File Provider, OneDrive on Windows NTFS reparse points, iCloud, Dropbox) produce a 503 with `Retry-After: 5` instead of corrupt bytes; the 30s read deadline produces 503 with `Retry-After: 10`; an `EDEADLK` on read (Docker bind-mount can't drive host-side materialization) produces 503 with `Retry-After: 60` and an actionable body. Every error response carries `X-Boardripper-Cloud-Error: <code>` (`edeadlk` / `deadline` / `short-read` / `read-error:<errno>` / `open-failed:<errno>` / `not-found` / `is-dir` / `too-large`) so the frontend can switch on the code without parsing free-form bodies. `st_blocks` is logged in every error branch alongside size/elapsed/bytes_read for diagnosis (placeholder signal = `size>0 && blocks==0`) but is NOT used for gating — see commit `4b9c722` for why. Frontend `fetchWithCloudRetry` (`src/frontend/src/store/fetch-with-cloud-retry.ts`) retries up to 6 attempts / 3 min, logs every attempt to `log.cloud`, surfaces a "Downloading from cloud storage…" toast on retry, and on exhaustion uses `formatCloudErrorToast()` which switches on the cloud-error code and incorporates the backend body so the user sees actionable text (e.g. "materialize on host first" for `edeadlk`). Diagnostic endpoint: `GET /api/files/probe?path=<libpath>` runs a 5 s probe read and returns `{size, blocks, blocks_known, placeholder_signal, probe: {ok, bytes_read, errno, error, elapsed_ms, timed_out}}`. Trade-offs: loses range-request and ETag support (no consumer needed them); 512 MiB cap on in-memory reads. Scoped log: `log.cloud.*`.

- **Text fast mode (default ON since the 2026-07-19 release; user graduation decision after hands-on :1234 testing):** board text rendered on a Canvas2D overlay (`renderer/label-overlay.ts`) instead of ~100k in-scene BitmapTexts. When Settings ▸ Rendering ▸ "Board text on an overlay (recommended)" (`renderSettings.textFastMode`; formerly "Text fast mode (experimental)") is on, `buildBoardScene` emits a `LabelModel` (plain records with exact BitmapText x/y/fontSize/color/anchor/bg per label; `renderer/label-model.ts`) INSTEAD of constructing part/pin/circle/two-pin/diode label BitmapTexts (via labels stay BitmapText); `BoardRenderer.syncLabelOverlay` draws visible-only, LoD-gated, anchor-compensated text each dirty frame, with an adaptive CSS-transform motion mode (heavy draws re-project the last bitmap during pans; `overlayContentDirty` forces full draws on content changes). Measured: 11→60 fps at label-heavy zoom (SwiftShader). Deliberate divergence: during ambient-dim with no selection the overlay keeps labels fully lit (more readable than Pixi's dim). BitmapText remains selectable as the fallback path (the same toggle, off) and stays the SettingsMockup path. Adjustable selected-label size floor: Settings ▸ Zoom LoD ▸ "Selected Part Labels" (`selectedLabelMinPx`, default 11px). Related default flips in the same release: `cap60Fps` now false (uncapped), `smoothZoom` true. Plan: `docs/plans/2026-07-19-smoothness-text-overlay-plan.md`. Related fix shipped with it: post-rebuild CullerPlugin re-cull (`cullRefreshFrames`) — a latent pre-existing bug where labels could vanish after any settings rebuild while zoomed.
- **MCP server + live-board bridge (feature/mcp-server-live-board-bridge):** BoardRipper exposes a standards-compliant Streamable-HTTP MCP server (official `github.com/modelcontextprotocol/go-sdk` v1.6.1) at `/api/mcp`, **off by default**, enabled in Settings ▸ Integrations (config keys `mcp_enabled` / `mcp_drive_ui`). Bearer-token gated by a per-install `<dataDir>/.mcp-secret` (mirrors `updater.EnsureSecret`); returns 404 when disabled so it's invisible. Package `src/backend/mcpserver/`. **Backend-native tools** (`pdf_search`, `obd_match`, `obd_data`, `board_resolve`, `file_list`, `file_get`) answer directly against the existing SQLite stores and mirror the matching `/api/*` endpoints exactly. **Live-board tools** (`board_active`, `board_sessions`, `list_nets`, `list_parts`, `net_info`, `net_neighbors`, `pin_connectivity`, `part_info`) + **drive-UI tools** (`highlight_net`, `clear_highlight`, `select_part`, `set_side`, `pdf_goto`) are proxied over a `coder/websocket` bridge (`/api/mcp/bridge`) to the focused browser page, which answers from in-memory `BoardData` (reusing `computeAdjacentNets`) or drives existing stores (`src/frontend/src/store/mcp-bridge.ts`). Bridge picks the most-recently-focused page; every live tool accepts an optional `session` (list via `board_sessions`). Drive-UI tools are always registered but **gated per-call** on `mcp_drive_ui` (so the toggle takes effect without restart) and each emits an info toast + `log.mcp` entry. The SDK requires every tool's output schema to be a JSON **object** — array results must be wrapped (`{files,total}`, `{sessions}`) and live results decode into `map[string]any`. Connect: `claude mcp add --transport http boardripper http://<host>:1336/api/mcp --header "Authorization: Bearer <token>"`. **Per-browser pairing (multi-user separation):** each browser profile mints a stable `client_id` (localStorage) sent in the bridge hello; `POST /api/mcp/pair` mints a per-browser bearer persisted at `<dataDir>/mcp-pairings.json`. `GateAuto` resolves the bearer into a request-context `Scope` (`ScopeFrom`); `Bridge.pick`/`Sessions` enforce it in one place — a paired token sees/drives ONLY its own browser's pages (default target = that browser's focused page), while the install secret is the explicit "Shared (all sessions)" token with install-wide reach (Settings ▸ Integrations presents both). Same-id bridge reconnects preserve `focusedAt` so background windows can't steal the focus default. Spec: `docs/specs/2026-07-20-mcp-per-browser-pairing-design.md`. Sub-project B (in-browser copilot panel consuming these tools) is a separate future spec. Specs: `docs/specs/2026-06-15-mcp-server-live-board-bridge-design.md`, plan `docs/plans/2026-06-15-mcp-server-live-board-bridge-plan.md`. Scoped log: `log.mcp.*`. **Desktop (Electron) opt-in:** a new `mcpEnabled` setting (off by default, `desktop/main.js`, persisted in `settings.json` with a stable `mcpPort`) spawns the exact same backend binary (`CGO_ENABLED=0`, cross-compiled + `lipo`'d to a universal mac binary in `desktop/build-all.mjs`, Windows `bin/win32/server.exe`) as a loopback child process, waits for `/api/health`, seeds `mcp_enabled`, and reloads the window from `http://127.0.0.1:<port>/` — after which the renderer is identical to the web build (`hasBackend()` in `databank-store.ts` gates every IPC-vs-backend fork; `electronMode` stays false so databank/PDF-index/dedup UI renders). Because `src/backend/mcpserver` is never forked, desktop and NAS always expose identical tools. On desktop the backend runs **only** while MCP is enabled (backend-running ⟺ MCP-enabled); enabling also unlocks databank/board-DB/OBD/PDF-search on desktop for free. The bundled backend carries no update ldflags, so the Toolbar update badge + drop-to-update handlers are hidden on Electron. (`handlers/freeBytes` was split into build-tagged unix/windows files so the backend cross-compiles for Windows.) **Desktop E2E:** `npm run test:electron` (`playwright.electron.config.ts`) launches the real app via Playwright's `_electron` against `desktop/webapp` and drives the board; it fails on any failed request that is not `/api/*` (the desktop app runs without a backend unless MCP is on), which is what catches packaging bugs like an absolute asset URL. It needs a built bundle (`npm run build:desktop-webapp`) and skips without one, and it must strip `ELECTRON_RUN_AS_NODE` from the env — VS Code's integrated terminal sets it, and Electron then runs `main.js` as plain Node where `app` is undefined. Spec: `docs/specs/2026-07-15-desktop-mcp-backend-sidecar-design.md`, plan `docs/plans/2026-07-15-desktop-mcp-backend-sidecar-plan.md`.

- **HDR selection outline (experimental, opt-in, default OFF):** on an HDR display the **selected part's outline** — the same shape `drawPartOutline` already strokes — burns **above SDR white** for as long as it stays selected: a "super-selection" that cannot be confused with the thousands of SDR-white neighbours on a dense board. It is the existing selection outline, lit; **not** a halo or glow blob behind the part (an early iteration drew a radial blob — wrong shape, corrected 2026-08-04). Implemented as a DOM layer (`renderer/hdr-selection-outline.ts`, `zIndex:3`, a `containerEl` sibling above the label overlay): one thin rotated `<div>` per polygon edge, each stretching a solid PQ-encoded AVIF tile, which is how an *image*-only technique can draw an arbitrary polygon outline (OBB, AABB, or a 24-gon for single-pin parts — mirroring `drawPartOutline`'s shape choice exactly so the HDR line sits ON the SDR one). Thickness matches the SDR primary stroke (`selectionWidth * 1.7`, 2px floor). **Not** the WebGPU `rgba16float` route: WebGL has no shipped HDR path, Canvas2D is 8-bit, Pixi clamps vertex colours to 8-bit and hardcodes `bgra8unorm`, and WebGPU-HDR is Chromium-only. HDR *images* are the only technique honoured by both Chrome and Safari — the cost is that all HDR geometry must be composed of stretched image tiles (straight edges, solid fills); no per-pixel shading, gradients, or HDR text. PNG `cICP` was rejected: Safari does not map it to EDR. **Measured on real hardware via `/hdr-probe.html`, not inferred from specs (which were wrong twice):** a PQ sprite keeps its headroom composited over a WebGL canvas ✓, but **opacity compositing flattens HDR back to SDR** ✗ — so brightness is NEVER an alpha, and equally never a CSS mask or `filter: brightness()`. Instead `scripts/make-hdr-glow.ts` (maintainer tool; `avifenc` is absent from CI and the image, so the assets are committed) bakes a **24-rung luminance ladder** of solid tiles `hdr-line-<0..23>.avif`, 4000→200 nits, ~0.19 stops apart, ~7 KB total; `hdrGlowIntensity` (1–10) selects a rung via `rungForIntensity()`. Tile URLs go through `hdrTileUrl()` (`import.meta.env.BASE_URL`), never a literal `/hdr-line-N.avif` — the desktop app loads the bundle from `file://` and the lite build is served under `/boardripper/web/`, where an absolute path leaves the bundle and the outline silently draws nothing (found 2026-09-08 by `tests/electron-smoke.spec.ts`, which fails on any non-`/api` request failure). Gated on `renderSettings.hdrFocusGlow` **and** live `matchMedia('(dynamic-range: high)')`, so undocking to an SDR monitor tears the layer down on the next frame. Reads `boardStore.selection` directly rather than a parallel focus-target field — **the feature makes zero changes to `board-store.ts` and is purely additive, so it cannot regress existing selection**; the SDR highlight is untouched, which is the entire SDR fallback story. Focus target only (never net members, never all hits — headroom is a shared budget that collapses under large lit areas). `focusHaloGeometry()` (`renderer/focus-halo.ts`) remains the dark spotlight's shared sizing rule, extracted in the same work. Switches: Settings ▸ Selection & Highlight (stays visible-but-hinted on SDR) and the start page card under Interface scale (always shown; the readout is the detection result and a two-board mockup draws the outline with the real PQ tiles at the slider's rung, so the user sees whether this screen has headroom). The earlier "Your monitor supports HDR / Don't show again" discovery prompt was retired 2026-09-05. Known open risk: a *steady* bright source is what makes macOS re-tone-map — if surrounding UI dims, lower the rung. Spec: `docs/specs/2026-08-04-hdr-focus-glow-design.md`, plan `docs/plans/2026-08-04-hdr-focus-glow.md`.
- **No fingers ⇒ the drag plugin is live (2026-09-23):** the board's gesture ownership paused pixi-viewport's `drag` plugin when WebKit's stream claimed a gesture, and resumed it only in `releaseGesture`, which bails unless the owner is still `'gesture'`. When the second finger's `pointerdown` arrived *after* the claim — the everyday iPadOS order — `seedPinch` took the owner over and the plugin stayed paused for good: from then on one finger could not pan at all, two still could, because `installTouchPinch` pans by itself. That was "after a few zooms, pan only works with two fingers". `endGestureOwnership` now runs on the last finger leaving **whoever owned the gesture**, and resumes the plugin unconditionally (a no-op when it was never paused). A pointer takeover from a `'gesture'` claim only stops the lapse timer — a paused drag is right while two fingers are down. Regression `tests/touch-pinch-zoom.spec.ts` ▸ "one finger still pans after a gesture the engine claimed first", with real touches, because it is the plugin that has to move the board afterwards.
- **Every PDF touch targets one element: `.pdf-touch-surface` (2026-09-22):** on the iPad a drag or pinch that started over page 1 worked and one that started over page 2 did nothing — and the *only* difference between them was the touch target. Page 1 is the main `<canvas>`; page 2 is a `pointer-events: none` adjacent canvas that falls through to `.pdf-canvas-container` itself, an `overflow: hidden` scroll container that also carries the UI-scale `zoom`; in tiled mode the main canvas is `visibility: hidden`, so there *everything* falls through. Chromium treats the two targets alike (verified with real CDP touches: both pan, both zoom), which is why no fixture ever showed it, and what iOS does with a scroll-container target I cannot observe from here — so the fix makes the question moot rather than answering it: a plain, persistent, untransformed `<div>` (`z-index: 5`, above the wrapper's `contain` stacking context, below the scrubber at 40) with `touch-action: none` **on the target itself** now carries every pointer handler, and nothing under a finger is ever added, removed, hidden or transformed. Pointer capture goes to that surface, not to an ancestor. `onPointerLeave` now ignores touch pointers — a captured touch cannot leave, so a touch "leave" is only ever spurious and killing a gesture on it is the failure this layer exists to prevent. The container keeps its `touch-action: none` (belt and braces; `touch-compat.spec.ts` asserts it) and its wheel/gesture listeners, which the surface's events bubble to. **Tests dispatch synthetic pointers at the surface, never at the container** — an event dispatched on a parent does not reach a child's handlers. `tests/pdf-touch.spec.ts` ▸ "targets the touch surface" pins the invariant (elementFromPoint at three heights), and ▸ "real touches over page 2" drives the scenario through Chromium's own hit-testing.
- **One stream owns a gesture, and the hand-off runs both ways (2026-09-21):** iPadOS delivers a two-finger pinch as **both** a pointer stream and WebKit's `gesture*` stream, and when the engine claims the gesture for its own recogniser it may never dispatch the second finger's `pointerdown` at all. `activeTouchesRef.size` then stays **1** — so the gesture path zoomed about the two-finger midpoint while the single-finger *drag* path panned along finger one, from the same gesture, on interleaved events. That is "zooms sometimes properly, sometimes around the edge": the visible anchor is a mixture of the two. The stand-down existed in one direction only — the gesture path deferred to fingers, nothing ever told the pointer path a gesture had begun. Both viewers now name the owner (`gestureOwnerRef` / `BoardRenderer.gestureOwner`): a **complete pointer pair wins** (it knows both finger positions, so it can anchor on the real midpoint), a claim made with fewer fingers takes the gesture and **cancels whatever the registered finger had started** — on the board that means pausing pixi-viewport's `drag` plugin, because the pan is not ours to stop otherwise — and a 400 ms lapse timer stands in for the `gesturecancel` WebKit does not fire. A finger still down when the gesture ends goes back to scrolling rather than waiting to be lifted and re-placed. The board's old test was `size > 0`, so a *single* finger also silenced its zoom entirely: that gesture panned and never zoomed. Both gesture paths also froze their anchor at `gesturestart`, the same bug the pointer pinch had — on iPadOS that anchor is the midpoint between two moving fingers, so it is stale from the second event on. Measured against the old code: a gesture claimed with one finger registered drifted the content **192 px** from a stationary anchor. E2E `tests/pdf-touch.spec.ts` ▸ "a gesture claimed while one finger is registered", `tests/touch-webkit.webkit.spec.ts` ▸ "a gesture claimed with one finger down".
- **Inertia had never run once, on any platform (2026-09-21):** the deceleration loop decayed `velocityRef` while `velocityRef.current = {x:0,y:0}` ran **synchronously below it**, before the first `requestAnimationFrame` callback could fire — so the first frame always failed the `< 0.2` test and returned. The velocity is a local now. Nobody had noticed because with a mouse releasing a drag is *meant* to stop dead; on a touch screen a flick that goes nowhere is most of what "scrolling is broken" means. Measured after: a gentle flick carries three pages, a hard one reaches the end of a 16-page document. It also runs through `flipPagesForPan`, as the keyboard pan now does — a fling that does not turn pages glides past the next boundary onto blank paper, which is why fixing the *drag* alone fixed almost nothing. The regression asserts the page advances **after the finger is up**; checking ink alone passes against a dead inertia, since the drag already flips correctly.
- **Two-finger rotation, "unlocked" (2026-09-28):** the renderer already took any angle — `applyFlips` writes `scene.root.rotation = rotation·π/180` as a continuous radian, the label overlay projects through the world matrix and draws text upright, hit-testing goes through `worldToScene` — so a free angle costs nothing there; what assumed 90° steps was the *store* (`rotateCW/CCW` did `+90 % 360`) and a few nearest-90 readers (`rot90Parity` for the pin-label stagger, the mirror-axis swap, `flipAxisForRotation`), which all round and are therefore right at every committed right angle and approximate at a free one. **The gesture never writes the store per move:** a store rotation runs the whole `onBoardUpdate` — `applyFlips`, net-line re-projection, cross-side ghosts, a full `renderSelection` (the `lastRenderedSel` key includes `rotation`) — which with a lit 4000-pin net is the 5–10 s stall of 2026-09-23, per finger movement. `BoardRenderer.rotateLive` turns `scene.root` (and `butterflyRoot`) directly, about the world point under the finger midpoint — `position' = P + R(Δ)·(position − P)`, `rotation += Δ`; screen-space finger angles and Pixi's rotation are both CW-positive in a y-down frame, no sign change — and `commitLiveRotation` hands the angle to `boardStore.setRotation` when the fingers lift, snapped to a right angle within `renderSettings.rotateSnapDeg` (10; Settings ▸ Input ▸ Navigation ▸ Rotation snap window, 0 = never). The commit's `applyFlips` rebuilds the root about the board centre and the existing rotation re-centre pivots about the *screen* centre, so the commit records the board-local point under the midpoint first and pans the viewport afterwards until `root.toGlobal(local)` is back under the fingers — that is what makes the snap turn about the fingers too. Both streams feed it: the pointer pair (`seedPinch` records `startAngle` and `rotBaseRad`, the rotation the roots already carry, so a third finger or a replaced pointer re-seeds without a jump; the angle delta is wrapped with `atan2(sin, cos)` so a finger crossing the axis is not a full turn) and WebKit's gesture stream, whose `GestureEvent.rotation` is cumulative degrees, **clockwise positive** — the first cut negated it on the strength of a doc sentence ("clockwise is negative"), but Apple's own sample in the Safari Web Content Guide applies `rotate(event.rotation + "deg")` and the element follows the fingers, and CSS `rotate()` is CW-positive. The field symptom of the wrong sign was "often rotates inverse to the fingers, when combined with movement" (2026-09-28): which stream owns a pinch on iPadOS depends on whether the engine claims it before the second `pointerdown` arrives, which it does when the fingers move while landing, so the sign was wrong on exactly those gestures and right on the rest. **Every gesture leaves one Debug-log line** (`touch: rotation committed … stream=pointer|gesture, raw=…°, board turned …° (CW+)`, and a "turn ended without rotating" line for a turn that stayed locked or under the dead zone): raw and applied disagreeing in sign is the sign bug, `stream` says which path to look at — read it off the device before theorising. **Rotation starts locked** (`store/rotation-lock-store.ts`, session state, one for the app): locked, a turn under `renderSettings.rotateUnlockDeg` (24 since 2026-10-07, was 12 — zooming pinches unlocked too easily; a one-time migration moves an untouched 12 to 24; ▸ Rotation unlock angle) does nothing — every pinch turns a little — and a turn past it unlocks with a toast and turns the board on *from there* (`liveRotate.baseRad`: the 24° were the unlock gesture, not rotation to catch up on, so the first 45° gesture leaves the board at ~21°, and exactly where depends on which move event crossed 24°); unlocked, a 2° dead zone (`ROTATE_DEAD_ZONE_DEG`) stops a pinch from drifting a free angle. A **two-finger double-tap** toggles the lock (two taps of ≤ 350 ms within 450 ms, no finger travelling past the tap tolerance — `touchDownPos`/`twoFingerMaxTravel`/`twoFingerDownAt`, tested in `boundPointerRelease` once every finger is up); locking snaps to the nearest right angle about the screen centre, as the buttons do, and both directions toast. Asked for 2026-09-28 after the first cut rotated from a 6° dead zone: "locked till 10–15°, then a message that rotation is unlocked; lock back with a two-finger double-tap". **`setRotation` leaves `flipAxis` alone:** the buttons' hinge rule swaps which board axis carries the bottom view's mirror at every parity change to keep the hinge fixed on the *screen*, and the two hinges (`scale.y=-1` vs `scale.x=-1`) differ by exactly 180° — applied to a free angle it turned the bottom view over on the release that crossed 45°/135° ("rotation lost, 180° flip"); a screen hinge is representable only at right angles, so the fingers' hinge turns with the board, and `buttonFlipAxis` applies the rule only when the button starts from a right angle. `nextRightAngle` gives the buttons their step from a free angle (45° → CW 90, CCW 0). **Butterfly at a free angle** (2026-09-28, "butterfly positioning broken on a rotated board"): `applyFlips` laid the halves out *per angle* — each turned about its own centre, separated along a screen axis, the bottom mirrored across a board axis — which is a mirror-across-the-seam only at right angles, so a commit after the live (rigid) turn re-laid the halves out wrong. Now the spread is laid out at `butterflyBaseDeg`, a right angle, and turned rigidly by the residual about the common centre (the shift `(dx,dy)` rotated by the residual; each root's own rotation stays base + residual = θ, so the label counter-rotation is untouched), identical at right angles and equal to what the live gesture showed. **The base is kept across gesture commits** — re-picking the nearest right angle at every commit swapped the separation axis and the hinge at 45° — and re-chosen only when Butterfly comes on or a right angle is reached from a right angle (a button press). Everything in the branch that asks "are the axes swapped" (`mirrorBoardX`, the mirrorX/Y swap) reads the *base*, not the nearest right angle: at 72° from base 0 the nearest is 90, and reading it flipped the bottom half's mirror onto the other axis with the positions still right. Spec: the bottom half's transform in the top half's frame (`root⁻¹·broot`) is invariant under a rigid turn and changes under a re-layout — `tests/touch-rotate.spec.ts` ▸ Butterfly asserts it across two commits, the second past 45°. Setting `twoFingerRotate` (Input ▸ Navigation, default on). E2E `tests/touch-rotate.spec.ts` (CDP touches: locked/unlock + toast, double-tap lock + square, single taps do not toggle, live follow, commit + anchor held within 4 px, snap, no rotation from a wobbling pinch, button stepping, the bottom-side screen-turn measurement across 45°/135°, the setting) — its reads around the unlock allow one CDP step of slack (the unlock fires on the first move *event* past 12°, and each step arrives as two pointer moves, one per finger), a turn meant to survive the release must leave more than 10° after the unlock or the snap squares it back, and every mid-gesture read **polls**, because Chromium delivers touch moves frame-aligned and a one-shot read after the dispatch resolves can be a step early; the gesture stream is covered in `tests/touch-webkit.webkit.spec.ts` with 1° steps so the unlock arithmetic is exact.
- **A 90° turn does not rebuild the scene (2026-10-05):** the only thing in a scene that depends on the turn is the BGA-alternating pin-number / net-name stagger (which board axis reads as a screen row, so the column parity, offset axis and sign). It used to cost a debounced full `invalidateAllScenes` + rebuild 140 ms after every 0/180 ↔ 90/270 turn — the 1–2 s freeze on a dense board. In text fast mode (the default) `buildBoardScene` stores both placements on each staggered `LabelRecord` (`rot: RotLayout`), and `BoardRenderer.syncLabelRotation` moves them in place (`applyRotationLayout`, keyed per model in a WeakMap; also run at the start of every overlay sync, which covers a cached scene shown after a turn). The BitmapText path still rebuilds. Unit `renderer/pin-label-stagger.test.ts` proves switched-in-place equals built-at-that-angle for 0/90/180/270, including 90↔270, which the parity-only rebuild never handled. **Space tap vs hold is timed with `KeyboardEvent.timeStamp`**, never handler time: the first flip after a render blocks inside the keydown handler and the keyup waits behind it, which read a quick tap as a 500 ms+ hold and peeked back. Threshold 500 ms. E2E `tests/space-flip-tap.spec.ts` (CDP key events with explicit timestamps, an 800 ms flip).
- **A board loading in the background says so, before the freeze (2026-09-28):** parsing runs in a worker and costs the main thread nothing; what follows — mounting the new tab's renderer and `buildBoardScene` — blocks it for as long as it takes (measured at 4× CPU throttle: ~170 ms mount + ~270 ms build for `starfish.kicad_pcb`; seconds for a large Allegro board on an A12Z), and a finger on the glass gets no answer meanwhile. That read as "touch is blocked" with nothing on screen to explain it. `boardStore.backgroundLoads` (`{count, names}`, a stable snapshot) is the union of `_loading` (parse in flight) and `_sceneBuilding` (tabId → file name: set by `loadFile`/`loadFromCache` **before** `onTabCreated`, cleared by `activateScene`'s new-scene path, by `closeTab`, and for every *other* tab in `switchTab` — a tab hidden before its scene was built is not blocking anyone and is re-marked when shown). `components/BackgroundLoadBanner.tsx` renders it (`data-testid="background-load-banner"`, `pointer-events:none`). **It is mounted in `App.tsx` over the dock area, never inside `BoardViewerPanel`:** Dockview drops an inactive panel's DOM, so a banner in the panel you are looking at disappears at the very moment the new tab mounts and builds — the block it exists to explain. Also **`BoardRenderer.destroy()` must not clear the mark** — under StrictMode the throw-away first renderer would take the banner down mid-mount. `onBoardUpdate` additionally defers any activation that needs a scene build (`sceneBuildDeferral`): it marks the tab, returns, and re-runs itself after `requestAnimationFrame` + `setTimeout 0` — a committed *and painted* frame — so a build the store did not mark (fold-mode / board-pack selection, which change `boardStore.board` and miss the scene cache) also starts with the banner on screen; a notify arriving while the frame is pending is folded into the deferred run. E2E `tests/background-load-banner.spec.ts` records `longtask` entries and banner transitions at 4× throttle and asserts that **no task blocks the main thread with the banner absent at both its start and its end** (a task may end by inserting it or start by removing it — MutationObserver timestamps are the mutating task's end). Two-way: it fails when the banner never renders and when the marks are never cleared. The one uncovered stretch left is the ~150 ms (throttled) React render that inserts the banner itself — the app-wide re-render every store `notify` costs, not this feature's. Same commit: the "gpu idle" HUD readout was removed — it read `needsRender`, which the dirty-flag loop clears every frame, so it was never true.
- **Panning with a finger turns pages; it used not to (2026-09-19):** `flipPagesForPan` is the one implementation of the continuous-mode page flip, called by both the wheel handler and the touch-drag path. It lived inline in the wheel handler only, so a finger could pan past the bottom of the current page for ever — the pan kept growing, the page indicator never moved, and since only `currentPage ± 1` is ever rendered, everything past the neighbour was **blank paper**. Measured on the 16-page fixture before the fix: fourteen swipes, pan at −11648 px, page still 1. It **loops**: one wheel notch crosses at most one boundary, but a swipe is most of a screen and a fling is several, so flipping once per gesture would leave the same hole one page further down (the first swipe of the repro crosses two). Bounded by the page count so a degenerate page height cannot spin it. E2E `tests/pdf-touch.spec.ts` ▸ "swiping through a long document": it samples the rendered canvas for **ink**, not just the page number — a viewer drawing a white rectangle with the right label underneath would pass a counter-only assertion, which is exactly the failure being tested. The page assertion is `> 2`, because a stuck counter sits at 1 and 1 ± 1 is the whole set of pages such a viewer can draw; how much further ten swipes get depends on the panel's height. **Fixture:** `tests/fixtures/many-page-text.pdf` (16 pages) joins the 2-page one, from the same generator — a paging bug cannot show itself inside a document whose every page is already in the rendered window.
- **A pinch must keep BOTH fingers on the content they started on (2026-09-19):** the correct update is `pan₁ = mid₀ - ratio·(mid₀ - pan₀) + (mid₁ - mid₀)` — scale about the **previous** midpoint, then translate by the new one's travel. Two mistakes hid behind the same symmetric test. The PDF froze its midpoint at the second `pointerdown`, which holds only while the two fingers' movements cancel exactly — `mid = (f1 + f2) / 2` is constant iff `Δf1 = -Δf2`. Moving both fingers at once is ordinary; moving them at matched speed and distance about a stationary centre is a knife edge, and anything off it (one finger faster, the hand drifting, one finger resting) moves the midpoint by half the difference and leaves the anchor stale by that much. Measured at the extreme of it — one finger parked — the page slid **140 px away from the finger that never moved**. And once the midpoint tracked, anchoring the scale on the *new* midpoint double-counted its travel by `(1 - ratio)` per event, leaving 24 px of drift across one gesture; `BoardRenderer.installTouchPinch` had that second bug too. Measured after: 0.0 px drift, anchored and symmetric alike, in both engines. A pinch that also **rotates** the fingers keeps a residual (−9 px at 4.3×) and that is geometry, not a bug — a scale+translate transform cannot hold two rotating points, and neither viewer rotates on pinch by design. Regression `tests/pdf-touch.spec.ts` ▸ "keeps the content under the fingers": it parks a finger because that is the **largest** midpoint drift for a given spread — putting the error where a 2 px tolerance can see it — not because it is the typical gesture. The perfectly symmetric pinch is the one case a frozen midpoint survives, which is how this went unnoticed.
- **What a WebKit review can and cannot settle (2026-09-19):** three iPad PDF reports were investigated with the WebKit fixture — pinch not zooming at the finger, adjacent pages at a different scale, panning that "moves a bit and stops". Only the first reproduced, and it was ours, not the engine's. The other two did not: Chromium and Playwright's WebKit are **byte-identical** through load → pinch → tier commit → pan (same transform strings, same canvas backing sizes, `adjacent/main` width ratio 1.0000 across 26 fine zoom steps and past the tier cap), pan tracks the finger 1:1 at `--ui-scale` 0.8/1/1.25, and `clientX - getBoundingClientRect().left` agrees between the engines even inside the counter-zoomed container — so the CSS-`zoom` coordinate-space difference that would have explained all three is **not present**. What the fixture cannot reach is the real iOS touch pipeline: Playwright's WebKit is the Mac port, reports `maxTouchPoints: 0`, and injects no multi-touch, so every pointer stream in these specs is synthetic and nothing can observe iPadOS cancelling a pointer when its own recogniser claims a gesture. The remaining two therefore get **diagnostics instead of a guess**: `log.pdf` now reports every `pointercancel` / `lostpointercapture` with the count still down, and one "pan clamped" line per gesture naming the axis, the zoom and the page-vs-container sizes — because clamping is also the *correct* behaviour when the page already fits, and only the device can say which one is happening. One latent hazard was fixed on the way: the adjacent-page "already at the right tier" branch repositioned the canvas without resizing it, so a **saturated** tier (the `medium` preset caps at 6, and a tablet defaults to `medium`) would hold the neighbour at the scale it had while the main page kept zooming.
- **Desktop Chrome is not allowed to pay for the touch work (2026-09-19):** verified by measuring a `main` worktree and HEAD side by side, not by reasoning about the diff. Board zoom-per-wheel-notch is byte-identical (bare 1.000, shift 1.500, ctrl 2.417, trackpad-sized ctrl 1.034); wheel-zoom fps is equal or better (median 120 both, min 58 → 60); render resolution and MSAA are untouched (`coarsePointer:false`, no `GestureEvent`, `maxTouchPoints:0` ⇒ `isTouchPrimary()` false ⇒ `touchPerformanceMode` inert); mouse hover and stacked-selection cycling are unchanged. Mouse clicking is *measurably better*: 4/4 at 0–4 px of travel against a near-random 0–1/4 on main, because the stale click-cycle was making the same spot unclickable — the drag boundary stays at ~5 px. The one intended desktop change is the PDF's `PINCH_STEP_CAP`: one Ctrl+mouse-wheel notch now zooms ~2.46× instead of ~3.3×, matching the board's 2.417× for the same gesture, and render quality at a given zoom is identical (both 5788×8192 backing at 1000 %).
- **A gesturing finger is not hovering (2026-09-19):** `boundHover` ignores touch pointers unless `touchIsResting()` — one pointer, travel within the tap tolerance — and `clearHoverState()` drops the tooltip, hover pin and hover net the moment a second finger lands. Hover runs `hitTest` **and** `traceHitTest` per pointermove, and both pointers of a pinch deliver moves: measured on a 3369-part / 39319-trace Allegro board, one pinch fired **90 hover hit-tests** and ran at a **median of 30 fps (min 20)**; with the gate, median 60 (min 30). Pan went to a flat 60. It is also wrong on its own terms — there is no cursor to follow, the tooltip sits under the fingers, and whatever the resting finger lit stays lit for the whole gesture, which matters because a lit net is redrawn on every zoom frame. **What must survive is the resting finger:** reading a pin's net and diode value is the main reason to touch a pin at all, so the gate is the same test `handleClick` uses for a tap and `pointerTravelPx` is a running maximum — once a gesture has moved, hover stays off for the rest of it instead of flickering back on when the finger pauses. The PDF viewer has no equivalent: `hitTestWord` runs on click, never on move. E2E `tests/touch-hover.spec.ts` asserts both directions; its pinch case parks one finger on a pin found by sweep, because two fingers flying apart leave the pin field within a few moves and the test would otherwise pass whether hover ran or not.
- **Pinch must be a function of finger distance, not of event count (2026-09-18):** `BoardRenderer.installTouchPinch` owns the two-finger gesture; pixi-viewport's pinch plugin stays registered as `pinch({ percent: 0, noDrag: true })` — an inert placeholder — because the *drag* plugin stands down only while a pinch plugin exists (`checkButtons`), and `plugins.get('pinch', true)` returns null for a **paused** one, so pausing it would hand two-finger gestures back to drag. The plugin's formula cannot be tuned into correctness: it adds `(1 - old/new) * percent * scale` per pointermove, a first-order approximation of a multiplicative zoom, so the total depends on how many move events the browser delivered. Measured on `tomu-fpga`: the same 4× finger spread came out as **1.8× zoom over three move events and 2.7× over eight**, and a pinch in followed by a pinch out drifted instead of returning — that is the "inconsistent, sometimes jumps" report, and `percent: 1` fixed only the magnitude. The replacement is `startScale * (dist / startDist)` anchored on the midpoint (`setScaleAtScreen`, absolute target scale — never an accumulated delta, which is the accumulation that went wrong), with the midpoint's own travel carrying the pan; `seedPinch()` re-seeds on every membership change because the baseline describes a *pair*. Measured after: 2.007× at 3, 8, 24 and 60 move events alike, and in-then-out returns exactly. E2E `tests/touch-pinch-zoom.spec.ts` asserts all four properties — rate-independence, reversibility, pan tracking, and anchor stability (the last two by tapping where the part must now be, which fails on a wrong anchor).
- **Two thresholds decide what a tap is, and they must be the same number (2026-09-18):** pixi-viewport suppresses its own `clicked` past `viewport.threshold` (a plain public field, default 5), so `handleClick`'s tolerance is never consulted unless that field agrees — the renderer sets `viewport.threshold` per gesture from the pointer type, 12 px for a finger against 5 for a mouse. `pointerTravelPx` is a **Euclidean** distance, not the Manhattan sum it used to be: summing the axes made a diagonal tap count as ~1.4× its real travel and rejected taps both thresholds considered stationary. Measured after: touch taps select up to 11 px of travel and read as drags from 13 px, the mouse boundary stays at 5 px.
- **The click-cycle is a claim about the current selection (2026-09-18):** `clickCycle` carries `selectedPartIndex`, and a click only counts as "same spot, advance the stack" while the store's selection is still what the cycle put there. Without that test, clearing the selection from anywhere but the board — a part picked in the Net List, an MCP `select_part`, a programmatic deselect — left the cycle pointing at the same spot, and a click there took the same-spot branch, which on a stack of one **does nothing at all**. Measured: the part stayed dead click after click (0/5, mouse and touch alike) until the user clicked somewhere else first. Regression in `tests/touch-pinch-selection.spec.ts`.
- **One ctrlKey wheel event is not always a trackpad pinch (2026-09-18):** the PDF viewer read `ctrlKey` as "trackpad pinch" and applied `TRACKPAD_PINCH_SPEED` (0.01) to the raw `deltaY`. Ctrl+wheel on a mouse is the browser's standard zoom gesture and delivers a deltaY two orders of magnitude larger: one notch took the page from **100 % to 739 %**. `PINCH_STEP_CAP` (90) bounds a single event's step without guessing the device — every real pinch event is far below it — and lands a mouse notch at about the same ~2.4× the board gives for Ctrl+wheel, which is the deliberate "Ctrl = coarse zoom" behaviour. `looksLikeMouseWheel` cannot help here: it hard-returns false for `ctrlKey`.
- **The E2E suite does not pass on a bare checkout, and that is not news (2026-09-18):** measured at released `main`, with the Go backend up and `samples/` present, **291 of 681 specs fail** — parser specs wanting proprietary boards, library and donor specs wanting a scanned library, `web-lite` wanting a built lite dist. So a full-suite pass/fail count proves nothing about a change; compare the failing **set** against a baseline instead (a `git worktree` of `main` with `node_modules` and `samples/` symlinked in, on a second `VITE_PORT`). Two traps found doing it: the worktree has no `samples/` unless you link it (36 ENOENT failures and a long tail downstream), and a `vite` dev server left running across a day of HMR reloads stalls the databank file stream — `library-persistent-state.spec.ts` then times out on `filesComplete` against a perfectly good build. Restart the dev server before believing a library or databank failure.
- **Testing touch and WebKit (2026-09-18):** `npm run test:webkit` runs the `webkit-ipad` Playwright project — WebKit with the `iPad Pro 11` descriptor — and is opt-in because the browser is a separate download CI does not have; the default run stays Chromium-only and ignores `*.webkit.spec.ts`. It matches those files **plus `pdf-touch.spec.ts`**, whose synthetic pointer events are engine-agnostic and worth running on the engine that actually ships on iPadOS. What it proves: the engine facts (WebKit really does expose `GestureEvent` — `tests/touch-webkit.webkit.spec.ts` asserts it, since the whole stand-down rule rests on it) and our own dispatch. What it does not: it is WebKit's **Mac** port, so there is no tile GPU, no iOS canvas-memory ceiling and no 120 Hz panel — correctness reproduces, performance does not; and it reports `maxTouchPoints: 0` even with `hasTouch`, so the WebKit branch of `isTouchPrimary()` is not exercised there. Three mechanics worth knowing before writing one of these: a real `GestureEvent` cannot be synthesised (the constructor returns something `dispatchEvent` rejects, `document.createEvent('GestureEvent')` throws `NotSupportedError`) so the specs dispatch a `MouseEvent` named `gesturestart` carrying a `scale` property, which is every field the handlers read; multi-touch injection exists only in Chromium, through CDP `Input.dispatchTouchEvent` (`tests/touch-pinch-selection.spec.ts`); and the two engines serialise transforms differently — Chromium writes `translate(0px, 0px)`, WebKit drops a zero y and writes `translate(0px)` — so a spec must compare parsed numbers, never the string. **Fixture:** `tests/fixtures/two-page-text.pdf`, generated by `make-test-pdf.mjs` beside it (hand-written PDF, no library, no licence question). Every board PDF in this project is proprietary and gitignored, so every PDF spec skipped on a clean checkout — which is how the suite stayed green while the viewer was unusable on a tablet. `pdf-rotation.spec.ts` now falls back to it too, and runs for the first time.
- **Touch screens (2026-09-18):** three separate failures, one root cause each, all invisible on a desktop browser. **(a) A pinch is not a tap.** pixi-viewport's `InputManager` routes `pointercancel` straight into `up()`, indistinguishably from a release — and iPadOS hands a two-finger gesture to its own recogniser freely, cancelling the first pointer *before* the second finger's `pointerdown` ever reaches the page. `up()` then finds no other pointer registered and no travel, and emits `clicked` in the middle of the pinch. No distance test catches it. So `BoardRenderer` keeps its own record of the gesture — `gestureWasMultiTouch` (set the moment a second finger lands, cleared only when the *next* gesture starts, because a pinch releases one finger at a time and the stale click can come out of either release) and `gestureWasCancelled` — and `handleClick` rejects on either. Travel is anchored to where the **first** finger landed, never re-anchored by the second, and the click/drag threshold is 12 px for a finger against 5 px for a mouse (a contact patch wanders 8–10 px on a tap a user would call stationary; pixi-viewport's own 5 px threshold still gates every gesture whose moves it saw, so relaxing ours cannot make the board more click-happy). The same rule as the drag-zoom case: **never infer click-vs-drag from what pixi-viewport reports.** Regression: `tests/touch-pinch-selection.spec.ts` drives real multi-touch through CDP; the load-bearing case is a lone `touchStart` + `touchCancel`, which is the iPadOS shape and the only one that fails without the guard. **(b) iPadOS fires the WebKit `gesture*` events for a two-finger TOUCH pinch, not only for a trackpad one.** Both the board and the PDF viewer had gesture handlers written for a Mac trackpad running *alongside* their pointer-event pinch — two zoom integrators, each anchored to its own start snapshot, writing the same transform on alternating events. That is the "jumping". Both now stand down whenever any touch pointer is on the glass (`activeTouchIds` / `activeTouchesRef`), and the window-level block in `browser-zoom-block.ts` still `preventDefault`s so the page never zooms. **(c) Pinch must be 1:1 with the fingers.** `pinch({ percent: 2 })` doubled pixi-viewport's per-event `(1 - old/new) * percent * scale`, which is a first-order approximation of a multiplicative zoom only at `percent: 1`; the PDF viewer had the identical `TOUCH_PINCH_FACTOR = 2`. Both are 1. Every browser on iPadOS is a WKWebView — Chrome included — so all three are engine bugs, not Safari bugs, and one fix covers both. **PDF, three more of the same kind, all found by driving the panel rather than by reading it:** (d) `container.setPointerCapture(e.pointerId)` ran unguarded at the top of `handlePointerDown` and **throws `NotFoundError` when the pointer is no longer active** — which happens for real, because iOS cancels a touch between the event being queued and the handler running. The throw aborted the rest of the handler, so the finger was never entered in `activeTouchesRef` while its eventual `pointerup` still ran the `delete`; with two fingers down and one registered the viewer takes the *drag* branch and pans off one finger in the middle of a pinch. Capture is an optimisation there, the bookkeeping is not, so it is now in a `try`. (e) The pinch branch tested `size === 2`, so a third finger resting on the glass — easy while holding a tablet — matched neither branch and the page stopped responding until every finger came off; it is `>= 2`, and `seedPinch()` re-seeds the baseline on **every** membership change, because the recorded start distance describes a *pair*, not a count, and a changed pair computes a ratio between two unrelated distances. (f) A pointer whose capture is taken back without a `pointerup` (iOS does this on a system interrupt) stayed in the map for good, wedging the viewer into a permanent pinch with no pan: `onLostPointerCapture` now drops it and `pointerdown` with `isPrimary` clears the map, the same self-heal the board uses. Also: a tap now performs the word lookup **on touch** — it was excluded outright, which left the viewer's one real interaction unreachable on a tablet; `wasDrag` plus the single-pointer test is what makes that safe, and `pdf-touch.spec.ts` asserts a drag does *not* count as a tap. **Performance:** `touchPerformanceMode` (Settings ▸ Rendering ▸ Fine-tune, default on, no effect on a desktop machine) resolves through `src/frontend/src/device-profile.ts`, whose `isTouchPrimary()` is `(pointer: coarse)` **or** WebKit-with-touch-points — an iPad with a Magic Keyboard reports a *fine* pointer while its GPU, panel and 120 Hz refresh are unchanged, and `window.GestureEvent` + `maxTouchPoints > 0` is the pair that names iOS/iPadOS without catching desktop Safari (0 touch points) or a Windows touch laptop (no `GestureEvent`). It and trades two of the costs that are multiplicative with each other on a tile-based tablet GPU — 60 fps instead of a 120 Hz ProMotion panel's native rate, and a 1.5× ceiling on the WebGL render resolution. **MSAA stays on (2026-09-28):** it was the third trade until the iPad showed every line aliased and the board outline at an uneven, unscaled width — the 3-mil outline stroke is sub-pixel at any overview zoom and needs coverage sampling, not pixels; without it the thin strokes draw as broken 1-px jaggies and the 1.5→2 CSS upscale makes their width uneven. The premise was wrong too: a tile-based deferred GPU resolves multisampling inside tile memory, which is why Apple documents MSAA as cheap on its GPUs. The touch profile no longer touches MSAA: `boardAntialias` returns the user's `boardAntialiasing` setting (Settings ▸ Rendering ▸ Fine-tune ▸ Edge smoothing (MSAA), default on), and both `Application.init` sites read it through that one function. Board **text** keeps full resolution: the Canvas2D label overlay has its own device pixel ratio. Measured motivation: an A12Z iPad Pro drives the same 22 Mpx backing store as an M1 one. PDF gets the matching treatment — `loadPdfQuality()` defaults to `medium` on a coarse pointer, and `applyDeviceMemoryScaling` halves the cache budgets when `navigator.deviceMemory` is missing on a touch device (Safari does not implement it, so the desktop `high` preset's ~750 MB of ImageBitmaps went unbraked). Unit: `src/device-profile.test.ts`.
- **A click can arrive in the middle of a drag (2026-09-12):** `BoardRenderer`'s drag-to-zoom calls `setPointerCapture(pointerId)` on `containerEl` when the gesture commits, which retargets every later pointer event from the **canvas** to its parent div. PixiJS's EventSystem listens on the canvas, so it stops seeing the pointer, decides it left, and emits `pointerupoutside`; pixi-viewport's `InputManager.up()` takes that as the end of a gesture whose moves it never saw (drag-zoom `stopPropagation`s them at window capture) and fires **`clicked` one move into the drag**. So `dragZoomConsumedClick`, which is set from the gesture's own pointerup cleanup, ran *after* `handleClick` — the guard arrived a click late and then sat armed, swallowing the next genuine click. Shift+drag therefore also read as shift+click and added the part under the cursor to the worklist. Three guards now, each covering a different emission moment, and `handleClick` needs all of them: `dragZoomActive` (set on commit, cleared in `forceCleanup`) rejects the mid-gesture click; `pointerTravelPx > CLICK_DRAG_TOLERANCE_PX` (5, matching pixi-viewport's own `threshold`) rejects one emitted at release for any gesture whose moves were hidden from pixi — it is tracked by a `pointermove` listener installed **once at init** on `window` capture, which is what puts it ahead of the per-gesture handlers that stop propagation; and `dragZoomConsumedClick` is now cleared on pointerdown so a late set cannot eat the following click. **Rule:** never infer click-vs-drag from what pixi-viewport reports when any handler stops propagation or takes pointer capture — the renderer must keep its own record. Regression: `tests/shift-drag-worklist.spec.ts` (uses the checked-in KiCad sample, so unlike `drag-to-zoom.spec.ts` it always runs).
- **First-run library setup + auto-bind rule ladder (2026-09-14):** `components/FirstRunSetup.tsx` is gated on **server state** — `stats.last_file_scan_at === 0 && boards + pdfs === 0` — not a localStorage flag, so `db.ResetAll` (which deletes `last_file_scan_at`) brings it back after the reload `databankStore.resetAll()` now performs; "Skip" is per session, "Don't show again" is a localStorage key the reset path clears (`store/first-run-store.ts`). Auto-show is suppressed under WebDriver unless `boardripper-firstrun-force=1` (the spec sets it). Start writes `auto_bind` / `auto_scan`, POSTs the scan and, when ticked, `obdStore.fetchAll()` (syncs the index first). **The Library refreshes while a scan runs** (`_midScanRefresh`, backoff 5/10/20/40 s then every 60 s) — the rows were always in the DB within seconds; the panel used to wait for `running === false`. **Auto-bind** (`databank/autobind.go`) is four named rules in fixed order — `exact`, `number` (hash lookups, any folder), `fuzzy` (≥ `min` % of the shorter name's ≥3-char tokens, containment = 100 %), `lone` (only PDF within radius, optionally only with one board) — where rules 3–4 are **folder-radius-bounded** (0/1/2 = same folder / share a parent / share a grandparent; PDFs are registered under their dir, parent and grandparent, so `maxRadius` also caps how deep below the scope a PDF may sit) because similarity cannot be indexed and "fuzzy × anywhere" is the old boards × pdfs loop. Config `auto_bind` (master) + `auto_bind_rules` (JSON, `ParseAutoBindRules` clamps); `bindings.rule` (schema v11, old auto rows = `legacy`); `POST /api/databank/autobind/preview|run` (optional `{rules}` body) and `DELETE /api/databank/autobind?rule=`; editor `panels/settings/AutoBindSection.tsx`. Loosening only adds; removal is explicit, per rule. `BindPicker` renders 200 rows + "Show all" and debounces its filter (measured 251 ms open / ~90 ms per keystroke at 7.5 k PDFs before). **OBD fetch-all** (`obd/fetchall.go`, `POST /api/obd/fetch-all`, 1 req/s, skips cached; `IndexStatus.cached`); `obdBoardNumberFor(tab)` prefers the databank's resolved `board_number` over the filename regex at every viewer call site. `pdfindex.NewEngineAsync` compiles the wasm off the boot path (was 10 s of connection-refused per start); `ExtractFile` waits on `ready`. Review with measurements: `docs/specs/2026-09-12-fresh-install-review.md`; plan `docs/plans/2026-09-12-fresh-install-plan.md`; E2E `tests/first-run-setup.spec.ts`, `tests/bind-picker-cap.spec.ts`; unit `databank/autobind_test.go`.
- **Cross-board part comparison (v0.40.0; board highlight, hover line and refined name rules v0.41.0):** `store/part-compare.ts` is a pure, store-free kernel that aligns one component's pins across two boards and classifies each pair; `panels/tools/PartCompareTool.tsx` is the only consumer, mounted as a Tools entry, and `store/part-compare-store.ts` holds nothing but the two `{tabId, partName}` refs (session-scoped — tab ids do not survive a reload). It resolves against **`tab.board`, the raw parse, never `deriveBoardView`'s output**: the derived view filters parts and rebuilds `nets`, so a board-pack selection or a hidden-part override would silently shrink a topology fingerprint and turn a rename into a difference. Two rules carry the feature. **(a) A strategy's own hit rate is not a quality measure.** `pin.number` is `String(i+1)` in file order on BRD/BDV/XZZ (`brd-parser.ts:284`, `bdv-parser.ts:235`, `xzz-parser.ts:1592`), so `number` alignment matches 100 % of pins whenever two parts have equal pin counts — it beat geometry on every shuffled delivery. Every candidate pairing, however produced (`name`/`number`/`order`/`geometry`), is therefore ranked by the same two independent kinds of evidence: **net agreement** (do paired pins carry the same net name) and **geometric agreement** (do they sit in the same place under one rigid transform), with coverage only as a ±5 % gate. Net agreement leads; geometric agreement decides the case the feature exists for, a delivery where every net was renamed and only the copper is comparable. **(b) Most packages are geometrically symmetric** — a two-row SOIC pad field maps onto itself under 180° and a mirror, a square BGA under all four rotations — so match count cannot orient one. Geometry anchors on the **pin centroid** (not `part.origin`, which differs per parser), never rescales (a pitch mismatch means it is not the same package, and that is surfaced), pairs by mutual-nearest-neighbour (greedy one-directional matching collapses two pads onto one on a dense BGA), ranks its 8 cardinal transforms by (matched, net agreement), and reports `ambiguous` when the top two still tie rather than pairing pin 1 with pin 40 in silence. **Name rules (refined 2026-09-13 against the M1 Air 820-02016 vs M1 Pro 820-02020 PMU pair — two *different* boards, where refdes differ by design so topology almost never matches and names carry the load):** a leading `NC`/`RSVD`/`TPT`/`TP`/`DNU`/`NU`/`RESERVED` labels **state, not identity** and is stripped before comparing (only leading — `PP3V8_NC_SENSE` is not marked); two no-connects go to `nc` only when the stripped signals agree or one side is blank, and otherwise to **`partial`, never `nc`** (`NC_FAN_PWR_EN` vs `NC_MPMU_GPIO26` — both unused is safe to say, "the same pin" is not); and two names sharing ≥2 leading tokens covering ≥40 % of the shorter **with tails of different length** read as the same rail (`PP3V8_AON_VDDMAIN` vs `PP3V8_AON_MPMU_ISNS_VIN`, ~50 rows of one real comparison). The differing-tail condition is load-bearing: without it the rule folds `SMC_RST_L` into `SMC_RST_R` and `PP1V8_S0_A` into `PP1V8_S0_B`, sibling nets that *substitute* at one position where a rail *extends*. Reported as "same `PP3V8_AON` prefix", never "the same net" — on two boards those may be opposite ends of a sense resistor. Whichever rule fired rides on the row as `nameReason` and is shown in the row title and the board tooltip. Net classification splits a name difference into `renamed`/`similar` (neighbour sets match — the everyday Apple case, one delivery with real names and one with `N$` numbers) and `differs`, with rails excluded from fingerprinting by the cheap `pinIndices.length > 120` pre-check plus `isGroundNet`. **A rail's size may confirm a match, never deny one:** two rails within ±20 % read as `bulk`, but when the sizes disagree the branch falls through to the name rules like every other place topology abstains — it used to return `differs` outright, which made the prefix rule unreachable for the exact case it was written for (`PP3V8_AON_*` stayed red across ~50 rows even after the rule existed). Across two *different* boards a rail's fanout differs by design, so size is not evidence either way. Diode readings flag at `max(50 mV, 10 %)` — the relative term must stay below the floor for ordinary junction readings. **Board highlight (opt-in):** the tool's "Highlight on board" toggle (`partCompareStore.highlight`, **off by default**, `data-testid="compare-highlight"`) outlines the compared part and marks every pin that is not a plain match — red for `differs`/`only-a`/`only-b`, amber for `renamed`/`similar`/`partial`; `same`/`bulk`/`nc` are deliberately left unmarked, since painting the 390 agreeing pins of a 400-pin BGA would bury the ten that matter. It is drawn by `BoardRenderer.drawCompareHighlight` into the **existing** `multiHighlightGfx` (the cyan multi-select / worklist layer) rather than a new Graphics — same kind of persistent store-driven overlay, already in `invalidateAllScenes`'s detach list, and a new layer is precisely the bug shape that took out the halo sprite and `butterflyDimGfx`. Pin marks reuse the net-highlight's shape resolution (`drawPadShape`/`drawPinShape`, `showPads` gate, per-part `pinRadiusClamp`) so a mark traces the pin as drawn, and are grouped one `stroke()` per colour because a Graphics flushes its path on every stroke. `partCompareStore.result` memoises `comparePart` keyed on both sides plus mode **and board identity** (a reload or `deriveBoardView` yields a fresh `BoardData`), and `highlightFor(tabId)` resolves which side a tab is so the renderer never has to know. **The hover tooltip carries the comparison too** (`.pnt-compare`, filled by `formatCompareForPin`, gated on the same toggle so the tooltip is unchanged for anyone not comparing): a red pin that does not say what it differs *to* only raises a question. The line names the other board and its net for that pin (`↔ 820-01598: PPBUS_G3H — renamed, same connections`) and is coloured by the same three-way split as the marks; it reads the identical `highlightFor` projection the overlay draws from, so the colour under the cursor and the words in the tooltip cannot disagree. Like the cyan multi-select, it has no butterfly top/bottom split. Spec: `docs/specs/2026-09-11-part-pin-comparison-design.md`; unit tests `store/part-compare.test.ts` + `store/part-compare-store.test.ts`, E2E `tests/part-compare.spec.ts` + `tests/compare-highlight.spec.ts` (fixtures generated from one KiCad sample by a named edit, so the identity case asserts zero differences with no authored expectation table).
- **Worklist panel (redesigned 2026-09-16):** `panels/WorklistPanel.tsx` + `.wl-*` in `index.css`. The **row is deliberately frozen** — mark cycle and its four colours, the condition flag (water on parts, surge on nets) which is a *control* and therefore visible and pressable when clear, the monospace reference, the note with its peek, remove — and parts/nets are now ONE `WorklistRow` taking `kind`, not two ~110-line near-duplicates. Everything else was brought into the app's language: real classes (it was 73 inline style objects written against `--panel-bg`/`--text`/`--muted`/`--input-bg`, names that do not exist in `index.css`, so it silently ran on hardcoded dark and never followed a theme); the wrapping pill row became a **finder** (`worklist-find` → `worklist-find-input`, recents first, filters as you type, lists every worklist, with New/Paste/All worklists beneath); and `window.prompt`/`window.confirm` are gone — creating takes the typed text as the name (or opens the header in edit) and wipe/delete ask through the in-panel `worklist-confirm` strip. **Readings are conditional:** `NetReadings` shows only the kinds that hold a value (`net-meas-value-<kind>`), and the three editable slots (`net-meas-input-<kind>`, the ids the older specs drive) exist **only while that row is selected** — so tests must click `.wl-row-main` first, and a document-level `pointerdown` outside `.wl-row` deselects. The Tools catalog is now the worklist navigator (see *Worklists are documents* below). Cyan selection band untouched, pending its own review. Design: `docs/specs/2026-09-09-worklist-tab-mockup.html`; E2E `tests/worklist-redesign.spec.ts` (public fixture, so it actually runs — `worklist-measurements.spec.ts` needs a proprietary `.brd` and skips on most machines).
- **Worklists are documents, not per-board records (2026-11-10):** spec `docs/specs/2026-11-10-worklist-storage-redesign.md`. A worklist is one JSON document (`store/worklist-doc.ts`: id, rev, updatedAt, editable `board` name, hand-set `tags`, rows with `at` and `removed` tombstones, `deleted` tombstone) and needs no board to exist. **Where it lives:** the Go backend's `worklists/` package, one file per worklist in `<dataDir>/worklists/` (atomic writes, strictly increasing `updatedAt`, `GET /api/worklists?since=`, `GET/PUT/DELETE /api/worklists/{id}`, a stale `rev` is a 409 carrying the server's copy, tombstones go to trash after 30 days); the desktop app's main process writes the same rules to `<userData>/worklists/` over IPC (`desktop/worklist-files.js`, `electronAPI.worklists`) — the MCP sidecar's `DATA_DIR` is userData, so with or without MCP both read one folder (before, toggling MCP switched origin `file://` ↔ `http://127.0.0.1` and the IndexedDB worklists appeared to vanish); lite/offline has no remote. `store/worklist-remote.ts` picks HTTP / IPC / none and keeps an IndexedDB cache (`boardripper-worklists`) that the UI starts from and that holds unsent (`dirty`) edits; a 409 is merged by `mergeDocs` (row by row newest `at` wins, readings per kind, meta from whoever changed it last). HTTP mode polls every 10 s; tabs talk on BroadcastChannel `boardripper-worklist-v2`. The old `boardripper-worklist` store is imported once, ids kept (`boardripper-worklists-migrated-v2`). **Loading is per tab and per session:** `worklistStore.loadOnTab(tabId, id)` records it in sessionStorage (`boardripper-worklist-loaded`), never on the board, so the same worklist can be open on two boards and a fresh open is never pre-loaded; each `BoardRenderer` draws its own tab's worklist (`tabWorklist()`), and `board_tabs` over MCP names each tab's. **Matches are suggestions only** (`store/worklist-match.ts`, board facts from `/api/boards/resolve` via `worklist-board-keys.ts`): loose equality at ≥ 3 characters or containment with the shorter side ≥ 5, against board number/aliases, file stem, model, family, brand and the worklist's tags; the board's Worklist tab shows the count with a spoiler, Open per worklist and + New (`worklist-suggestions`), and nothing auto-loads. Tags on library boards are later; today the worklist's tags are the only source. **Navigator** (Tools ▸ Worklists, `tools/WorklistsTool.tsx`, `wlnav-*` ids): grouped by board name, searchable by name/board/tag, editor for name, board name, tags, Load into any open tab, Duplicate/Copy/Delete and the rows themselves without a board. MCP: `worklist_list` / `worklist_read` answer from the store with no browser open. **E2E:** `tests/worklist-documents.spec.ts` needs a backend — run one on :1336 and pass `BACKEND_PORT=1336` (Playwright defaults to 11336; it skips without one); the Vite proxy rewrites `Origin` to the backend's own, or the CSRF same-origin check refuses every PUT made through the dev server.
- **Butterfly on layered boards (2026-09-27):** `toggleButterfly` no longer refuses boards with `layerNames`, and the ribbon button shows on every format (the two gates disagreed — the store keyed on `layerNames`, the slot on the descriptor's `hasLayers`, which only TVW sets — so on KiCad/Allegro/Altium/EAGLE the button was visible and dead). The reason the gate existed is real and is handled in the renderer instead: traces, vias and pours are attached to `scene.root`, not to a side layer, so `setupButterfly` mirroring only `bottomLayer` would leave bottom copper under the top half. `BoardRenderer.copperHidden` (= `boardStore.butterfly`) therefore gates trace containers, the surfaces layer, the via layer, the net trace-highlight and `traceHitTest`; the user's `showTraces`/`showVias`/`showSurfaces` toggles are not written, so they return when Butterfly is off. `silkscreenBottom` is moved into `butterflyRoot` in `setupButterfly` and back in `teardownButterfly`. The store shows one info toast (`BUTTERFLY_COPPER_HIDDEN_MESSAGE`) when the board has copper to hide (`boardHasCopper`) — not on a traceless BVR; the Layers tab shows a hint row and the ribbon's traces button renders disabled while on. `defaultButterfly` (persisted preference) now applies to every board too. E2E `tests/butterfly-layers.spec.ts`.
- **One body rectangle per part (2026-09-05):** `computePartRenderBounds` (`store/render-settings.ts`) is the single rule for a part's body box, and `board-scene`'s border, the selection outline, net-member boxes, hit-testing, the halo and net-line anchors all read it. Two rules live inside it and nowhere else: `computeTwoPinBodyRect` (a 2-pin part with real `pin.padBounds` — TVW / Allegro / iPhone-era XZZ — is exactly the pad union; without them it is the pin span plus one synthesised pad depth) and `computeOutlinePadding` (multi-pin padding uses the pin radius **as drawn**, i.e. clamped to 45 % of the pitch by `pinOverlapClamp`). Before, the scene shrank only its own border for the clamp and widened `eb` by a pad depth only in the selection path, so the yellow net-member box sat a full pad outside the border on every pad-outline format, by an amount that ignored `selectionPadding`. Unit test: `store/part-render-bounds.test.ts`.
- **Selection outline is dynamic (2026-09-05):** `selectionOutlineWorld` (`store/render-settings.ts`) is the rule for the **selected part's** outline padding and stroke, read through `BoardRenderer.selOutline`/`viewScale` by exactly two sites: the primary SDR outline (a child Graphics of `selectionGfx`, redrawn by `onTick` on any frame whose zoom changed — one polygon, so no debounce; rebuilding the parent's member geometry is what made zooming with a lit net lag, and a settle-debounced world-unit stroke is what made it look too thick mid-zoom) and its HDR mirror. Net-member boxes, highlight rings, search-result outlines, cross-side ghosts and the disco halo deliberately keep fixed world-unit padding/stroke (`selectionPadding` mils, `selectionWidth` read as mils there): they are context, not the thing being pointed at — routing them through the rule padded every member of a lit rail out to 24 px (0e7e78d). `selectionWidth` is **screen px** (constant at every zoom; it used to be world mils, which is why the outline was a 20 px band at 600 % and the "huge padding even at 0"). `selectionMinScreenPx` (default 24) pads a part that draws smaller than that on screen out to that size so it never becomes a dot; once the part is that big the padding is zero and the outline is exactly the border from `computePartRenderBounds`. `selectionPadding` (mils, default 0) only ever adds. Because the geometry depends on zoom, the settle timer repaints the selection. Tests: `store/selection-outline.test.ts`, `tests/selection-outline.spec.ts`.
- **Activity rail (sidebar navigation, `feat/activity-rail`):** a 46px always-visible icon column (`components/ActivityRail.tsx`) replaces the sidebar's horizontal text tab strip — Library / History / Tools on top, Debug / Settings pinned at the bottom (work vs. check-and-configure). It is a **second view over the existing module state in `Sidebar.utils.ts`, not a second store**: `activeTab` / `collapsed` / `side` / `width` remain the whole truth and `showSidebarTab`, `toggleSidebar`, `toggleLibrarySidebar`, `flipSidebarSide` keep their signatures, so the toolbar update badge, keyboard shortcuts and Library deep links did not change. **Show/hide model — three controls, no redundant buttons:** clicking the *active* destination (or the keyboard shortcut) hides just the panel (`toggleSidebar`) — there is deliberately **no** separate "hide panel" button on the rail, that would double the gesture; the **status bar has its own small toggle at the very foot of the rail** (`toggleStatusBar`, window-with-bottom-bar glyph, lit while showing, persisted as `boardripper-statusbar-hidden`, honoured **only in the rail layout** because the legacy strip has no control to un-hide it); the toolbar sidebar button (`cycleSidebar`, sidebar-collapse glyph, `data-sidebar-stage`) **cycles open → icons only → completely hidden → open** — the last stage is "nothing but the board" (panel + rail + status bar; `getStatusBarHidden()` folds `railHidden` in *without* touching the foot toggle's own preference, so coming back restores whatever it was), persisted as `boardripper-sidebar-rail-hidden`; the legacy floating edge arrow returns only in that stage and jumps straight back to open (it calls the same `cycleSidebar`), as does any `showSidebarTab` deep link. Every mutator goes through one private `setStage(open|icons|hidden)`, the sole writer of the `collapsed`/`railHidden` pair; `TABS` is the single registry of destinations (`{id,label,icon,group}`) from which the rail groups, `TAB_LABELS` and the legacy strip all derive. **Auto-hide** (rail foot switch `rail-autohide-toggle` — a pin / crossed-out pin at one weight, `.activity-rail-foot`; deliberately **not** accent-lit, since lit everywhere else on the rail means "this is showing" and here it would mean the panel is the one that leaves — the icon carries the state, `aria-pressed` carries it for tests; also the rail menu and Settings, all three writing `setSidebarAutoHide`; `boardripper-sidebar-autohide`, rail layout only): the panel is taken out of flow and **overlays** the dockview area (`.sidebar-overlay`, inline `position:absolute` in `Sidebar.tsx`) so open/hide never resize the WebGL canvas, and a capture-phase `pointerdown` whose target is inside `.dockview-container` hides it — toolbar, dialogs, toasts and the rail menu do not; it always boots hidden. **It never covers a tab strip:** `Sidebar.tsx` measures the dockview groups (`.dv-tabs-and-actions-container` rects in its own column) and insets the overlay to start below the top-most strip and stop above the next one down (vertical split), re-measuring via ResizeObserver + MutationObserver on `.dockview-container` and on window resize (`data-overlay-top` exposes the inset). There is no single content-top line in a docking layout, which is why this is measured, not a constant. In every state the rail stays unless the cycle removed it, and the muted indicator marks where you return to. **Hover labels are `Name · something`:** on a destination the tail is the action (`Library · click to hide`), since the name fixes the subject; on the two foot switches it is the current value (`Auto-hide · on`, `Status bar · off`) — a mode with no visible state cannot contradict an action label, so `Keep panel open` read as a claim about how things already were, the inverse of the truth (reported 2026-09-12). Toggle `aria-label`s are the stable control name, with `aria-pressed` carrying the state. Panel-internal tab strips (Library, Settings) share one `icon-tab` cell: fixed 44×40 cells, icon pinned, only the open tab captioned underneath (absolutely positioned, so icons never shift); tests locate them by `data-library-tab` / `data-settings-tab`, never by text. Hidden = `display:none` on `.sidebar` with every panel still mounted, so the drill-down, search text and scroll offsets come back exactly as left (asserted by E2E, not assumed). **Badges are signals only** (`hooks/useRailBadges.ts`): Settings ← `updateStore.has_update`, Debug ← log errors *since Debug was last visible* (module-level watermark, not total), Library ← librarysync `phase==='error'`/`errors>0`; they never open the sidebar. `collapsed` and `activeTab` are now persisted (`boardripper-sidebar-collapsed` / `-tab`) alongside `-side` / `-width`; absent keys reproduce the old first-load. The legacy text strip survives behind `boardripper-sidebar-rail=false` (Settings ▸ Theme ▸ Sidebar navigation, `components/SidebarNavSwitch.tsx`) so both layouts can be compared before the strip is deleted. **Tests locate destinations by `data-sidebar-tab="…"`**, present on both the rail items and the legacy strip buttons — never by `.sidebar-tab` text. No slide animation on purpose (open/hide resizes the WebGL canvas once; animating width would do it per frame). Deferred to a phase 2: hover-*peek* overlay while hidden (no canvas resize at all), and folding `BoardSidebar`'s tabs into a secondary rail. Spec `docs/specs/2026-09-02-activity-rail-design.md`, plan `docs/plans/2026-09-07-activity-rail.md`, E2E `tests/activity-rail.spec.ts`.
- **Interactive Mode (direct-manipulation sizing):** a session toggle (Settings ▸ Board, top card; `store/resize-mode-store.ts`, not persisted) that repurposes a plain board click. While on, `BoardRenderer.handleResizeClick` classifies what was clicked into a **group** and opens a cursor-anchored DOM popup (`components/ResizePopup.tsx`, portaled to `document.body` so Dockview panel transforms don't offset it) whose sliders edit **global** `RenderSettings` keys live — the whole board previews because settings are reactive. Groups: **pin** (pin/pin-number/net labels) → pinSizeScale, pinNumberScale, netLabelScale, diodeValueScale, netHighlightGrow/Alpha, selectedLabelMinPx, selectedLabelLodRelax; **part** (component label / body) → partLabelScale, partBorderWidth + the selection/selected-label keys; **netline** (a highlighted net's connection line, hit via point-to-segment vs `netLineSegments`) → netLineWidth, netLineColor (a `type:'color'` swatch control), netLineAlpha; **board** (empty area) → boardFillAlpha. A click also **selects** the element so the selected-* controls preview; pin-level clicks resolve to the specific pin (via `hitTestStack`, falling back to a new `nearestPin()` scan) so dense-BGA net-label clicks highlight the net instead of `selectPart`-ing (which highlights none). New multiplier settings (`pinSizeScale`, `partLabelScale`, `pinNumberScale`, `netLabelScale`, `diodeValueScale`) are applied where the base size is computed (`computePinRadius`, the label-font sites in `board-scene.ts`); `selectedLabelLodRelax` promoted the former hardcoded 0.75 LoD-relax constant. Overlay-only keys (`selectedLabelMinPx`, `selectedLabelLodRelax`) go through `onSettingsUpdate`'s no-rebuild fast path (they're read at label-overlay draw time). Pin scaling hardening: `computeGridSize` refines the pin-batching grid by `pinSizeScale`, keeping per-Graphics vertices under the uint16 index ceiling when pins are enlarged (the matching pad budget in `flushChunked` went with the `ba53bf5` render rollback; the grid is the only guard now). **Two crash fixes shipped with it** (both were latent, surfaced by rebuild-on-settings-change): (1) the selection **halo sprite** is persistent but mounted into `scene.root`, yet `invalidateAllScenes` detached every *other* persistent overlay before `destroy({children:true})` — now it detaches the halo too (+ `updateHalo` self-heals a destroyed sprite); regression `tests/halo-rebuild-regression.spec.ts`. Same family, found 2026-09-08: `butterflyDimGfx` lives in `butterflyRoot` and was not detached there either, so Butterfly + any rebuild left a dead Graphics in the tree (`_gpuData`/`instructions` of null, ticker stopped, "buttons look fine but nothing responds"); regression `tests/butterfly-rebuild-regression.spec.ts`. **Rule:** every persistent display object the renderer mounts into a scene must be in the detach list of `invalidateAllScenes`, and DOM-only settings (the ribbon's `overlayLayout/Position/Orientation/FloatX/Y`) belong in `onSettingsUpdate`'s `INTERACTION_ONLY` set so they never rebuild. (2) The uint16 vertex-ceiling guards above. **Component-name fade:** in the Canvas2D label overlay, `part` labels fade toward a 0.14 ghost as their on-screen size grows (`partNameFade`, smoothstep 80→240px) so a big BGA designator stops blanketing the net names underneath (net names are drawn earlier, pins are on the WebGL canvas below the overlay — the fade reveals both without a z-reorder). Related UI (same branch): Settings tab strip is now icon-based (Tabler icons, `library-tab` styling) and responsively expands labels to fit / collapses to icons via a ResizeObserver; the settings search bar moved below the tabs; Library's **Donor boards** is now a sub-item of the **PDF** tab (mirrors the Folders source-menu dropdown).

- **Navigation history (2026-09-29):** one timeline of *visits* across every board and PDF — `store/nav-history.ts` (pure reducer, unit-tested), `nav-history-store.ts` (state, layer toggles, camera providers; imports no other store so `board-store`/`pdf-store` record into it freely), `nav-history-actions.ts` (back / forward / jump, the only module that drives the viewers). **Cause is the layer, place is a facet:** `click` (the seven selection mutators default to it), `search` (`focusPart`/`focusNet` default; a typed query is committed 1.5 s after the last keystroke or by the pick, and the pick *completes* the query's entry rather than adding one), `lookup` (a PDF word click, a forced follow, `crossProbe` — passed explicitly through `NavOpts`), `view` (a settled user camera move that left the previous view, a tab switch, a hand-turned PDF page, the Fit button; **off by default and a record gate, not a filter**; a candidate commits only after a **2 s dwell** (`recordViewpoint`) — a newer candidate replaces it and any other visit drops it, so flipping through pages leaves one row, the Fit button is immediate). Auto PDF-follow is never a stop — it annotates the click (`entry.follow`). **Jumplist, not browser truncation:** a new visit with the cursor behind the end re-appends the cursor entry (`revisit`) then the new one, so no row is ever deleted by navigating. **The camera is written at departure** (browser scroll restoration): `push` copies the current pose into the cursor entry before appending, back/forward do the same, and a restore puts the pose back exactly through the provider the panel registered (`BoardRenderer.animateToViewport`, the PDF panel's `applyPose`) — never a fit. Places name refdes / pin number / net, never `partIndex`. Restores replay through the ordinary store methods under `isRestoring` (and `quietly()` for a pose that lands after a late panel mount), so nothing records twice. **The settle detector is in the renderer's ticker**, not on `moved` — keyboard pans and several handlers set the position without emitting — and `programmaticMove` / the animation frame flag tell ours from the user's. ⌘[ / ⌘] (Ctrl elsewhere, `ignoreAlt` so ⌘⌥5/6 on a German Mac works) are handled **before** the input bailout and `preventDefault` unconditionally: ⌘[ is the browser's own Back on macOS and would leave the hosted PWA. Ribbon slot `history` (‹ › after the transforms; hold or right-click lists the ten nearest entries), rail destination **History** (`panels/HistoryPanel.tsx`: layer chips, Everything / This board, All / Boards / PDFs (list filter only), cursor row, click to jump, Delete, Clear via the in-panel confirm strip), and the board Search field lists the last eight queries while empty. Persisted in `boardripper-nav-history` with tab ids stripped on load — entries re-find tabs by `name:size`; a closed row with a `fileId` reopens the file on click. Spec `docs/specs/2026-09-29-navigation-history-design.md`; unit `store/nav-history.test.ts`; E2E `tests/nav-history.spec.ts` (public KiCad sample + PDF fixture).
- **Colour schemes (2026-10-11, v0.50):** review, decisions and mockup in `docs/specs/2026-10-11-colour-schemes-review.md`. A theme with a `palette` (Dark, Landrex, Light, Atari) owns every board colour; **BoardRipper Default has none and must stay byte-identical** (users are used to it). The palette becomes render-setting overrides through the existing theme-overrides provider (`paletteOverrides` in `store/themes.ts`; the provider receives the board-merged settings, so a palette rebuilds pin groups and part types from the board's own keywords too): built-in pin-group rules are recoloured by rule id (`RULE_ROLE`), unmatched pins take `signalTop`/`signalBottom` (the underside has its own code, so red means power only), built-in part types are recoloured by id, keywords and prefixes stay the user's, and `ambientDim` is off. New optional `board` slots read with Default fallbacks: `selectedPart` (white), `dimColor` (black), `partOutlineTop/Bottom` (green/red), `copperTop/Bottom` (`defaultLayerColor` in `layer-store.ts`; inner layers keep the palette). `palette.fadeLargeBodies` buckets body fills by share of the board (`fadeStep`, four alpha steps). A scheme switch (`themeStore.lastSwitch`, bumped only by `setTheme`, never by knob changes or init) makes `boardStore.applyThemeChange` turn the dim off everywhere and move default-coloured copper layers. Retired ids (`drafting-paper`, `daylight`, `blueprint-light`) load as `light`; a custom theme seeded from a palette scheme copies the palette. `palette.netLine`/`adjacentNetLine` become `netLineColor`/`adjacentNetLineColor` (2026-10-13: Default's yellow line is invisible on paper — Light/Atari use their selection orange and butterfly blue; the Settings ▸ Net Lines row says the scheme sets them). Contrast gates (labels ≥ 4.5 on board and every body, pins/selection/net lines ≥ 3) in `store/themes.test.ts` — a new or edited scheme must pass them. Screenshot harness: `THEME_REVIEW=1 npx playwright test tests/theme-review-shots.spec.ts`. **The theme overlay layers above board overrides on purpose** (`withTheme` in `render-settings.ts`), so anything that edits a board's overrides must diff against a baseline *without* it: `renderSettingsStore.boardSnapshot()` (global ⊕ board, no theme) — diffing the effective settings stores the whole palette as per-board overrides. The Settings draft previews through `previewSettings(settings, 'global'|'board')`, which re-layers the theme so a preview never drops the scheme; theme changes call `refreshEffective()`, which notifies only when the merged result changed. **v0.50.1:** `RenderSettings.lightSelectionStyle` (`'highlighter' | 'ink' | 'inkHighlighter'`, default `'highlighter'`, Settings ▸ Selection & Highlight) marks the selected net's pins on light schemes; the optional `board.highlighter` slot is the yellow, and `BoardRenderer` runs that light pass only when the theme has one (Light, Atari) — the rest keep the glow. `palette.padAlpha` sets the copper-pad overlay opacity per scheme (Default 0.95).
- **PDF colours (issue #41, v0.50):** `store/pdf-night-store.ts` + `components/PdfNightMenu.tsx`; the ◐ button opens its menu on click (like the wand). Styles: invert, dark paper keeping hue (`hue-rotate(180deg)` + inversion, they commute), custom (SVG `feComponentTransfer`: white → bg, black → fg exactly). Coloured lines: saturation slider and a contrast table curve whose Auto picks the limit from the colour pair; only the pale range is squeezed (a power curve and a uniform scale both lost colour). Auto contrast is on by default for every style, also for installs that had the old on/off night mode, so plain invert lifts pale lines too; the CSS-only path (no SVG filter, `nightPipeline`) is taken only for a non-custom style with Auto off and Contrast 0. E2E `tests/pdf-night-style.spec.ts` reads screen pixels.
- **PDF performance review (2026-10-04):** `docs/specs/2026-10-03-pdf-performance-review.md` — five measured lenses plus a refutation pass on the real schematic samples; read it before touching PDF render code. What it settled: the lag is **not** per-frame pan/pinch on a GPU (flat 60 fps in a production build — the 150–200 MP compositing cost was a software-raster artifact of headless Chromium), not the worker patches, not pdf.js opening the file. It **is**: four neighbour pages rendered at up to tier 4 on every settle though none is on screen (~3.2 s of a 5.2 s zoom at 4× CPU, 608 MB of canvas); the page cache keyed by nominal tier, so past the 8192 px clamp each half-tier step re-renders identical pixels; the whole panel re-rendering per zoom frame for the `%` readout; the 4 s idle `cleanup(true)` dropping the pages being looked at; one ~9 MB IndexedDB put at the end of text extraction; and no loading indicator at all (`pdfStore.loading` has no reader, the panel is created after `loadFile` resolves, the library download tracker is boards-only). Engine facts: a render is an **operator walk** (~0.35 µs/op; an empty tile costs what a full one does), so tiles multiply it and a culling fork gains nothing (tested); pdf.js 6.x has nothing for it; upstream's answers are `PDFPageDetailView` (one viewport-sized canvas) and the open worker-render PR #21860 (2.4× slower on vector pages today). Do **not** flip the default to `auto` before the tile overdraw is fixed (a settle at 800 % is slower with tiles). Safari was never measured — every Safari cause in the doc is inference. Phase 1 list is in the doc's Plan. **Phase 1 shipped to main 2026-10-09** (f15b683…): neighbours ±1 and tier ≤ 2 off screen with one shared in-flight map (`_neighbourInflight`); page cache keyed on the **clamped** size (`_tierAlias`, `effectiveTier`) and an identical in-flight full-page render **adopted** (`mainRenderInflightRef` — adoption also requires the same page proxy, rotation and watermark terms, or a reparse/filter change would show the old pixels); zoom readout written to the DOM; per-page idle cleanup (`_pagesUsed`, ±3 kept, doc-wide only past 40); highlight canvas 1×1 unless it has matches; text extraction after the first paint (`markFirstPaint`), notify ≥ 250 ms, text cache as per-page JSON strings; and the load indicator — `pdfStore.loads` phases, `beginPdfOpen` opens the panel before the load, panel/overlay/banner/chip, `Could not open` on failure, the `rendering` phase ends on first paint, a render error or 8 s. **A PDF panel can now mount before its document:** any effect that grabs `containerRef`/`wrapperRef` once must list `isLoaded` in its deps (the gesture listeners and the ResizeObserver did not, and a panel opened first never zoomed on an iPad gesture). Measured, NR2201ZE p68 8-notch zoom at 4× CPU: quiet 5.8 → 2.8–3.4 s, neighbour canvases 604 → 76 MB; longest task after the first page 330 → 63 ms. **Neighbours must not look soft (2026-10-10):** a neighbour within the preset's near margin (`neighbourNearMargin`, in viewport heights) gets the full neighbour tier, a far one `neighbourFarTier`; `neighbourTierFor` is the one rule, and `scheduleNeighbourCheck` (150 ms after a **pan** — never while a zoom awaits its render, or it starts renders the next zoom step cancels) upgrades a neighbour that came near; a canvas already sharper than asked is kept, its CSS size on `dataset.cssW/H`. **Presets** live in `store/pdf-quality.ts` (moved out of the panel so the start page can import them): the stored ids stay `max|high|medium|low`, shown as Quality / Balanced / Fast / Fastest like the board presets; the start page has a `PdfQualityCard` beside the board card, Settings ▸ PDF the same choice; every write goes through `setPdfQuality` (the `pdf-quality-changed` event). E2E `tests/pdf-neighbour-sharpness.spec.ts` (fails on the phase-1 code). **Settled detail render (2026-10-10):** the full-page canvas is the *moving* picture only — `maxCanvasDim` per preset (8192/6144/4096/4096) — and 150 ms after the view settles `renderDetail` draws the visible area (+25 % each side, ≤ 8192 px a side) at the exact zoom into a `.pdf-detail` canvas laid over the page (pdf.js's PDFPageDetailView design): one op-walk per settle, every preset sharp at any zoom, canvas memory ~ one screen. Reported trigger: 820-02020.pdf at > 500 % in Chrome went **black** — the 'max' preset held a 16384-px page canvas (~700 MB) plus a same-size near neighbour plus copies, past Chrome's GPU budget. The detail canvas sits **after** the highlight canvas in the DOM (z-index 1 < highlight's 2) so `canvas:has(+ .pdf-highlight-canvas)` still finds the main canvas; it is rescaled with the wrapper children and dropped by `clearDetail` on page change, rotation, reparse, watermark/quality change, resize and in tiled mode. E2E `tests/pdf-detail-render.spec.ts` (sharp + in place, rotated too). Phase 2 otherwise untouched. **Settings ▸ Rendering (2026-10-10):** a top-level tab (after Board) replaced System ▸ Performance & Debug; the section id is still `performance` (deep links, saved open state) and it opens unfolded. It holds the Board quality preset with a *Fine-tune* fold of exactly the knobs the preset sets (60 FPS cap, touch performance mode, resolution limit, MSAA, simple trace corners), the PDF preset with *Render mode* folded under it, *Board text on an overlay* (the old "Text fast mode (experimental)") with the two legacy-text settings shown only when it is off (they do nothing for the overlay: label atlas resolution, hide text during zoom), and Diagnostics (perf overlay, two debug drawings). Smooth wheel zoom and two-finger rotation moved to Input ▸ Navigation; System ▸ PDF Viewer keeps watermark, navigation and shortcuts. The Settings icon strip has seven cells (38 px) so it still fits one row.
- **A wheel over an inactive board must wake it (#6, regressed 2026-07-19, fixed 2026-10-03):** a board whose panel is not the active one is paused (ticker stopped). The wake is a `wheel` listener on the **canvas** (`boundWheelWake`), but `installShiftWheelHandler`'s **capture** handler on the container consumes every zoom-bound and modifier wheel (`stopPropagation`) — since the smooth wheel zoom that is the plain mouse-wheel zoom too — so the canvas never saw it: the board took the zoom and drew nothing, while the PDF beside it scrolled fine. The capture handler now calls the wake itself, first thing; the 300 ms idle pause waits for a running zoom tween / animation / glide. **Rule:** any wheel path that stops propagation before the canvas must wake the ticker itself. E2E `tests/board-wheel-inactive.spec.ts` counts real renders, not just the pose — a pan changes the pose while paused and draws nothing.
- **Slide mode + pan minimap (2026-10-02):** two ribbon slots. `slides` toggles `store/slide-store.ts` (session, global): arrow / rectangle / text annotations stored per tab in **board space** (top root local coords, so they stay on the parts through pan, zoom and rotation; stroke and text size are screen px), drawn by one Canvas2D routine (`components/slide/annotation-draw.ts`) for both the live sheet and the PNG, so the export is what was on screen. The sheet (`SlideLayer.tsx`, `.slide-layer`, same counter-zoom as the board canvas) never takes pointer events — a capture `pointerdown` on the panel root claims a primary press over the board when the tool wants it (draw tools always, select only on an annotation) and lets wheel, pinch, other buttons and empty-board presses reach the renderer. Save PNG / Copy call `BoardRenderer.captureView`: `app.render()` then `drawImage` of the WebGL canvas **in the same task** (no `preserveDrawingBuffer`), plus the label-overlay canvas, plus the annotations, plus the minimap (mapped from its DOM rect, which sits outside the board canvas's UI-scale counter-zoom) and a large caption with the **selected part's name** (`layoutCaptions` + `drawCaption` in `components/slide/annotation-draw.ts`: beside the part's *render* body — `computePartRenderBounds`, since `part.bounds` spans only pin centres — trying above/below/left/right, then a corner, never over the minimap; drawn live in slide mode too, so the PNG stays what was on screen); the PNG gets a 40 px header band with the board file name and the date. **Every selected part is captioned** — the primary selection plus `selectionSetStore` (the cyan multi-select, otherwise only fed by worklists): in slide mode ⌘+click (Ctrl+click off macOS — Ctrl+click is the Mac's right-click), or any tap with the toolbar's *Several parts* toggle (`slideStore.multi`, for tablets), toggles it there (`handleClick`, `lastPointerAddMod`). **Shift+click is never taken** — it adds to the worklist in slide mode as everywhere else. Only the part picked last (`slideStore.focusPart`: a new primary selection, or the last ⌘-added part) shows the two symbol-only inputs (`slide-ohm-<ref>` / `slide-diode-<ref>`, no placeholder words); a filled reading prints on its caption's second line for every part, an empty one shows nothing. A part already selected but not in focus takes the focus on the first pick and is removed on the second — a tablet with the toggle on has no other way back to an earlier part's boxes. A **guide card** (`slide-guide`, lower left, toolbar `?` toggles it, hidden state in `boardripper-slide-guide-hidden`) words itself for the tool in hand and the device (touch / ⌘ / Ctrl); it is DOM only, never in the PNG, and live labels are laid out clear of it as of the minimap. E2E covers Windows Ctrl+click (`navigator.platform` stubbed) and CDP touch taps. Readings are kept per tab by refdes in `slideStore`; `layoutCaptions` places all captions clear of each other, their parts and the minimap. Any annotation is dragged with any tool but the pen (Text: click edits, drag moves). The **pen** tool records coalesced pointer samples in board space (`kind: 'ink'`, quadratic-smoothed); once a stylus has been seen, finger presses with the pen tool fall through to the board so fingers keep panning, and a cancelled stroke (a second finger) is discarded. `minimap` toggles `renderSettings.minimapOnPan` (in `INTERACTION_ONLY`); `BoardMinimap.tsx` draws outline + part boxes from `BoardData` through each root's transform (`getViewModel`, so rotation/mirror/Butterfly hold), caches the board and redraws only the view box, and click/drag recentres (`centerOnWorld`). It fades in only when the view is zoomed to **≥ 2× the fit view** (relative to the board, never the HUD's raw mils→px %, which means something different on every board) **and** the pans of one chain (frames ≤ 1.5 s apart) have moved the board a **net 12 % of the screen** in either axis; out ~0.9 s after panning stops. The first cut (third gesture in a row) showed up for routine nudges at the fit view (2026-10-02). Rule: `store/pan-activity.ts` (unit-tested); pans and their px deltas come from `BoardRenderer.onViewChange`, whose `userPan` is a user move at unchanged scale. In slide mode the minimap stays up. E2E `tests/slide-mode-minimap.spec.ts`.
- **"Translate page" must not crash React (#36, 2026-10-04):** Chrome/Edge translation replaces text nodes with `<font><font>…</font></font>`; React keeps the old node and its next `insertBefore`/`removeChild` against it throws `NotFoundError` — a panel crash, or a blank window from anything outside a panel boundary (the status bar on a tab switch did exactly that). Three layers: (1) any text that changes is **one string in its own element** — never `{n} item{n===1?'':'s'}` or a bare `{name}` beside a conditional sibling — and identifiers (net, part, pin, file, format names) carry `translate="no"`; (2) `dom-translate-guard.ts`, installed in `main.tsx` before React mounts, makes both calls tolerate a node that is no longer a child (logs once) for the ~60 rarer sites a static scan found; on its own it leaves stale duplicated text, which is why (1) still matters; (3) `RootErrorBoundary` around `<App/>` ("Reload interface" remounts; stores survive). E2E `tests/page-translation.spec.ts` runs a continuous translator (MutationObserver, as Chrome re-translates added content) on public samples; all five cases fail on the old code. Safari's translator is unverified.
- **Status messages: "waiting" is not a fact (2026-09-22):** the Library showed *"Library is empty — no files indexed yet"* with a button to go scan, for the seconds a request to a rebooting NAS hung, then flipped to *"Backend unreachable"*. Two different facts, and the panel claimed the first while it only knew the second — because the empty branch was `files.length === 0 && !loading`, which is also what "nothing has answered yet" looks like. The rules, which every panel with a data source follows: **(1) Three kinds of state, and they are not interchangeable:** *waiting* (no answer yet), a *confirmed fact* (the source answered: rows, or provably none), and a *confirmed failure* (the source is unreachable or errored). A terminal claim — "empty", "no results", "nothing here" — needs the source's own completion signal (`filesComplete`, a finished stream, a 200 with an empty list), never the absence of data. Unknown renders as waiting. **(2) Branch in this order:** confirmed failure → waiting → confirmed empty → content; `backendState` (`store/databank-store.ts`, `unknown|ok|down`) is the tri-state behind it and `backendAvailable` stays the boolean feature gate (`!== 'down'`, optimistic while unknown). **(3) A message that promises an action is backed by code that does it.** "Retrying automatically" was text with no timer for a year; now `_setBackendState('down')` arms a 2/4/8/15/30 s back-off against `/api/health`, the notice shows the current interval and a *Retry now* button, and the first `ok` after a `down` re-runs `ensureLoaded` (every fetch in the starved run returned null and the store reported `loaded` with nothing in it). **(4) One transition per load.** waiting → outcome, and the outcome stays until the user acts or the source changes; never outcome → waiting → outcome from a retry, and never two outcomes for one condition (the orange notice strip and the content-area notice are one condition rendered in two places, so they must key on the same state). **(5) Status lives in the status strip, content in the content area.** A notice, progress or error goes in the panel's strip above the list so the list never jumps; the content area carries a message only while there is no content. **(6) Test the sequence, not the final frame:** `tests/library-unreachable-state.spec.ts` samples the panel's text every 50 ms through three ways a backend is unreachable in the field (refused at once, hangs then fails, a proxy's 502) and fails on any "empty" claim or on a notice that yields to another state; a spec that only awaits the final message cannot see a flash. It also proves the reconnect: *Retry now* fires a probe at once, and a backend that comes back is found by the timer with no reload.

- **Taken from the 2R Tools fork (2026-10-30, reparomac/boardripper-2r; review folder `~/Projects/boardripper-2r-review`):** five ideas, re-implemented, none of its code merged (its renderer is ported from a minified bundle, `@ts-nocheck`, state on `window`). **Loose search** (`store/loose-match.ts`): case and `_ - . /` ignored, several words in any order; it only *adds* — substring hits keep their order and place, loose-only names follow under "Similar names" (ribbon finds) or after them (Search tab), and the board highlight and the global search's board count use the same rule. A dropdown group header now shows even when its group is the only one. **N / Shift+N** (`store/search-step.ts`, shortcut `searchStep`): on a lit net one stop per part at its pin on the net, ≤3-pin parts first; otherwise the search's parts in natural refdes order; starts from whatever is selected, no cursor state; on a PDF panel it steps matches. **Pin table filter + sortable columns** (`components/pin-order.ts`, parts with >12 pins; the selected pin is never filtered out). **Worklist ‹ ›** in the header, parts then nets, no wrap, a net row opens its reading slots. **Diode readings in supplement mode** get the pin centre on a plate on IC/BGA pins (`LabelRecord.slot`); the overlay moves the pin number and net name out of the reading's band and splits the pitch three ways (`readingSlots`/`clearOfReading`, screen space, any rotation), and a slotted reading appears at the net names' floor, not 3 px. **`BIND_HOST`** limits the backend listener; the desktop MCP sidecar passes `127.0.0.1` (its library and file endpoints have no auth). Not taken, measured: the fork's inferred BGA ball names — the grid is found, but the A1 corner/axis guess was fully right on 17 of 38 named BGAs (iPhone 16, MacBook Air M1, tomu), and wrong guesses name nearly every ball wrongly. **Two new ribbon slots** (switched on/off from the ribbon's right-click menu or Settings ▸ Board overlay): `legendHelp`, on by default ("?", after Traces — not at the row's end, where a narrow panel puts it under the board-sidebar toggle) opens `components/BoardLegend.tsx`, a half-transparent card that starts in the board panel's lower right (moving left of the board sidebar) and drags by its header anywhere in the panel — a press there becomes a drag only past 4 px, so the mode tabs still click, and a double-click puts it back; the position persists and is CSS-clamped inside the panel — with modes Pins (each pin-group rule with this board's pin count, plus the unmatched top/bottom colours — every pin counted once), Parts (coloured types present on the board), Readings (the four diode bands, counts from the file's own readings), Lines (net / next-net lines, lit net, selected part, part outlines, from the active theme) and Keys (from the shortcut registry); counting is pure in `store/board-legend.ts`, open + mode persist in `store/board-legend-store.ts`. `sideFlipAxis` (on by default; both new ids were renamed once during the pre-flight builds so layouts saved there with the slots hidden pick up the defaults) is the one-click ⇅/⇄ toggle the pre-v0.39 toolbar had, drawn **inside** the Side switch between Top and Bottom (`SideSwitch` reads the slot's visibility; the slot's own renderer is null, so its row position means nothing); the ⋯ menu keeps the same choice. **Minimap has three states** (ribbon button cycles off → while panning → always, the always state drawn as the map-with-pin icon): `minimapAlways` beside `minimapOnPan`, resolved by `minimapMode()`, written by `setMinimapMode`; slide mode still forces it on. **Defaults (2026-11-10): always on, top centre, 60 % opacity** (`minimapOpacity`, the slider at the foot of its right-click menu; a QuickMenu `range` item stays open while dragged), shown at full strength under the pointer and, unless *Full strength while in use* is unticked (`minimapOpaqueWhileActive`), while the board is panned or the map dragged — an `.active` class `BoardMinimap` sets on the element, not React state, since it changes every pan frame. Installs still on the old defaults (while panning, 100 %) are moved once, gated by `boardripper-minimap-defaults-v2`; a chosen Off stays. **History on the map (2026-11-10):** ‹ › in its lower-left corner call the same `historyBack`/`historyForward` as the ribbon (global timeline, layers honoured), and *Show history* (`minimapShowHistory`, on by default, right-click menu) marks this session's visits on this board — `store/minimap-trail.ts`, unit-tested: **counted from the history cursor, not the newest entry** (after a few Backs the place Back reached is rank 0 and older ones fade from there; the places Forward returns to are `ahead`, drawn hollow and joined by a dashed line — before, Back walked off the end of a trail anchored at the newest visit and the marks stopped following), at most `minimapHistorySteps` distinct **parts** each way, tab-id + session-start filtered so restored entries never show (the walk breaks at the first entry older than the session). Visits without a part are not marked: the dashed view boxes were dropped (2026-11-10) — a camera is the pose left, not the net, and goes stale under any turn or resize. It only **reads** the history and records nothing, so the History list is not clogged; the views come from the departure camera `push` already writes into each entry, so the `view` layer stays off. Drawn per paint over the cached board (`paintTrail`): parts filled newest = `--board-selection` fading to magenta (never the accent — the map's edges and view box are drawn in it), each mark with a dark rim, a faint line through them in visit order; parts whose side is switched off are drawn **hollow** (still listed, still stepped), `part.hidden` ones not at all; `data-trail` / `data-wl-marks` on the map carry the counts. **Two canvases:** `minimap-base` (the cached board picture) and `minimap-marks` (trail / worklist marks + view box) on top — the opacity setting (`--mm-user` → `--mm-opacity`) fades only the base and the frame, so the marks keep full contrast; the slide PNG draws both. **Everything per paint is read from the map's own tab** (`tabState(tabId)`: selection, sides) — the `boardStore` getters are the *active* tab, another board in a split view — and the header's worklist ‹ › `switchTab` to that board before `focusPart`. One clamped `dpr` per paint and in the board picture's cache key, with a per-`BoardData` id (a new parse / revision / Swap sides keeps roots and part count). Colours are read once per paint; the scheme background and data attributes are written only when they change. `.board-minimap` has `touch-action: none` (a finger drags it), and on `(hover: none)` a sticky `:hover` does not hold it at full strength. Size: `minimapScale` 0.6–2 on the 220 × 150 box (menu slider). Its hooks sit above the component's early `return null` — one placed below it crashed the board panel ("Rendered more hooks"). **Header strip** (`minimap-head`, 20 px above the canvas — 34 px with 40 × 30 buttons and `MIN_W` 250 when `isTouchPrimary()`, the class `touch` on the map — presses never reach the map's drag; **dragging it moves the map** — past 4 px a press is a drag even when it began on a header button, whose click is then swallowed, and the map is put down as Float): ‹ ›, a History / Worklist switch (`minimapNav`, global) and a button that opens the matching tab (rail History, or the board sidebar's Worklist via `openBoardSidebarTab`). **Worklist mode** marks the loaded worklist's parts in their mark colours, ringed when selected, and ‹ › step its rows with the panel's rule — parts then nets, absent rows skipped, no wrap — taken from the *board's* selection, not the panel's row state (`store/minimap-worklist.ts`, unit-tested), with a `n/m` readout. *Steps* (`minimapHistorySteps`, 2–20, default 8) sets the trail length; the connecting line is drawn at a third of the boxes' alpha, a hint of order, not a mark. The map is at least `MIN_W` (180 px) wide so the header fits a tall or 90°-turned board (canvas centred), and its outer size counts the 1 px frame (`FRAME`) — it used to clip the board image's right and bottom pixel. E2E `tests/minimap-history.spec.ts`, `tests/minimap-rotation.spec.ts` (0/90/180/270/37° and Butterfly: every header button on one row inside the map, the map inside the panel). **Minimap placement** (right-click the map, 2026-11-10): Left / Centre / Right / Float — Float makes the map follow the pointer inside the board panel until a click puts it down (window-capture listeners; the settling press and its click never reach the board; Escape keeps the old place); `minimapPosition` + `minimapFloatX/Y`, global, CSS-clamped inside the panel. The placement origin is the **panel's** padding box, never the map's own rect (its show/hide slide animates a transform). The map sits at `calc(var(--ribbon-bottom) + 6px)`: `BoardViewerPanel` publishes the ribbon's bottom edge, because a narrow panel now **wraps the ribbon** onto a second row (`max-width: calc(100% - 64px)`) instead of running it past the edge under the board-sidebar toggle. `.board-panel-root` is `overflow: clip` (fallback `hidden`): a hidden box can still be scrolled by focus or a click's scroll-into-view, which shifted every overlay in the panel sideways. **The PDF page scrubber** (the rail on the PDF's right edge) is off by default — `pdfPageScrubber`, Settings ▸ PDF Viewer ▸ Navigation. Mockup that preceded it: `docs/specs/2026-10-30-pin-colour-legend-mockup.html`. Specs that pin the default slot list (`overlay-customizer`, `board-bar-menu`) list both.

## Safety Rules
- **COMMIT before removing code.** Before deleting or replacing any significant block of code (>10 lines), commit the current working state first. A stray `git checkout` must never destroy hours of work.
- **COMMIT at milestones.** When a feature, phase, or significant progress is complete and building, commit immediately — don't accumulate uncommitted work.

## Conventions
- TypeScript strict mode
- All coordinates internally in mils (thousandths of an inch)
- Component naming: PascalCase for React components, camelCase for functions/variables
- File format parsers are pure functions: `(buffer: ArrayBuffer) => BoardData | Promise<BoardData>` (see `FormatDescriptor.parse` in `parsers/registry.ts`)
- **Logging:** Use scoped loggers from `store/log-store.ts` — never raw `console.log`. Import `{ log }` and use `log.parser.*`, `log.render.*`, `log.pdf.*`, `log.scan.*`, `log.ui.*`, `log.cache.*`, `log.perf.*`, `log.update.*`, `log.obd.*`, `log.cloud.*`. The Debug Panel filters by scope. Avoid logging in hot paths (per-frame, per-pointer-move).

## Public landing page (`landing/`)
The `landing/` folder owns the static page served at <https://www.ripperdoc.de/boardripper/>. It is plain HTML5 with embedded CSS — no JS, no build step, not part of any application build. To update the page (new feature, new screenshot, format-table change): edit `landing/index.html` directly and replace screenshots in `landing/screenshots/`. The RipperDocWeb deploy script rsyncs this folder; nothing in this repo's build pipeline is involved. See `landing/README.md` for the full update workflow.

## Reference
- OpenBoardView source: https://github.com/OpenBoardView/OpenBoardView
- PixiJS v8 docs: https://pixijs.com/8.x/guides
- Dockview docs: https://dockview.dev/