# Changelog

All notable changes to the Ranger Compiler will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [3.5.1] - 2026-09-17

- **The npm README still said 3.3.0.** The package was 3.5.0; the first line
  of `README.md` was never bumped with it, so the npm page advertised the
  old version. npm does not rewrite a published tarball, so this is 3.5.1.
- **This file now dates 3.4.0 and 3.5.0.** Both shipped on npm with the
  notes below still under Unreleased, so a reader of the package changelog
  could not tell what was in the tarball.

## [3.5.0] - 2026-09-17

### Added

- **`rgrc install` fetches what `ranger.json` names.** `Import "pkg:…"`
  used to resolve only against files already on disk, so a clean checkout
  of a project that depends on `gallery/evg` had nothing to compile
  against. The compiler now walks the graph, sparse-fetches each git
  dependency at its pinned revision, writes it into the cache
  (`RANGER_PKG_CACHE`, else `~/.cache/ranger/packages/<sha256>`), recurses
  into each fetched package's own manifest, and records `rev` + `sha256`
  in `ranger.lock`. A branch or tag is pinned to the commit it resolved
  to. `-vendor` also writes `vendor/ranger/<name>` so a vendored project
  needs no cache; `-frozen` fails rather than fetching what the lock does
  not cover; `-force` refetches; `-cache=<dir>` overrides the default.

- **The Git pack client is MIT, and it runs inside `rgrc`.** It sat under
  `gallery/` and was therefore AGPL, so the MIT compiler could resolve
  `pkg:` but not fetch it — that split cost a second npm package
  (`ranger-pkg`) and a shell-out. The client is a clean-room read of
  published formats (pack layout, pkt-line, smart HTTP, FIPS 180-1 SHA-1,
  RFC 1950/1951), which is how you use the language, not an application
  built with it. `gallery/pkg` moved to `pkg/`. `gallery/zip/Inflate.rgr`
  and `ZipBuffer.rgr` moved to `lib/zip/` (MIT) because DEFLATE is a
  published format and the pack needs it; the ZIP container, the writer
  and CRC32 stay in `gallery/zip`. `compiler/PkgFetch.rgr` drives the
  client: advertisement, want, side-band demux, pack v2 with deltas, the
  tree walk and the content cache. `bin/git-http.mjs` is the only Node
  left — GET and POST of bytes, because Node has no synchronous HTTPS and
  this compiler is synchronous — and it ships in `dist/` beside `rgrc.js`.
  `ranger-pkg` and `tools/install.mjs` are gone.

- **The site root is a page about the language.** It was the playground.
  It now says what Ranger is, what it compiles to, which platforms it
  reaches, how the portability claims are checked, and what is in the
  gallery. The playground moved to `/playground/`. `landing/` is plain
  HTML, one stylesheet and one module: the logo is the bitmap tracer
  walking the project's mark into three paths, the backdrop is an EVG
  display list drawn on the GPU by the same painter the editors use, and
  `landing/examples/Cart.rgr` is recompiled for Swift, Kotlin, JavaScript,
  TypeScript, C#, C++, PHP and Rust when the site is built, so the code
  beside each tab is what that commit's compiler writes.

### Changed

- **A lock entry whose checkout is still in the cache is not fetched
  again.** A sha256 names content and the content at a commit does not
  move. treeni-week's install went from 10s to 0.12s and needs no
  network. `--frozen` makes the cache the only answer; `--force` ignores
  it; a `git` or `rev` repointed in `ranger.json` still outranks the lock.

- **PkgImport falls back to `ranger.lock` for a package the manifest does
  not declare.** The lock is the whole graph; `ranger.json` is only its
  first row. A path dependency inside a fetched package is resolved
  against the origin that package came from (`gallery/statechart` plus
  `../vela` is `gallery/vela` in the same repo at the same revision).
  Imports dedup on the folded path as well as the string, so
  `../../evg/X.rgr` and `pkg:evg/X.rgr` are one file.

- **Getting started leads, not the playground.** The front page's "three
  ways in" is a numbered path: clone and build, point the agent at
  `AGENTS.md`, start from a gallery project, compile for the target you
  are shipping to. The playground keeps a link in the navigation.

### Fixed

- **`-d=/absolute/path` wrote the tree under cwd + that path**, because
  the working directory was joined onto it unconditionally.
- **`compiler/bin/api.ts` is not in the tree.** It is the TypeScript
  `build:dist:module` hands to tsc on the way to `dist/api.js`, 77k
  generated lines that had never been tracked and went in with the 3.5.0
  build. `dist/api.js` and `dist/api.d.ts` are what ships.

## [3.4.0] - 2026-09-17

The last package on npm before this one was **3.2.0**. 3.2.1, 3.3.0 and
3.3.1 were already dated in this file and never published there, so this
tarball is the first that carries them.

On the compiler: generic classes (`class History @params(Op)`), expanded
into ordinary classes before any writer runs; a dotted call in return
position now parses (`return this.helper()`); Swift string operators
walk UTF-16 in amortised constant time; Kotlin emits `open class` for
the header form of inheritance; a Python empty constructor writes
`pass`; the C++ `Any` union only names classes the program actually
emits. The gallery grew in the same window — EVG, Office, the book
engine, Mermaid, Figma, CodeGraph, Rave — and is not in the npm tarball.

- **All thirty-eight of Mermaid's header keywords are drawn.** The matrix in
  `docs/MERMAID_PARITY.md` is read off Mermaid's own detector registry — the
  record its parser itself consults. It used to say thirty of thirty, and both
  of those numbers were wrong.

### Added

- **The code editor has a minimap.** The right edge of `ScriptEditor` is a
  scaled picture of the file, the same idea as VS Code's and Monaco's: each
  token is a coloured speck, the slider is the viewport, a problem is a tick,
  and a click or a drag there jumps the view without moving the caret. The
  picture keeps a compact scale (two pixels a line, shrinking only when the
  file would not fit); each letter is a faint cell at the opacity of its
  glyph (~3 % for `.`, ~10 % for `I`, ~20 % for `R`) rather than a solid
  bar. The pointer's place on that picture, as a fraction of its height,
  is how far the editor scrolls. On r5 a finger on the strip stays a
  pointer, not a line-by-line flick. A panel too narrow to spare the strip
  hides it. The same map shows on the r5 page, on the light theme.

- **FigJam boards draw.** A sticky, a shape with text, a connector and a table
  carry no children: Figma builds their layers itself and a `.jam` ships only
  what it built, as two lists that pair by `guidPath` —
  `derivedImmutableFrameData` with each layer's size, transform, flattened
  paths and shaped glyphs, and `nodeGenerationData` with its paints, its text
  and its `visible`. Neither was read, so a real board opened as a page of
  empty boxes: all 26 of its stickies, all 56 shapes, all 5 connectors and all
  5 tables drew nothing whatever. Each layer is now merged the way an instance
  override is and run through the ordinary reader, so a sticky's body is a
  vector and its text is text with outlines, and nothing is written twice. The
  first guid on a path names the layer and the rest name the node, which is why
  a cell's background and its text share a path *tail* and not a prefix — a
  layer is placed against the first entry with the same tail, and the third
  cell's text lands in the third cell.

- **A design for the camera on the GPU.**
  `gallery/evg/PLAN_VIEW_TRANSFORM.md` asks why a canvas rebuilds its whole
  display list for every frame of a pan, when the pan is a translate the vertex
  shader already applies for scroll layers (`uShift`) and the scene has not
  changed. The list would be built in scene space and carry the camera beside
  it; a kept frame is then drawn at any pan for nothing, and at a scale within
  a band — the glyph atlas and the flattened curves are what a zoom cannot
  stretch, and the band is where the design is honest. Measured, not projected:
  a pan frame of the FigJam board is 87 ms of which 18 is the draw, and drawing
  a frame that is already built costs 18.4 ms and rebuilds nothing. It says
  what each painter would do, who else it helps (rangerflow's 12.1 ms frame,
  markdown's per-frame `offsetBy` and list copy, the layer shifts it would
  generalize), where it gives nothing (anything that draws once), and what
  would go wrong. Design only — nothing is built.

- **Two fingers pinch the canvas, and the gestures are one module.** Drag to
  pan, wheel to zoom, a press that does not travel is a click — every
  standalone had written its own, and none of them had a pinch.
  `gallery/evg/gl/evg-gestures.js` is that handling once, for any EVG canvas:
  it reads the view the host keeps and hands back another, so the host goes on
  deciding when to paint. The anchor holds the point under the cursor, or
  under the midpoint of two fingers, where it is; a trackpad pinch (a wheel
  with `ctrl` held, a fraction of a notch at a time) gets a rate of its own or
  it crawls where the wheel flies; Safari's `gesture*` events are read; and a
  finger lifted out of a pinch leaves the other one panning. `npm run
  evg:gestures:check` drives all of it against a canvas that is not one.

- **`fig_cli fields <file> <node-id>`** prints one node's raw kiwi fields and
  lists its children, which is how a layer that draws wrong is read against
  what the file says about it, and how a variant set's variants are found.

- **The layers pane is a tree, and it is rooted where you are looking.** It
  used to be every layer in the file in one flat list — three and a half
  thousand rows on a board, which is a wall and not a tree. Rows fold now, and
  picking something on the canvas roots the pane at it (or at its parent, when
  what you picked has nothing under it), with crumbs back out. Everything is
  open by default; what folds itself is what does not fit, counting the rows
  still owed to the layers queued behind it, so every section of a board gets a
  row even when the first one could have filled the pane on its own.

- **An inspector for the selected layer, and it edits.** The numbers in the
  right-hand pane are the layer, not a report about it: type one and the page
  is painted again. Position and size, opacity, corner radius, fill and stroke
  colour and the text itself are editable, a field's label is a scrub handle,
  and retyping a text layer drops the glyph outlines the editor shaped with it
  for the font this machine has — which says plainly which half of the pipeline
  drew what you are looking at. Nothing is written back to the file; **Revert
  edits** re-reads the document the scene was converted from.

- **Mermaid event models.** Time across the page, kind down it: each `tf` is a
  time frame and lands in the lane its kind belongs to, under the three names
  Mermaid's own config gives them. The lane is not a choice — an event drawn in
  the command lane is a different diagram — and the nine spellings Mermaid
  gives five kinds collapse to five before anything is placed. `->>` is the
  only edge on the page, `rf` marks where the story starts again, `[[Name]]`
  points at a declared `data` block, and a `gwt` block is drawn below the lanes
  because a test of the model is not part of it. There is no `title`: Mermaid's
  own grammar rejects one here, and a reader that took a file Mermaid will not
  take would be claiming a parity it does not have.
- **Mermaid tree views.** The same hierarchy the treemap draws by area, drawn
  as an outline: one row per entry, indented under its parent, with elbow rules
  that say which row belongs to which. Indentation is the hierarchy, ` ::: name`
  puts a class on a row, ` ## words` is a description beside it and
  ` icon(name)` names an icon — and the three are cut off the end of a line in
  the one order that cannot go wrong, the description first, because it runs to
  the end of the line and would swallow the other two. Mermaid resolves an icon
  name against an icon pack and there is none here: inventing a picture for a
  name this library has never seen would be worse than drawing none, so the
  marker says only what this reader knows — whether the row has anything under
  it — and the icon name is kept on the row as its tooltip rather than dropped.
- **Mermaid treemaps.** A tree whose branches are drawn to scale: the nesting
  says what contains what and the area says how big each part is, so the one
  thing this must not do is round a rectangle to make it look tidier. The
  indentation is the whole of the syntax, a leaf carries `: value`, a branch is
  worth the sum of what is under it, and `:::name` puts a class on a box. The
  layout is not a slice down every level the same way — that makes slivers, and
  a sliver a hundred times longer than it is wide has an area nobody can judge.
  Each list of siblings is cut in two at the place nearest to halving its
  weight and its rectangle is cut across the longer side in the same
  proportion, so every step halves the weight and turns the grain ninety
  degrees.
- **Mermaid radar charts.** A bar chart bent into a circle: one spoke per axis,
  one closed line per subject, and the shape of that line is the comparison.
  The readings are read either as a plain list in axis order or as `name:
  value` pairs, which is the only safe way to write a curve that skips an axis;
  `max`, `min`, `ticks`, `graticule` and `showLegend` are all read, and the
  scale is honoured — a reading at the top of it sits on the rim and one at the
  bottom sits dead centre, which is the whole of what a radar claims. Every
  line on the chart is a thin quadrilateral given as `shapePoints`: the display
  list fills whatever it draws, so an unfilled ring would have to be painted in
  the paper's own colour and would be lost the moment the paper changed. The
  curves are outlines with a dot at each reading rather than filled areas for
  the same reason in reverse — there is no transparency in the display list,
  and three filled curves are three opaque blobs with the last one drawn
  winning.
- **Mermaid packet diagrams.** A ruler with names written on it: every field is
  a range of bit numbers, the ruler is 32 bits wide, and the whole of the
  layout is arithmetic on those numbers. The one thing that needs care is the
  wrap — `192-255` is not one box, it is two boxes on two rows with the same
  name in both, because a field 64 bits wide does not fit on a 32-bit ruler.
  All three ways of writing a range are read: `0-15:`, `32:` for a single bit,
  and `+16:` for the next sixteen after whatever came before. The bit numbers
  above the boxes are the diagram rather than decoration, so they are drawn at
  both ends of every box; and a header whose flags are one-bit fields called
  `URG` and `ACK` widens the ruler until those words fit, because six boxes
  with an ellipsis in each say nothing at all.

### Changed

- **A frame crosses as typed arrays, not as text.** The Figma viewer hands
  the page `EVGDisplayList.toBinary()` — three `Int32Array`s and a string
  pool — where it used to hand it JSON: on a board of 3,565 nodes that is
  5,630 ms a frame against 180, and the two bridges describe the same picture
  to the hundredth (`gallery/evg/gl/list-binary-check.mjs` holds them to it).
  `scene()` still answers in JSON for anything that wants to read a frame.

- **A flattened outline is kept on the element it belongs to.** `d` is a
  string and the painter wants points, so every walk parsed and flattened
  every path — and a page of text drawn as glyph outlines is thousands of
  them. What comes out depends on the path and on the box it is drawn in,
  and a pan changes neither: a transform moves the pixels after the boxes are
  placed. `EVGElement.ringsCache` keeps it and re-flattens when either
  changes, which takes a pan of that board from 966 ms to 581.

- **A curve is flattened for the size it is drawn at, not the size it was
  laid out at.** A transform is exactly the difference between the two, and
  on a canvas that zooms it is a large one: a Figma board at 10% was cutting
  every glyph into the 48 segments a curve 640 layout pixels wide deserves,
  in order to draw it five pixels long. The scale of the transforms a subtree
  is under is carried down the walk now, so the subdivision follows the
  pixels — 581 ms to 125 on that pan, and finer rather than coarser when you
  zoom in.

- **And a curve is cut by its own length, not by the box it lives in.**
  `steps` is chosen from the size of the thing being drawn, and inside a path
  that size says nothing about the curves: a heading 2,855 pixels wide cut
  every curve of every glyph in it 47 ways in order to draw those glyphs two
  pixels tall, and one frame of that board carried 2.5 million points because
  of it. Each curve is now measured through the transform and cut at about a
  point every two device pixels, never finer than the caller's ceiling — so
  nothing draws heavier than it did, and a curve big enough to show facets
  keeps every segment it had. 767,000 points in that frame, 125 ms to 37, and
  `fixtures/health.fig` — three phone screens — from 27 ms a frame to 12.
  With the cache above, every EVG page with vectors on it redraws for less.

- **The viewer stopped rebuilding what a pan does not change.** The EVG tree
  and its layout are the same from one frame of a pan to the next — only the
  world element's transform moves — and the twelve-megabyte text dump of that
  tree, built on every rebuild and thrown away unread, is built when the
  debug pane asks for it.

### Fixed

- **`return this.helper()` compiles.** A method call in return position,
  written the way every C-family language writes it, used to fail analysis
  with `Could not match argument types for return` followed by `Function does
  not return any values!` — two messages, neither of which names the problem,
  and on a class with inheritance often a phantom missing method in a
  different file. It parsed as three sibling nodes rather than one call. The
  parser now folds a `(` that TOUCHES a dotted name back onto that name, which
  also retires the rule that arithmetic on a call result needs a temporary
  local: `def v:int (this.helper() + 1)`, `return this.helper() + 10`,
  `this.other(this.helper())` and `return a.b().c()` all parse now, and the
  parenthesised spellings still mean exactly what they did. A callee that is
  not dotted — a lambda in a local — still needs its own parentheses, because
  an undotted name in front of a `(` is also how `new Type(...)` and a method
  declaration are spelled. Gated on es6, Go, Python and Rust output, and by
  the compiler rebuilding itself byte-identically from 50,000 lines of Ranger.
  ISSUES.md #63.

- **Editing the markdown while looking at a slide jumped the deck back to
  the first one.** Rebuilding the slides hands the editor a new presentation,
  and `attachPresentation` always opened on slide 1 — so a keystroke on slide
  3 of r5's markdown pane made the preview leap to the title, and the heading
  just typed was off-screen. The slide that is in front stays in front.

- **A short heading wrapped onto the table under it.** `### A tabledd` drew
  as "A" / "tabledd" over the grid. The layout's boxes for a heading are the
  glyphs, each as wide as that run, and the exporter made a PowerPoint text
  box that size — already full, so the first millimetre a bolder face or a
  default inset measured wider wrapped the last word out of the box and over
  the next block. H1 and H2 escaped because they draw a rule the width of the
  column; H3 has no rule. Flowing blocks now get that column, and wrapping
  stays inside it.

- **A page panned away from the origin drew almost nothing.** EVG skips a
  subtree that cannot reach the clip it is inside, and the test was made
  against the boxes the layout placed while a transform moves the pixels
  afterwards. A Figma page is laid out around the origin and never noticed it;
  a FigJam board is laid out where the designer left it — x = -13,264 on the
  board that found this — so every frame measured as ten thousand pixels
  off-screen and was skipped whole: 193 draw commands for 3,565 nodes. The clip
  now travels into the space the subtree is laid out in, and a rotation, which
  no rectangle can follow, turns culling off for that subtree rather than
  guessing at one.

- **An instance showed the component's placeholder, at the component's size.**
  An override path is spelled in `overrideKey` — a component copied in from a
  library is re-guided on the way in and keeps its old identity there — and
  matching on the node's own guid placed 55 of a board's 1,323 overrides. It is
  also two lists and not one: `derivedSymbolData` is what Figma computed, the
  shaped glyphs and the size the instance laid the node out at, and
  `symbolData.symbolOverrides` is what the designer typed. Reading only the
  first left a 2,030-pixel card spilling 4,600 pixels of placeholder down the
  board.

- **The selection ring was drawn in the corner of the page.** It read `x`/`y`
  off the node, and those are an offset from the PARENT: a title twenty pixels
  into a card three thousand pixels across the board ringed 20,16 — nowhere
  near the layer it was pointing at. The box is walked to now, the same walk
  the hit test makes, and a layer with no height still gets a ring you can see.

- **The Frame control was blank, and picking a frame did nothing.** Its options
  carried the frames' ids while the code put the frame's index into the
  control and read an index back out, so nothing ever matched and
  `parseInt("13709:3271")` asked for frame 13,709 — out of range, which
  quietly showed the whole page again. A section with no name now wears its id
  rather than an empty row.

- **An instance swapped for another component drew the one it was not
  swapped to.** `overriddenSymbolID` is an override of the symbol the
  instance names and was read after it, so it never won: a template card
  that swaps its thumbnail for the "you are here" variant of a set drew the
  placeholder artwork the set happens to list first.

- **An instance now clips the way its component does.** The flag is on the
  component and the instance carries only `frameMaskDisabled`, so the
  screenshot inside a tip card ran out of the side of the card.

- **A layer whose only fill was switched off was painted opaque black.**
  `firstFill` handed back an empty paint that read as visible black when every
  paint on the node was hidden, which put a black box over every icon on a
  FigJam board that carried one.

- **The scene-graph JSON was not JSON.** Layer names, path data and a sticky's
  text went in unescaped, so a board whose first sticky ran to two lines made
  the whole pane unparseable.

- **The C++ backend named classes it had not declared.** `CreateUnions` wrote
  every class the program knows into the `r_union_Any` variant; the forward
  declarations came from `writeClass`, which runs only for the classes the
  program actually emits. A class nobody calls is eliminated, keeps its place
  in the variant, and leaves the C++ compiler reading a name it has never
  seen — `error: 'X' was not declared in this scope`, pointing at a typedef
  several thousand characters long. Two lists of the same classes, built in
  two different places, and nothing made them agree.

  `Any` is where it bit, because `Any` names every class in the program.
  `gallery/realtrainer` could not be built for C++ **at all** — a seven-line
  file that imports `RtHost.rgr`, constructs one and prints its scene name
  reproduced it — stopped by `VlChartExamples`, a documentation-only class of
  unused static examples over in `gallery/vela`. Nothing about either was
  wrong.

  The union now declares what it names. Only the pointer forms:
  `shared_ptr<T>` of an incomplete `T` is legal, while a value case
  (`PLAN_SHAPES.md` S5) lives inside the variant and needs its definition —
  and a value case belongs to a live family, so it is never the one that was
  eliminated. Which form a member takes is asked of `getObjectTypeString`
  rather than decided a second time here. Nearly every name is now declared
  twice, which is legal and costs 8.9 KB on a 4.0 MB file; tracking what was
  already emitted to save that would be a third list to keep correct, in the
  writer whose two lists disagreeing is the defect. The compiler still
  reproduces itself byte for byte, still compiles itself to C++ that `g++`
  accepts, and `gallery/vela`'s native goldens are unchanged.

- **The diagram-type matrix was measured against a list that could not be
  complete.** It discovered Mermaid's types by listing
  `dist/chunks/mermaid.core/*.mjs` and reading the name out of each filename —
  but five of those chunks are called `diagram-<hash>.mjs` and say nothing
  about which diagram they hold, so five types were invisible to the very
  matrix that exists to notice a type with no reader: `packet`, `radar`,
  `treemap`, `treeView` and `eventmodeling`. The fault was in the meter, which
  is the third one of that class here, after the swimlane and railroad
  keywords. The oracle now imports Mermaid's own `detectors` registry — the
  record the parser itself consults — reads each keyword off the detector's
  regular expression, and hands the keyword back to that detector to check it;
  a keyword derived wrongly is dropped rather than asked of this reader as
  Mermaid's. Keywords rather than renderers, because `graph` and `flowchart`
  are one picture behind two detectors and a reader has to know both words.
  `harness/out/mermaid.json` carries the list, so the dump script and the
  parity tool stop guessing and the hand-written chunk→header map is gone.
- **A Gantt chart had no date axis, unfilled bars, and one section band drawn
  on top of another.** The axis is the diagram — a chart that spaced its bars
  evenly instead of by date would be a list with rounded corners — and there
  was none: no ticks, no dates, no rules. It has one now, labelled with real
  dates turned back out of the day numbers by the inverse of the civil-calendar
  formula that made them rather than by a second calendar, counted from the
  first day so the left edge of the chart is never the one without a label.
  The bands were framed around the bounding box of each section's bars, and two
  sections whose dates overlap have overlapping boxes: one band was drawn over
  another and the label underneath was lost. A band is a run of consecutive
  rows now, which is what a section is. The bars are filled rather than
  outlined, with a colour for `done`, `active` and `crit`, and are exactly as
  long as the task is — the old 96-pixel minimum was a lie about a short task,
  so a bar too narrow for its name gets the name beside it instead. The
  dependency arrow between `after` tasks is gone: `after <id>` is arithmetic on
  the start date, and an arrow between the bars claims something about the plan
  that the plan does not say.
- **`note` in a class diagram was read and thrown away.** Both spellings are
  drawn now: `note "text"` stands on its own, and `note for Duck "text"` is
  pinned to its class with the dashed leader UML has always drawn — no
  arrowhead, because a comment does not depend on what it comments on. They are
  placed after the layout rather than laid out with it: a note is prose about
  the program rather than part of it, and a note given a rank pushes the
  classes apart to make room for a sentence and lands in the middle of the
  inheritance it was describing.
- **A hand-written line break could be silently merged with the next line.** A
  node label was capped at three lines, which is right for text that WRAPPED —
  a paragraph in a box is not a label — and wrong for text the author broke
  themselves: `can fly<br>can swim<br>can dive<br>can help in debugging` came
  out as three lines with the last two run together, which is a sentence nobody
  wrote. Hard breaks now raise the cap, and the height still decides whether
  they fit: the text shrinks first and is cut last, exactly as before.
- **An arrow to a subgraph pointed at a box that was not there.** `C --> A`
  where `subgraph A` exists means the group, and Mermaid keeps `A` as a vertex
  all the same — the clustering is a drawing decision, not a parsing one. This
  reader drew both: the frame *and* a phantom node called `A` beside it, so a
  diagram whose arrows all end at subgraphs came out with a second, empty copy
  of every group. The expansion now happens when the GRAPH is built rather than
  in the model, which is what keeps the parity dump agreeing with Mermaid's own
  parser: an edge to a group becomes an edge to every member of it, with all
  but the drawn one hidden. Pointing at a single member instead would split the
  group across ranks, because the layout ranks by longest path. Frames grew
  side handles so an edge can land on one, and an edge to a frame faces it by
  the direction of the layout rather than by the larger delta — a frame is
  wide, so "larger delta" always chose sideways.
- **Three headers fell through to the flowchart parser.** `requirement`
  (Mermaid's detector is `requirement(Diagram)?`, and only the long spelling
  was known), `treeView-beta` and `eventmodeling`. The corrected matrix found
  all three on its first run.

### Added

- **Mermaid railroad diagrams, in all four notations.** Types twenty-six to
  twenty-nine. Mermaid ships four headers for one picture — `railroad-beta`
  and the grammar notations `railroad-ebnf-beta`, `railroad-abnf-beta` and
  `railroad-peg-beta` — which disagree about how to spell a choice and agree
  about everything else, so this is one syntax tree, four front ends and one
  renderer. There is not one edge in the output: a railroad's lines are square,
  exact, and go where the grammar says.
- **Mermaid `info`.** The thirtieth, and the smallest diagram Mermaid has: the
  whole source is the word, and what it renders is the version of the thing
  that rendered it. This is not Mermaid and does not know Mermaid's version, so
  it gives the same kind of answer and not the same answer — it says what it
  is. A version number invented on the spot would be printed in a box and
  believed.

### Fixed

- **The four railroad keywords were wrong, in the reader and in the meter.**
  The headers are `railroad-beta` and `railroad-<notation>-beta`; the chunks
  Mermaid ships are called `abnf`, `ebnf`, `peg` and `railroad`. Both the
  reader and the parity harness had taken the file names for the keywords, so
  the matrix was asking about four headers that do not exist. Mermaid's own
  detectors settle it. This is the same fault as the swimlane keyword, found
  the same way.

### Added

- **Mermaid Wardley maps.** The twenty-fifth type: a value chain drawn against
  evolution, where up the page is how visible a thing is and across it is how
  evolved. Both are numbers the author wrote, so nothing is laid out. `evolve`
  is drawn as the dashed move to the right that it is, and visible is *up*
  while the page counts down — the one conversion the map needs, and the one
  that would otherwise turn it into a map of the opposite argument.
- **Mermaid Venn diagrams.** The twenty-fourth type. Two facts and one picture:
  how big each set is, and how much of it is also in another one. Circles are
  sized by area rather than by radius — a set twice as big is twice the ink —
  and placed to overlap, because a Venn diagram whose circles miss each other
  has drawn the one thing it exists to deny.

### Changed

- **Round shapes are round.** A circle's outline was a fixed twenty-four
  straight pieces, which is right for a 40px node and visibly faceted on a
  300px one. The piece count now follows the radius, and circles and ellipses
  additionally carry the exact four-cubic-Bezier path for the backends that
  draw a path rather than tessellate one — the point ring stays, because it is
  what the WebGL backend fills and what a click is tested against.

### Added

- **Mermaid fishbone (Ishikawa) diagrams.** The twenty-third type. Indentation
  is the whole of Mermaid's grammar for it, so it is the whole of the reading:
  the first line is the effect, the lines under it the categories, and the
  lines under those the causes — to any depth, because a cause of a cause is
  what the diagram was invented for. Drawn as a fishbone: the effect at the
  head, the categories alternating above and below the spine.
- **Mermaid Cynefin frameworks.** The twenty-second type. The five domains are
  always in the same places, because the places are the argument, so there is
  nothing to lay out: the reader puts each item in the domain it was written
  under and draws the arrows that say something moved. `confusion` is drawn in
  the middle and over the four it sits between; a move from a domain to itself
  is dropped, because it says nothing.
- **Mermaid swimlane diagrams.** The twenty-first type, and the one with no
  grammar of its own: Mermaid reuses the flowchart parser wholesale and swaps
  the layout engine, so this reads the source with the flowchart reader and
  puts every step in the lane that owns it. A step nobody claimed gets a lane
  at the bottom.

### Fixed

- **The swimlane keyword was wrong, in the reader and in the meter.** The
  header is `swimlane-beta`, singular; the chunk Mermaid ships is called
  `swimlanes`. Both the reader and the parity harness had taken the file name
  for the keyword, so a real swimlane diagram fell through to the flowchart
  parser and the matrix reported it as recognised. Mermaid's own detector
  settles it.

### Added

- **Mermaid architecture diagrams.** The twentieth type. `architecture-beta`
  writes a side on each end of every connection — `db:L -- R:server` — and that
  is the only placement information the diagram has, so it is used as the
  layout: every service lands on the side its own connection asked for. A
  layout engine would draw a perfectly good picture of a different arrangement.
  `group`, `service` and `junction` with their icons and `in` parents, all four
  connection arrows, and `{group}` endpoints.
- **Mermaid block diagrams.** The nineteenth type, and the only one that says
  where its own boxes go: `columns 3` and a list is arithmetic, so nothing here
  asks the layout engine anything. `id:n` spans columns, `space` leaves holes,
  `block:id … end` nests with columns of its own, and the shapes are the
  flowchart's because `block-beta` borrowed the vocabulary wholesale. Sizing is
  two passes — a nested block is as wide as what is inside it and a row as tall
  as the tallest thing in it.
- **Mermaid Sankey diagrams.** The eighteenth type: a graph whose edges have a
  width, and the width is the whole point. A node is as tall as the quantity
  through it, a column as tall as the quantities in it, and the ribbons are
  polygons worked out from the numbers — a band from where a flow leaves to
  where it arrives, sampled along a smooth curve, so a Sankey is drawn by the
  same renderer as everything else. The whole language is three CSV columns,
  which makes it the shortest reader here and the one that does the most
  arithmetic.
- **Mermaid xy charts.** The seventeenth type, and the first with a *scale* in
  it: every other diagram here places things by counting, and this one places
  them by measuring. `bar` and `line` series can be mixed, several bar series
  share a band side by side, and `horizontal` swaps the axes. Where the range
  is not given it comes from the data with zero at the bottom, because an axis
  that starts just under the smallest bar makes a 4% difference look like a
  tenfold one.
- **Mermaid pie charts.** The sixteenth type, and the one that is arithmetic
  all the way down: no nodes, no edges, a list of numbers and one rule. The
  shape library has no wedge, so the wedges are given as `shapePoints`
  polygons — a fan along the arc, the centre, and back — which means a pie is
  drawn by the same renderer as everything else rather than by a special case.
  The names go in a legend beside the circle, and twelve palette colours arrive
  as classes a stylesheet can disagree with.
- **Mermaid quadrant charts.** The fifteenth type: a scatter plot that has been
  told what its corners mean. The numbers place the dots and nothing else may;
  the one conversion the chart needs is that y counts up in the source and down
  on the page, and getting it wrong puts every dot in the wrong quadrant while
  the picture still looks fine. `radius:`, `color:` and `stroke-color:` are read
  off the point's own line, `:::name` and `classDef` colour a group of them, and
  a colour written on the point wins over the stylesheet.
- **Mermaid kanban boards.** The fourteenth type, and the one with no edges in
  it at all: a board has columns and cards and the single relation "this is in
  that". Indentation is the hierarchy, `id[Label]` names either, and the
  `@{ ticket / assigned / priority }` block is written under the card rather
  than dropped — a board with none of that on it is a list. The priority also
  becomes a class a stylesheet can match.
- **Mermaid git graphs.** The twelfth type, and the one whose syntax already
  contains its layout: commits run along the axis in the order they were
  written, one row per branch, so nothing is asked of the layout engine. A
  merge draws both of its parents and a cherry-pick a dashed line back to what
  it picked; `id:`, `tag:`, `type:` and `order:` are read, and the four commit
  types are drawn as the four shapes Mermaid draws.
- **Mermaid sequence diagrams.** The eleventh type, and the one where both axes
  are content: who across the page, when down it. Nothing asks the layout
  engine anything — the columns are the participants in declaration order, the
  rows are the statements in the order they were written, and every arrow is
  pinned to its own row. All ten arrow tokens are drawn as what they say
  (`->>` a filled head, `-)` an open one, `-x` a cross, `<<->>` both ends);
  activation is a bar on the lifeline, from `activate` / `deactivate` or the
  `+` / `-` shorthand, nested where a participant calls itself; `loop`, `alt` /
  `else`, `opt`, `par` / `and`, `critical` / `option`, `break` and `rect` box
  exactly the participants they touch; `box` groups the participants declared
  inside it, and `create` / `destroy` place a participant late and end its
  lifeline with the cross.
- **Mermaid Gantt charts.** The tenth type. The axis is the diagram, so the
  reader does arithmetic: every date becomes a day number by the civil-calendar
  formula (the only way to know that the 1st of March follows the 28th of
  February in a year with no 29th), `after <id>` starts where that task
  finished, and a bare duration follows the one before it. Bars are placed on a
  real time axis scaled to the page; `done` / `active` / `crit` become classes
  a stylesheet can match, and a milestone is drawn as the diamond it is.
- **Mermaid timelines and user journeys** — the eighth and ninth types, and
  the two that are PLACED rather than laid out. A timeline runs along its axis
  in the order it was written, with the events under the period they belong to;
  a journey's height is the score against each task. Handing either to a
  layered layout would throw away the one quantity the diagram has: a chart
  that puts a 5 and a 1 at the same height is not a journey. Sections become
  the frames a sub-flow already has.
- **Mermaid C4 diagrams.** The seventh type. C4 is a naming convention over a
  very ordinary picture — labelled boxes with a type and a sentence, dashed
  boundaries around groups of them, arrows carrying a protocol — and all three
  were already drawn here. Every element (`Person`, `System`, `Container`,
  `Component`, `Node` and their `_Ext`, `Db`, `Queue` variants), every
  boundary, nested, and `Rel` / `BiRel` / the directional variants with the
  technology in brackets after the label. `UpdateElementStyle` and friends are
  read and dropped.
- **Mermaid requirement diagrams.** The sixth type, and another that needed
  no renderer: a SysML requirement diagram is a class diagram whose boxes are
  requirements, so `MermaidRequirementReader` reads it into the same UML model
  — the keyword becomes the stereotype, the fields inside the braces become
  the rows, and a relationship is a dashed line carrying its own name in
  guillemets. All six requirement types and `element`, every relationship
  Mermaid has, and both directions of writing one.
- **Mermaid mind maps.** The fifth type, and the one with no arrows in it:
  indentation is the syntax, so `MermaidMindMapReader` turns the outline into a
  tree and hands it to `MindMapLayout`, which has balanced a mind map's
  branches either side of its root since the tree layouts were written. Every
  node shape Mermaid has, `::icon()` read and dropped, `:::class` kept for a
  stylesheet to match.
- **Mermaid state diagrams.** The fourth type, and again no new renderer: a
  state machine and a UML activity diagram are the same picture with two
  vocabularies over it, so `MermaidStateReader` reads `stateDiagram` into
  `domains/uml/UMLActivity` — the filled circle, the ring, the fork bar and
  the choice diamond were already there. `[*]` becomes a start where it is
  written first and an end where it is written second, one of each per scope;
  `state X { … }` composite states are the frames a sub-flow already has,
  nested, and a transition that names one enters it at the state it starts at
  rather than making a second empty box beside the frame. `state "A long name"
  as s`, `s : a description` on a second line, `<<fork>>` / `<<join>>` /
  `<<choice>>` and `direction`. `ActivityFlow` grew a plain `label`, because a
  state machine writes the event on the arrow and an activity diagram writes a
  guard in brackets, and they are not the same thing.
- **Mermaid ER diagrams — the third type, and the one this library was built
  for.** `erDiagram` is a schema, so
  `gallery/rangerflow/domains/mermaid/MermaidErReader.rgr` reads it into the
  schema model and hands it to the ERD editor's own `SchemaToGraph`: entities
  are tables, attributes are columns with their `PK` / `FK` / `UK` and their
  comment, and a relationship is the crow's foot `EdgeDecoration` has drawn
  since the first commit. Every cardinality pair Mermaid has — `|o` `||` `}o`
  `}|` and their mirrors — with the identifying `--` and non-identifying `..`
  line, entity aliases, and `direction`. Where it differs is stated rather
  than hidden: Mermaid's relationship names no columns, so the line joins two
  boxes rather than two rows. Which types have a reader is now one function —
  `MermaidReader.draws` — that the page, the demo and the parity meter all
  read, so the matrix in `docs/MERMAID_PARITY.md` cannot drift from the code:
  thirty types, three drawn, twenty-seven recognised and refused.
- **Mermaid class diagrams, drawn with the UML model that was already
  there.** `classDiagram` is the second Mermaid diagram RangerFlow reads, and
  it needed no new renderer: `gallery/rangerflow/domains/mermaid/MermaidClassReader.rgr`
  translates it into `domains/uml`, and from there it is the compartment node
  the schema editor is built on — the hollow triangle at the supertype, the
  filled diamond at the whole, the dashed realization. It reads `class X { … }`
  blocks and the `X : +member` one-liner, visibility, `type name` attributes
  and `name(params) returnType` operations, `$` static and `*` abstract,
  `<<interface>>` stereotypes, every relation with its cardinalities and label,
  and puts the ornament on the end the syntax names — the class written first
  is the one being pointed at, which is why `UMLRelation` grew a `markAtSource`.
  Paste one into the page's Mermaid box and it draws.
- **Every diagram type Mermaid ships, accounted for.** The parity run now reads
  the diagram list out of the INSTALLED Mermaid's own build and asks this
  reader what it makes of each header: thirty types, two drawn and twenty-eight
  recognised and refused, with a type nobody has taught it about failing the
  run rather than being read as a flowchart and drawn as a page of invented
  boxes. The matrix is written into `docs/MERMAID_PARITY.md` by the run.
- **Mermaid parity, measured against Mermaid.** A claim of parity with a
  format is worth what the person making it wanted it to be worth, so this one
  is not a claim: `npm run rangerflow:mermaid:parity` hands every diagram in
  `gallery/rangerflow/fixtures/mermaid/` to **Mermaid's own parser** (11.17,
  under jsdom, in the parity harness) and compares its answer — vertices with
  their shapes, edges with their strokes and arrowheads, subgraphs with their
  members, the classes a `classDef` handed out — with what RangerFlow's reader
  made of the same text. 182/182 checks over 26 examples, written up in
  `docs/MERMAID_PARITY.md` by the run rather than by hand. What the corpus
  turned up and the reader now does: ids with a `-` or a `.` in them
  (`node-1`), Mermaid 11's `A@{ shape: rounded, label: "…" }` nodes with the
  alias table that comes with it, named edges (`A e1@--> B`, `e1@{ animate:
  true }`), markdown strings, labels that run past the end of their line,
  `subgraph "A title"` named `subGraph0` the way Mermaid names it, and
  Mermaid's real rule for what a subgraph contains — a node mentioned inside a
  block belongs to it, even when it was named earlier outside. `<br/>` now
  arrives as a newline and the text layout breaks on it, so a two-line label is
  two lines. The dozen other diagrams Mermaid draws — sequence, class, state,
  gantt, ER, … — are recognised by their header and read as nothing at all,
  because a sequence diagram read as a flowchart is a page of invented boxes.
- **A diagram's look, as a stylesheet.** EVG already carries a small
  print-safe CSS engine, so RangerFlow's colours are now something you write
  rather than something you pass: `gallery/rangerflow/core/FlowStyleSheet.rgr`
  maps class selectors, `@vars`, `@media` and themes onto nodes, edges and the
  canvas. A node answers to what it already is — `.node`, its type,
  `.shape-diamond`, `.id-<id>` — plus the vocabulary its domain wrote, so a
  Mermaid node wears its kind, the shape it was written as and every
  `classDef` name it was given, and one sheet works on the next diagram too.
  Five looks ship with it — default, forest, dark, neutral and print — as one
  sheet whose themes vary a `@vars` block rather than restating the rules,
  because the engine's one precedence rule is that theme-scoped beats
  unscoped. `npm run rangerflow:mermaid -- --style=print` writes the printable
  version; `--style=house.css` uses your own; `--restyle` lets the sheet beat
  the diagram's own `classDef` instead of the other way round. The browser
  page has a **look** dropdown beside the Mermaid box, and
  `?scenario=mermaid&look=dark` picks one on load. Two gaps in the core it
  needed: `FlowNode.textColor` was a field nothing painted (labels now use it,
  and a `classDef` fill with no text colour gets a readable one computed from
  its luma), and a node had no border weight of its own.
- **Mermaid, rendered by RangerFlow.** Mermaid is how a diagram travels
  through a README, a ticket and a review, and it is also a diagram you cannot
  print, hit-test or drag a node in. `gallery/rangerflow/domains/mermaid`
  reads the flowchart dialect — every node shape, every link including `--o`,
  `--x` and the `<-->` family, both label forms, chains and `&` fan-outs,
  `subgraph … end` nested, `classDef` / `class` / `:::name` / `style`, `%%`
  comments and `---` front matter — and hands back a `FlowGraph`. From there
  it is the layered layout, the lane router and the four backends the ERD
  already uses: `npm run rangerflow:mermaid` writes SVG, PDF, HTML, JSON and a
  GPU scene from `fixtures/order_flow.mmd`. A subgraph becomes the sub-flow
  frame RangerFlow already had, drawn behind its members and dragging them
  with it, and the layer ordering keeps a group's nodes together so the frame
  encloses what it says it encloses. `BT` and `RL` are the layout mirrored
  rather than a second layout. In the browser page, `?scenario=mermaid` opens a
  text box: paste Mermaid, press render, and the result is draggable, editable
  and exportable to SVG — checked on every `rangerflow:web:test` like the other
  scenarios. What is ignored on purpose: `click`, `linkStyle` by index, and
  `direction` inside a subgraph.
- **A database workbench over the simulator, at `/firesim/`.** A backend you
  cannot see is a backend you have to take on trust, so `gallery/firesim/demo`
  puts the thing a person actually reaches for on top of it: a data browser.
  Collections, the documents in one, and a document's fields with their
  Firestore TYPES — string, integer, double, boolean, timestamp, array, map,
  reference, geopoint, null — with a subcollection listed apart from the
  fields, because it is not one, and pressable, so the tree is walked rather
  than described. The document list is an `FsWatch` polled on the app's own
  tick, so a row appears because a listener said it did, and switching
  identity re-registers it. The Rules tab is a real tester: every cell is
  `FsRulesEval.check` against the rules file printed under it, for that
  identity, ON WHATEVER PATH IS SELECTED IN THE BROWSER. The Query tab builds
  a `where` and an `orderBy` by pressing — fields and values read out of the
  data — and the answers worth having are the refusals. Users is the accounts
  table with providers and custom claims; Traffic is every request answered,
  with runs of listener polls collapsed under a count. Two datasets and
  neither is built in: a sample written in Ranger with one of every value
  type, and `gallery/realtrainer`'s own 747-document seed as EXAMPLE DATA,
  fetched by the host and loaded through `FsSeed` unconverted, each carrying
  its own rules file. Drawn by EVG on WebGL, controlled by `gallery/ui`'s own
  controllers, full screen, with the whole backend in the tab and no server
  behind the page — and no bundler and no install either, since the compiled
  module is one self-contained file. Two gates: `firesim:demo` drives the same
  app with a made-up clock and presses its controls at the rectangles the
  accessibility tree reports (102 assertions, no browser), and
  `firesim:demo:frame` loads the page in Chromium and reads the framebuffer,
  because a script that 404s and a WebGL context that is never created both
  look like a working app to a check that never opens one.
- **Snapshot listeners, as the simulator's own rather than as a claim about
  Google's.** `onSnapshot` travels over a gRPC `Listen` channel that a REST
  surface cannot pretend to be, so `FsWatch` sits under the `firesim/` prefix
  and is named for what it is. A watch is a registered query plus what it last
  answered, and a poll re-runs it and diffs the RESULT SET — not the store's
  change log, which is the decision worth arguing about: a write log cannot
  tell you that a document nobody touched left the result because someone
  else's write pushed it past the `limit`, that an edit to an unrelated field
  made a document start matching a `where`, or that a rule stopped allowing a
  row that is still there. The rules run again on every poll, as a `list`, so
  revoking access makes rows leave a live view instead of sitting in it.
- **A Firebase, simulated, in Ranger — `gallery/firesim`.**
  `gallery/realtrainer` is a five-screen application with no backend: it reads
  its week out of a file and "saves" through a countdown with nothing behind
  it. This is the backend it did not have — a Firestore, the Identity Toolkit,
  a security-rules engine and a model that streams — with no Google account,
  no emulator, no Java and no network. The decision the rest follows from is
  that the seam is the WIRE PROTOCOL and not an interface invented for a mock:
  it answers Google's own REST shapes (`documents`, `:runQuery`, `:commit`,
  `:batchGet`, `accounts:signUp`, `accounts:signInWithPassword`, the
  emulator's wipes), so switching an app to a real project is a base URL. What
  it is worth having is not running a query but REFUSING the ones the real API
  refuses: `update` on a missing document is a 404, an `orderBy` field a
  document lacks removes that document, an inequality on two fields is a 400,
  a filter on an absent field matches nothing (not even `!=`), and a `list`
  where one row is unreadable is denied whole. `FsRules` parses
  `firestore.rules` — the file a project deploys, not a JSON description of it
  — including nested matches, `{var=**}`, user functions, `get()`, path
  literals and `.keys().hasAll()`; the subset's boundary is a parse error with
  a line number and never a quiet allow. A call is a HANDLE on the app's own
  clock, so the wait, the retry and the failure states are reachable and
  nothing sleeps; the model answers a word per `chunkMs` over SSE in Gemini's
  shape and its own, deterministically, and can be made to break part-way. It
  runs in Node, on a socket for an Android or iOS simulator, and wholly inside
  a browser tab (`web/build.mjs` wraps the compiled module with no bundler,
  because the compiled `.cjs` has no `require` in it). `firesim:realtrainer`
  is the proof: the reference recorder's own `seed.json` goes in unconverted,
  comes back over `:runQuery` as a signed-in user through the rules, and the
  demo has to draw the same accessibility tree from it — six scenarios, node
  for node. 101 + 102 + 17 + 16 assertions and 24/24 target builds; the client
  build an app carries is 113 kB of Kotlin, which is the measured answer to
  whether it fits on a watch. Plan and the phases left in
  [`PLAN_FIRESIM.md`](PLAN_FIRESIM.md).
- **A segmented date field, measured against the browser's own.** The
  calendar demo's date box was a formatted label; a person asked for the
  `__/__/____` editor, and shadcn has none to measure (its Date Picker is a
  calendar in a popover), so the oracle is Chromium's `<input type="date">`,
  read segment by segment through the accessibility tree. `DateFieldCtl`
  reproduces what it does rather than what one would guess: "1" then "3" in
  the month is 12 and moves on, "0" then "0" is 01, a lone zero shows nothing,
  the year takes five and six digits and never advances, ArrowUp on 12 wraps
  to 01, the empty year steps to this year, February 31 is reachable and the
  value is empty until it is possible, Backspace empties a segment and stays.
  `ui:datefield:check` replays all 26 recorded scenarios; the calendar fills
  the field and a typed date moves the calendar.
- **A one-time code field, measured against input-otp.** `OtpCtl` reproduces
  the library behind shadcn's Input OTP, whose whole behaviour is a hidden
  text input plus a normalisation of its selection: a caret on a character
  becomes a range over it so typing inside replaces, Backspace on a middle
  slot leaves the slot before it active, a full field keeps its last slot
  active and the seventh digit replaces the sixth, a letter into a digits
  field is refused whole, focus lands at the end wherever the pointer was.
  `ui:otp:check` replays 33 scenarios; one rule is deliberately not copied and
  asserted the other way round — a click on a focused field selects the slot
  clicked, where the reference's caret in its invisible squeezed text made
  slot 0 select slot 3. A new `otp` demo page draws it (`ui:otp:demo`), and
  the input bench lists both fields as measured elsewhere rather than scoring
  their selection rules against a plain `<input>`.

### Fixed

- **The workbench's latency slider snapped to an end on every press, and its
  in-flight meter strobed.** Two defects with one shape: a control measured
  against the wrong thing. The slider's `role` and id were on the THUMB, so a
  drag was measured against a 14-pixel box that had already moved — the role
  and the id are on the track now, which is the box that stays put, and the
  thumb no longer carries a margin that moved it a second time. The progress
  bar counted the listener's polls, which happen three times a second whether
  anything is happening or not; it counts only calls a person caused. Also
  fixed in the same pass: a listener that was refused (offline, or a rule)
  gave up for good and left the browser frozen on whatever it had, which is
  indistinguishable from an empty database — it retries and says when it is
  back; answers still in flight from the previous collection were applied to
  the new one, so walking quickly into a subcollection listed the parent's
  documents in it; and a dataset loaded from the rail was seeded to a fixed
  account rather than to whoever loaded it, so picking one emptied the
  browser for being right.
- **Firestore rules: `&&` and `||` now absorb errors, as CEL does.** Found
  while building the console. `resource.data.userId == uid ||
  request.resource.data.userId == uid` is the ordinary way to write one rule
  for a create and an update, and on a create the left half reads a document
  that is not there — which the strict operators here were turning into a
  denial. `||` now answers true when either side is true even if the other
  errored, `&&` false when either is false, and only a combination that
  decides nothing keeps the error; the left side still short-circuits when it
  decides the answer, so a `get()` on the right is not a read that should not
  have happened. `resource` is `null` on a create rather than an empty map
  too, which is what makes `resource == null` work as a create guard.
- **Keyboard focus was invisible on the demo forms and the calendar.** Not one
  of `form.css`, `profile.css` and `calendar.css` had a `:focus` rule, so Tab
  moved the focus and drew nothing: a Tab into the readonly invoice number,
  which draws no caret, looked like a Tab that did nothing, and so did every
  Tab after it through the radios and the buttons; on the calendar the arrows
  moved the focused day, as the hint says, and no rule drew the day they had
  reached. Focus is drawn now — the border turns dark on a box, a button and
  the select's trigger, the radio's ring and the switch's knob carry a class
  the demo writes, the calendar's day gets a 2px ring — and the invoice form
  routes the arrows and Space to `RadioGroupCtl`, which had the rule all along
  and was never asked. `input-bench` gained a `ring` column that fails a field
  whose border does not change when it takes focus.
- **Text ran on as a flat line where the glyphs stopped.** The WebGL painter's
  text atlas is sized on the first frame from the widest run it holds; a later
  run wider than that whole texture was placed at x=0, uploaded up to the
  edge, and kept a slot whose u1 ran past 1.0 — and CLAMP_TO_EDGE answered
  every sample beyond the edge with the last column of ink, a streak in the
  text's colour. Seen on the timeline's descriptions on a Retina display,
  whose wider face outgrew the 512px atlas a menubar's short runs had sized.
  A run wider than the shelf is refused and the atlas rebuilt at the width it
  needs; a run wider than the card's largest texture is cut where the texture
  ends, with the quad cut to match.
- **A chosen calendar day turned hover-grey under the pointer.** A pseudo-class
  outranks a plain class in `EVGStyleSheet` as in CSS, so `.cd-day:hover` beat
  `.cd-day-selected` however the two were ordered; the day just clicked read as
  not chosen until the pointer left it.


- **The dashboard on Android stopped responding.** Every frame of
  `DashboardDemo.display()` ran the whole stylesheet cascade, laid the whole
  tree out and parsed and ran the chart's Vela spec again, and the Android host
  asks for a frame on every pointer event — so a drag was a full layout plus a
  chart per report, on an emulator's cold ART, until the input queue passed the
  five seconds the platform allows and declared the app gone. The demo now keeps
  its layout: a scroll moves the scrolled subtree by the difference
  (`EVGLayout.scrollOnlyFrom`, the realtrainer's shortcut), a hover or a press
  runs the sheet and lays out only if the sheet says a box could have moved, and
  the chart's commands are kept until the spec's text changes. In Node a warm
  frame went from 22-80ms to 5-9ms and a scroll frame from 33ms to 7ms; the
  dashboard gate compares the shortcut's frames with a full layout's at nine
  offsets and they are identical. On the device side the first frame is
  computed off the main thread behind the page's own background, the ripple
  shader is off by default on an emulator, and a fling now drops the cached
  frame — it had been advancing the offset while the screen showed the frame
  from before it.


- **The page was fitted to its canvas rather than to what it draws, and came
  out a quarter smaller than it needed to be.** The demo is a 980x760
  composition, and the fit scaled that whole rectangle into the window. But the
  scenes do not fill it: measured on the display list, the words stop at y=607
  on the loading screen, y=586 on the dashboard and y=346 on the session --
  which uses less than half its canvas. The rest is the full-height rail and
  the panel behind it, and fitting that empty remainder is what made the page
  small on a phone.

  The fit is now against the content. On an iPhone 16 Pro in landscape that is
  20% bigger on the loading screen, 10% on the sign-in page, 25% on the
  dashboard and 69% on the session. Nothing is cut off, by construction:
  fitting the content box is what guarantees it, where before the guarantee
  came from fitting a canvas that was mostly empty.

  Decoration below the last line is allowed to run off the bottom of the
  screen, which is what a full-height rail should do -- ending partway up with
  a gap beneath it is the thing that looked wrong. The content box is measured
  from the display list, which is in page coordinates and so does not depend on
  the scale; a tab change that moves it refits, with a threshold so the page
  does not twitch on rounding noise.

  `check_rt_ios.rgr` asserts the invariant this rests on: every word of every
  scene is on screen, on every device the port claims to support, notch and
  home indicator included.

  Two earlier attempts are worth recording as measured dead ends. Filling the
  width and letting the reader pan hides real content -- the page has no
  scrollbar and no cut-off row, so nothing announces that it continues. And
  containing the canvas is what this replaces. What CSS cannot do here is
  unchanged: the stylesheet's viewport is the fixed 980x760 canvas on every
  target, so a `@media` query would be answering questions about a rectangle
  that never changes size.

### Added

- **`RANGER_PROFILE` reports where a frame's time went, on the device.** With
  it set in the launched process's environment the host logs, every 60 frames,
  the milliseconds spent laying the page out and building the display list
  against the milliseconds spent turning that list into pixels, plus the
  command count. Guessing which of the two is slow from the outside is how an
  afternoon disappears.

### Fixed

- **Both native painters ignored a rotation's origin, which drew RealTrainer's
  loading spinner as one small bar.** The ring is twelve 8x26 blades fanned
  into a circle by `transform: rotate(Ndeg)` about
  `transform-origin: 4px 54px` -- a point 41px BELOW each blade's own centre.
  `EvgPainter` turned every command about its own box centre instead, so all
  twelve spun in place and landed on top of each other.

  `EVGDisplayList` has carried `rotOriginX`/`rotOriginY` and a `hasRotOrigin`
  flag for exactly this, and says so: the centre is "right for a lone rotated
  label and wrong for everything else: a box, its text and its children have to
  turn about ONE point or they come apart", and "a backend that ignores these
  two fields keeps the old behaviour exactly". Both native backends were
  ignoring them -- the WebGL painter reads them, which is why the browser drew
  the ring correctly and neither Apple nor Android did. Fixed in both; the
  surfaces' `rotate(degrees:px:py:)` already took the pivot.

- **A face cache that was thrown away sixty times a second.**
  `CoreGraphicsEvgSurface` wraps the `CGContext` handed to `draw(_:)`, which is
  a different object every frame, so the surface is built per frame -- and it
  owned the `CTFont` cache. Its own comment explains what that costs: a page
  draws around 190 text runs a frame and asks for about six distinct faces, and
  "making a `CTFont` per run is the difference between a frame and a stutter".
  The faces now outlive the surface, which is what the cache was for. All three
  Apple hosts were affected.

### Added

- **A fit mode for the RealTrainer port, and the one platform-specific
  decision in it.** Contain -- the whole 980x760 composition on screen -- is
  right on an iPad, where the window's shape is close enough that it gives up
  about 12%. On a phone it is not: a 19.5:9 window against a 1.29:1 page leaves
  roughly 40% of the screen empty and halves the size of the text. A phone now
  spends the whole width on the page and pans down it, starting at the top
  rather than opening with the heading already scrolled off. Both fits are in
  `rt_ios.rgr` where `check_rt_ios.rgr` drives them; only the choice between
  them is in Swift, which is the same division `ui_ios.rgr` makes for the
  watch.

### Fixed

- **Every Swift string operation was O(n) where every other target's is O(1),
  which made EVG's linear scans quadratic.** The RealTrainer port ran visibly
  slower on an iPhone than the same Ranger does on an Android emulator, with a
  lag between a tap and the page responding. It is not the painter and not the
  demo: it is that `strlen`, `charAt`, `substring`, `indexOf` and
  `lastIndexOf` were written against Swift's `String`, which indexes by
  GRAPHEME CLUSTER and walks from `startIndex` for every one of them.

  EVG scans strings the way anything parsing text does --
  `while (i < (strlen s)) { def c:char (charAt s i) ... }` -- and that loop is
  O(n) on Kotlin, Java, JavaScript and C#, where `s.length` and `s[i]` are
  constant time. On Swift it was O(n squared). Measured on the same page:

  | | charAt calls | characters walked |
  | --- | --- | --- |
  | parsing the 28 KB stylesheet | 84 057 | **1 250 993 706** |
  | one frame of the dashboard | 68 392 | **4 608 275** |

  The first column is the work Kotlin does. The second is what Swift was doing.

  Ranger's string is a sequence of UTF-16 code units -- that is what `charAt`
  and `strlen` mean on JavaScript, Kotlin, Java and C#. Swift's UTF-16 view is
  the matching model AND the one the standard library keeps *breadcrumbs* for,
  so that offsetting into it is amortised constant time; it exists for NSString
  bridging. The six operators now go through small helpers over `s.utf16`,
  installed once per file as a polyfill. Swift is both faster and more
  consistent with the other targets than it was.

### Added

- **`gallery/realtrainer` runs on an iPad, from the same Ranger.**
  `ranger/rt_ios.rgr` imports `RealTrainerDemo.rgr` unchanged and compiles to
  19 000 lines of Swift holding the EVG controllers, the cascade, the layout
  engine, the display list and the demo. Nothing about the app is written twice
  for Apple; `gallery/evg/apple` paints it, as it paints the dashboard.

  The facade is not a copy of the dashboard's, because the two pages are not
  the same shape. The dashboard is a DOCUMENT -- a fixed width that scrolls, so
  it is scaled by a ratio of widths and its height becomes whatever the
  viewport is worth at that scale. RealTrainer is a COMPOSITION -- 980x760,
  designed whole, with nothing to scroll -- and scaling it that way would
  re-lay it out into a shape its author never drew. So it is CONTAINED:
  `min(w-ratio, h-ratio)`, centred, letterboxed, and the same size on every
  screen. That difference is the whole reason there are two facades rather than
  a flag on one, and it is in Ranger rather than in the `UIView` for the reason
  the port exists -- `check_rt_ios.rgr` drives all 55 of its rules on Node.

  `RealTrainerDemo` grew a `display():EVGDisplayList` beside its
  `displayListJson()`, which now calls it. A browser host has to parse
  something anyway; a host sharing a process with the demo should not
  serialise a display list only to parse it back.

- **The Apple build driver builds any gallery demo, not just the dashboard.**
  Everything that was specific to one demo -- its Ranger entry point, where the
  Swift lands, the bundle id, which hand-written host drives it, which
  stylesheet is packaged -- is now an `IosApp` record rather than constants in
  `IosBuild`, and `--app=KEY` picks one. A port is an entry in that registry
  plus a facade and a view, not a second copy of the build. `ui:ios:smoke`
  drives the second app end to end through the same fake toolchain, so a change
  that breaks it for RealTrainer cannot pass by being tested on the dashboard
  alone. A target a port has no host for -- a watch, for this one -- says so
  instead of building a bundle that cannot run.

### Fixed

- **`gallery/evg`: a grid flag nothing read.** `EVGLayout.layoutGrid` set
  `usingSubgrid = true` when a column template inherited its tracks from the
  enclosing grid, and then never looked at it. The subgrid effect is carried
  entirely by rewriting `colSpec` into the parent's pixel tracks, and the rows
  branch a few lines down does the same job with no flag at all -- so this was
  a leftover from an earlier shape of the code, not a guard someone forgot to
  finish. Removed.

  This one was the source's own, not the compiler's: `swiftc` is simply the
  first target that says "written to, but never read" out loud. Worth keeping
  it that way rather than teaching the writer to drop a dead store silently --
  the warning found real dead code, and eliding it would have hidden exactly
  that. It was the only one in 46 000 generated lines.

### Added

- **Four more things swiftc could not build, and the last of the warnings.**
  Each was a gap in a mechanism that already existed rather than something
  needing a new one:

  - **A parameter named `where`.** `reserved_words` in `compiler/Lang.rgr` is
    the compiler's table for exactly this, and it renames a word once, in
    `assignParamCompiledName` / `defineVariable`, so the declaration, the uses
    and the call-site argument label all move together. The `swift6` block held
    four entries, filled in as errors were hit -- and `where`, which opens a
    generic constraint in Swift, was not among them, so
    `bootstrapCI(values : values, where : where)` did not parse. The block is
    now the whole of Swift's keyword list, taken from the one
    `scripts/reserved_probe.py` already carries rather than guessed at. Five
    words actually occur in gallery/ui: `init`, `open`, `where`, `guard`,
    `any`.
  - **A public class with internal witnesses.** A class the docs mark `public`
    came out as `public final class VlJson : Hashable` with a non-public
    `hash(into:)` and `==`, which Swift rejects: a protocol witness has to be
    at least as visible as the conforming type. Those two members are
    synthesized by the writer, not written by the author, so their visibility
    is not a separate decision -- it is the class's, and it is now read once
    and used for all three.
  - **An expression swiftc gave up on.** "The compiler is unable to type-check
    this expression in reasonable time" on a six-term concatenation, because
    the inline Swift for `strfromcode` and `charAt` was a tower of generic
    initialisers -- `String(UnicodeScalar(UInt32(Int(s[s.index(...)]…)))
    ?? …)` -- and the overload search across six of them exploded. Both now
    call one monomorphic helper installed through `create_polyfill`, the same
    way the C++ target has always emitted `r_str_from_code`. The line went from
    roughly 900 characters of nested generics to a plain concatenation of
    calls.
  - **A loop item nobody reads.** The index was elided and the item was not,
    because `swift_rc` asked `ref_cnt`, and the loop assigns the ITEM from the
    list on every iteration -- so its counter is never 0 even when the body
    ignores it. It now walks the loop body, which is how `go_for_bind` has
    always decided the same thing. This fixes swift3 as well.

- **`swiftc` reported the Swift target's real errors underneath about four
  hundred warnings.** Compiling `gallery/ui` to Swift printed screen after
  screen of "immutable value 'gi' was never used", "variable 'sorted' was never
  mutated" and "result of call to 'readUInt16(offset:)' is unused" -- so many
  that finding the errors among them meant scrolling. Every one of them was the
  compiler's own doing, and each has a real fix:

  - **`char` is now `Int`, not `UInt8`.** Ranger's `char` IS an integer code
    unit: Lang.rgr defines `==`, `<`, `>` and the rest across `char` and `int`
    in both directions, and `(charAt s i)` answers an `int` the source
    routinely stores in a `char`. Every other target maps both to one integer
    type. Swift was the one where `let ch : UInt8 = Int(...)` and `let n : Int
    = ch` are hard errors -- 22 of them in `SVGPathParser` alone. Buffers stay
    `[UInt8]`, since `buffer_set` already wraps the value; only the scalar
    changed. `charAt` on a charbuffer had no Swift 6 template at all and fell
    through to the JavaScript one, emitting `.charCodeAt(...)` into Swift; it
    has one now.
  - **A loop binding the body never reads is written `_`.** `for x in list`
    binds an index whether or not anyone wants it, and Swift warns on each. The
    swift3 template already had `swift_rc`, which writes `_` when a name's
    ref_cnt is 0; swift6 did not, and the item name needs it just as much.
  - **A local is `let` unless the body writes to it.** Arrays, maps and buffers
    were declared `var` unconditionally, because `.append` does not bump
    set_cnt -- but StaticAnalyzer's `is_mutating` answers exactly that
    question, and now that the pass runs for Swift the writer can ask it. A
    Ranger class is a Swift class, so `p.x = 1` writes through a `let` binding
    perfectly well; only the value types (collections, buffers, String) and a
    local handed to an `inout` parameter as `&x` need `var`.

  Both of the last two rest on ASKING THE TYPE, not on matching its name. The
  writer used to carry a list of the strings "buffer", "charbuffer",
  "int_buffer" and "double_buffer", because a parameter declared `b:buffer`
  reaches it with `value_type` unset. The resolved type was there the whole
  time in `eval_type`, which the flow phase fills in
  (`nameNode.eval_type = (nameNode.typeNameAsType(ctx))`); the list is gone,
  and so is the one inside StaticAnalyzer, which as a side effect now answers
  for `charbuffer` too. `paramNeedsInout` also had a second, local rule beside
  the analysis -- `set_cnt > 0` on a value collection. It answered a strictly
  smaller question than the pass does; gallery/ui compiles byte for byte
  identical without it, and one question now has one answer.
  - **A call kept for its side effect says so.** `def x:int (readUInt16 off)`
    where nothing reads `x` becomes a bare value-returning call, which Swift
    warns about; it is written `_ = ...` now.

  The regenerated 46 000-line file holds zero `let` locals that are then
  mutated, zero `var` locals that are never mutated, zero unread loop bindings,
  and zero assignments to a `let` parameter.

- **Swift `inout` is now inferred across call chains, not just at the function
  that does the mutating.** The previous fix looked at one function at a time:
  a parameter got `inout` if that body assigned to it. That is not where the
  requirement ends. `put16(b:buffer ...)` writes into `b`, so `putLoca` -- which
  only passes its own `out` along -- has to take `out` `inout` as well, and so
  does `writeLoca` above it. Fixing one level exposed the next, and hand
  annotation was chasing a moving target: the shape is transitive and the fix
  has to be too.

  The Rust and C++ targets had already answered exactly this question.
  `StaticAnalyzer` marks a mutated array, map or buffer parameter, then runs a
  fixpoint (`propagateArgMutRef`, `analyzeClassTransitiveMutBorrow`) that
  carries the requirement from a callee's parameter to whatever the caller
  passed in, repeating until nothing changes. Swift now uses that pass instead
  of a second, weaker rule of its own.

  It uses a marker of its own inside it -- `needs_swift_inout` beside
  `needs_cpp_reference` and `rust_borrow_type` -- because the two languages
  disagree about objects: a class instance is a reference in Swift and needs no
  `inout`, but is a value in C++ and needs `&`. The value types are the ones
  that carry it: `[T]`, `[K:V]`, the four buffers, and `String`. Each marker
  still propagates independently, so Rust and C++ inference is unchanged.

  The static analysis pass, which ran only for `cpp` and `rust`, now runs for
  `swift6` as well. `gallery/ui` compiled to Swift -- 46 000 lines -- holds zero
  `let` parameters passed as `&x`, `writeLoca` included, with no `@(mutates)`
  annotations anywhere in the source; the compiler still reproduces itself byte
  for byte.

  A reassigned `string` parameter went the other way and stopped being `inout`.
  Ranger has no operator that writes into a string in place, so `s = (s + "x")`
  rebinds a local name and every other target reads it that way; Swift alone
  was writing the new value back to the caller, and demanding a `var` at every
  call site to do it. It gets the same `var` copy an `int` gets.

- **The Swift target could not compile a function that assigns to its own
  parameter, and silently mis-compiled one that mutates a `buffer`
  parameter.** A Swift parameter is a `let`, and Ranger lets a body assign to
  one -- `if (x < 0) { x = 0 }` is the ordinary way to clamp an argument, and
  every other target takes it because their parameters are mutable bindings.
  Kotlin already solved this by renaming the parameter and opening the body
  with a `var` copy; Swift now does the same through Swift's two-name parameter
  form, so the CALL is unchanged: `func f(x x__p: Int)` is still `f(x: 1)`.

  The second half was worse than a build failure. `paramNeedsInout` already
  existed and already handled `[T]` and `[K:V]`, but a `buffer` parameter
  reaches it with its `value_type` unset and only its NAME saying what it is,
  so `sfn put16 (b:buffer at:int v:int)` came out as a `let [UInt8]` its own
  body assigned into. Swift arrays are VALUE types: had that compiled, the
  callee would have written into a copy and every caller would have seen
  nothing. `buffer`, `charbuffer`, `int_buffer` and `double_buffer` are
  recognised now and get `inout` + `&` like every other collection.

  Found by compiling `gallery/ui` to Swift for the iOS port -- 46 000 lines
  that reach the raster and TrueType stack. `swiftc` reported 24 errors across
  seven functions; the generated file now holds zero assignments to a `let`
  parameter, and the compiler still reproduces itself byte for byte.

- **`npm run ui:ios:check` now says which of four look-alike states a Mac is
  in, instead of a list of "not installed".** They fail identically and have
  four different fixes: only the Command Line Tools installed (they carry no
  platform SDK, no simulator runtime and no `devicectl`, while `xcrun` reports
  fine, which is what makes it confusing); Xcode installed but not the SELECTED
  developer directory, which is one `xcode-select -s` and not an hour of
  downloading, so the report checks whether `/Applications/Xcode.app` is there
  before telling anyone to install it; Xcode selected but its licence not
  accepted or its first launch never finished; and the iOS platform never
  downloaded, which Xcode 15 and later leave out of the initial install. The
  report now also prints **what `xcrun` itself said** — `sdkPath` answers "" for
  every one of those, which is the right shape for a build and the wrong one for
  a diagnostic.

- A device build with `--run` on a machine with no `devicectl` said "no device
  is connected", which sent the reader to look for a cable problem they did not
  have. It says that `devicectl` is not there.

- **`npm run ui:ios:smoke` — the iOS build driver, run for real, on a machine
  that is not a Mac.** `--dry-run` checks the plan and cannot check the code
  that runs: the directories the driver makes, the plist it writes, the profile
  it decodes. That code went untested until the real thing was run on a Mac and
  died on `mkdir` for a directory that already existed. A stand-in `xcrun`,
  `security`, `plutil`, `codesign`, `open` and `xcode-select` on PATH now
  exercise everything around those tools — a device found and a paired-absent
  one skipped, an identity and a profile discovered, the five files in the
  bundle, `--no-build` not compiling, and the whole thing **twice**, because the
  second run over its own output is where the interesting bugs are.

- A device that is not plugged in is now said **before** `swiftc`, not after.
  It was resolved early enough to pick a profile with and then not acted on, so
  a missing phone cost a full 46 000-line Swift compile before the message.

- **`npm run ui:ios:device` — one command from Ranger source to a running app
  on the iPhone or iPad on the cable.** A device build needs three things a
  simulator build does not, and typing them is what made it a three-flag
  command: they are all on the machine already, so `lib/apple/AppleSigning.rgr`
  and `lib/apple/AppleDevice.rgr` find them. The **device** from
  `xcrun devicectl list devices`, taking the first CONNECTED one — a
  paired-but-absent device is listed and is not a candidate, and an install
  onto one fails four seconds later. The **identity** from
  `security find-identity -v -p codesigning`, preferring an Apple Development
  certificate, because this is testing on a cable and a distribution
  certificate cannot do it. The **profile** from the `.mobileprovision` files
  Xcode leaves behind — in both the directory it used before Xcode 16 and the
  one it uses now — matched on the bundle id *and* on this device being listed
  in it, with an exact bundle id beating a wildcard because the exact one is
  what Xcode made for this app. `--identity` and `--profile` still win when
  given.

  Then `devicectl device install app` and `devicectl device process launch`,
  which is two commands where a simulator needs five: there is nothing to boot
  and no window to bring forward. `--console` keeps the process attached with
  its output coming back, which is the whole point of a test build on a cable —
  a device has no console you can otherwise see.

  What it cannot do is CREATE a provisioning profile: that is a conversation
  with Apple's developer portal and the only command line tool that has it is
  `xcodebuild -allowProvisioningUpdates`, which is the thing this driver exists
  to avoid. The error says so in as many words, with the one-time Xcode step
  that fixes it.

  Both new parsers are pure functions over the text their tool prints, so both
  are checked on a machine with nothing plugged in: a device name with a space
  in it, a two-word state, a header row, an unavailable runtime, a 42-character
  fingerprint that is not one. `npm run apple:test` is 151 assertions now, on
  the same seven target languages.

- **`--no-build`**, for parity with the Android port: `--no-ranger` skips the
  Ranger→Swift compile, `--no-build` skips `swiftc` too and reinstalls the
  `.app` that is already there — the 46 000 lines that make a re-run slow when
  nothing but the device changed.

- **Ranger can call other command line programs, and the first thing it does
  with that is build an iOS app.** One new compiler primitive,
  `run_process_result (program args cwd capture env)`, answers
  `([exit code, stdout, stderr])` — three strings, because an array of strings
  is a shape every target already has and a record would need a systemclass and
  a type mapping per language. It either captures the child's output or lets it
  stream to this program's own, takes a working directory, and merges extra
  `"NAME=VALUE"` entries over the environment rather than replacing it, so a
  child never loses `PATH`. Arguments go as a **vector**: there is no shell in
  between, so nothing inside an argument is re-read as a redirection, a glob or
  a second command.

  Thirteen backends. `spawnSync` on Node, `subprocess` on Python, `os/exec` on
  Go, `ProcessBuilder` on Kotlin and Java, `System.Diagnostics.Process` on C#,
  `std::process::Command` on Rust, `Foundation.Process` on Swift,
  `Process.runSync` on Dart, `proc_open` on PHP, and `popen`/`system` on C++,
  which is the one target with no portable spawn and therefore the one that
  quotes the vector back into a shell word list. Where output is captured,
  stderr is drained on its own thread or queue — reading stdout to the end
  first deadlocks the moment a compiler fills the error pipe, which is exactly
  the case this was built for.

  [`lib/Shell.rgr`](lib/Shell.rgr) (MIT) is the API over it: a result object
  instead of three strings read out by index, a remembered working directory, a
  log of every command line asked for, and a **dry run** that records instead
  of executing. `npm run shell:test` — 44 assertions that really do start child
  processes — passes on **JavaScript, Python, Go, Rust, C++, Java and PHP**.

- **`lib/apple/` — an iOS, iPadOS and watchOS app builder, with no Xcode
  project.** An `.app` is a directory with an executable and a property list in
  it; `swiftc` can build an iOS executable directly, and everything above that
  is project bookkeeping a program can do instead. `AppleTarget` names the four
  decisions Xcode hides behind a scheme pop-up (the SDK, the triple with its
  deployment target and `-simulator` environment, the device families, the
  signing identity), `AppleAppSpec` writes the Info.plist, `AppleSimulator`
  reads `xcrun simctl list devices` as a pure function over its text, and
  `AppleAppBuilder` runs the pipeline: SDK lookup, bundle, plist,
  `plutil -lint`, `swiftc`, resources, entitlements read **out of** the
  provisioning profile, `codesign`, then `simctl boot`/`install`/`launch`.
  Nothing calls `xcodebuild` and nothing opens Xcode.

  Because every step goes through a `Shell`, the whole thing runs **dry** —
  which is how `npm run apple:test` asserts 151 things about the plan (which
  program, which arguments, in which order, including that the plist is linted
  before an hour of compiling and that signing happens after the binary is in
  the bundle) on a machine that is not a Mac. Those 151 checks pass on seven
  target languages.

- **[`gallery/ui/ios`](gallery/ui/ios/README.md) — the dashboard demo on
  iPhone, iPad and Apple Watch.** The same `DashboardDemo.rgr` the browser
  page, the gates and the Android port run, compiled to Swift 6 (46 039 lines,
  one file) and painted with CoreGraphics through the new
  [`gallery/evg/apple`](gallery/evg/apple/README.md) — an `EvgSurface` protocol,
  an `EvgPainter` transliterated from the Android one, and a single `CGContext`
  backend that serves UIKit, SwiftUI and watchOS alike.

  Three things are the port's own and all three are in Ranger rather than in
  Swift, so that nothing without a Mac is unable to check them. The **safe
  area**: an Android surface is the screen and an iPhone's is not, so the page
  is fitted into the window less the notch and the home indicator, and a
  window-to-page conversion that skipped that would be wrong by the height of
  the status bar everywhere on the page. The **readable fit**: 1336 page pixels
  across a 45mm watch is a scale of 0.148, which is a photograph of a dashboard
  rather than a dashboard, so a watch gets a floor on the scale and pans. And
  the **crown**, which reports a rotation rather than a distance and therefore
  does not go through the scale the way a finger does.

  The build itself is `ranger/build_ios.rgr` — a Ranger program, not a shell
  script — and `npm run ui:ios:plan` prints the ten commands it would run.
  `npm run ui:ios:verify` drives the port on Node: 82 checks covering the fit,
  the insets, presses at window coordinates, scrolling, the keys, the pinch,
  the watch, the crown, and a ripple clock that cannot get stuck. The Swift
  hosts themselves are written but **not compiled here** — Swift for Apple
  platforms needs a Mac, and the README says so rather than implying otherwise.
  The first `swiftc` run on a Mac found two things: `public` on host types whose
  generated counterparts are `internal` (everything builds into one module, so
  `public` bought nothing), and the two compiler defects above.

- **EVG on a smartwatch, as a number instead of an opinion.**
  `gallery/watch_evg/bench` runs three real watch screens — a dial with sixty
  minute ticks, a Wear list of chips, and a workout view — through EVG's whole
  pipeline on a 454×454 panel, phase by phase, on **four** targets from one
  source: Kotlin on a JVM (the Wear OS language, with a C1-only run as the
  ART-quality bracket), C++ ahead-of-time and reference-counted (the watchOS
  proxy), and JavaScript on Node. The Kotlin run paints through
  `gallery/evg/android`, the same painter and surface the `ui` and `pptx`
  Android ports compile into their APKs, and writes the three PNGs.

  The answer is **no, EVG is not too heavy**: the busiest screen is 0.91 ms for
  a full declarative rebuild and 0.12 ms retained on the pessimistic bracket,
  which scales to roughly 9 ms and 1.2 ms on a Cortex-A53/A55 watch core
  against a 16.7 ms budget. Three conditions come with it — do not rebuild
  declaratively at 60 fps on the little cores, keep paint on the platform's
  hardware canvas, and mind the cold frame, which on a JIT target is five
  hundred times the steady state. Every harness reports a `calibrate` figure so
  the estimates can be replaced by measurements from a real device.
  See `gallery/watch_evg/WATCH_PERFORMANCE.md`.

- **A "Taikasauva" in the edit mode: pick an object out of the drawing and cut
  the rest away.** Click a shape and the selection grows outward from it across
  what touches it and is within *Sieto* of the color you clicked — or, when the
  picture has no color boundary that agrees with the object, **draw a rough
  outline by hand and let the shapes decide where the edge really is**. ⇧ adds
  a second region, ⌥ takes one out; *Eristä* removes everything outside the
  selection and, with *rajaa reunoihin*, pulls the frame in around what is
  left. What remains is on transparency, which is how you cut an icon out of a
  photograph. One undo puts the whole drawing and its old frame back.

  The hand-drawn mode is the one that works on photographs. A suit and the
  curtain behind it are the same tone, so no tolerance separates them — and a
  background flood proves it: seeded from outside the line at a tolerance as
  tight as 30, it eats half the suit. The line you draw says *where*, roughly,
  and the shapes say *exactly*, on two questions rather than one:

  - **Admitted** — does two fifths of what you can see of a shape fall inside
    the line? Below that the shape is background, and even the part of it
    inside the line goes. This is what clears the curtain, the flag and the
    desk out from behind a person.
  - **Kept whole** — is it almost entirely inside (85%), in which case the line
    was a rough guess and the shape's own edge is the better answer. A sloppy
    circle wobbling ±25 px around a ball returns the ball, exactly.
  - In between, the line is the answer: the shape is clipped to it.

  Taking every admitted shape whole was the first version and it leaked badly,
  which is the difference between the two: one large background shape reaching
  across the line drags its whole visible area in, and the enclosure rule then
  closes over everything that shape surrounds — 178 shapes selected, 231 after
  closing, and 72 of those sat outside the line altogether. Measured as painted
  pixels landing outside the outline that was drawn, that is **30%**; clipping
  the shapes the line cuts through takes it to **0.2%**.

  Measured against ground truth rather than by eye: two photographs
  composited through a silhouette this repository knows exactly, then cut with
  a deliberately sloppy version of that silhouette — every point pushed out or
  in by up to 18 px. A rough outline scores **IoU 0.98** when the figure and
  the background are different pictures, **0.89** with a photographic
  background, and **0.86** when figure and background are cut from the *same*
  photograph and share every tone — recall 0.98, so what it loses is a rim, not
  a limb. A colour click on the same figures scores 0.08 to 0.67, which is the
  honest number for one click on a many-toned object and the reason the line
  exists. `npm run evg:trace:web:smoke` now builds a composite of its own and
  fails below 0.8.

  Everything works on what is **visible**, and that is the whole design. A
  traced picture is stacked: a lower layer's shape is the union of itself and
  everything painted on top of it. On a test image of a box with a ball
  overlapping it, the blue layer is 240 px wide and contains the ball — so
  asking that geometry which shapes touch which says every shape touches the
  one beneath it, and deleting the shapes on top uncovers a union that was
  never visible. That is what made cutting the box return a box with the ball's
  bulge painted blue, and cutting a person out of a portrait return a full
  rectangle. So the drawing is rasterized once in its own z-order into a buffer
  of shape indices — a z-buffer — and adjacency, "how much of this shape is
  inside your line", the enclosure rule and the crop box all read that. Canvas
  antialiases and a blend of two index colors decodes to a third, unrelated
  shape, so the raster is drawn twice under two different encodings and a pixel
  is believed only where both agree.

  The cut carries the z-order into a mask: white where the selection is, black
  where a removed shape used to cover it, in the order they are drawn, so a
  kept shape paints exactly where it painted before and nowhere else. What
  leaves the drawing is *moved* into that mask rather than copied into it, and
  only a kept shape with something removed above it needs the mask at all — a
  portrait cut costs 876 KB where cloning everything cost 936 KB. The mask
  carries an explicit user-space region, because a mask's default region is a
  percentage measured from user-space zero rather than from the viewBox origin,
  and on a cropped drawing that cuts the object in half along a straight line —
  the same trap the refiner's filter region fell into.

  A color flood cannot reach what is *painted on top of* the object — that is
  what tolerance is for — but the eyes and the tie are the object. So the
  selection closes over what it surrounds: the holes in its visible area join
  it. A ball merely crossing a box reaches the outside and is left alone, and
  the step stops once the selection covers 80% of the frame, because "inside
  the object" means nothing when the object is the picture.

- **The color count now goes to 64.** It stopped at 16, and the engine was never
  the reason: measured on a portrait the palette keeps growing well past that —
  16 asked gives 15 layers, 32 gives 24, 64 gives 38 — and the trace does not
  get slower for it (704 ms → 788 ms). Past 64 the returns stop being worth the
  bytes: 128 buys seven more layers for another half megabyte.

### Fixed

- **`create_dir` meant four different things on four targets, and every one of
  them failed only on the SECOND run** — which is the shape of every build
  script. `es6` was `mkdirSync` with no `recursive`, so a directory that already
  existed was an EEXIST stack trace; `php` was `mkdir` with no recursive flag, a
  warning and no parents; `go` was `os.Mkdir` rather than `os.MkdirAll`; `java7`
  was `File.mkdir` rather than `File.mkdirs`; and the C++ polyfill made one
  level. Seven targets already did the right thing — make the path, parents and
  all, and say nothing when it is there — and the other four do now.
  `tests/create-dir.test.ts` checks it on ES6, Python and Go, cleaning the
  directory out of each target's own run cwd first: a leftover tree from the
  previous passing run is exactly what makes a broken `create_dir` look fixed.
  Found by running the iOS build driver twice on a Mac.
- `write_file` did not import `java.io.FileNotFoundException` on the java7
  target, and its own polyfill catches it — so any Ranger program that writes a
  file failed to compile on Java with "cannot find symbol". Found by compiling
  `lib/apple` to all seven targets.
- The `exit` operator narrowed its argument on Go and Rust, and gained a
  `swift6` template. `os.Exit(int64)` does not compile, `std::process::exit`
  wants an `i32`, and swift6 was falling through to the `*` template — which is
  JavaScript.
- `env_var` gained `java7`, `swift3` and `swift6` templates. It had `kotlin` and
  not `java7`, so any Ranger program reading an environment variable failed type
  analysis on the Java target.

- **EVG laid out every element by formatting a debug message nobody read.**
  `EVGLayout.log` takes an already-built string and discards it unless `debug`
  is on, and one of its call sites is per-element in `layoutElement` — so every
  element of every layout pass formatted four doubles into a message that went
  nowhere. On the C++ target, where a double becomes a string through
  `ostringstream`, `callgrind` put 47% of the layout phase's instructions
  inside `vsnprintf`: 2,654 double→string conversions for one layout of an
  82-element tree. Seven sites now build their string inside `if debug`.
  Layout of that tree: **75× faster in C++** (2.107 ms → 0.028 ms), 2.4× in
  Kotlin, 1.3× in JavaScript.

- **`EVGStyleSheet.stripComments` was quadratic on every immutable-string
  target.** It appended one character at a time, which on Kotlin, Swift, C# or
  Java copies the whole accumulator per character — O(n²) for an n-byte
  stylesheet. V8 hid it entirely by concatenating with a rope, so only the JVM
  and native builds paid. It now appends whole slices between comments, with
  byte-identical output on 19 cases including both real stylesheets in the
  repository. Parsing a 6 KB sheet: **12× faster in Kotlin** (14.18 ms →
  1.20 ms), 6.8× in C++, 1.7× in JavaScript — and together with the layout fix,
  a cold first frame that is roughly half what it was.

- **An element's gradient never reached the display list.** `hasGrad`, the
  `gd`/`c2` fields of `toJson` and the WebGL shader's two-stop mix have all
  been there for a long time, and nothing ever wrote one from an *element*: the
  walk in `EVGDisplayList` read `backgroundColor` and no more. So
  `background: linear-gradient(…)` came out flat when there was a
  `background-color` beside it, and emitted no rectangle at all when there was
  not — a box that simply did not appear. `applyGradient` is the walk that was
  missing. Two directions and two stops is what the list can carry, so an angle
  is snapped to the nearer axis and the stops are swapped when it points the
  other way (exact for the four right angles); a radial gradient is left flat
  rather than turned into a lie about its shape, and the RGU1
  `gradient-from`/`gradient-to`/`gradient-dir` trio is passed through as it
  stands. `EVGJsonTest` now covers the walk as well as the serializer, which is
  the half it could not see.

- **The Rust backend hoists a self call the argument only *contains*.** One
  written directly as an argument — `this.dist(this.near(x))` — was already
  lifted into a local, because both calls borrow `self` and Rust allows only
  one such borrow at a time (E0499). One reached through anything else was not:
  the check looked at the argument's own head, so `itemAt outR (this
  .nearestIndex(…))` hid the call behind an index and the generated code did
  not compile. Only the nested call moves out now, into a local of its own,
  which is what rustc's own advice on the error says: an index stays the place
  it names and nothing is moved out of it.

  Nothing that opens a scope is searched, and the search stops at that branch
  rather than stepping over it. A loop body is the case that matters — `for xs
  item { this.markAsyncFrom(item visited) }` becomes a closure taking `item`,
  so the call in it borrows `self` when the closure runs, not at the call site,
  and a `let` lifted out of it lands where `item` does not exist. Hoisting into
  loop bodies took the compiler's own Rust rendering from 6 errors to 119.

- **The compiler's own Rust rendering compiles with no rustc errors.** It had
  stood at 6, all one defect: a field read whose object is an EXPRESSION rather
  than a name. `(node.getSecond()).vref` reads a String out of a `Ref` exactly
  as `head.vref` does, but it is a property node with no dotted path, so every
  test that decides "this read has to be cloned" turned it away and the String
  moved out of the borrow (E0507). Three places asked that question and each
  now recognises the shape: a local's initializer, a call argument, and the
  temporary an operator hoists its operand into — the last of which was not
  taking ownership of anything it hoisted. `rustc -O` builds the 81 000-line
  rendering, and that binary compiles the compiler's own sources to output
  byte-identical to the JavaScript build's.

- **A union-typed field keeps its variant wrap when the right side goes to a
  temporary.** `body = (new EvalValue.Map(…))` stores a member into a slot of
  the family type, and Rust wants the enum variant around it. The wrap was
  written at the assignment, so a right side pre-evaluated into a local — which
  is what a receiverless method does, to keep a `borrow_mut()` from spanning
  the right side — left the assignment with only `= <name>` to write, and the
  bare member went into the slot: "expected `union_EvalValue`, found
  `EvalValue_Map`". The wrap travels with the value into the temporary now. It
  fixed 13 of the 14 Rust shape tests, which were failing on this one cause.

- **A program may name a class `Cell`.** The Rust preamble imported
  `std::cell::Cell` into every generated program, so a class of that name
  collided with it — "the name `Cell` is defined multiple times" — and every
  later mention resolved to the std type instead, with `value` suddenly a
  private field and a missing generic argument. The std type is spelled in full
  at its two use sites now, as the pool header already did with `UnsafeCell`,
  so nothing is brought into scope to be shadowed. It also drops an unused
  import from every generated program that has no interior cell at all — the
  `evg-trace` Rust build now compiles with no warnings rather than one.

- **`evg-trace` builds for Rust.** The tracer's palette refinement is where the
  nested-call shape above came from, so `-l=rust` produced a program rustc
  rejected. `npm run evg:trace:cli:rust` now builds it, and the smoke test
  checks four targets rather than three: Node, Python, C++ and Rust all return
  byte-for-byte the same SVG.

### Changed

- **Tarkennin is the tool the edit mode opens on**, rather than Yhdistä. It is
  the one that adds something to the picture, where the others take things away.

- **A smooth stroke that dissolves a shape is still one undo step.** The shape it
  erased was recorded separately, so that one stroke cost two clicks to take
  back; the erased shape now travels with the stroke that erased it, and goes
  back where it was drawn rather than on top of everything.

### Added

- **A pre-processing stage on the bitmap, before anything is quantized or
  traced.** It sits beside the picture rather than in the parameter column — it
  is about the bitmap, and you want it where you can watch the bitmap change —
  behind an *Esikäsittely* button. There is **one picture**: the filters edit
  what you are working on and the pane shows the result, rather than an original
  and a copy of it side by side, which costs more room than it explains. *Kumoa*
  steps back one setting at a time and *Palauta alkuperäinen* returns the
  original in a click. Undo works on the settings rather than the pixels, and
  records on release rather than on every frame, so dragging a slider across the
  bar is one step back rather than forty.

  What it offers: grayscale, invert, brightness, contrast, gamma, saturation, a
  median despeckle, a blur, an unsharp mask, and posterize, plus four preset
  chains (logo, line art, photo, poster). The order is fixed and it matters —
  exposure first because everything after reads brightness, then color, then the
  blur that decides how much detail survives at all, then sharpening on what the
  blur left, and posterize last because it is a decision about levels rather
  than a correction.

  Deliberately a short list. Most of what a filter menu usually offers is
  already a tracing parameter here — the color count *is* quantization, the
  smallest area *is* despeckle, mono mode *is* threshold, the background section
  *is* background removal — and a second way to do the same thing makes a page
  harder to reason about rather than more capable. What earns a place is the
  work that has to happen before the palette is chosen, because afterwards it is
  too late. The blur is the one that pays: on a portrait it takes the SVG from
  **377 KB to 85 KB**, and that is detail the tracer never has to spend paths on.

- **A "Pehmennä" brush in the edit mode** — pick the color it should act on,
  then drag over the outline to take the corners out of it. Each dab pulls the
  points under the brush toward the midpoint of their neighbours, which rounds a
  corner, and then drops the points that have become nearly collinear, which
  makes the outline simpler rather than merely rounder. Both effects are local
  and small, so it behaves like a tool: one pass barely shows, several over the
  same place take the detail out, and a shape small enough to sit inside the
  brush loses points and area on every pass until there is nothing left of it
  and it goes. Measured on a portrait with a 72 px brush: ten small shapes in
  one window became two and their area fell 544 → 62 (−89%); on a large shape
  three strokes took the outline from 64313 to 52029 characters. The brush size
  is what decides which shapes count as small. A stroke is one undo step however
  many shapes it crossed.

  Only rings the brush actually reaches are converted to points — potrace's
  curve fit is far more compact than any point list (79 shapes are 1272 cubics),
  so flattening a whole shape to touch one edge of it made the file 2.5× bigger
  for nothing. A dab that would make an outline cross itself is refused, since
  under evenodd a crossed ring turns its own inside into a mesh of alternating
  slivers.

- **The refiner's patch is quantized with the colors it is about to butt up
  against.** The seam at the edge of a refine stroke is a palette problem before
  it is anything else: a crop quantized in ignorance of its surroundings invents
  its own near-equivalents of the same tones, and every one of them shows as a
  line where the two meet. The colors at the stroke's border are now sampled
  from the picture — walking a ring just outside the stroke and asking which
  shape is on top, the same question the eye asks — and pinned into the crop's
  palette with `paletteMode: "seeded"`, so the border matches by construction.

  How many colors the crop may invent *beyond* those is now the control that
  matters, and it is a sharp one. Measured on a portrait, against the seam the
  picture has there naturally: border colors alone leaves the seam **1.2** above
  natural; letting the crop add two of its own takes it to **6.7**, and sixteen
  to **8.4**. Detail runs the other way, 6.8 → 8.6 → 17.9. Four extra colors
  with a firm edge snap sits at **1.6 with 10.2** — nearly the seam of none and
  half again the detail — and that is the default. `Lisävärit` and `Sulautus`
  expose both ends of the trade.

  The edge snap is the second half: a shape's tolerance for being snapped onto a
  surrounding color widens toward the border, so at the edge almost anything
  returns to the level around it and deep inside almost nothing does. A color
  with no near neighbour out there keeps its own — sometimes the patch really
  did find something the frame cannot say, and forcing it to the nearest swatch
  would throw the find away.

- **The refiner is a brush**: drag across an area and the vectorizer runs again
  on exactly the source pixels under the stroke, quantizing that crop on its own
  and laying the result on top, masked to what was painted. A whole-frame
  palette is counted over a whole frame, so it spends itself on the large dull
  areas and a small feature gets a swatch or two; the crop gets a palette of its
  own with room to spare (twice the frame's count, capped at 16), which is what
  "zoom in on it" means done where a person points. Measured on a portrait's
  eyes at 6 colors: a 126×46 stroke re-traced in 19 ms with 10 shades and
  changed **80% of the pixels under it**, and nothing outside it. The stroke is
  previewed while dragging, tracing happens once when it ends — a trace is tens
  of milliseconds and a drag is hundreds of dabs — and the whole thing is one
  undo step. The patch keeps its own palette, so the edge of a stroke is
  visible where its colors meet the frame's; that is the trade the tool makes.

- **An edit mode on the traced result** — asked for as a way to finish a trace
  by hand: merge parts that were split for no reason, and rebuild parts that
  deserve more care. Both tools work by clicking shapes.

  **Yhdistä** repaints the clicked shape in a chosen color, which is how a shape
  merges into what surrounds it: adjacent areas of one fill read as one surface.
  The red knuckles on a hand become skin in four clicks. **Poimi väri** takes a
  shape's own fill into the picker, so the target is usually one click away.
  **Tarkenna** is the inverse: it re-traces a box around the click using a
  palette built from that box alone — the same reasoning the detail
  neighbourhoods use, applied where a person points — and lays the result on
  top. Every action is undoable, *Palauta* returns to the traced result, and the
  download saves what is on screen rather than what the tracer produced.

  What makes it possible at all is that entering the mode splits each traced
  layer into one path per shape. A layer is one path holding every ring of its
  color, so without that, clicking selects a whole color rather than a shape —
  on a portrait it is the difference between 10 clickable things and 286. Rings
  nested inside another ring travel with it as its holes, decided by bounding
  box; without that grouping every hole would fill in and every eye would close.

- **A detail neighbourhood now quantizes itself with its own palette**, and its
  shapes are drawn on top of the picture the global palette produced. This is
  the fix for a complaint worth stating precisely: trace an eye on its own and
  it comes out beautifully; trace the same eye inside a whole face and it comes
  back a smudge. Nothing is wrong in either case. Zoomed in, the entire color
  budget goes to an eye; in a portrait the eye gets two swatches out of ten,
  because the quantizer counts the whole frame and a face is mostly cheek. What
  survives is the eye's outline; what is lost is the lid, the lash line and the
  opening — everything that makes it read as an eye rather than a dark almond.
  Each neighbourhood is split into `detailColors` (4) bands of its own luma
  range, and each band is traced as a whole connected region the way the plain
  algorithm makes shapes, rather than grown on a leash and fragmented.

  `detailMinShare` (8%) is what makes it usable: a band traces to one large ring
  plus a spray of small ones, and the spray is speckle. Swept on a portrait's
  eyes — **0%**: every bit of structure and every bit of grain, 687 KB; **8%**:
  the lid, the lash line and the opening all still read and the grain is gone,
  599 KB; **15% and up**: it starts eating the structure and converges on what
  the plain algorithm already gives. An earlier version of this control gated
  whole bands instead of rings and was measured to do nothing at all, which
  makes sense in hindsight — equal-share bands are each about 1/k of the
  neighbourhood by construction, so none is ever small.

  Whole-frame cost, 640 px wide: clip art 38 shapes / 35 KB, a detailed photo
  84 / 229 KB, a portrait 561 / 658 KB. The color count stops being a hard
  ceiling in this mode, since the local palettes are the point; `detailColors:
  0` turns it off and gives the ceiling back. Verified that the other two
  algorithms are untouched by any of this: **perus and jatkuvuus are
  byte-identical before and after, 24/24** across four images, three modes and
  two color counts.

  **Neighbourhoods share one palette.** Deriving bands in ignorance of one
  another, forty neighbourhoods invent forty nearly identical browns: a portrait
  came back with 456 distinct fills for 561 shapes, which is close to a color
  per shape and fair to ask about. `detailColorMerge` (16) is how close two of
  them may be before they become one entry. Measured on that portrait —
  **456 → 97 fills**, SVG 599 → 601 KB, per-pixel error 12.78 → 12.85 on the
  eyes and 10.87 → 10.93 overall. That is a 79% cut in colors for half a percent
  of error, and the result is visually indistinguishable. 24 gives 55 fills and
  still beats the plain algorithm; 48 merges across too far and the eyebrows go
  blue. Whole-frame fills after merging: clip art 17, a detailed photo 18, a
  portrait 103.

  A note on how this was nearly lost. Judged by eye against the plain result it
  looked worse — speckled — and it was written off as a negative result and
  reverted, even though the error metric had improved (eyes 13.80 → 12.51, whole
  frame 11.51 → 10.54). The metric was right and the glance was wrong: the
  structure was there under the grain, and the grain was one control away.

- **The tracer finds where a picture needs fine work, and spends its precision
  there** — asked for after the eyes of a portrait stayed coarse: could the
  algorithm notice a spot where clearly different colors are doing exacting
  work, and adapt? It can, and the detector is the solid half of this. A
  neighbourhood counts as detail when it holds at least `detailSwatches` (4)
  distinct swatches *and* spans `detailSpread` (120) levels of luma inside
  `detailRadius` (2). Both halves are needed: a gradient passes a contrast test
  and noise inside one flat color passes a variety test, and neither is detail.
  Swept against landmarks on a portrait, the defaults mark **2.8% of the frame
  and fire on both eyes while missing the cheek, the suit, the curtain and the
  flag**. Inside it, overlay divides its smallest-area and similarity limits by
  `detailBoost` and comes off the base-region leash.

  **What that alone buys is nothing, and the measurement says so**: the finer
  thresholds took the portrait from 37 overlay shapes to 54 and changed the
  per-pixel error by 0.01. The reason is worth stating — an overlay is snapped
  to the same swatches the base picture used, and a shape sitting inside one
  region has a mean dominated by that region, so it snaps back to that region's
  own swatch and repaints what is already there. `detailTrueColor` is the switch
  that breaks that circle: inside a detail neighbourhood the shape keeps its own
  measured color. The mouth's error then falls **14.46 → 13.20 (−8.7%)** and the
  teeth come out as one white block instead of a segmented grey one. **The eyes
  themselves barely move (13.80 → 13.78)** — their limit is the base
  quantization, not the overlay. It costs the color count its meaning as a hard
  ceiling (10 asked, 109 drawn), so it is off by default and offered as its own
  checkbox.

- **The continuity threshold moved to the top of the tracer's controls**, right
  under the algorithm picker, and shows only for the algorithms it affects. It
  is the main tuning knob for *jatkuvuus* — raising it collects scattered
  surfaces into one and leaves behind what is genuinely an edge, which is what
  makes eyes and eyebrows come out sharp — but it was sitting eight controls
  down inside the color section where nobody would find it.

- **Run the tracer locally** — the page had a build script and a headless-browser
  check but no way to just *open* it, so trying it out meant either publishing to
  Pages or hand-rolling a static server over `dist/`. `npm run evg:trace:web:serve`
  now builds the page and serves it at <http://localhost:8006/>, matching the
  convention the other web pages in this repo already follow. Nothing about the
  tracer needs a server — it decodes, quantizes and traces entirely in the
  browser, and no image ever leaves the machine — and opening `dist/index.html`
  straight off disk does trace images you drop on it. What it cannot do from
  `file://` is the *Kokeile esimerkkiä* button: the sample is `fetch`ed
  relatively, and Chromium blocks that from a null origin. A real origin makes
  the whole page work, so that is what the script gives you.

- **Choose the tracer's colors, or steer how it chooses them** — asked for after a ball-pit photo came back in browns and blues with the reds and greens gone. The palette was decided one way only: count-weighted k-means, which spends swatches on *area*. A photograph's big dull background therefore always takes the first few, and a hundred small vivid balls — large in total but scattered across many histogram bins — take none. There was no lever.

  `paletteMode` is now the lever. `"fixed"` takes a list of colors and uses exactly those, in the given order, with `colorCount` out of the picture: black, white and yellow gives a three-color poster of any photo. `"seeded"` pins the colors you name and quantizes the rest around them — a pinned swatch is never moved by a Lloyd pass and never dropped by the near-swatch merge, so it survives even when an automatic swatch that close would be collapsed. Colors come from `paletteHex` as anything `EVGColor.parse` reads (`"#1a1a1a"`, `"#f80"`, `"rgb(20 30 40)"`, a CSS name it knows); one it cannot read is dropped, and a list that parses to nothing falls back to quantizing.

  `paletteBias` steers the automatic choice instead of replacing it: `"area"` is population × distance² as before, `"balanced"` is its square root, `"distinct"` is distance alone above a noise floor that stops a single stray pixel from claiming a swatch. On a gray gradient with one small red patch and two swatches to spend, `"area"` buys two steps of the gradient and loses the patch; `"balanced"` buys the patch. That trade is the whole feature, and the test asserts both halves of it.

  **The defaults are unchanged and deliberately so**: `paletteMode: "auto"` with `paletteBias: "area"` is the existing quantizer, and it was verified swatch-for-swatch — identical palettes, ring counts, painted area and SVG byte counts on three images at 5 and 9 colors. The muted, area-driven palette is a choice worth keeping, so it stays what you get by default.

  On the page the palette strip under the output is now clickable: one click adopts the palette the quantizer just produced into an editable row of color wells, where a color can be nudged, removed, or added to, with a few ready-made lists (black + white, black/white/yellow, grayscale, sepia). The color-count slider hides itself in `"fixed"` mode, where the list *is* the count, and the status line says how many chosen colors found nothing to paint.

### Added

- **A step counts as an edge only when it is unique and continuous** (`edgeMinRun`, default 3) — reported as the tracer being too eager to find edges, with a lot of noise as a result. On a photograph nearly every neighbouring pair differs by a few levels, so taking each one at face value stops the region growth everywhere and the picture comes apart. A boundary is now two things at once: **unique** — at least as strong as the steps either side of it across its own direction, so a ridge keeps its crest and a lone noisy pixel loses to whatever it sits next to — and **continuous**, part of a run of at least `edgeMinRun` such pixels, because a boundary is a line and noise is scattered.

  On a portrait under `contourMode: "smooth"`: false contours **6855 → 2537 (−63%)**, rings 350 → 244, SVG 221 KB → 169 KB, and the skin comes out smooth where it was blotched. On the Hokusai print, rings 322 → 153 and 260 KB → 191 KB. On flat clip art it changes nothing at all, which is right — there is no noise there to filter. The test pins the mechanism rather than a number: with one straight boundary and five isolated specks of the same step size, the boundary is marked on all 36 of its rows and not one speck is.

  It is used to decide where a *boundary* is, not where growth stops in overlay mode: relaxing the stop there let shapes run until the spread leash tripped in an arbitrary place, and a portrait fragmented into 42120 shapes and 37 MB. Measured, reverted, and the filter kept where it works.

  **The previous algorithm is untouched and still the default.** `contourMode: "off"` produces byte-identical output — a portrait at 10 colors is 385 rings and 261100 bytes before and after. The new modes are additions, not replacements, and the page now says so: the choice of algorithm is the first control under the image buttons rather than buried among the fine adjustments, and the adjustments themselves only appear once a mode that uses them is chosen.

### Added

- **Stacked overlay shapes, so a shape swallowed by a leak comes back** (`contourMode: "overlay"`, off by default) — the idea, and the diagnosis behind it, came from the report. The partitioning flood has one fatal weakness: a boundary only has to fade for a few pixels somewhere along its length, and the surface walks through that gap and takes everything behind it. Measured on a ramp with an inset block, `smooth` loses the block completely — the color inside it and outside it come out **identical**, a difference of 0.

  Stacking removes the consequence instead of chasing the cause. A surface is grown to its end and keeps whatever it took; every edge it ran into becomes a seed for a shape started on the far side and drawn *on top*. An overlay may cover ground already claimed, so a leak costs nothing — the shape is simply painted again from above. It stops at an edge, and also where it has become indistinguishable from what is under it (`overlaySimilar`), so an overlay only exists where it shows. The block comes back: a difference of **21**, against 0.

  It wants `gradientFill` on with it, and the reason is worth recording. The similarity stop asks whether an overlay would show against what is beneath. Against a flat mean the answer is yes everywhere a ramp leaves that mean, so every gradient shatters into bands — 18 shapes and 42 KB on a test image. Painting each shape's *fitted model* into the map instead, the question becomes "is the model wrong here", and the same image comes back as **9 shapes and 22 KB** with the highlight in the middle of the tube where it belongs. The two features are one idea in two halves.

  **Radial fits are not emitted.** They were, briefly, and the report on them was right: away from a genuinely round region an isotropic cone *infers* structure the picture does not contain, and the shapes it invents are strange rather than merely wrong. An elliptical radial fitted to the region's own proportions was tried and measured worse — the model choice shifted and a test image fragmented from 9 shapes into 13 with hard angular wedges. Holding it back behind a margin was tried too. Both are gone; only flat and linear are emitted, and a test asserts no `<radialGradient>` ever reaches the output. The cost is honest: a tube lit down its middle really is a ridge, and a linear ramp describes it poorly.

  Both stay off by default and `gradientGain` is the restraint on the second: a fitted gradient replaces a flat fill only when it explains at least that much more of the region, and at 100 nothing can out-argue flat.

### Added

- **Overlay shapes: steepest edge first, both sides seeded, and a base underneath** — three fixes to `contourMode: "overlay"`, all from the report. Seeds now sit in 256 buckets by the strength of the edge that produced them and the steepest is always served first, and *every* steep pixel is queued up front rather than one: a figure can be star-shaped or in several pieces, and growing out from a single point does not reach the sharp corners of the others. Both sides of a crossing are seeded, not just the far one — seeding one side leaves the other to whichever surface arrives there, and at a boundary that fades that is the surface which already leaked through it.

  And a base surface is drawn under the stack. The shape budget is finite and a photograph spends it on small overlays long before it has covered the frame; measured, **62% of a portrait came out unpainted**. The base is drawn but never entered into the paint map, so it changes nothing about where the shapes above it stop, and coverage is 100% with no seams.

  What none of this fixes, measured rather than assumed: where a boundary's contrast reaches *zero* — the two sides passing through the same tone — the shape's outline on that side is not the real edge but wherever the similarity stop happened to cut it. On the test image the source itself scores 40% on a probe of the inset block, `smooth` scores 0% (the block is gone), and overlay scores 52% — the block is recovered, its light-side outline is not.

- **Linear gradient fills, fitted per region** (`gradientFill`, off by default) — asked for after the banding work: a ramp is *really* a gradient, and a flat fill is only the second-best answer. Each continuity region is now fitted with three models — one flat color, a linear ramp and a radial one — and keeps whichever leaves the least squared error, with `gradientGain` refusing a gradient that does not explain meaningfully more than flat. Measured before writing any of it: on a portrait the weighted mean absolute error is flat 8.95, linear 5.43, radial 8.71; on a sky-and-tube image it is flat 12.40, linear 4.47, radial 9.09 — **and best-of-both 1.82**. Neither model wins alone, which is the case for choosing per region: the tube is lit down its middle and is radial (4.90) where a linear fit is worse than useless (12.97).

  It reproduces a synthetic sky-and-tube almost exactly, in **1114 bytes against 4754** for the flat version, and the emitted stops are the source colors to the level: a 30 → 124 ramp comes out `#1E1E1E` → `#7C7C7C`. Coefficients come from the normal equations by Cramer's rule; the gradient's axis is the direction *luma* runs along, since the three channels each have their own slope and SVG has one axis.

  **Where it fails, and it does**: a detailed photograph segments into ~1500 regions, each traced on its own, and the result is worse than the flat version, not better. It is for images with large smooth surfaces — skies, plastic, product shots, the playground tubing this came from. Fitting is refused below 64 pixels and for an axis under two pixels long: a thin region's normal equations are near-singular, Cramer answers with wild coefficients whose residual looks *excellent*, and the stop color comes out black. That was a real bug, found by a test asserting a linear axis has length. `toEVGElements` still returns flat fills — an EVGElement carries one color — so gradients are `toSVG` only.

### Added

- **Read small color changes as continuous and large ones as a break** (`contourMode: "smooth"`, off by default) — reported from a photograph of playground tubing and again from a portrait: a quantizer cuts a hard edge wherever a smooth ramp crosses the midpoint between two swatches, so a sky gains stripes and a cheek gains blotches, while a genuine edge between two *similar* colors can go unmarked. Both are the same missing distinction, and the fix is the one the report proposed: grow regions across neighbours that differ by at most `contourEdge`, then give the whole region the single swatch nearest its mean. A ramp becomes one region and loses its false contour; a step bigger than the threshold still splits.

  Measured by counting adjacent pixel pairs whose labels differ, split by whether the image is actually discontinuous there. On a portrait at 10 colors, **false contours fell 22075 → 11667 (−47%) while real edges moved 34848 → 34820, that is by 0.1%** — the banding goes and the detail stays. With the photo preset as well it is −69%, and the SVG halves.

  `contourSpread` is the leash, and it is the interesting part: without it one gradient walks across the whole picture and merges everything it touches. The tests pin both sides of that trade — a 110-level ramp is longer than the default leash of 48, so one seam is left in it, and only with the leash lengthened does the ramp come out unbroken.

  It stays **off by default and is labelled experimental on the page**, because it is a segmentation and segmentations fail hard: at `contourEdge: 10` a soft-edged tube merged into the field behind it and disappeared entirely. The default of 3 is chosen against exactly that, and the slider's hint says so.

### Added

- **Remove a background color you name, and a checkerboard to see what is transparent** — `bgMode: "auto"` floods the page in from the border, but it first requires the border to be 80% one flat color, and it *declines silently* when it is not: a banner across the top, a drop shadow, a screenshot with the page furniture in it, and the background comes through painted with no explanation. `bgMode: "color"` is the same border flood from a color you name in `bgColor`, with no uniformity test to pass. It is still a flood rather than a "delete every white pixel", so white inside the subject survives — on a face with white eyes and a white page, the eyes come out as a real white layer of exactly their own pixels while the page goes. `bgTolerance` is now on the page too, for backgrounds with a gradient or compression noise in them. And `auto` no longer declines quietly: the status line says the background was left and points at the color mode.

  The output stage now sits on a **checkerboard**, because a transparent hole and a painted cream background look identical against a cream stage — that is what made this hard to see in the first place. It is on by default for both the source and the result, and there is a checkbox to turn it off. `EvgBitmapTracer.backgroundRemoved()` reports whether a page color was actually flooded away.

### Added

- **Smoothing, so more colors stop meaning more noise** — reported from a tiger photograph: at a low `colorCount` the trace is clean and vector-like, and every color added past that fills it with speckle. The cause is that a photograph's per-pixel noise is real signal to a quantizer. Two adjacent pixels of fur differ by a level or two, land in different swatches, and each speck becomes its own ring — so the extra colors buy fragments instead of shapes.

  `smooth` is a count of 3×3 median passes run over the color planes before anything else looks at them. It moves whole pixels rather than filtering each channel: of the nine neighbours it takes the one whose *luma* is the median and copies its RGB, so it cannot invent a color that is nowhere in the image, and unlike a blur it leaves an edge where it found one. `minRegion` — which absorbs a small run of pixels into what surrounds it — already existed but its slider stopped at 40, far too low for a photograph; it now goes to 400.

  The two belong together on a photo, so the page offers them as a pair: **Piirros** (0 / 6) and **Valokuva** (2 / 60). On a 640×799 portrait at 10 colors, `Valokuva` takes it from 104 rings and 1.33 MB of SVG to 49 rings and 580 KB, and it is *faster* — there is less noise to trace than there was to smooth. `Piirros` restores the default output byte for byte.

  `smooth` defaults to **0** and `minRegion`'s default is unchanged, so line art and flat-color work are untouched — a median would eat exactly the thin strokes the tracer was taught to keep. Verified: three images at 4 and 10 colors produce identical palettes and layers against the previous build.

### Added

- **Paste an image into the tracer** — ⌘/Ctrl+V on the page loads a screenshot or a copy from another tab and traces it straight away, without a trip through the file picker. It reads the `File` that Chrome and Safari put on the clipboard, and falls back to fetching the `image/*` URL string Firefox may offer instead. A paste with no image in it is left alone, and so is one aimed at a text field the user is typing in — the smoke test asserts all three.

### Fixed

- **The refiner was re-tracing the original bitmap, not the edited one.**
  Pre-processing edits the picture, so everything that samples pixels afterwards
  has to sample the edited one — the tracer did, the refine brush did not. On a
  blurred picture it cut its patch out of the unblurred original and put sharp
  detail back exactly where the filters had been asked to take it away.
  Measured on a portrait with a strong blur, edge energy inside the stroke
  against the **0.99** the blurred picture has there: **4.63 before, 1.97
  after** — 3.4× the surrounding detail level, down to 1.4×. Both reads now go
  through one accessor so they cannot drift apart again, and the smoke test
  fails if a refine stroke more than doubles the local edge energy.

- **Gradient fill: the palette ran away, the smallest-area control did nothing,
  and gradients barely appeared.** Three complaints, and measuring them found
  two causes that between them explain all three.

  Every region was painted `hexOf(mean)` — its own measured average, never
  quantized. A portrait asked for ten swatches came back with **1128 distinct
  fills**, one per region. The mean is snapped onto the palette now; a
  gradient's *stops* stay as measured, since describing a ramp is the whole
  point of the mode, but the color a region falls back to — and the color the
  palette strip reads — is a swatch. **1128 → 10.**

  And absorbing small regions ran a single pass, in which a region below the
  floor may only join a neighbour that is already big enough. On a photograph a
  small region's neighbours are small too, so almost nothing moved: `minRegion`
  from **6 to 4000** shifted the region count from **1131 to 1052**, a control
  that does nothing. It now repeats until the count stops falling — each pass
  grows the survivors, so neighbours that were too small last time are eligible
  this time.

  That second fix is what makes gradients visible, which was the third
  complaint. The mode was drowning in tiny flat scraps: 108 gradients among
  1131 layers, **9.5%**. At the default it is now 106 among 568 (**19%**), and
  at `minRegion` 200 it is **47 among 79 — 59%** — with the SVG down from 419 KB
  to 120 KB. The control that does the work is the smallest area, and it works
  now: 1131 → 568 → 117 → 79 → 20 → 7 layers across its range.

- **The status line went on describing the file the tracer produced while you
  edited a different one.** It quotes a shape count and a byte count, both of
  which an edit changes — splitting the layers on entering edit mode alone takes
  a portrait from 8 shapes to 424 and adds 19 KB — and neither was refreshed
  again. The line is now in two halves: what the trace did (size, time, unused
  swatches, whether a background was found) stays put, and what the drawing *is*
  is re-read from the SVG on screen after every edit. Debounced, because the
  smoothing brush reports on every dab and serializing half a megabyte a hundred
  times a second would be worse than a stale number. The smoke test compares
  what the line says against the live document.

- **Redundant work removed from the overlay algorithm's inner loop**, from a
  profile of a photograph. Three things were being paid for repeatedly and are
  not any more: `isDetail` and `isBoundary` each re-derived whether their mask
  was in play at all — an options lookup and an `array_length` against
  `width * height` — on every call, which is once per neighbour per pixel per
  shape for an answer that cannot change during a trace; the growth loop
  measured the step between two pixels, compared it to the tolerance, and then
  called `isBoundary`, which measured the same step again; and `buildDetailMask`
  allocated a list per pixel and searched it linearly to count how many swatches
  sat in a small window, a quarter of a million allocations for a question a
  256-slot table stamped with the window number answers directly.

  Honest about the size of it: **3503 ms → 3396 ms** over three 640-pixel images,
  about 3%, with identical output (same shapes, same colors). The profile
  afterwards is flat — no line above 10% — so the remaining time is the
  algorithm's own work rather than waste. One further 8% sits in copying a
  bitmap per emitted shape that the sub-tracer owns outright and could consume
  in place; Ranger's optionality rules will not pass the field as a value or an
  argument, and it was left alone rather than worked around.

- **The overlay algorithm still picked an enormous number of colors**, reported
  from the page with a palette strip hundreds of squares long. Two separate
  causes, and the strip was the louder of them: it drew **one swatch per layer**,
  and a layer is a shape. The overlay algorithm makes hundreds of shapes out of a
  handful of colors, so a portrait showed **407 squares for 18 colors** — a
  palette that has run away when it has not. The strip is one swatch per color
  now.

  The count underneath was real too. Merging near-duplicates across
  neighbourhoods bounds nothing on its own: colors that are genuinely different
  keep arriving, one per neighbourhood per band, and a busy picture has hundreds
  of neighbourhoods. `detailColorMax` (12) is the ceiling — past it a band takes
  the closest color already in hand, however far that is — so the total is at
  most `colorCount` plus that, whatever the picture does. A portrait asked for
  six: **103 fills → 18**, for 3.6% more per-pixel error (13.25 → 13.73). A
  ball pit: **71 → 18** at 0.2% (17.29 → 17.33). Clip art was already fine and
  is unchanged.

- **Transparency reached inside the subject.** Reported on a cartoon still: the
  whites of the eyes and the boots came out as holes while the blue page behind
  the characters stayed put — the exact opposite of what removing a background
  is for. The border flood was never the culprit; what was, was the rule it fell
  back to. When the border is not one flat color — a gradient page, or a
  photograph with no page at all — `auto` gave up and left a plain
  *delete every pixel brighter than `skipLuma`* in charge, and that rule has no
  idea where it is. A white belly, a highlight and a white page all read the
  same to it.

  There is no longer any such rule. Transparency is decided by the border floods
  and nothing else, and when the modal-color flood stands down, `auto` now tries
  the same question the other way round: is there a *bright area connected to
  the edge*? That removes a white or near-white page whose border is not one
  flat tone, and cannot reach an interior, because an interior does not touch
  the border. Measured on a figure with pure-white eyes and boots on a blue
  gradient: the eye went from **2359 of 3575 pixels transparent to 0**, the boot
  from **all 1064 to 0**, and the blue page stayed opaque in both — it is not a
  page being removed, so it should not go.

- **A refine stroke drew a straight line along its own boundary.** Reported as a
  horizontal line at the edge of a refined area, running at odds with whatever
  the picture was doing there and parallel to the region border — which is the
  tell, because nothing in a photograph lines up with a brush stroke. Two causes,
  both of them the stroke's own geometry leaking into the result:

  The crop and the mask ended on exactly the same line. The stroke's half-width
  bounded both, so every shape the tracer made was cut flat against the crop
  rectangle and the mask revealed precisely that cut. The crop is now traced
  with a margin past what the mask shows, so the tracer has context there and
  its cut falls where nothing is drawn. And the mask was a hard cut between
  refined and unrefined content; it is now feathered, so the patch arrives
  rather than starts.

  Measured on a horizontal stroke over smooth skin, edge energy on the boundary
  row against the **0.5** the picture has there naturally: **14.9 → 2.9** at the
  top edge, **9.9 → 3.4** at the bottom, where the natural value is 3.5. Colour
  disagreement across the boundary, as excess over natural, fell from **5.5 to
  0.2**, and the detail inside went **up**, 10.2 → 11.9.

  One of those fixes was briefly worse than the bug: the feather's filter region
  was given in percentages, which are measured against the filtered element's
  bounding box — and a straight horizontal stroke has a bounding box of zero
  height. The region collapsed, the mask rendered empty, and the entire patch
  disappeared while the tool reported success and the boundary measured
  perfectly clean, because there was nothing there. The smoke test's "did any
  pixel actually change" check caught it; the filter region is in user space
  now.

- **Splitting a traced layer into shapes now happens in one place.** The
  refiner grew its own copy that split on `M` and treated every ring as a shape,
  which fills in every hole — on a portrait it closed both eyes and washed the
  face to flat skin. It is the same parity rule the edit mode already needed, so
  it is now one `splitShapes` used by both.

- **The refiner reported success and changed nothing.** Its result is masked to
  the brush stroke, and the mask went on the same group that carried the crop's
  `translate`. A mask is resolved in the coordinate system that element's own
  transform establishes, so a mask drawn in image coordinates landed offset by
  the crop origin — far enough to miss the shapes entirely and hide the whole
  result. The tool still said "10 shades, 19 ms" while the picture was
  byte-identical: **0 of 4536 pixels under the stroke changed**. The mask now
  goes on an outer group and the translate on an inner one, and the same stroke
  changes 80% of them. The smoke test asserts the changed pixels, not just that
  a group was added — "a group was added" was true the whole time it was broken.

- **Entering the tracer's edit mode repainted part of the picture before any
  edit was made.** Splitting a traced layer into individual shapes has to decide
  which rings are holes, and it decided by bounding box. Under evenodd that is
  simply the wrong question: what makes a ring painted or punched out is the
  *parity* of how many rings enclose it, so an island sitting inside a hole came
  out as a hole, and rings that merely overlapped in extent were treated as
  nested. On a portrait that repainted **1472 pixels (0.67% of the frame), with
  a peak channel error of 478**, mostly by exposing the layer beneath a stacked
  one. Nesting is now decided by an exact point-in-fill test, which costs a few
  milliseconds once: **142 pixels (0.06%), peak 89, and none of them with more
  than five differing neighbours** — that is antialiasing along the new seams
  between shapes that used to be one path, which is inherent to splitting them.

- **The overlay tracer now stands on the plain picture instead of a flat
  rectangle.** Reported as it destroying photographs, and it was: under every
  overlay shape sat a single frame-filling rectangle painted the average color
  of the image, on the theory that the shapes above would cover it. They do
  not. Measured on the cheek of a portrait — layer 0 painted that pixel
  `#6C2D21`, and of the 1467 shapes stacked above it exactly one touched it at
  all, changing it to `#75372a`. The face read as a brown sheet while its own
  skin shapes sat in the file underneath, drawn and invisible. The base is now
  the ordinary quantized result, so the floor of this mode is the plain result
  rather than a flat sheet, and a bad overlay can only be as wrong as its own
  outline.

  Two consequences follow. The paint map starts as that picture, which is what
  makes the mode's own stop rule mean anything: "does this overlay show against
  what is under it" was being asked against a blank map, so every overlay
  showed and every overlay was drawn — `overlaySimilar` had no effect at all.
  Answered against the real picture, an overlay exists only where it differs,
  and shapes that merely repeat the picture fall out. And an overlay may no
  longer cross a region boundary of the base picture (`overlayFollowBase`,
  default on), so it refines that picture rather than contradicting it, which
  also bounds what any one shape can claim.

  Measured, per-pixel mean absolute error against the source: a detailed photo
  **10.9 → 5.34** and its SVG 1.3 MB → 153 KB (3990 shapes → 50); clip art
  1.50 → 1.12 and 29 KB → 26 KB. The portrait that prompted this now traces its
  cheek to the same swatch the plain algorithm gives it.

- **The overlay tracer no longer explodes the palette or the CPU on photographs.**
  Two independent defects met in the worst possible input. Every overlay shape
  was painted its own mean color, so the color count did nothing whatever in
  this mode: a photograph decomposes into thousands of surfaces and each one
  took a color of its own — a measured 5194 distinct fills under a slider that
  said four. And a pixel was queued as a growth seed once per neighbour that
  ever touched it, in three arrays that only grow, so the queue ran to millions
  of entries: one 640-pixel-wide photo spent **10.1 seconds** in the tracer with
  a fifth of the profile in `pushSeed` and another eighth in the garbage
  collector. Shape means are now snapped onto the palette the quantizer already
  built, and a pixel is queued once — a steeper claim can still promote it, but
  a repeat cannot. The same photo now traces in **183 ms**, and the palette
  comes back with 5 colors instead of 5194. `minRegion` also reached this mode
  read-but-ignored; wiring it up gives back a real size control (on a detailed
  photo, 6 → 24 cuts the shapes from 3990 to 853 and the SVG from 1.3 MB to
  672 KB, for 30% more per-pixel error).

- **Overlay mode was not leaking, it was running out of shapes** — reported as a leak: on a portrait the US flag came out as bare background even though its stripes are about the strongest boundaries in the picture. It is not a leak. Raising the shape budget from 3000 to 60000 brought the flag, the curtains and the face all back, which settles it: those shapes were never made.

  The budget was there because each shape cost the *whole frame*. A per-shape mask allocated a full width × height bitmap, `decompose` copied it, and the scan for the next ring swept it — so a photograph, which is thousands of shapes, paid the image area thousands of times over. That is now a mask over the shape's own bounding box with the traced path moved back afterwards, and the same portrait went from **26.5 s to 950 ms**, a factor of 52. With the cost gone the budget can be what the picture needs: it scales with the pixel count instead of sitting at a fixed 3000.

  Steepest-first also starved the large quiet areas — a photograph has thousands of sharp spots and a face is nearly all low contrast — so every fourth shape is now taken from a plain sweep instead of the queue.

  Overlay on a photograph is still not what `smooth` is: the face comes out speckled where `smooth` renders it cleanly, and the file is 1.4 MB against 221 KB. It no longer *loses* anything, which is the difference between a limitation and a defect.

### Fixed

- **The tracer bench could pass on code that never compiled** — `run_trace_bench.sh` checked the compiler's exit code, and the compiler prints `[FAIL]` and still exits 0. A test file with a syntax error therefore left the previous build in place and the script reported a pass for code that does not exist. Found by making exactly that mistake. It now greps the compile log the way `run-gallery-editor-tests.sh` already does.

- **White hairlines between the traced shapes** — reported as the harder half of the noise problem: as `colorCount` grows, thin light lines open up along the seams where two colors meet. Not a tuning matter. Every layer was traced and curve-fitted **independently**, so the one pixel boundary two regions share got two different fitted curves, one approached from each side. They never agree exactly, and wherever they bow apart the page shows through.

  Layers are now **stacked** rather than laid side by side: layer *i* claims its own swatch and everything above it in the palette order, so each layer is painted onto a filled neighbour instead of onto the page. The palette is already sorted dark → light, so what a gap can expose is the next darker color, and a one-pixel disagreement between two fits stops being visible at all. The mechanism is what the test asserts: with a light square inside a dark field, the disjoint dark layer needs a hole cut for the square (2 rings) while the stacked one runs underneath it whole (1 ring).

  Measured on four images by counting unpainted pixels that have paint on *both* sides — the page around a figure fails that test, a hairline between two shapes passes it. **Seams went to zero in every case**: 106 → 0 on flat clip art, 959 → 0, 1402 → 0, 1274 → 0. Ring counts fell too (749 → 626, 509 → 384): a cumulative mask is a simpler shape than the fragments a disjoint one leaves. SVG size moves either way — smaller on line art and photographs (331 KB → 274 KB on a portrait), larger where many colors interleave (248 KB → 305 KB on the Hokusai). `layerMode: "flat"` keeps the old disjoint behaviour for anyone who wants the smaller file and can live with the seams.

### Fixed

- **The tracer's palette controls looked inert** — reported from the live page: switching the palette or the weighting seemed to change nothing. Three things stacked up. The editable color list carried an inline `display:block`, which beats the `.hidden` class it was toggled with, so it stayed on screen in `"auto"` mode — a mode that ignores it. A color edited there therefore did nothing, which is exactly what "the options don't work" looks like. Hiding it properly would have been the wrong fix: `Poimi tuloksesta` lives in that block and is the whole path from an automatic palette to an edited one. It now stays visible in every color mode, is dimmed and labelled as idle under `"auto"`, and **touching any color there switches the palette over to it** rather than swallowing the edit. Separately, a `<select>` sized by its widest option pushed 49px out of a 300px panel and its label was clipped mid-word; selects now take the full row like the sliders do. `npm run evg:trace:web:smoke` asserts all three — the editor is visible and marked idle on `"auto"`, no select is clipped, and an edit made under `"auto"` reaches the output — and each assertion was checked against the bug reintroduced.

  Worth recording, because it was the other half of the confusion: the quantizer was fine. On a muted photograph `"balanced"` and `"distinct"` genuinely agree, and only `"area"` differs, so two of the three settings really do produce the same picture. That is the weighting doing its job, not a control failing to fire.

- **Adding colors to the bitmap tracer deleted the picture's features** — reported against the live page at `/evg/tracer/` with a tiger: at a low colorCount the drawing came through, and every extra color took something away, the eye first and the black linework worst, while the painted area stayed the same or *shrank*. Three separate causes, all of them in the posterize path, and none of them in the curve fitting.

  The palette was built from every pixel, and most of the pixels along an outline are neither ink nor fur — they are the anti-aliased blend between the two. A blend ramp is a long, densely populated streak in color space, so the extra swatches were spent describing the halo instead of finding a new region: at 10 colors a tiger produced five browns between black and orange and no yellow at all. Worse, seeding was farthest-point-first, which is drawn to exactly the outlier pixels that then paint nothing. `buildFlatMask` now classifies each pixel as region interior or transition (`flatTolerance`), only interiors vote, and the vote is a 5-bit histogram run through **count-weighted k-means++** — a rare color has to be *both* distinct and actually present to claim a swatch.

  With halo swatches gone, the ink strokes stopped being eaten from both sides; `minColorDelta` finishes the job by collapsing swatches the eye cannot separate, so asking for 16 colors from an image that holds 5 yields 5 layers instead of splitting one region between two near-identical ones and letting `turdsize` delete both halves. That split is precisely how a solid outline turned into a dashed one. Boundary pixels are then snapped to a region that actually *touches* them, but only when that region fits about as well as the global best (`snapRatio`) — an unguarded snap hands the one-pixel core of a hairline to the large region beside it, which is the same dashes by another route.

  The vanishing area was the third cause: `turdsize` drops a small ring and leaves a hole, and quantizing into more colors makes more small rings. `minRegion` now absorbs a run of pixels smaller than itself into whatever surrounds it *before* tracing, so a fragment becomes part of its neighbour instead of a gap. On the Hokusai sample, painted area across 4 → 16 colors went from 94.8% → 74.8% to 98.7% → 96.5%, rings from 2447 to 829, and the SVG from 639 KB to 286 KB — the output is smaller because it is no longer describing noise.

- **`skipLuma` could not tell the page from a white belly** — the old rule dropped every pixel above a luminance, wherever it was, so a highlight inside the subject was thrown out along with the paper it stood on. `bgMode: "auto"` (the new default) instead floods the border color inwards and drops only what is connected to the edge of the image, and it refuses the job unless the border really is one flat color — a photograph has no page to remove and falls back to the old rule. The tracer page has the mode as a dropdown, with `skipLuma` shown only when it applies.

- **The bitmap tracer's own suite ran nowhere in CI** — `evg:trace:test` existed and passed, and no job called it, so the regression above could have come back on any later change without a single check going red. It is now in `scripts/run-gallery-editor-tests.sh` alongside the editor suites, which is the job that exists for exactly this reason.

- **Text boxes with no width: the PPTX reader stopped inheriting geometry one step too early** — reported from a deck the repository does not have: some text boxes were drawn as if they were zero wide, so the words came down the left edge one per line broken at every space, and none of that text could be selected, because a hit test asks whether a point is inside the box and no point is inside a box of no width. Both symptoms are the same missing number, and the author's guess — that the sizes were in a template attached to the file — was right.

  Two rules were wrong. `a:off` and `a:ext` are separate elements and a shape can state one without the other; the reader read "the file said something about geometry" as "the file said everything about geometry", so the half that was missing was inherited as zero — and, worse, a placeholder that had been MOVED on the slide but never resized had its stated position overwritten by the layout's. `PptxShape.hasOff` / `hasExt` are those two statements, read and inherited separately. And a slide placeholder inherits from the layout's, which inherits from the **master's**: the reader consulted the master only when the layout had no matching placeholder at all, and marked the geometry settled whether or not what it took had a size. A corporate template's layouts commonly do nothing but name their placeholders — the boxes are on the master — so every one of them came out 0 × 0. The merge now fills only what is still missing and the master is always asked afterwards.

  Under both of those, `PptxResolver.fallbackBox` is what happens when the answer is still nothing: a shape with words in it and no size anywhere gets a box — where the file said it goes when it said, the slide's margins when it did not, stacked under what is already placed rather than across the title, and a height the text needs. It is flagged `geomFallback`, the outline panel says "no size in file", and the writer never states it back into the package (`sheetXfrmSig`), so a save cannot turn the reader's guess into the document's geometry. Empty shapes are left at nothing: a zero-sized rectangle with nothing in it draws nothing in PowerPoint too. `gallery/pptx/fixtures/37-loose-geometry.pptx` is the report, built — a template layout that states no geometry, a placeholder written `<p:ph idx="1"/>`, a box with `a:off` and no `a:ext`, one with neither and one with `cx="0"` — and `npm run pptx:box:test` asserts the numbers, that the text lays out as lines rather than as a column of words, that every box contains its own centre so a click can reach it, and that a save leaves the file saying exactly what it said before.

### Added

- **An outline of the slide, and a shape's box as numbers** — the left-hand column has two tabs. `Slides` is the deck as before; `Outline` is this slide's own shapes, front to back the way a click meets them, with what is inside a group indented under it and each row saying what the shape is — a placeholder and which kind, locked, or that the box it is drawn in is not the file's. Picking a row selects the shape, and the properties panel on the other side then shows its **X, Y, W, H and rotation** in points with a step either way: `geom.x` … `geom.rot` take a number and `geom.dx` … `geom.drot` a step, so a host with a text field can set a coordinate exactly and two shapes selected together each move by one point instead of both jumping to the first one's. A size can never be typed to zero, which is the state the whole of this was reported out of, and typing a number makes the box the document's own — it is written out and the warning goes away. `npm run pptx:outline:test`; `gallery/pptx/artifacts/15_outline_panel.png` is what it looks like.


### Added

- **187 shapes were already drawable and two of them were reachable** — `gallery/office/geom/assets/presets.txt` has held the DrawingML preset geometries since that work landed, both visual editors already *render* every one of them, and `PptxEdit.addShapeAt(preset …)` has always taken any preset name as a string. The slide editor's toolbar offered `rect` and `ellipse`; the book editor's offered a plain box. Nothing named the rest. `OfficeShapeCatalog.rgr` is the missing table — id, label, category, keywords, in the ten categories PowerPoint's own gallery uses, because a person looking for a chevron looks under Arrows whatever drew it — and it holds **no drawing code at all**: the slide editor stores the preset name, the book editor asks `OfficePresetShapes` for the outline and stores a path, and that difference is exactly why the catalogue holds neither. Generated from `presets.txt` rather than typed, so a preset this build has and the list does not is impossible; the test asserts the categories *partition* the 187 rather than merely covering them.

  It ships with a **wiring test**, which is the rule `gallery/PLAN_EDITOR_KERNEL.md` §4 asks of every shared module: `OfficeShapeCatalogTest` drives the real command in each editor — `frame.shape` on `BookApp`, `shape.insert` on `PptxApp` — and looks at what appeared on the page. A catalogue that is correct and that nobody calls passes its own unit tests and fails that one. It is also the book editor's **first import from `gallery/office`**, where it previously had none. An entry whose outline cannot be produced is **not inserted**: an empty path frame is an invisible frame the reader has to find and delete, which is worse than the insert plainly not happening.

### Fixed

- **The native book editor drew every glyph in a bitmap font while measuring with the real one** — reported from a screenshot of the SDL window, where the type looks like a deliberate retro style rather than a defect. The host loaded its seven faces with `tr.fm.loadFont(path)`, which loads the file into the FontManager, returns **true**, and logs `Loaded font 'Cinzel' (Regular)` — and does none of the things `UITextRenderer.loadFont(family, path)` does: bind the face to the rasterizer, install the TrueType measurer, set `hasFont`. `applyFace` returns immediately while that is false, so `RasterText` kept its built-in bitmap font. The layout was measured with the real metrics throughout, so the line breaks were right and only the letterforms were wrong, which is the hardest version of this to see. The host's own guard — "measuring with N face(s) and drawing with M" — passed cleanly, because both counters counted files that had loaded rather than asking whether anything was bound; a check that counts the wrong noun reports the failure as fine. The first face now goes through `tr.loadFont` and the rest through `tr.addFace`, which is also what joins the bold, italic and per-codepoint fallback pool, and the host asks `tr.hasFont` and prints the bound face on every run and in the headless report. `gallery/book/ISSUES.md` #16.

### Added

- **Generic classes, and the first thing they were wanted for** — `class History @params(Op)` takes a type parameter and a reference names its argument, `History@(int)`. A parameter can be an array element, a parameter type and a return type; there are **no bounds, no constraints and no variance**, which was a decision rather than an omission — the case that would have forced a constraint system is `Selection<T>`'s `contains`, and passing the comparison in costs one field where designing bounds costs fourteen backends. Traits have taken `@params` for years; classes could not, and the file that most wanted them said so in its own header.

  **Each instantiation is expanded into an ordinary class before any writer runs.** `History@(int)` becomes `History_int`, `History@([string])` becomes `History_arr_string`, and the fourteen targets — es6, go, python, cpp, rust, swift3, swift6, java7, kotlin, php, csharp, scala, dart, llvm — were not touched at all. That is the whole design: PHP has no generics, Python erases them, C++ wants templates, Rust wants bounds it can check, and the LLVM path here does its own retain/release, so none of them is asked to learn a type system. `shape` already desugars this way. A generic class is never emitted on its own; only the instantiations a program asks for exist, and two of them are unrelated classes that cannot see each other's fields.

  Three things had to be fixed under it. `RangerArgMatch.add` answered *true* for any type-parameter name longer than one character **without recording it** — reasonable for the operator matcher, where a long name is a concrete type to match against, and fatal for a declaration, so `@params(Op)` bound nothing and every copy kept `Op` as a type name. Declared parameters now bind whatever they are called. A type argument that is itself a collection may not stay a type NAME — left as one, every writer spells a class called `[string]` — so `def x:Op` with `Op` bound to `[string]` becomes the array node it stands for, and the writers see an ordinary `[string]` parameter. And templates are registered in a pass of their own before collection: a class variable of a generic type collected before the file declaring it saw an undeclared name, which made **import order** decide whether a program compiled — four of the docx suites built and two did not, on the same source.

  **What a type parameter may be and where it may go**, after working through
  what an author reaches for next: an array element, a **map value**
  (`[string:T]`, which is `Store<T>`), a parameter, a return type, and the
  parameter of **another generic class held as a field** — `Cell@(T)` inside
  `Holder@(T)`, which is the shape `Transaction<Op>` inside `History<Op>`
  needs, and the one that proves expansion recurses rather than stopping at the
  outermost reference. A generic class may take constructor arguments and may
  `Extends` a plain class. A type ARGUMENT may be a class, a record, a shape, a
  primitive, an array or a map — the map form needed a fix of its own, because
  the annotation's own parse splits `[string:int]` at the colon (a colon inside
  a word is a type separator everywhere else in the language) and the argument
  arrived as `[string`, silently. It is put back together rather than teaching
  the tokeniser about brackets, which would put every other token in the
  language up for re-checking.

  **An instantiation is an ordinary type**, so it can be a collection's element
  type — `def kids:[Tree@(T)]`, `def byName:[string:Tree@(int)]` — and a
  generic class can name ITSELF at its own parameter. The element type of a
  collection lives in `array_type` rather than in `type_name`, and the
  annotation was being looked up against an empty name, so the program was told
  "Trait class  is not defined" with a blank where the class should be. Naming
  itself does not send the expansion round for ever: the instance is registered
  before its body is walked.

  **A generic class has no static side**, and now says so. Only instantiations
  exist at run time, so `sfn` inside one is unreachable; the error used to be
  "no description for called object", which sends the reader looking for a
  missing import. Three more messages went the same way: naming a generic class
  with no argument at all said "Unknown type: Box" and now says it is a generic
  class and shows the spelling; the wrong number of arguments now names what
  the class was declared with; and a type argument list on a class that takes
  none was **dropped in silence**, so `new Plain@(int)` compiled as
  `new Plain` and left the author believing Plain was generic.

  `tests/conformance/generic_class/` is the case worth writing first: one generic class at **two different types** in one program, one at an **array type**, one at a **shape type**. `npm run test:generics` compiles it for all fourteen targets and runs it on every toolchain present, comparing output. It found two defects. The Rust writer pushed a borrowed slice parameter into a nested array with `.clone()`, which clones the reference rather than the elements — `[[string]]` never compiled on Rust, with or without a generic class; it takes `.to_vec()` now. And LLVM loses the elements of a nested array entirely, which also reproduces with no generic class in sight and is left open as ISSUES #73 rather than papered over.

  A second case, `tests/conformance/generic_class_kernel/`, covers the shapes the editors actually ask for, and turned up two more target defects of the same character — both reproducible in twenty lines with no generic class in them. **ISSUES #73 grew a worse form**: `[string:[string:int]]` does not merely come back wrong on LLVM, it segfaults, and only once the inner map holds a *second* entry — one entry reads back correctly, which is what a freed-but-not-yet-reused buffer looks like. **ISSUES #74 is new**: Rust emits `&self` for a method whose only statement is a mutating call on a field object, so the output does not compile. The cause is pinned down — a call in statement position keeps the shape `(slot.put (v))` while the mutability analysis only reads the desugared `(call slot get ())` that a call in value position becomes — but the fix decides `&self` vs `&mut self` for every method on the target and belongs in its own change. Both are skipped by name, with the issue number, and codegen is still asserted for both targets.

  Documented where a reader will actually look: the language guide's *Types*
  page, the FAQ (with a compiled example whose output the site shows for all
  thirteen documented targets — `History_int` and `History_string` side by
  side, which is the design in one screenshot), the README and the offline
  syntax card.

- **`OfficeHistory` holds the operations, and three editors deleted their undo stacks** — the shared undo file used to end its explanation with an apology: *"Ranger has no generics either, so this deliberately does not try to hold the ops themselves — each editor keeps its own array."* It held the *rules* — what one action is, what falls off at the cap, when trimming is safe — and two of the three editors that needed them ignored it, because taking the rules without the array meant rebuilding a parallel `[int]` of transaction ids at every call site. The apology is gone: `OfficeHistory@(DocEditOp)`, `OfficeHistory@(SpreadsheetUndoOp)`, `OfficeHistory@(BookDocument)` and `OfficeHistory@(PptxEditSnapshot)` are four separate concrete classes, none of which knows anything about the others' entries.

  The four editors do not agree on what an entry IS, and no longer have to. The document and the spreadsheet record **operations** and undo by inverting them, so they walk `undoSpan()` entries with `peekUndo` / `moveUndoToRedo` — one at a time, because an op that will not apply has to stay where it is. The book and the deck record **snapshots** and undo by swapping states, so they use `popUndo` / `pushRedo` and decide themselves which state crosses. Both get the cap, the transaction stamping and the redo invalidation from the one file, which is the part that was wrong in two editors out of three. `PptxEdit` gave up its `history` array, its `historyPos` cursor, its own trim loop and its `set_at` coalescing; `SpreadsheetModel` gave up two stacks, `maxUndo`, `txDepth`, `curTx`, `txCounter` and `trimUndo`; `DocxEditController` gave up two stacks, `maxUndo` and `trimUndo`; `BookEdit` gave up two arrays and `historyLimit`. `DocEditOp.txId` and `SpreadsheetUndoOp.txId` went with them — the history stamps now. Net: **356 lines deleted against 528 added**, and the added ones are almost all in the shared file and its suite (`npm run office:history:test`, 55 checks, JavaScript and C++).

- **Every visual editor's suite runs in CI** — none of `book:test`, `pptx:test`, `docx_viewer:test`, `datagrid:test` or `office:*:test` ran anywhere in `.github/workflows/ci.yml`. A fix crossed from one editor to the next only when a human remembered to run five suites by hand, which is literally what the `JPEGDecoder` change cost. The new `gallery-editors` job runs nineteen of them and is required by `test-gate`; it is deliberately **ungated**, since a compiler change breaks these exactly as easily as a gallery change does. It runs through `scripts/run-gallery-editor-tests.sh` rather than a chain of npm scripts for a reason worth stating: **the compiler prints `[FAIL]` and still exits 0**, so a chain runs the stale build from the previous compile and reports a pass — a CI job that goes green on a compile error is worse than no CI job. Each suite fails on `[FAIL]` in the output, on a missing pass marker, and on a non-zero exit.

- **The book editor as a native window, configured by a file** — `gallery/book/platform/sdl` compiles the whole editor to C++ and runs it on SDL2 + OpenGL. Nothing in the editor changed to make that possible, which is the point of the display-list seam: `evg-webgl.js` draws the list in a browser tab and `EvgGlPainter` draws the same list in a window, from the same commands, with no second copy of the tree walk. The window, the input and the GL present path are the DataGrid's `dgfx_*` layer, borrowed the way the schema editor borrows it.

  **A window has no URL and no argument list**, so what it opens comes from `book.config.json`, and **every field has a default**: `{ "spread": 3 }` is a complete config meaning "like the default, but that". A missing file is not an error, it is the defaults — the first run of a fresh build should show a book rather than a diagnostic, and a half-written config is the normal state of a config. What the host will not do is hide what it decided: it prints the settings actually in force before opening anything, because a host that silently fell back to a default is indistinguishable from one that read your file correctly, and the difference matters the moment a path is wrong. It opens the sample, an Apple album, or a photo query — the same query `book:photos` takes, written as data.

  `npm run book:sdl:smoke` runs thirty frames with no display and then prints what the last frame held: commands, text runs, pictures, pages, preflight counts. Deliberately not "it did not crash" — a host that opened nothing and drew an empty desk would also not crash.

  **Compiling the book for a third target found three defects that three targets had not.** `if optional` on a string does not compile on C++ at all: an optional string is a pointer on es6 and a plain `std::string` there, so `if body` becomes `if (body != NULL)`. Three file reads had it, and all 220 assertions passed on JavaScript, Go and Python throughout. Passing `this` out of a **constructor** throws `std::bad_weak_ptr` on C++, because the object is not yet owned by its `shared_ptr` while the constructor runs — the native host died before printing a line, from code that is correct everywhere else. And `JPEGDecoder` narrates: twenty-five lines per photograph, every marker and every tenth row, which goes to a console nobody has open in a browser and to the user's terminal in a window. It has a `quiet` flag now, off by default so every existing caller is unchanged, turned on by the two places that decode a picture as a side effect of *drawing* something. `datagrid`, `pptx`, `docx_viewer` and the PDF finishing suites all still pass.

  Also here: `BookApp` gained `spreadSvgAt`, `documentTsxText` and `runPreflightText`, because both the browser host and this one had built their own `BookApi` around the editor's document to get them.

- **Photographs chosen out of a library by when and where they were taken** — the Apple-album importer reads a file that says which pictures belong together, and a modern Photos library has no such file: it is tens of thousands of pictures with a date and a position buried in each one. Choosing the twenty that belong in a book means asking questions of the whole pile — *that week in June*, *within twenty kilometres of the cottage* — and a question you cannot ask until you have opened forty thousand files is not a question anybody asks twice. So the pile is **indexed once** and the searches run over the index: `PhotoIndex.rgr` holds one small record per photograph and searches them by date range, by great-circle radius, by text, by rating; `PhotoScan.rgr` builds that index by reading the JPEGs' own EXIF. `npm run book:photos` asks the question and lays out the answer, through the same `BookAlbumImport` an iPhoto album goes through — which is what `AppleAlbum` was shaped for.

  **The editor searches in the page.** *Open photos…*, or drop a folder of JPEGs, and each one's EXIF is read by the same Ranger parser compiled into `book_web.js`; the search bar then answers from dates, coordinates and camera. Nothing is uploaded, which for somebody's photographs is not a performance detail. `JPEGMetadataParser` grew `parseBuffer` for it — a browser cannot open a file by name, it is handed one — and the file path and the buffer path now share one marker walk so they cannot drift.

  **On a Mac, `tools/mac_photos.mjs` reaches the real library.** Photos.app stopped writing `AlbumData.xml` and its database is inside a package the system guards, so the supported way in is to *ask* Photos.app over AppleScript; macOS puts up its permission dialog, which is the system asking on the user's behalf. An iPhone writes HEIC, which cannot go into a PDF and which no browser but Safari will draw, so the chosen pictures are converted with `sips` — only the chosen ones, since converting twenty is a second and converting nine thousand is an afternoon, and that asymmetry is the whole reason the index exists. For a plain folder it asks Spotlight instead, which has already read every file and reaches HEIC too. Spawning `osascript` and `sips` cannot be tested off a Mac, so the file is written the other way round: the parsing of what those tools print is pure and tested (`npm run book:photos:test`, 17 checks, no Mac needed) and the process calls around it are thin. Those calls have not been run on a Mac from here; the parsers have.

  Two details decide whether a search is right rather than merely plausible, and both were found by writing the round trip down. Distances are **great-circle**: a degree of longitude is 111 km at the equator and 55 km at sixty north, so the flat formula is wrong by half a Finland, and the test pins Helsinki–Tampere at 160 km and a date-line crossing at one degree rather than 359. Coordinates are written with **six decimal places**: `BookRenderer.num` rounds to two, which is exactly right for a typographic point and up to a kilometre of latitude, and an index written with it and read back moved every photograph while looking entirely plausible. Beside them, a third: an empty position is **absent**, never 0,0 — that is a real coordinate in the Gulf of Guinea, and encoding "unknown" as it would put every untagged picture in the library inside a radius search centred there. `JPEGMetadataParser` also gained signed decimal `gpsLatitudeDeg`/`gpsLongitudeDeg`, because the degrees-minutes-seconds string it already had is for a person to read and nothing can compute a distance from it.

  `npm run book:test` is at **220 assertions** and passes on JavaScript, Go and Python — the geotagged fixture is a real photograph taken in Tuscany in 2008, which is the only honest fixture for a search by place. `npm run book:web:test` is at 41 in a real browser, including the finder driven the way a person drives it. Two things came out of running those: a method named `select` compiles on es6 and produces Go source the Go toolchain cannot parse at all, and `book:test:go` was `cd`-ing into the output directory, so every test that opened a file passed vacuously — it runs from the repository root now, where the other two targets already ran.


- **The book editor opens an Apple photo album** — iPhoto and Aperture describe a whole library in one XML property list (`AlbumData.xml`, `ApertureData.xml`), and that file plus the photographs it names is all an album is; four new files read it and turn it into a book. `ApplePlist.rgr` parses the property list into typed values rather than into a DOM, because a DOM would be walked once and thrown away — a missing key answers with a value that does not exist instead of an optional, so `album.get("KeyList").at(3).asString()` is a chain rather than five unwraps, the DOCTYPE is skipped **whole** and only the five predefined entities plus numeric references are expanded (that is the XXE policy, and it is a policy rather than an oversight), and a binary plist is refused with the one command that converts it instead of being half-parsed into plausible nonsense. `BookAppleAlbum.rgr` joins `List of Albums` to `Master Image List` — the album holds ids, the master list holds pictures — and knows that `DateAsTimerInterval` counts from **2001**, not 1970, which is a 31-year error that still produces a plausible-looking date. `BookAlbumImport.rgr` lays the album out, and touches no file, so the same import runs in the browser. The command line is `npm run book:album` (with `-list`, `-album`, `-images`, `-min-rating`, `-max`, `-format`); the editor takes an album by **drop**, with nothing uploaded — the parser and the layout are compiled into `book_web.js`, so the library is read where it is opened.

  Three decisions are what separate this from one photograph per page. **Orientation chooses the page**: a landscape photograph bleeds off all four edges, a portrait one sits inside the margin in a frame that has taken the picture's own proportions, so the caption underneath is under the *picture* rather than under empty paper (`BookAutoLayout.byOrientation` and `.fitFramesToAspect`, both off by default — a book whose pictures illustrate a story wants the rhythm of the rotation). That needs the pixel sizes **before** the layout, so `BookAlbumMeasure.rgr` reads them off the JPEG headers — including the EXIF orientation, since a phone photograph is stored landscape with a tag saying "turn it", and measuring it landscape puts every portrait picture in the wrong frame. In the browser there is no disk, so the page measures each dropped file and hands the sizes over through `noteImageSize` before opening the album. **A caption is the album's, not the file's**: iPhoto captions an untouched photograph with its file name, so `IMG_4021.JPG` would otherwise be printed under it — a caption that looks like a file name falls through to the comment, then to the date, then to silence — and a full-bleed page gets its caption on a small slab of paper at the foot rather than losing it, because the auto layout calls a caption over a bleeding picture a manual edit, which is right for a story book and wrong for an album. **A modern Photos library has no index**: Apple stopped writing `AlbumData.xml`, so an exported folder arrives through `AppleAlbum.fromPaths` — the host enumerates the files, since Ranger has no directory listing — and everything downstream is the same code.

  `gallery/book/fixtures/AlbumData.xml` is a real iPhoto index in miniature: three photographs, two albums plus one of Apple's own, a movie, and Finnish captions written as numeric character references exactly as iPhoto writes them. It ships in the browser build, so the page's self test drives the whole album path — list the albums, measure the pictures, open one, check the photographs got **textures** rather than merely draw commands — the way a reader drives it, through the same function the drop handler calls. `npm run book:test` is at 161 assertions and passes on JavaScript, Go and Python; `npm run book:web:test` is at 27 in a real browser on WebGL.


### Changed

- **The repository now states two licenses** — Ranger-authored code outside `gallery/` (`compiler/`, `lib/`, `examples/`, …) stays **MIT** unless a file or subdirectory says otherwise. Ranger-authored code under `gallery/` is **AGPL-3.0-or-later** unless a file or subdirectory says otherwise. Third-party trees keep the license their authors gave them. `gallery/` is the application stack (EVG, Office, DataGrid, PDF, editors), not a folder of samples; using those modules is using that framework. The compiler does not put the AGPL on a program it compiles. Root [`LICENSE`](LICENSE) is a mixed-license overview (not a single license text, so GitHub will not badge the repo as MIT-only). [`LICENSE-MIT`](LICENSE-MIT) and [`LICENSE-AGPL-3.0`](LICENSE-AGPL-3.0) hold the full texts. [`gallery/LICENSE`](gallery/LICENSE) states the AGPL for that tree so a gallery-only copy still names its license. [`LICENSING.md`](LICENSING.md) records the path rule, that generated output follows the source (not the compiler), that compiled gallery programs stay AGPL, that runtime helpers in the output are MIT, that a commercial license for `gallery/` may be available as an alternative to the AGPL, the MIT→AGPL dependency direction, third-party exceptions, and that earlier snapshots keep the license they were published under. Ranger sources carry `SPDX-License-Identifier` so a copied file keeps its license.

### Fixed

- **The Android viewer rendered perfectly and did not respond to a single touch** — found by running it on an emulator, which is the only place it could be found: the defect was in `SlideView`, the one file in the port that cannot be checked without a device. `GestureDetector.onTouchEvent` returns whatever its listener returned, and `onDown` returning `true` — which is what almost every example writes, because most views want the detector to own the stream — makes it return `true` for **every** `ACTION_DOWN`. The host treated that as "handled" and stopped, so the viewer never learned a finger had landed: no toolbar button, no slide thumbnail, nothing on the page responded, while the page itself drew exactly right. Nothing in the host consumes a touch now — every callback returns `false`, `onTouchEvent` ignores the return value, the detectors observe and `TouchRouter` decides — because a press the viewer never sees is also a press it never releases, and half a drag left open inside the app is worse than a gesture that did not fire. Two more things came out of the same session. A flick is decided on the way UP, by which time the viewer has had the press and the moves, so in edit mode a swipe has already picked a shape up or pulled a rubber band out and turning the page on top of that would leave the edit behind; the flick is now gated on the app's own answer to `pointerAt`, which is false on the page in view mode and true in edit mode, so the rule needs no copy of the app's state. And the host opens the deck in **view** mode — `PptxApp` starts in edit mode, which is right for a mouse and wrong for a finger, where every tap on a slide would drag something. `npm run pptx:android:verify` is at 176 checks, and the new ones include the thing that fails silently: that a press on the strip and on the slide panel reaches the viewer at all, asked of the viewer rather than of the router

- **Kotlin wrote a `final` class for `class Child extends Base`, so its own subclass would not compile** — Kotlin classes and methods are final unless they say `open`, and the writer knows it: it emits `open class` and `open fun` when `is_extended_by_children` is set. That flag was set for **two of the three** ways Ranger spells inheritance. `Extends(Base)` and a body-level `extends Base` go through `markParentClass`, which sets it; the class-HEADER form is collected into a separate map and re-applied in `CollectMethods`, which set `is_inherited` and stopped — so every target that asks "is anything derived from this class?" was told no. Twelve lines reproduce it. It survived this long because the compiler's own sources and the `@process` runtime use the other two spellings and because JavaScript, TypeScript, Go and Python do not care either way; what it was actually blocking was `gallery/pptx` on Kotlin, where **one** subclass (`class PptxToolbar extends EVGToolbar`) produced the **only** `kotlinc` error in 66,082 generated lines — everything else in the viewer, the ZIP reader and the OOXML parser and the theme resolver and the JPEG/PNG decoders and the TrueType reader and the EVG layout engine, compiled clean on the first attempt. Self-host fixpoint held over the rebuild, byte for byte. ISSUES.md #72

- **A click on the page turned it, so nothing on it could ever be edited** — the book editor's reading mode gave the left and right thirds of the window to the page turn, and the page was inside those thirds. The first thing anyone reaches for — click the picture, move it — turned the spread instead, and there was no gesture that reached a frame at all until you found Ctrl+E first. The turn lives on the **desk** now: a click on a page selects what is under it and arms editing, which is what clicking a thing in an editor is supposed to mean, and only the margin on either side turns the spread. The same shape of defect the slide editor had, for the same reason. Beside it, **a full-bleed picture was drawn across the desk and over the facing page**: a page had no clip, so the 3 mm every bleeding element overhangs the trim by design went wherever it liked. Each page is cut to its own sheet — trim plus bleed — and the overhang is drawn and then **veiled** towards the desk colour with the trim as a hairline inside it, so past-the-edge reads as past the edge rather than as part of the picture. That has a consequence worth stating: the two pages of a spread cannot sit edge to edge any more, because one page's bleed would be painted onto the other's live area, so they are held two bleeds apart and the **Bleed** toggle closes the gap again when you want to see the sheet as it prints. 15 more assertions in `npm run book:editor:test` (79), including the one that would have caught the first bug: a click on a frame while reading leaves the spread index where it was

- **The same three defects the slide editor had, in the document editor, found by pointing the same instruments at it** — asked whether the pptx work carried across, and the answer was two thirds no. **Drawing one page laid out all of them**: `DocxView.present` called the full-document layout and then drew a single page, so a page frame cost 3.5 ms at a hundred and twenty-five paragraphs and **25.6 ms at a thousand**, and typing paid it twice — once to edit, once to draw. `RichDocument.revision` is bumped by every mutating primitive and `DocxLayout.layoutIfNeeded` lays out only when that number has moved: a page frame is **0.25 ms at any length**, 100× cheaper at a thousand paragraphs, and a keystroke and an undo stop following the document (35 ms → 8 ms). The dependency is one integer, which is a dependency that cannot be got wrong — and the way it CAN go wrong is a mutation that does not announce itself, so the bumps are deliberately over-eager, a document swapped in front of the layout calls `invalidate` rather than trusting the number, and the suites caught exactly that on the first attempt (list renumbering and paging both went stale until every primitive announced). **Every bullet drew as an empty box**: Word writes its levels as ● ○ ▪ — geometric shapes, not letters — and the emoji gap was there too; `FontManager` has had per-codepoint fallback all along and what was missing was a face to fall back to, so Noto Sans and Noto Emoji join the pool exactly as they did on the slide side. And **twips truncated instead of rounding**: fifteen twips to the pixel, so anything not a multiple of fifteen lands between two and `to_int` floors it — and floors a NEGATIVE outwards, so an outdent landed a whole pixel too far left. Same for `emuToPx`. That is the fourth place in this repository to meet the identical defect. Two things did carry across and needed nothing: the document editor's history is a **true operation log with inverses** rather than whole-document snapshots (the design the slide side argued its way to a cheaper equivalent of), and `WordStyleResolver` already walks docDefaults → `basedOn` chain → paragraph style → character style with cycle guards, which is the inheritance chain the slide side had to be taught. `npm run docx:bench` and `npm run docx:audit` are the two instruments, ported; `docs/EDITOR_BENCH.md` has the before and after; 22 new checks (264 in `docx_viewer:test`)

- **The editor window forwarded five keys, so boxes could be dragged and their words could not be changed** — reported from a session, and the defect was not in the editor at all: `gallery/pptx/web/client.mjs` sent the four arrows and Escape, and nothing else. No text, no F2, no Ctrl chords, no pointer MOVES — so a shape teleported on release instead of dragging, a rubber band never appeared, and typing did nothing anywhere. The app underneath was fine and every test that existed proved it, because every test drove the app directly. The window is a real editor host now — the whole key table, text with its modifiers, moves while the button is held, the wheel — and the gap cannot reopen: `pptx:window:smoke` presses on a shape THROUGH the window's own event path, checks it selected, sends F2, checks a caret appeared, types, checks the words changed, and undoes it with Ctrl+Z. A smoke test that only asks whether the app can draw will pass forever while the thing in front of the user does nothing

- **Every emoji in every deck drew as a filled circle** — the text face has no emoji glyphs, and a codepoint a face cannot draw is `.notdef`. Two things were missing and the second is the one that mattered: an emoji face was not loaded (Noto Emoji, the monochrome one, has been in `assets/fonts` all along), and **`UITextRenderer` never gave the rasterizer the font manager**. `FontManager` has had per-codepoint and per-grapheme-cluster fallback for a while — it is what lets a run cross faces and a flag sequence collapse to one glyph — and `RasterText` has had the code to use it, but nothing ever called `setFallbackManager`, so `hasFallbackManager` was false in every EVG app and the fallback path was dead code. It is wired at load now, along with `addFace` for a face that joins the fallback pool WITHOUT becoming the family the renderer draws with (an emoji font loaded the ordinary way makes emoji the default face for the whole document). Turning it on immediately found the bug underneath: `fallbackFace` returns an unloaded face when nothing has the codepoint — a newline, say — and both callers were switching to it and reading a glyph outline out of an empty buffer. They check `isLoaded()` now and draw `.notdef` from the primary instead, which is what they did before there was a fallback at all; the doc comment that said to check `unitsPerEm == 0` was wrong, since a fresh face carries the class default of 1000. 4 checks in the frame suite, including the codepoint no face has

- **Six rendering defects found by opening two real decks, all of them in what the reader never read** — reported from a session with a `.pptx` nobody here wrote, which is the only way this kind of hole gets found: every fixture in the suite was written by the same hand as the reader. The largest is **`<p:style>`**, which was not read at all. A shape inserted in PowerPoint and left alone states **no fill and no outline** — it carries a `fillRef` / `lnRef` / `effectRef` / `fontRef` into the theme's `fmtScheme`, and the theme is where the gradient lives. So the deck's whole top bar, its navigation boxes and its footer band drew as *nothing*, and the white text on them came out black, because `fontRef` is what says the text on a themed shape is white. Reading it needed the piece under it as well: DrawingML states a colour and then a **chain of adjustments** to it (`lumMod`, `lumOff`, `tint`, `shade`, `satMod`), and a theme's fill styles are made almost entirely of those — the colour in them is the placeholder `phClr` and the STYLE is the modifiers. Both are read now, and a colour that has been through them is marked **baked** so nothing resolves it a second time back to the flat accent it started from — which is also why the writer now writes it as the literal `srgbClr` it became rather than as the slot name it can no longer express. Beside it: **a connector was drawn as a rectangle**, so every arrow in the deck was invisible (it is a LINE across its box, with `flipH`/`flipV` saying which corner is which and `a:headEnd`/`a:tailEnd` saying what is on its ends — drawn now as a quad with a triangle, and written back as a `p:cxnSp` rather than as a grey ghost box); **a run that is a single space was thrown away**, because whitespace-only text nodes are layout in every other part of an OOXML file and content inside `<a:t>`, which ran "The Ultimate Training Diary" together; **a rounded rectangle ignored its own `adj`** and drew every corner at a fixed twelve pixels, and its outline was drawn square in any case, because the display list's border primitive has no radius — the corners now follow the adjustment (a sixth of the shorter side when nothing is stated, which is DrawingML's own default) and the outline is stroked round them; and **a bracket or a brace drew nothing**, because a preset with `noFill` and a line is a *stroke* and every preset went through the area filler — `leftBracket`, `rightBracket`, `leftBrace`, `rightBrace` and `line` are open paths now and are stroked with the shape's own line. Also read on the way through: `sysClr` with its `lastClr`, `<a:ln><a:noFill/>`, and a shape's `p:cNvPr/@id`, which a slide's timing tree needs to name it

- **A table drew a white box with the text running out the side of it** — the second deck, and the table model was the thinnest thing in this renderer: a cell was a string, a bold flag and a size. So a table lost its **column widths** (every column an equal share of a frame whose `ext` some writers fill with a round 3000000 × 3000000 placeholder — the grid the file states is the real size, and the row heights are a *minimum* that grows to fit the words), its **cell text colour, runs and wrapping** (one line, drawn straight out of the cell), its **edges** (a grey box round every cell, where the file states each of the four separately and marks the ones it does not want at zero alpha), its **fills** (white, always, so every table on a dark slide was a white slab), and its **margins and vertical anchor**. A cell is a `PptxTextBody` in a box now, laid out by the same wrap, measure and paint the rest of this renderer uses — so it wraps where the glyphs wrap, it grows the row it is in, and it is clipped to its own cell — with per-edge lines, per-cell margins, an anchor, and `gridSpan` / `rowSpan` merges. The writer carries all of it back out. Two new fixtures pin the three classes of defect down (`29-theme-styles.pptx`, `30-table-grid.pptx`) and the suites are at 81 parser, 172 writer, 1257 structural checks, with the write-and-redraw oracle now covering **50 slides, every one byte for byte identical**

- **Clicking the slide turned the page, which meant the editor could not be used at all** — reported from a real session, and it is the kind of defect no test finds because every test knew to turn editing on first. The deck opened **read-only**, with the editor behind a toolbar toggle, and in that state the left and right thirds of the slide were "previous" and "next" — the way a deck is READ. So the first thing anyone does with an editor, click a shape, moved the deck out from under them, and nothing they did afterwards was editing. Two changes: a deck now **opens ready to edit**, and **clicking the slide never turns the page** in any mode. Turning the page is the slide panel, the strip, and the keyboard — and the keyboard needed a rule of its own, because an arrow key with a shape selected nudges the shape (which is what every editor does and what this one already did): **Page Up and Page Down always turn the page**, whatever is selected, and the arrows turn it only when nothing is. The browser build spelled its page keys as arrows, harmless while an arrow only ever meant the deck and wrong the moment it meant a shape, so they are themselves now. `edit.toggle` (Ctrl+E) still exists and now turns editing OFF — for reading, and for the present mode of phase E6, which is where clicking to advance belongs. The host suite is at 103 checks: clicking the right of the slide stays on the slide, clicking the left stays too, the strip and an arrow with nothing selected still turn it, an arrow with a shape selected moves the shape instead, and Page Down turns it anyway

- **A turned shape drew its box straight and its words crooked** — spotted from a screenshot, and two defects under it. The first: **only text ever carried the rotation into the display list**. The fill, the outline and the picture never set `rotate` at all, so a shape you turned in the editor kept its upright rectangle in *both* backends while its text leaned — the thing the picture showed. They carry it now, and a filled path, which has no angle in the display list to carry, has its points turned when they are emitted (so a rotated preset shape draws rotated for the first time). The second is subtler and is why the text was crooked in the wrong place: both backends turn an element about the element's **own** centre — the shader's `aRot` says so and `UIContext.textRotated` does the same — which is exactly right for a shape's box and exactly wrong for everything drawn INSIDE it. A line of text turned twenty degrees about its own middle is not the same line turned with the shape it sits in, so each piece of text is now placed by turning its own centre about the SHAPE's centre and then drawn turned about itself, which composes to the right thing on both backends. Beside them, the editor's chrome was upright over a turned shape, which is what made it read as a bug rather than as a rendering quirk: a single turned selection now draws a turned outline, its handles sit on the turned corners, the rotation handle leans out of the turned top edge, and **a resize drag is turned back into the shape's own axes** before the box hears about it — dragging the right-hand handle of a shape lying on its side has to make it longer, not taller. The CPU backend's own half of this (it had been ignoring `rotate` outright) is a turned rectangle through the polygon filler it already had, four quads for a turned outline, and the rotated blit for a picture. 9 checks in the host suite, including the handle that moves from the right-hand edge to the bottom on a quarter turn and the drag there that grows the width and leaves the height alone

- **A chart that was exact to a quarter of a pixel said nothing at all to a screen reader** — the ink had been compared for eighty-eight charts and the *text* for none of them, and `VlSvg.rgr` emitted no `aria-label`, no `role` and no `aria-hidden` anywhere in the document. Half of what a reader is handed was already right and unnoticed: the per-mark sentence ("c: A; v: 30") is computed from the data, lives in the scene, and the parity harness had been holding it item for item all along — it just never reached the page. The other half is written by the RENDERER, from the scale, and is in neither scenegraph, which is why it needed a harness of its own: `tools/reference/aria.mjs` renders each spec with both implementations and compares every labelled element in document order — what it says, what role it claims, and how much of the document is announced as nothing. It agreed on **20 of 88** charts on its first run and now agrees on all 88, over 956 labels, as a step of `tests/run.sh` (`npm run vela:aria`). The sentences are the reference's own, down to the parts that look like mistakes: a domain past seven values names the first five and then the last ("9 values: A, B, C, D, E, ending with I"); a mark that carries a description but no role description is called "<marktype> mark", which is why a bar is a "bar" (Vega-Lite says so) and a line is a "line mark" (nobody does); the ends of a continuous scale are formatted the way its TICKS are, so 0 to 3 is read "0.0 to 3.0" and a share of a whole with `"format": "%"` is read "0% to 100%"; a date specifier written for an axis is expanded before it is spoken — `%b` to `%B` — because a reader has no label width to fit in, which is what turns thirteen digits of milliseconds into "January, February, March, April"; `%X` does not pad the hour; a legend names a colour a colour and leaves every other channel under the name the grammar gives it; and a degenerate domain, having no step to take a precision from, falls back to six decimals and reads "values from 1.000000 to 1.000000". Two of the ten things it found are not accessibility defects at all, which is the argument for having it: every title in a faceted chart was missing (ten unlabelled strips, no way to tell the columns apart), and **a heatmap drew its axes underneath its cells** — ordering inside a group is `zindex`, and `"zindex": 1` on an axis means its ticks and domain line are drawn *over* the data. Nothing else could see that one: parity matches marks by role, and an ink comparison does not care what order ink arrives in. Beside it, `VlAxis.labelSet` moved to `VlScale` where it belongs — `tickFormat` is a scale method in the reference, and a guide's sentence needs the same rule its labels use. 1877 parity marks, 4848 drawn primitives, 44 Vega-Lite sources, the 175-example corpus, 111 API checks and the C++ byte-for-byte build are unchanged; three goldens were rewritten, and the only difference in them is the order the heatmap's marks are stacked in. The live page at `/evg/chart-api/` gets it for nothing — it renders the same SVG — but that is exactly the half that can go missing without a word, since a stale bundle ships a picture that says nothing while every other check still passes, so the browser suite (**24 checks** now) asserts the sentences are in the DOM the reader actually gets

- **Tooltips: what a reader is shown by pointing, and told by listening** — the last two charts in the catalogue harness differed in one thing, an error bar's description naming `lower_y` and `upper_y` where the reference names the mean and the error either side of it. Those names come from a **tooltip**, and Vela had none: `"tooltip": true` on a mark was read by nobody, and a `tooltip` channel was read as a position channel naming no column. All three shapes are compiled now and produce the reference's own signal text — `true` gives an object of every column the mark encodes, a single field def gives that value on its own, and a LIST gives an object of exactly those entries in that order. A tooltip is also what the mark's `description` says, so the two are one sentence written twice: one function decides what a value looks like, and the separator a list is joined by is the only difference between them (a space reading along a line, a newline in a tooltip). The rule for what gets named twice is the reference's own and worth writing down: a channel is named once per COLUMN and a tooltip entry once per NAME — which is what lets an interval's upper end be told as `upper_y` *and* as "Mean + stderr of y", while the column a chart is grouped by, named the same both times, is told once. The API says it as `mark.tooltip(true)`, `mark.tooltipField("c")` and `mark.tooltipFields([…])`, with the types inside that list read off the data like every other channel's. **The catalogue harness is now 37 of 37** — every chart type measured against Syncfusion's and Observable Plot's catalogues draws exactly what the reference draws — and the corpus (172 exact), 48 goldens, 1877 parity marks and 4848 drawn primitives are unchanged by it.

- **A dual-axis chart measured its first layer against the second one's column** — `resolve` was inherited by a layer, and it belongs to the composition that states it. A layer that expands into layers of its own — a line asked to show its points is two marks — numbered its scales from zero again and handed them the names the OUTER layers were already using, so a Pareto chart's left axis was scaled to the line's column and grew a fifth tick. Two more defects came out from under it: a layer inside a layer is `layer_1_layer_0_marks` and not `layer_11_marks` (run together, the eleventh layer of a chart and the first sub-layer of its second layer have the same name), and a nested layer has to be told what its parent already knows about the scales — otherwise it decides afresh that a category is a *point* scale and its line stands at the start of the band the bar beside it fills, twenty pixels out on a forty-five pixel band. And a fourth, beside them: the second layer's grid axis is *dropped* rather than kept with its grid turned off, because an axis with no ink still reserves room beside the plot. The API can say it now — `chart.independent("y")` and `mark.axisOrient("right")` — and the catalogue harness is at **35 of 37**, with only two aria strings left.

- **A polar column and a rose: an angle read from a category** — `theta` typed as a number answered nothing for a category, so every wedge of a polar column came out at the same degenerate angle (`startAngle == endAngle == π`). An angle read from a CATEGORY is a **band of the circle** — one slice per category, all the same size — and the length of the wedge inside it is what the chart measures, which makes the RADIUS what accumulates: a polar column stacks by default the way a bar does, and that stack beats a stated `innerRadius` (otherwise every segment starts at the same ring and draws over the one below it). Two more of Syncfusion's types draw exactly as the reference draws them, and the catalogue harness is at **34 of 37**. What is still missing from that family is a polar *line* or *area* — a radar chart — which needs a coordinate system rather than a scale.

- **Three defects in Vela that only a program calling it could reach** — each found by building six charts through the new chart API and comparing the drawing against official Vega-Lite and Vega, and none of them in the API. `%q` was **not a directive** in the time formatter, and an unknown directive answers its own letter, so every chart with `timeUnit: "yearquarter"` — which the official compiler turns into the format `"%Y Q%q"` — labelled its axis `2016 Qq` and said nothing about being wrong. A **grouped bar chart of stated width drew twenty-pixel bars**: which way round the two bands are sized depends on who decided the plot's size, and the sub-scale was always given a fixed twenty-pixel step, which is right only when nobody said how wide the plot is (then the band above it is sized *from* the sub-bands) and leaves a third of every group empty when somebody did — the sub-scale has to span the band it sits in, `[0, bandwidth('x')]`, exactly as the reference writes it. And **the key to a see-through area was drawn solid**, because a legend symbol's opacity was read only off an `opacity` channel and not off the mark (`{"type": "area", "opacity": 0.35}`), which is how every layered chart shades its filled half. All 48 goldens, 4848 drawn primitives, 44 Vega-Lite sources and the 188-example gallery corpus (172 exact, 3 random-by-construction) are unchanged by the three

- **A long value in a dialog field was drawn straight out of the field, across the panel and over the sheet behind it** — `EVGWindow`'s input control drew `c.value` from the field's left edge at full length, with no window and no cut. Clipping alone does not fix it: `SoftPainter` honours a clip rectangle **per draw call** (`textInClip` tests the text's ORIGIN), so a string that starts inside the box is drawn whole, and the WebGL and SDL hosts scissor at their own granularities again. Text that must not escape a box has to be cut to the box on every backend. The cut now lives in one place, `gallery/evg/EVGTextFit.rgr`: the window that fits the field **and** contains the caret (grown left from the caret while there is room, then right with what is left, so typing at the end scrolls the text and moving back brings the earlier text into view), the measured caret offset inside that window, a hit test measured the same way, and the label trimmer the grid already had. `EVGWindow`'s input, the grid's cell labels and the formula field all go through it. The display-list primitives moved to `EVGDisplayList` itself (`addRect` / `addFrame` / `addText` / `addClip` / `addClipEnd`) — `GridView` and `EVGWindow` each had their own encoder for the same commands — so a dialog and the grid behind it cannot drift into two encodings. `npm run datagrid:textfit:test` checks the display list rather than the picture: for every piece of text that starts inside a panel, does it end inside it too? It fails on the code from before the fix. Also: the database window smoke now addresses cells by name (`nav.goto E2`) instead of by pixel, after a merge with master moved the grid geometry under its hard-coded clicks

- **A declared empty constructor produced a body-less `__init__` on the Python target** — `ng_RangerPythonClassWriter` already emits `pass` for an empty body, but it set `hasContent = true` for any class that *declared* a constructor rather than for one whose body actually wrote something. A class with no fields, no parent and `Constructor () { }` therefore compiled to `def __init__(self):` with nothing under it, and Python failed to parse the whole module at the next method (`IndentationError: expected an indented block`). Every other target was unaffected, because only Python's block structure is whitespace. It is now measured the same way `pyWalkBody` already measures a method body — the writer's line and column before and after walking the constructor — and a `super().__init__()` call counts as content, so a subclass with an empty constructor no longer gets both the super call and a stray `pass`. Found by RangerSQL's dialect classes, which are pure behaviour and declare no fields at all. Compiler reproduces itself byte-identically; `compiler-python`, `compiler-conformance`, `compiler-selfhost`, `compiler-record`, `compiler-serialize` and `operator-coverage` pass, and `shapes`' 14 Rust failures reproduce on the unmodified compiler

- **The gallery JPEG encoder quantized every negative coefficient one step too far from zero** — `JPEGEncoder.encodeBlock` wrote round-half-away-from-zero as `(c + q/2)/q` for positive `c` and `(c - q/2)/q` for negative, but `to_int` **floors** (it emits `(int)floor(...)` on C++, and the Rust backend was aligned to floor for byte-identity). On the negative branch the `- q/2` bias and the floor both round outwards, so `-10/3` quantized to `-4` instead of `-3` and `-1/3` to `-1` instead of `0`. About half of all DCT coefficients are negative, so half were inflated by a full quantization step and coefficients that belonged at zero became ±1 — which is why a smooth gradient block carried 25–33 nonzero AC coefficients instead of 2–3. The same double bias sat in the six DC-predictor updates that have to agree with `encodeBlock`. Rewriting the negative case as `0 - to_int(((0 - c) + q/2) / q)` keeps the division on a non-negative value, which is exactly round-half-away-from-zero **and** gives the same answer whether `to_int` floors or truncates (over `|c| ≤ 2000, 1 ≤ q ≤ 255` the old form disagreed between the two semantics on 497,757 pairs, the new form on none), so cross-target agreement here no longer depends on the backends rounding alike. C++/Rust/Go/Node still write byte-identical output; the reference image changes from `180280` bytes / `2d08fcfb…` to `75593` bytes / `3f9ef8fa…`. Identity-scale round trip on Example.jpg goes 23.6 → 37.7 dB, on GPS_test.jpg 20.0 → 34.8 dB, and the encode-heavy 300→2400 job drops from 1.207 s to 629 ms because there is far less to entropy-code. Recorded in `gallery/pdf_writer/docs/JPEG_SCALER_BENCH.md`

### Changed

- **jpeg_scaler decode/encode hot paths** — three bit-exact wins found by profiling the HD downscale: `JPEGEncoder.extractBlock` no longer allocates a `Color` per pixel per channel (1.09 M heap objects on a 1080p frame) and converts only the channel it was asked for; `IDCT.idct1d` short-circuits a row whose AC coefficients are all zero, since `cosTable[x * 8]` is 1024 for every x; and `FDCT.dct1d` folds the eight cosine terms into four sums and four differences using the table's even/odd symmetry, halving the multiplies without changing a single result. Hoisting the IDCT's per-block scratch buffer to an instance field — the usual fix — was reverted: the Rust backend emits `self.field.clone()` for a member buffer passed as an argument and does not infer `&mut self` for the enclosing method, so it is a compile error there and two 64-element clones per block if it were not
- **`gallery/pdf_writer/bench/jpeg_scaler_quality.py` finds the repo root** — `ROOT` was hard-coded to `/workspace`; it now honours `RANGER_ROOT` and otherwise resolves from the script's own location, so the quality harness runs in any checkout

### Added

- **The PPTX viewer, on Android** (`gallery/pptx/android/`) — not a rewrite and not a WebView: the same `gallery/pptx/src/*.rgr` the browser build, the SDL desktop host and the oracles run, compiled to **Kotlin** and painted with `android.graphics.Canvas`. There is no PowerPoint code in the new directory. What the port needed turned out to be a facade (`ranger/pptx_android.rgr`), one walk over the display list, and two implementations of an eight-method surface — because EVG "computes geometry and stops there", so a backend is a page of delegation rather than a renderer. **The seam is the display list itself, not a rendering of it.** The browser has to serialise it (`sceneJson()`) because a page parses it back anyway; Android has no such boundary, so `frame()` returns the `EVGDisplayList` — no 25-30 KB string per slide, no parse, and the **gradients and shadows `toJson` does not carry** survive to the painter, which is why the GL host cannot draw them and this one can. The painter also honours the two things the CPU rasteriser drops: clip push/pop, and multi-ring paths with an even-odd rule (it flattens every path to one polygon, so a ring with a hole comes out solid). Input costs nothing it should not: `PptxWeb.pointerAt` rasterises the whole frame into SoftCanvas and the page then throws those pixels away and asks for the scene instead — a full CPU rasterisation per touch, for nothing — so this facade's `pointerAt` only updates the app. The app is sized and hit-tested in **dp** and the canvas is scaled by the display density instead, the same trick the WebGL host plays with `dpr`: a 30dp toolbar is touchable on every screen, and text and vector shapes rasterise at the panel's real resolution because they are drawn *through* the scale — which is also why pinch-zooming in a show stays crisp. **The second surface is not decoration.** An `android.graphics` painter can only be run on a device, and there is none in CI, so the same `EvgPainter` drives a `java.awt.Graphics2D` twin: `npm run pptx:android:verify` compiles the generated Kotlin with `kotlinc`, opens five fixture decks on a JVM — ZIP, OOXML, theme and placeholder resolution, JPEG and PNG decoding, TrueType metrics, EVG layout — and renders every slide to a PNG. Chrome, slide panel with thumbnails, per-run colour, rounded rects, ellipses and `custGeom` chevrons as real curves, two-stop gradients, pictures, tables. 1-10 ms to build a frame and 8-28 ms to paint one at 1280x800, neither of them per frame. The view is hardware-accelerated and stays that way: on Android the accelerated `Canvas` IS the GPU path — Skia through Ganesh/Vulkan, with glyph rasterisation and path filling already in it — which is why this port draws through `Canvas` rather than through a `GLSurfaceView` and a hand-written GLES backend the way the SDL desktop host has to, having no Skia under it. The one thing an accelerated canvas will not do is a `BlurMaskFilter`, and it does not fail: it draws a hard-edged grey shape. Rather than force the whole view onto a software layer for one effect, the surface DRAWS the shadow's falloff — the silhouette a few times, each pass grown by a stroke and drawn fainter — which batches like any other geometry. `npm run pptx:android:run` builds it, boots an emulator if none is running, installs and opens it. **What a finger means is not in the Android file any more.** Every rule about which half of the screen turns the page, which flicks count and how a pinch moves the page lived inside `SlideView` — the one file in this port that cannot run without a device, which made them rules nothing could check. They are `TouchRouter` now, plain Kotlin beside the painter, and the verify run drives all of them against a real deck: a tap on a thumbnail selects that slide, a tap on a toolbar button runs its command, a flick over the page turns it while one that started over the panel does not, a mostly-vertical flick is a scroll, and the show's tap / pinch / pan / double-tap / Back behave — including that the point under a pinch stays under the pinch and that the pan never leaves the page. Writing those found two defects. The pointer conversion had the zoom missing from its division (`view / density` rather than `view / (density * zoom)`), which would have sent every touch drifting towards the top-left the further you were zoomed in — latent only because a zoom is reachable during a show and a show swallowed the pointer, which is one edit away from being live. And `next` / `prev` were `nextSlide` / `prevSlide` even while presenting, so a tap during a show skipped the slide's **build** and its transition: the animations a deck states were not run at all. Both fixed, and the build case is a test that walks a real fixture's build one tap at a time. The render check widened from five decks to **all 31 fixtures, 39 slides**, through a `RecordingSurface` that counts what the painter dispatched — so "some ink appeared" became "the corpus reaches text, borders, pictures, clips, vector paths, rounded corners, gradients, shadows, bold and non-ASCII runs, and every `save` was restored and was a clip or a rotation and nothing else" — and it PRINTS what no fixture reaches rather than leaving the gap silent (`strokePath`, holed and even-odd paths, mirrored pictures, italic runs). 167 checks. Not verified here: the APK build and `AndroidEvgSurface` itself, both of which need an SDK. And because the spreadsheet and the document viewer present through the same display list, `common/` and the two surfaces are already most of *their* Android port

- **A PDF that says what its colours are for, and refuses to claim what it is not** — the book engine could produce the right pages at the right size; what came out was an ordinary PDF, and an ordinary PDF is not a print file. `EVGPDFRenderer` finishes one now: `%PDF-1.6` (PDF/X-4 is a 1.6 feature set, and claiming it in a 1.5 header is the contradiction a preflight tool opens with), an XMP packet carrying `pdfxid:GTS_PDFXVersion` — which is where PDF/X is *identified*, not a dictionary key — an `/OutputIntents` naming the printing condition the numbers are for, `/Trapped /False`, which is not optional because a file that does not say cannot be PDF/X, and an `/Info` dictionary. All of it is written only when the file is asked to claim conformance, so an ordinary PDF comes out byte-shaped exactly as it did before any of this existed. **`-colors cmyk` separates every fill, stroke and glyph to process ink**, with maximum black generation, so pure black lands as **100% K and nothing else** — which matters more than accuracy does, because black text separated into four inks goes soft the moment the registration drifts and is the most common way a file that looked fine prints badly. Every colour in the renderer went through one of eleven hand-written `rg`/`RG` sites; they go through `fillOp`/`strokeOp` now, which is what made a one-line conversion possible at all. It is a **device** conversion and says so: no profile is consulted, so it is what a press would do to untagged RGB anyway, done where it can at least be declared rather than left to happen silently at the other end. **The gap is pictures, and it is not papered over.** Text and vectors separate; a JPEG does not, and an untagged DeviceRGB image inside a file whose output intent is a CMYK condition is a PDF/X conformance failure rather than a cosmetic one. The renderer counts them, names the two honest ways out (supply the pictures already in CMYK, or keep the whole job in RGB with an RGB output intent, which several print-on-demand services prefer), and under `-strict-print` **exits non-zero without writing the file** — a PDF that claims PDF/X and is not one is worse than a PDF that claims nothing, and `npm run book:print -- offset-sewn` now stops there instead of shipping the claim. Two encoding traps were caught by the tests rather than by a broken file: `GrowableBuffer.writeString` writes one byte per UTF-16 unit, so a title with an umlaut in it would have gone into the XMP as a single truncated byte inside a packet declaring itself UTF-8 — the packet is kept ASCII with numeric character references (surrogate pairs combined, so an astral character is one reference and not two invalid halves), which also makes its `/Length` arithmetic trivially right — and a PDF text string outside ASCII is written as UTF-16BE with a byte-order mark instead of raw units. `BookPrintSpec` gained the output intent per preset (`sRGB IEC61966-2.1` for the print-on-demand books, `FOGRA39` for coated offset) and preflight's colour check is now accurate rather than vague: with pictures in a CMYK job it is an error naming the count and the remedy, without them a warning. `render.sh` carries the finishing flags along with the computed page sizes. **46 new assertions in `npm run pdf:print:test`** — the conversion, the operators, the identification, the escaping and the refusal — and the book suite is at **121 on JavaScript, Go and Python**

- **The rules a photo book has to satisfy belong to the printer, so they are data now** — `gallery/book` could lay a book out and draw it; what it could not do was answer "is this ready to send". `BookPrintSpec` is a supplier's requirements as a value — trim size, bleed, outer **and gutter** safety margins, the extent's minimum, maximum and multiple, the dpi floor and target, colour space, PDF profile, whether they want crop marks — with presets for layflat 210, hardcover A4, softcover A5 and sheet-fed offset, and preflight measures the book against whichever one you name. Hard-coding any of it would have produced an engine that is confidently wrong for every supplier but one: 3 mm of bleed is a European convention and not a law, a multiple of four is one binding's arithmetic, and one service wants CMYK while the next one wants RGB. Three things stayed assertions rather than settings because they are not negotiable. **Page 1 is a recto** — odd pages right, even left, and a cover is always the right-hand side of the sheet it is bound onto. **Single pages, in reader's order**, 1, 2, 3, 4: printer spreads (32–1 / 2–31) are the press's business, and imposing them yourself is how a book comes back bound inside out — the engine designs in spreads and exports leaves, which is the whole point of a page-layout program. And **a blank page is a page**: it takes a leaf, it is counted, and it has to be *in* the file, so `padToExtent` adds real ones rather than leaving a gap that silently moves every page after it onto the wrong side of the leaf. The gutter check is the one that earns its keep: it is a **bigger** margin than the cut edges and it applies to whichever side of the page the spine is on, which is the left of a recto and the right of a verso — a face centred in a spread lands in the fold, and on a cased book the fold takes 15 mm. **The spine is arithmetic and `BookCover` does it**: leaves × caliper for the text block, plus two boards for a case or the wrap for a softcover, with the squares, the hinge and the turn-in giving the cover sheet its real size — built as its own one-page landscape document, so the same renderer, the same PDF writer and the same picture-resolution checks apply to it. It still says, in the output and in the README, to take the supplier's own template before a production run; this exists so a cover can be proofed before that template arrives and so their number can be checked against one. `npm run book:print` writes `interior.pdf` (single pages at trim + bleed, with a **TrimBox** — 210 mm square is delivered as 216 mm square), `cover.pdf`, a `print.json` manifest carrying the fields a print-on-demand API asks for, `preflight.txt`, and `render.sh` — the exact commands with the computed page sizes already in them, because a cover PDF made at a retyped width has the title on the front board and the spine somewhere in the picture. It found a live trap on the way: **the committed `evg_pdf_tool.js` build predates its own `-bleed` flag** and contains no reference to bleed at all, so running it with `-bleed` accepted the argument, warned about nothing, and wrote a PDF at trim size — invisible until a press trims into the artwork. The script recompiles the tool before using it. 40 more assertions, **116 on the engine now, running on JavaScript, Go and Python**, plus the 64 editor ones and 17 in a real browser

- **The book engine got an editor, and it runs in the page** — `gallery/book` could lay a book out and print it; it could not be *used*. Now it can: select a frame, drag it, resize it from eight handles, insert text and picture frames, reorder pages, undo. The frame around the canvas is the **shared** one the spreadsheet, the document and the deck already use — a fourth toolbar in a fourth style would have been the wrong kind of new code — and the selection chrome goes into the same `EVGDisplayList` as the pages, after them, so every host draws a complete editor without knowing what a handle is, and an exported book has none of it. Four rules are taken from the slide editor because they were right there and are right here (an id is not an index; history is snapshots; a drag is one edit; editing is a mode) and one is this program's own and is the whole difference: **every geometry edit re-flows** — resizing a text frame does not move text inside it, it moves text onto other pages, and an editor that does not re-flow after a drag is showing a book that does not exist. The gesture with no equivalent in a slide editor is **Link flow** (Ctrl+L): select two text frames and the story runs from one into the other. It moves no text; it changes where the text is *allowed* to go. **It is not a Node app with a browser attached.** `web/standalone/` compiles the entire engine — model, flow, preflight — to JavaScript and puts it IN the page: the browser hands it font bytes and photographs and draws the display list it gets back through WebGL 2, so a pointer move is a function call rather than a round trip and there is no host process at all (`npm run book:web`). The Node-hosted variant stays as `book:window`, for driving the editor from a script. Three findings came out of building it, all of the kind a screenshot makes look fine. **Snapping per-delta means a frame on a guide can never be dragged off it** — every one-point step is pulled back onto the line — so a drag is now measured from where it *began*. **A host that hands over every font face with `addFaceBytes` gets a renderer that draws correctly and measures with a 3×5 bitmap fallback**, because only `loadFontBytes` sets `hasFont`; the page was drawing the title in exactly the right typeface, at the wrong widths, so nothing ever wrapped. And **a WebGL page needs `@font-face` for the faces the engine measured with**, since `evg-webgl.js` rasterizes runs with the browser's canvas 2D and cannot see the engine's. The serverless page's self test now asserts the title wraps *and* that it is set in the display face, which is the pair that catches all three. Four things moved into `gallery/evg` because the book editor was the second caller: `EVGImageDecode` (PNG/JPEG bytes → pixels — it had been sitting in the deck viewer with a note on it saying it was not deck-specific), `EVGSelectChrome` (where the eight handles are and which edges each one owns — both editors now number their handles from the same file and read `applyResize`'s answers out of it), `EVGContextMeasurer` (EVG text measurement backed by a host's own renderer, which is what makes the flow engine measure with the faces the screen paints with), and `EVGDisplayList.offsetBy` / `.appendFrom`. The sample book is typeset rather than filled in — the showcase's editorial palette, Cinzel and Josefin Sans, a leaf ornament as a vector frame, captions on the pictures, first-line indents after the first paragraph — because a sample set entirely in the UI face proves nothing about a page-layout engine. **80 assertions on the engine (76) run on JavaScript, Go and Python; 64 more cover the editor and the host seam; 17 run in real headless Chrome on WebGL**, and `pptx`, `docx_viewer`, `datagrid` and the EVG toolbar suites are unchanged by the extractions (33 / 220 / 100 / 125 / 25, 244, 113, 23)

- **A tool that says what this reader does not understand, and then most of the answer** — every fixture in the pptx gallery was written by the same hand as the reader, so every fixture is understood by construction; two decks from outside turned up six defects in an afternoon, all of them the same shape: an element walked straight past, drawing nothing and saying nothing. `npm run pptx:audit -- deck.pptx` walks every part of a package through the reader's OWN parser and reports what it never looks at, most-used first, in **two** lists — *known and deliberately not drawn* (3-D, embedded fonts, hyperlinks, per-script font fallbacks, animation beyond the build) and *UNREAD, nobody decided about these*. The difference between those lists is the difference between a decision and an oversight, and it turns "the slide looks wrong" into a work order; `npm run pptx:audit:check` runs it over every fixture and fails when one says something nobody has decided about. Pointed at the two decks the list was 24 and 39 kinds long, and it was mostly saying one thing: **text is inherited, not stated**. DrawingML states nine levels of list style, `a:lvl1pPr` … `a:lvl9pPr`, and this reader read the first — and only from the shape itself — so every sub-bullet in every real deck came out in the top level's size, colour and indent. A list style is nine levels now, every field paired with a "was this stated" flag because they are MERGED down a chain: master `p:txStyles` → the master's own placeholder → the layout's placeholder → the shape → the paragraph. The order cost a defect to get right: a deck built by anything but PowerPoint leaves `p:txStyles` as a generic black nobody meant and puts the real typography on the master's PLACEHOLDER, which sits above it — and the placeholder merge was applying its default run properties to every run before the level each paragraph sits at had been consulted, which marked them all as sized and coloured. Beside it: **`a:normAutofit`**, which is PowerPoint writing down how far it already shrank the text to make it fit (ignoring it is an overflow with a known cause); **bullets with their own colour, size and face**, and a layout that uses the deck's own `marL`/`indent` rather than a level's worth of invented indent; a paragraph's own `<a:buNone/>` and a stated `marL="0"` treated as decisions rather than silences; **`a:highlight`**, the colour behind a run; and the same fallback gap the emoji had one block down the codepoint chart — **a bullet is a geometric shape** (● ○ ■ ▪) that the text face does not have, so every list drew a column of empty boxes until Noto Sans joined the fallback pool. The two decks now report 9 and 22 kinds, **all deliberate, nothing unread**. `31-inherited-text.pptx` pins it down and round-trips byte for byte, which needed the writer to state what the levels resolved to — there is no master left to inherit from — and turned up one more instance of a defect this repository keeps meeting: `to_int` FLOORS, so `emu(-18pt)` biased and floored a negative twice and wrote -228601 EMU where the file said -228600, moving every hanging bullet by a fraction of a pixel

- **A keystroke stopped costing the whole deck** — the slide editor's scale phase, and the first thing it needed was a way to be wrong in public: `npm run pptx:bench` builds a deck of any size and times the six things an editor does between one frame and the next. What it found was not what the phase was written expecting. **Frames were already flat** in the size of the deck, because the slide panel has only ever built the thumbnails that are on screen. What was linear was **every edit**: a step copied all five hundred slides to record a change to one, so at five hundred slides a keystroke cost **30 ms** — three dropped frames per character — a drag frame 25 ms and an undo 29 ms. The phase was written asking for an operation log with inverses, and the argument against writing one is still the one recorded when snapshots were chosen: a shape is a tree, one operation touches several levels of it, and an op log is a rewrite of every operation in the editor. There is a cheaper way to the same number. **A step copies only the slides whose revision moved on and shares the rest** — in both directions, since a restore can keep the live slide where it stands when it already holds the state the snapshot does. The whole thing rests on one invariant, *a slide's revision changes whenever its content does*, so the bump happens **before** the capture rather than after it and the two operations that rewrite every slide rather than the one in front of you (`remergeChrome`, `retheme`) say so explicitly — a small, auditable set, which is what the two new tests are for, because a shared copy that goes stale is an undo that restores the wrong thing with nothing else noticing. A slide gets a `key` of its own for this, the same idea as a shape's `editId` and for the same reason: an index stops naming the same slide the moment the deck is reordered (and a duplicated slide gets a fresh one, or two slides answer to the same name). Beside it, **the panel keeps its thumbnails**, tagged with the revision, the place and the width they were built at — it was seven eighths of every frame, and an idle frame now builds none of them, editing a slide builds exactly one, and scrolling builds the ones that moved. At **a thousand slides** a keystroke is 0.04 ms, an undo 0.22 ms and a whole frame 1.0 ms: nothing between frames follows the size of the deck any more, and at five hundred slides a keystroke is 500x cheaper than it was, an undo 200x, a drag frame 43x. `gallery/pptx/docs/EDITOR_BENCH.md` has the before and after; 18 new checks across the editor and host suites (331 and 191)

- **The deck can be shown, and a show is a picture that changes over time** — the editor's last phase, and the thing that made it work is admitting that the app has no clock. Everything before this paints the FINAL state of a slide; a transition and a build do not have one. So a host calls `tick(seconds)` and everything that moves moves from there: a headless host that never calls it sees a still, which is the right answer for a test and for a PNG, and a browser that calls it every animation frame sees the show. **Transitions** are read out of `p:transition` (the effect is the element name inside it, the speed is a word, the real length is milliseconds out in the extension list), written back, and animated — a `fade` goes through black, because a display list has no way to draw one scene at half strength, and a `push` takes the outgoing page with it while a `wipe` or `cover` slides the new one over, both by drawing the two scenes at an offset inside one clip. **Builds** come out of `p:timing`, which is a deep tree — a sequence of click groups, each a tree of parallel and sequential nodes — and what is wanted from it is flat: which shape, which paragraph, what effect, and whether it waits for a click; the walk looks for the **innermost** `p:par` that names a shape, which is one effect, and reads the answer off it. A step names its shape by the number the FILE uses and everything in the editor names shapes by `editId`, so the two are linked on attach and re-linked at save — the writer renumbers every shape it writes, and a build written with the old numbers animates the wrong things. **The show** is the deck with nothing around it: the slide fitted to the window, a click going on (the next build step if there is one, the next slide otherwise), and going back landing on the slide before **fully built**, which is what a presenter means by "back". **The presenter's own screen** (N) is the slide, the one after it, the notes and a clock, drawn into the same display list as everything else. **P** draws on the slide, **E** rubs it off, **L** is a dot that follows the pointer and leaves nothing behind — ink kept in SLIDE POINTS rather than window pixels, so it stays where it was drawn when the window is resized or the presenter's screen is a different shape from the audience's — and **B** blanks the screen. Checked in the three places that differ in the way that matters: 55 checks in the host suite, a round trip through the writer on a new `28-transitions.pptx` fixture, and 10 in the browser, which is the only host here with a real clock. `artifacts/13_present_transition.png` is a push caught a third of the way through

- **The master, the sections and the fields — the rest of the deck-level editing** — the second half of the editor's document phase, and the three pieces of it are the ones that stop a deck being a pile of pages. **The master is edited in place**: `editSheet` stands a master (or a layout) up as if it were a slide and hands it to the same painter, the same pointer and the same tools, so nothing in the editing stack has to learn what a master is — `exitSheet` writes it back and gives every slide that follows it its chrome again, and the status line says `master` so you can tell where you are standing. That exposed a defect nothing could see while masters were only ever inherited from: **a sheet's own background was never resolved**, so a master whose background is a `schemeClr` drew *black* the moment it was put on screen; the resolver resolves master and layout backgrounds now, like a slide's. **Sections** are read from `p14:sectionLst` and written back into it, which forced a smaller thing first: a slide has to keep the id `p:sldIdLst` gave it (`PptxSlide.slideId`), because a section names its members by id and a save that renumbered the slides would have scrambled every section in the file — so the slide list is written from the deck's own ids rather than from a counter, and a section list is spliced into the presentation part beside it. A section starts at the slide in front of you and runs to the slide before the next section begins (the first version stopped at the first slide another section already held, which cut a new section down to one slide and left an orphaned run behind it), and the panel writes each name once, above the run it names. **Slide numbers and footers** live on the master, which is where PowerPoint keeps them, so one of each serves the whole deck — and the number is a **field** (`a:fld` with `type="slidenum"`) rather than the number that was true when it was written, which is the difference between a page number and a caption: it counts up when the slides move. That meant teaching the parser and the writer about fields, and telling the converter which slide it is drawing. Commands: `master.edit`, `master.exit`, `footer.set`, `slide.numbers`, `section.add`, `section.rename`, `section.remove`. Verified where it matters, in the file: the writer suite (**162 checks**) makes two sections, saves, reopens and reads them back, then removes them all and checks the deck still opens; draws on a master, saves and finds it there; and sets a footer and page numbers, saves, and checks the number came back as a *field* and not as a digit. The host suite is at **127**, `pptx:editor:test` at 278, the structural verifier at 1132, and the write-and-redraw oracle still reports 42 slides byte for byte identical. One harness check was reworded rather than satisfied: `noUnresolvedSchemeFills` counted every fill that *names* a theme slot, which was the same thing as "unresolved" until re-theming needed the name kept — it asks whether a scheme fill has a colour now, which is what it always meant. `artifacts/11_sections_and_footer.png` and `12_master_edit.png`

- **A deck can be re-themed, given a different layout, and told what to say** — the first half of the editor's document phase, and the part that made it possible is one line in the resolver: a colour resolved from the palette now **keeps the name it came from**. Painting only ever reads the resolved `srgb`, so remembering `accent1` costs nothing to draw, and it is the whole of what re-theming needs — put a new palette in the theme and ask every colour that came from one to resolve again. `applyPalette` does that across the slides, the notes, the masters and the layouts (so a layout applied afterwards arrives in the new colours), with five palettes to pick from and `setThemeColor` for one slot; a colour somebody picked by hand does not move, which is the behaviour the test pins down. **Picking a layout** swaps the chrome a slide inherits — the master's, then the new layout's — and leaves what the slide itself declares exactly where it is, which is the resolver's own merge run again with a different layout. **The speaker's notes** are modelled as a *shape that is not on the slide*, which is why they cost so little: everything that knows how to lay out, measure, caret and edit a shape's text works on them unchanged, and the strip under the slide is simply the box that shape is laid out in — click it and the caret goes there, type and it types, undo takes it back. They are read from the `notesSlide` part, written back to one (a new part for a deck that never had notes, the original part for a deck that did), and the caret machinery grew the one generalization that made it possible: the layout, the caret rectangle and the click-to-column all take the box and the scale they work in rather than assuming the shape's own. Also here: a slide's **background**, as a colour or as a palette slot that follows a re-theme. And a fix that fell out of testing it: **undo used to move the deck under you** — it restored the slide index the snapshot was taken on, so undoing an edit on slide two could put you on slide one; it keeps the slide you are looking at now, and falls back to the snapshot's own only when that slide is gone. 44 new checks in `pptx:editor:test` (264), 11 in the writer suite (136) for notes and a re-themed palette surviving the file, and the structural verifier is at 1052

- **A book is a story that does not know which page it is on** — `gallery/book`, a visual book composition engine for picture books and print, built on the one structure a vector editor has no reason to own: text lives in a **Story**, and a page only owns a **Frame** the story happens to be passing through. Move the frame, delete the page, resize the column — the words are untouched and simply land somewhere else. Everything else follows from that separation: linked frames, master pages mirrored across the spine (an "outer" element stays outer on a verso), overset text, and automatic pagination. The flow engine is Paged.js's question asked in Ranger — measure, fill, overflow, next — with the part that makes it typography rather than geometry: it must be able to **retract** a decision, so the unit of work is the paragraph and not the line. Orphans push a paragraph forward whole rather than leave a stub at the foot of a column; widows hold lines back rather than carry one alone to the top of the next; and because the fix for one can create the other, the two rules are satisfied together or the paragraph moves. A heading (`keepWithNext`) travels with the paragraph it introduces. **Auto layout asks the flow engine instead of estimating how long the story is**: place a page from the next recipe in the rotation, flow, still overset? place another — which is the same overflow question one level up, about the book rather than the page. What comes out is ordinary frames; an author drags one and it stays dragged. **Preflight** is the other half of publishing, and every one of its checks is invisible on screen by construction: effective resolution (a 600 px photo across a 210 mm page is 72 dpi and looks perfect on a monitor), bleed (an element that stops *at* the trim leaves a white sliver when the guillotine drifts, so full-bleed images are generated 3 mm past it), the safety margin, overset text, a page count that is not a multiple of four, and — through `EVGTextEngine` — text measured with guessed widths, which will not break where it prints. The demo deliberately fails three of them, because the sample photographs bundled with `pdf_writer` are 500–640 px and genuinely too small for a 210 mm book. **No PDF code was added**: the renderer transcribes placed lines (never re-lays them out) into SVG for the canvas and into the TSX form the existing tooling already reads, so `npm run book:pdf` writes an 8-page PDF through `evg_pdf_tool`, with the same `TTFTextMeasurer` installed in the flow engine that the PDF writer paints with — preview and proof break lines identically by construction. 76 assertions, and the Go run earns its place: **an array passed to a method is a copy on Go**, so a helper that appended lines to the caller's array worked on JavaScript and Python and produced a completely empty book on Go. `npm run book:test:go` found it on the first run; the rule that came out of it — a helper returns the value and the caller pushes — is written down in `gallery/book/ISSUES.md` beside two more: `if!` losing field resolution for a local inside its block, and `create_dir` mapping to a non-idempotent `mkdirSync` on es6 while Rust and Kotlin get `create_dir_all`/`mkdirs`. `npm run book:test` / `book:test:go` / `book:test:python` all pass 76 of 76

- **The deck down the left, and the thumbnails are the slides themselves** — the most visible thing a slide editor has, and what was here was `slide.pick`: a dialog of numbered radio buttons. The panel is a strip of the deck now, and the decision that made it small is that a thumbnail is **the slide's own display list at a small scale** rather than a cached picture of it. That removes the whole problem a thumbnail panel is usually about: no second renderer, no PNG to encode, nothing to invalidate, and no way for a thumbnail to drift from its slide, because it *is* the slide's scene — both backends draw it without being told what a thumbnail is. It takes width out of the frame the way the toolbar takes height (`PptxView.chromeW` beside `chromeH`, so the fit-to-window scale and the slide's origin account for it), `view.panel` folds it away, only the thumbnails on screen are built (which is also what E7 meant by virtualizing it — the rule is here, the decks are just never long enough yet to notice), the wheel scrolls it, clicking goes to a slide and dragging reorders the deck with a drop line drawn between thumbnails. The editor grew `moveSlideTo` for that, because a drag is a destination and not a sequence of one-step moves, and `PptxSlide.revision` — a number bumped by every edit on a slide and never reused, so that anything which *does* cache a picture of a slide can tell whether it is looking at the same one. 15 checks in the host suite, and the one worth naming is the bug the first version had: a press on the TOOLBAR above the panel was being swallowed by the panel, which killed the left-hand buttons, so the panel now claims only the band between the toolbar and the status line. 3 more in the browser, and `artifacts/09_slide_panel.png` is a slide mid-drag with the drop line where it would land

- **The rest of picking things up: a band, a clipboard, a lock, a grid, a rotation handle, a mirror and a format painter** — the slide editor could move one shape at a time and style it one property at a time. **A drag from empty canvas is a rubber band now**, and it takes what it *touches* rather than only what it encloses, because the enclosing rule is PowerPoint's and it makes a band drawn across a row of shapes select nothing at all. **Copy, cut and paste** are the editor's own clipboard rather than the system one — a browser tab cannot read that without asking, and a deck's shapes are not text — and pasting on the slide they came from offsets while pasting on a different slide puts them back where they were, which is what makes copying a header from one slide to the next useful. **Lock** is written as `a:spLocks noMove/noResize/noRot` and read back, so it survives a save and arrives from decks PowerPoint locked; a locked shape can still be selected, because a lock you cannot select is a lock you cannot undo, but move, resize, rotate, flip and delete pass it over and its selection draws no handles — drawing handles that do nothing is a lie. The **grid** is another set of lines to snap to, drawn as dots at its own step, and it shares the snapping the alignment guides already had. The **rotation handle** is the ninth handle, on a stalk above the box, with shift snapping the angle to 15°. **Flip** is a flag rather than a transform, the way OOXML states it: the path geometry mirrors its unit points, the CPU backend got a mirrored blit (`blitImageRectScaledFlipped` — mirroring is a different SOURCE column for the same destination one, not a transform of the quad), and the GPU one swaps the ends of its UV range, which is two lines because `aUV` is `(u0,v0,u1,v1)` and the fragment mixes between them. The **format painter** picks up fill, gradient, outline, shadow and the text's weight, size, colour and alignment from one shape and paints them onto others without touching their geometry or their words. And one fidelity debt this uncovered, which is not new but was newly visible once shapes could be turned by hand: **the CPU backend ignored `rotate` entirely**, so a shape you rotated drew straight in a PNG and turned in the browser — a turned rectangle is four rotated corners through the polygon filler `SoftCanvas` already had, and turned text goes through `UIContext.textRotated`. 45 new checks (220 in `pptx:editor:test`), 5 in the host suite including a rotation drag that lands within six degrees of the pointer and a mirrored picture whose PIXELS differ (a flag alone would not prove the blit reads the other column), 6 in the writer suite for locks and mirrors surviving the file, and 4 in the browser. Still open and named rather than dropped: crop and picture filters, multi-stop gradients, shadow and outline dialogs, a colour picker, rulers — and right-click menus, which cannot be built at all until `UIInput` carries button identity, since the UI layer currently cannot tell a right press from a left one

- **One layout for the text, so the caret is where the glyphs are** — the painter handed the renderer a `maxWidth` and let it decide where a line broke; the caret assumed a paragraph was one line. Both were true separately and could not be true together, because **nothing in the program knew where the second line of a wrapped paragraph started**. `gallery/pptx/src/PptxTextLayout.rgr` is that knowledge: a shape's text broken into lines — which paragraph, which columns, where on screen, how wide — computed once and asked for by three things, the painter, the caret, and the click that puts the caret somewhere. The other half is `PptxTextMeasure`, one object that answers how wide a string is: with the real font when a host has attached its text renderer, and with the old average-width guess when nothing has, because a headless test and an export path have no fonts loaded and a layout that cannot be computed at all is worse than one computed approximately. It switches face by weight the way the painter does — a bold string is wider than the regular one, and a caret that measured the wrong face lands short of the text it belongs to. Two improvements fell out that were not the point: a paragraph of **mixed runs** is now positioned by measurement rather than by advancing the pen `0.52em` per character between runs, which drifted visibly over a few words (the fidelity fixture's "…mixed **bold** and *italic* runs…" line is the one that shows it), and **vertical anchoring is exact**, because centring a text box used to divide by a block height that guessed the line count from an average character width and the count is now known before anything is drawn. The bullet became part of the layout rather than a prefix the painter prepends: it is measured, the text is indented past it, and a wrapped line hangs under the text rather than under the bullet. 25 checks in `npm run pptx:text:test` — the lines tile the paragraph with no gap, no overlap and no lost character; every line fits the width it was given; anchoring and alignment move lines without changing where the text breaks; a bulleted line has less room so it breaks earlier — and 7 in the host suite, including the one that could not exist before: a caret in the middle of the **second** line, measured out to a rectangle and clicked back in, comes home to the same column. `artifacts/07_wrapped_selection.png` is a selection running across two of four wrapped lines, and every other pptx suite — 33 parser, 41 geometry, 25 frame, 151 editor, 116 writer, 959 structural, 42 write-and-redraw slides, 37 browser — is unchanged by it

- **Saving keeps the file that was opened** — the writer could produce a deck from the model alone, which loses everything the model does not describe: the master, the layouts, notes, SmartArt, the document properties, and the template, which came back baked into every slide. `PptxWriter.saveOver` keeps the package instead. Every part is copied through **byte for byte** and only the slides that were touched are written again, along with the three parts that have to agree with the slide list: `ppt/presentation.xml` — its `sldIdLst` replaced by string surgery and nothing else, because the slide size, the master list and the default text style are not ours to regenerate — its relationships, and `[Content_Types].xml`, rebuilt from the original's own entries minus the parts that left plus the ones the edit brought in. A deck of forty slides with one edited word changes one part. `PptxSlide.dirty` is what makes it possible: `pushSnapshot` marks the slide the step was on, cloning carries the mark through undo (so undoing back to the original leaves the slide clean and its part untouched), and a save clears them. Because the master survives, a slide writes only what the slide itself declares — writing the resolved chrome would draw it twice, once from the slide and once from the master that still exists — and a picture already in the package is referenced where it lies rather than copied under a new name. The checks that matter are the negative ones: after editing one slide of the five-slide business deck, **four slide parts are byte-identical to the original** and exactly one is not, every part of the original package is still present, the chrome comes back as `inherited` rather than as shapes on the slide, no picture was duplicated, a new slide gets a part of its own and a deleted slide's part leaves with it (20 new checks, 116 in `pptx:writer:test`). The visual oracle now renders both routes — 42 slides, every one identical to the original render — and the structural verifier is at 959 checks. Two things it found on the way, neither of them in the new code: the **accented-text fixture was not well-formed XML** (a bare `&` in character data, which our own tolerant reader had been quietly accepting and no external tool would), so the fixture is escaped and the raw-ampersand tolerance moved to a unit test where it belongs; and the verifier's "names a slide master" check was reporting the INPUT rather than the output — a package with no master at all is a legitimate minimal deck, and copying it faithfully is not a defect — so it now asks whether the presentation and the package agree with each other

- **A caret inside a shape, and the runs it has to keep** — the slide editor could type, in the sense that a keystroke was appended to the end of a shape's text and backspace took one off the end. `gallery/pptx/src/PptxTextEdit.rgr` puts a real caret in there. A position is a paragraph and a column in **UTF-16 units of that paragraph's plain text** — the unit `strlen` and `substring` already count in, so nothing is converted before an edit — and `EVGCodepoint` is what steps over a surrogate pair, so a caret cannot land inside one and an emoji deletes whole. The interesting half is not the characters, it is the **runs**: typing inside a bold word stays bold (the style is taken from the run to the LEFT of the caret, which is what every editor does and what "you type in the style of what you just typed" means), styling a selection splits the runs it covers and leaves its neighbours alone, and runs that end up saying the same thing merge back into one — without that last part a paragraph grows a run per keystroke and a five-word line ends up with forty of them. Insert (multi-line text included), delete across paragraphs, Enter splitting a paragraph and passing on its alignment, bullet and level, character and word movement, Home/End, shift-selection, select-all, and bold / italic / size / colour over a selection with an "is all of it already bold?" question behind the toggle. It is deliberately **not** `EditorBuffer` from the text editor: that buffer's runs carry a weight and a font name over a line of plain text and OOXML's carry the text itself along with a size and a colour, so one of the two would have to be converted on every keystroke; what is shared is `EVGCodepoint` and the host's measuring. The seam that had to be right is the one between "paragraph 2, column 7" and a rectangle on screen: `PptxToEvg.paragraphBoxes` walks the **same stacking arithmetic** `emitTextBody` does, so the caret cannot drift from the glyphs by disagreeing about where a paragraph starts, and `PptxApp` measures the text before the caret run by run through the same renderer that will draw it, alignment included — a centred paragraph's caret sits where the centred glyphs are. Clicking maps back the other way, and the check that proves the pair is honest is the round trip: click on a caret's own rectangle and the caret comes back to the column it was measured from. F2 or Enter puts the caret in the selected shape, Escape gives it up, a click inside moves it, a click outside ends it, Ctrl+A means the text rather than the slide while it is there, and the caret and its selection highlight go into the same display list as everything else. 75 checks in `npm run pptx:text:test`, 12 more in the host suite, 4 more in the browser (F2, typing, Home, Escape), and `artifacts/05_text_caret.png` is two words selected with the caret against the first glyph of the third. What is left, and stated where it will be fixed: a paragraph that WRAPS reports one line box, so the caret is exact on its first line and approximate after it — the renderer decides where a wrapped line breaks, so sharing that decision is its own piece of work

- **Shapes are paths now, and the curve commands a custom shape is made of finally draw** — `PptxGeom` was a table of points typed out by hand, and the shape that mattered was the one it got worst: `ellipse` was a **twelve-sided polygon**, with a dead loop above it that computed nothing and a comment saying a math library was unavailable. EVG has had a vector stack for a while — `SVGPathParser`, `PathBuilder`, `VectorShapes` — so the presets are **SVG path data** now, one readable line each, parsed and flattened by it: `parse` turns an `A` arc into cubics the way SVG says to and `flatten` walks the curve at whatever resolution it is asked for. An ellipse's points now sit on the ellipse to within a thousandth of its box, which is a thing a test can ask and a table of twelve points fails by 3%. Writing them as paths also made the shape library cheap: `rtTriangle`, `parallelogram`, `trapezoid`, `pentagon`, `hexagon`, `octagon`, `homePlate`, the four arrows, `plus` and `flowChartTerminator` are each one line. `rect` and `roundRect` deliberately stay off the path route — the rounded-rect primitive knows about gradients and shadows, and a polygon does not. The same translation fixed a hole in the READER: `parseCustGeomPath` handled `moveTo` and `lnTo` and **silently dropped `cubicBezTo`, `quadBezTo`, `arcTo` and `close`**, so any custom shape with a curve in it was drawn as a polygon of whichever of its points happened to be straight. DrawingML's path grammar is not SVG's but says the same things, so it is translated into path data and handed to the same parser — including `arcTo`, which states an arc as two radii and two angles where SVG states it as two radii, an endpoint and which of the four arcs between them is meant. And one more instance of a bug this repository has met before: the number formatter rounded `-0.25` to `-0.250001`, because `to_int` floors and subtracting a half rounds a negative away from zero twice; it rounds the magnitude and puts the sign back, the same fix the JPEG quantizer needed. 41 checks in `npm run pptx:geom:test` — the ellipse's roundness, every preset inside its box, a flattened cubic bowing the right way, an arc whose every point is on its circle and which ends where the sweep says. The 23-fixture harness, the 21-slide write-and-redraw comparison and every other pptx suite are unchanged by it

- **The slide editor can save, and what it writes redraws byte for byte** — an editor that cannot save is a demo, and the PPTX side could only ever read. `gallery/pptx/src/PptxWriter.rgr` turns the model back into an OPC package over `gallery/zip`'s `ZipWriter`: content types, the relationship parts, `ppt/presentation.xml`, a theme carrying the deck's own colour scheme and fonts, a master and a layout, a part per slide, the picture bytes in `ppt/media`, tables as `a:tbl` and charts as **ChartML with the numbers cached** — a chart part normally points at an embedded workbook for its data and a deck written here has none, so the cache is the data, which is what every reader looks at first anyway. The EMU conversions run backwards (points × 12700, degrees × 60000, sizes × 100), and two details are the difference between a file that opens and one that does not: `ZipWriter.addString` writes **one byte per UTF-16 unit**, which is wrong for every deck not written in English, so the XML parts are encoded to UTF-8 here rather than passed through as a string; and `txBody` is **optional** on a shape, so writing an empty one turns a shape that has no text into one that has an empty paragraph — a different thing to everything that reads the file afterwards. Relationship ids are names rather than numbers (`rIdImg1`, `rIdChart1`) so pictures and charts do not have to know how many of the other kind the walk will turn up. It is checked three ways, none of them our own opinion twice: a **round trip** (96 checks) writes a deck built in memory and fourteen fixtures, reopens them with `PptxParser` and compares geometry, rotation, fills, outlines, presets, groups and their child boxes, run styles, paragraph alignment, picture bytes and an edit made through `PptxEditor`; a **structural check from outside** (`npm run pptx:writer:verify`, 624 checks) reads the written packages with nothing but Python's `zipfile` and `ElementTree` and asks a consumer's questions — does every part parse, is every part covered by a content type, does every relationship target exist, does every `r:embed` resolve in that slide's rels, does the presentation name a master and a slide size; and a **pixel comparison** (`npm run pptx:writer:visual`) renders every fixture at 96dpi, writes it, reopens it and renders it again — 21 slides covering text, groups, presets, pictures, gradients, shadows, bullets, custom geometry, tables, a chart and accented text come back with a mean difference of exactly zero. What comes out is a **flat** deck: the model handed to the writer is the resolved one, so the master's chrome is already on each slide and the written master and layout are blank. It looks like what was on screen, which is the contract a save has, and it is not the file that came in — keeping the original parts and rewriting only what changed is phase E3b in `PLAN_EDITOR.md`. In a browser there is no path to save to, so `file.save` hands the page bytes and the page hands them to the download it started: Ctrl+S, and the browser suite (33 checks) proves it by **opening what it just wrote**

- **A slide sits in the middle of its window, and a circle is round** — three things one picture of the editor showed at once. The page was drawn into the top-left corner of whatever the window was, which reads as a rendering fault rather than as a margin; it is centred in what the frame leaves now, with a hairline around it so the desk and the page are different things. The origin that centring introduces is one function — `slideOriginX` / `slideOriginY` — and the paint, the pointer, the handles, the guides and the browser's `selectionBox` all ask it, because two of them disagreeing is a shape that jumps when you grab it. And the `ellipse` preset was a **twelve-sided polygon typed out by hand**, with a dead loop above it that computed nothing and a comment explaining that `cos` and `sin` were unavailable; they are builtins, so it is 48 points now and looks like a circle at any size. `gallery/pptx/artifacts/` carries what the editor draws, from `npm run pptx:editor:shots`: a shape picked up with its handles, a drag with the snap guide up, a multiple selection, and something inserted and typed into

- **The deck viewer can be edited, and none of it is another editor's code** — the PPTX side could read a `.pptx`, resolve theme → master → layout → slide and paint it, and that was all one way: the model was mutable and nothing could move a shape. `gallery/pptx/src/PptxEdit.rgr` is the layer between a pointer and that model — selection, hit testing, transforms, history — and it knows slide points and nothing about pixels, windows or events, which is what lets the same core answer a `UIInput` host, a Node test and a browser tab. Four decisions carry it. **An index is not a name**: z-order rewrites `slide.shapes`, so a selection held as an index selects something else after every reorder, and every shape gets an `editId` on attach instead. **History is whole-deck snapshots, not an operation log**: a shape is a tree (a group holds shapes, a shape holds paragraphs, a paragraph holds runs) and one operation touches several of them, so copying the deck is the cheap correct thing while the operation set is still moving — an op log is worth writing once the operations stop changing, not before. **A drag is one edit**: the host applies the move or the resize per frame and pushes a single snapshot on release, so thirty frames of dragging are one Ctrl+Z, and a burst of typing coalesces the same way by replacing the snapshot on top of the stack rather than pushing another. And **editing is a mode** — a deck opened to read cannot be changed by a stray click, and while `edit.toggle` is off the left and right thirds of the slide still turn the page. What is in it: click and shift-click selection with rotation-aware hit testing (a shape turned a quarter turn is hit at its new corners and not at its old ones), master and layout chrome that is drawn but never selectable, move, resize from eight handles with a multiple selection scaling inside its own box, rotate, alignment guides that snap to other shapes' edges and centres and to the slide's own thirds, z-order that cannot push a shape behind the template it inherits, group and ungroup that leave the picture unchanged (the group's child box is its own bounds, so the viewer's `chOff` / `chExt` mapping is the identity), align six ways, distribute, insert box / ellipse / text box / picture, delete, duplicate, fill, outline, opacity, preset, text bold / italic / size / colour / alignment, and slides that can be added, duplicated, deleted and reordered. The selection outline and its handles go into the **same `EVGDisplayList` as the slide**, after it — so WebGL and SoftCanvas both draw them without being told what a selection is, and an exported deck has none of it. Every operation is a command id (`edit.undo`, `shape.rect`, `edit.align.left`, `slide.duplicate`, …), so the shared toolbar, a keyboard chord and a host over a socket all reach the same code. In the browser the page now remembers whether the button is still held — a move event that says "not down" cannot be a drag, which is the whole of dragging a shape — and asks the app where the selection is in window pixels rather than guessing at the arithmetic. 151 checks on the core (`npm run pptx:editor:test`), 33 on the host seam — pointer to slide point, a drag as one history step, the overlay entering and leaving the display list, and the viewer still turning the page when it is not in edit mode (`npm run pptx:editor:host:test`) — and the browser suite is at 28, including a real press-drag-release in WebGL. The 30 parser checks, 25 frame checks and the 23-fixture feature harness are unchanged. `gallery/pptx/PLAN_EDITOR.md` has the phases that follow (a text caret over the text editor's own buffer, writing `.pptx` back out on `ZipWriter`, clipboard, themes, animations) and the rule they are written under: PPTist is AGPL-3.0, so its source is not read, ported or paraphrased here — a feature list is what a user of a product can see, and the formats are ECMA-376, which is a published spec

- **A query sheet is a question you keep asking, not a tab you keep making** — running a `SELECT` from the box made a new sheet every time, so refining one query left a row of tabs behind it, all called `SQL`, none of them saying which statement had produced it. The box has two buttons now: **Update** re-runs into the sheet you are on and **New sheet** adds one, with Update the default while the sheet in front of you is a query's own — an ordinary sheet, or the table sheet the workbook opened with, still defaults to New sheet, so a stray Enter cannot overwrite data. A new sheet is named after the table its query is about (`sales`, then `sales2`) rather than counting `SQL`s. **Each sheet remembers its own query** (`SpreadsheetModel.dbSql`): selecting a tab puts that statement back in the box, which is what makes a workbook of query sheets legible, and pressing Update there re-runs it into that sheet. `db.sql` follows the same rule for a host driving the app and `db.sql.new` is the explicit new-sheet command. A statement the engine refuses no longer leaves an empty tab behind as evidence of the attempt: the query is bound before the sheet is made. 42 more checks over the three engines (248 in `datagrid:db:test`), and the browser smoke drives Update twice and asserts no third sheet appeared
- **The spreadsheet has colour themes, and they are not CSS** — the obvious way to recolour an EVG app is a stylesheet, and it is the one way that cannot work here: `GridView` walks the model and pushes rects and text straight into an `EVGDisplayList`, so there is no element to carry a class, no cascade to run, and a class lookup per visible cell is exactly the cost the display list exists to avoid (`EVGStyleSheet` styles `EVGElement` documents; a virtualized sheet is not one). What the list carries is **resolved colours**, so the theme is the thing they resolve from: `GridTheme` moved out of `GridView` into `gallery/datagrid/src/GridTheme.rgr` and grew named palettes — `default` (the neutral slate the app has always opened with, spelled out so it is byte-identical), `green` (#217346, Excel's own), `blue` (#185ABD) and `red` (#B72D29). A palette is derived from **one accent**: chrome tints are the accent lightened toward white, the selection wash is it at 0.2 alpha, the active-cell border is it exactly, and the status bar along the bottom is it at full strength with white text — the band that makes a theme legible at a glance rather than a slightly different shade of pale. Dialogs follow, without the switch having to know which dialogs exist: a window owns its `EVGWindowTheme` and they are built lazily, so the view re-tints from the palette's `revision` and the window count on the next paint. Reachable as `app.setTheme("green")`, as the commands `view.theme` / `view.theme.next` (so a host over a socket and the toolbar surface get it for free), as **Ctrl+Shift+T** in any host, as `--theme green` on `datagrid:window`, and as `?theme=green` in the browser build. A theme deliberately does **not** touch the workbook's own formatting — a cell fill or font colour read out of the `.xlsx` is the document's, and Excel does not repaint your data either — nor the signals that must keep reading as themselves (comment marker, note bubble, open editor). 26 checks in `npm run datagrid:test` cover the switch, the refusal of an unknown name, the cycle wrapping, the dialog tint and the neutral cell ink; `artifacts/22_theme_{green,blue,red,dialog}.png` are the same sheet, one accent apart

- **A dot asks what the thing can do** — member completion, with a guess at the type behind it. Typing `.` opens the list with no prefix (because `body.` is already a question) and the answer comes from reading BACKWARDS for where the receiver was declared and classifying what is on the right of the `=`: an array literal offers `push` / `map` / `length`, a quoted string offers `toUpperCase` / `split`, `sheetRows(…)` is known to return rows so its members are an array's, `usedRows(…)` offers `toFixed`, an object literal offers **its own keys**, and `rows[i]` takes one step down through the subscript. There is no type checker and there is not going to be one on a keystroke — what there is, is the four ways a value gets its shape in a report script, which covers nearly every line anyone writes; when none of them matches the guess is `any` and the list is everything, which is what a type checker says about `any` too and is better than an empty popup. The member lists are **the methods ComponentEngine actually implements**, read off the engine rather than off the JavaScript standard: offering `.flatMap()` because JavaScript has it would be offering to write a line that cannot run. The Ranger plugin answers `this.` from the file — every `fn`, `sfn` and `def` in it, labelled `method`, `static` and `field` — because in a language with no imports to chase, the file is the scope. 121 unit checks and 83 in the browser
- **The editor suggests, and its clipboard stopped lying** — autocomplete is the third question a language plugin answers, beside colours and problems: `complete(lines, line, col)` returns labelled candidates, two letters open the list, **Ctrl+Space** opens it whatever the prefix, arrows choose, Enter or Tab accepts and Escape closes. Accepting **replaces the word being typed** rather than appending to it. What is in the list is the plugin's opinion and the order is the whole of it: for TSX the **workbook API first** (`sheetRows`, `formatNumber`, `param`), then keywords, then every word already in the file — a report script is mostly the first and third and almost never `instanceof`; for Ranger the same shape with operators first. Neither plugin parses to answer, because `complete` runs on a keystroke. The popup is drawn into the display list like everything else, so WebGL and OpenGL both get it without knowing what a completion is, and it is **mirrored into the DOM** for assistive technology — the textarea becomes a `combobox` with `aria-expanded` and `aria-activedescendant`, the options live in a hidden `listbox`, and the live region says "6 suggestions, sheetRows selected". **Copy and cut now answer from the model.** Letting the hidden textarea serve Ctrl+C was right for a selection inside one line — the mirror holds exactly that — and silently wrong for every other: three selected lines copied one, and cut copied without deleting. `selectionText()` and `cutSelection()` come from the buffer now, the chords are let through to the browser so `copy` / `cut` / `paste` fire on the textarea (which is the only way to reach the system clipboard without a permission), and `metaKey` counts as well as `ctrlKey`, which is what made it look broken on macOS. Also: double click selects a word and triple click the line, through `EditorWord`, so a word means the same thing to the mouse as it does to Ctrl+arrow. And the header's **fps meter is gone** — the page deliberately does not redraw when nothing changed, so a frames-per-second reading of an idle editor is 2 and means nothing; the numbers worth watching (lines, tokens, lex and check times, draw commands) are in the status bar and come from the editor rather than from the loop. 102 unit checks and 77 in the browser
- **A click handed the keyboard back, and the page redrew for nothing** — two defects a real browser session found that 52 passing checks did not. **Clicking into the code editor and then typing did nothing at all**: the browser moves focus on mousedown *after* the handler runs, and the canvas is deliberately not focusable (it is `aria-hidden`; the focusable element is the textarea that mirrors the caret's line), so the default action took the keyboard straight off it and gave it to `<body>`. One `preventDefault` on `pointerdown` fixes it — and the reason no test caught it is that **every test reached the editor with the keyboard**, by Tab or a programmatic focus, and none had ever clicked; `keyboard.mjs` now opens with a real mouse click and types after it. Second, the page ran at **4 fps** because the animation loop called `scene()` on every frame — build the display list, walk it, serialize it to JSON, ~1.4 ms — and then compared the string with the last one and threw it away. The loop now asks `revision()` first, a few string joins over the document version, the caret, the selection, the scroll line and the blink phase, and builds a scene only when that changed: idle went from 60 rebuilds a second to **2**, which is the caret, and which is why the blink now comes off the clock rather than off a frame counter. Third, and this one is everyone's: a Chrome profile put **160 ms — 36% of the frame — in `getShaderParameter`**, because `renderDisplayList` compiled and linked both of its shader programs on *every call*, and asking for `COMPILE_STATUS` is synchronous — it makes the CPU wait for a compile the driver was entitled to defer. `gallery/evg/gl/evg-webgl.js` caches the two programs and their attribute and uniform locations per GL context now, so the DataGrid's own WebGL page gets the same fix. A full redraw of the editor measures 2.9 ms of GL plus 1.4 ms of scene building on software rasterization in headless Chrome. Regression checks added for both: a mouse click that hands over the keyboard, and an idle second that redraws a handful of times rather than every frame (58 checks)
- **The SQL box shows the schema and offers examples written for it** — a prompt with no manual is not much help, and the one thing a person needs before writing a `SELECT` is the names of the tables and their columns. The box now lists them (`sales(id*, region, country, month, revenue, units)`, a star marking a key column, which is what decides whether a sheet built from the query can be edited) and offers five one-click examples generated from that schema — first rows, a `GROUP BY` over a text column and a numeric one, a `LIKE` filter, a `ORDER BY … DESC LIMIT`, and a `COUNT(*)` — so each of them runs as it stands instead of being a template to adapt. Both are asked of the session every time the box opens, so they are right after a reconnect too. A test parses every generated example and checks it names a table of this database. Dialog labels are also trimmed to the panel now (`EVGTextFit.fitLabel`, the same cut the input already used): a status line or a column list is text somebody else wrote, and it must not run out of the window

- **The chart API runs in the browser, at `/evg/chart-api/`, and JavaScript calls it directly** — every other page in the EVG gallery is drawn ahead of time and published as a file, which proves the API built a chart *once, on a build machine, in Node*, and says nothing about whether the thing that drew it still runs where a reader is. `gallery/vela/tools/vela_chart_web.rgr` compiles the API to a browser bundle that publishes the compiled classes themselves — `VlChart`, `VlChartMark`, `VlDataset` — so the page's **primary language is JavaScript** and `chart.bar().x("region")` in the editor is those classes' own methods with no binding layer in between (the page counts the calls with a `Proxy` and hands the finished chart back to `VelaChartApi.draw`). **Ranger is the second tab**, through a small dispatcher over the same methods, and **the same chart in either language draws the same SVG byte for byte** — which is the whole claim the page makes, so the browser check asserts it for all eight presets rather than assuming it. The rest of the entry describes that dispatcher: `chart.bar().x("region")` calls `VlChart.bar()` and then `VlChartMark.x("region")`, the chart redraws as you edit, and the Vega-Lite those calls built and the Vega it compiles to are on the tabs beside it. Editing the **data** redraws too — the difference between a live page and a picture of one. It is an interpreter over the real methods rather than a second implementation: there is no chart type in that file and no specification is written by hand, so a name the API does not have is refused *by name* (`line 2: a chart has no method 'colour'`, `'x' needs its argument in quotes: region`, `the data has no column called 'profit'` — the last being the API's own check running client-side, because the dataset is there). `npm run showcase:api` opens it in Chromium and checks **through** the page: every preset draws a different chart, an unknown method is refused, editing the data redraws, the inferred column types are shown, the specification and the Vega are both present, and a page error or console error fails the run — **22 checks**. One thing worth knowing for anyone shipping a Ranger bundle to a browser: the compiler writes a `#!/usr/bin/env node` shebang, and a `<script>` is not a shell — left in, the page loads nothing and says nothing.
- **Errors get underlined, and which errors is a plugin's business** — `EditorLanguage` is a seam with two questions on it (`tokenize` for colours, `diagnose` / `quickDiagnose` for problems) and a `LanguageRegistry` that picks a plugin by file name. It is the Language Server idea **with the server taken out**: a plugin is a class in the same binary, `registry.add(new MyLanguage)` is the whole installation, and nothing is spawned, connected to or serialized — which is what lets the same editor do it in a browser tab and inside a native SDL binary, two places with nowhere to run a server. The record it hands back is an LSP diagnostic in all but transport (range, severity, message, source), so moving to a real server later is a change of transport rather than of meaning. Two plugins ship. **`JsLanguage`** checks in two passes: structure from the lexer (a bracket with no partner, a string with no end, a `/*` with no `*/` — exact positions, cheap, and the errors a document has *while it is being typed*), then `TSParserSimple` — **the same parser `ComponentEngine` runs the script with**, so what the editor underlines is what the engine will refuse rather than a second grammar's opinion. That needed the parser to say *where*: `firstErrorLine` / `firstErrorCol` are now captured from the token the parser was looking at, beside the `firstErrorText` it already kept, which every host gets for free. **`RangerLanguage`** shares no code with it — `;` comments, `def` / `fn` keywords, its own tokenizer, and bracket balance, which in an S-expression language is nearly the whole error category — plus the two mistakes AGENTS.md says cost this repository most: a returned call without its own parentheses, and a statement starting with a parenthesised receiver. It is a lint rather than the compiler, and the point of the registry is that the compiler can arrive next to it later. **Diagnostics have two speeds, decided by measurement.** The compare bench caught the obvious version being unusable: a full JavaScript parse per keystroke costs **72 ms** on a 2 000-line document (against 1 ms for the structural pass), so `quickDiagnose` runs on every change while `diagnose` runs immediately only while it keeps answering in under 8 ms — once it does not, it waits for a gap in the typing, and the status bar says `(fast pass)` so a slow plugin is visible rather than merely slow. Same benchmark, before and after: **75.8 ms → 5.8 ms** per insert, against CodeMirror 6's 3.5 ms. Painting is a gutter mark in the severity's colour, a squiggle drawn as rectangles (every backend can draw a rectangle; the SDL host has no shape library), and the caret line's message in the status bar; **Ctrl+E** jumps to the next problem. Gates: `datagrid:script:editor:test` (77 checks — both plugins, the registry, positions, and the editor forgetting a complaint when the code is fixed) and `datagrid:editor:keys:test` (52 — including that the squiggle reaches the WebGL scene and that a `.rgr` file is diagnosed by the other plugin). Token records moved out of the JavaScript lexer into `CodeTokens.rgr` on the way, because a keyword is a keyword in both languages and the painter should not have to be told twice
- **The code editor is tested with a real keyboard, against CodeMirror 6** — two harnesses beside the editor page. The first (`npm run datagrid:editor:keys:test`, 43 checks) drives it with **Playwright**: real key events, through focus, `keydown`, `beforeinput` and composition, which is the part of a canvas editor that is impossible to unit test and easiest to get wrong. Making those pass meant rebuilding the input layer around the fact that **a canvas is a picture**: it has no text for a screen reader to read and no caret for one to follow, so the page now keeps a hidden `<textarea>` holding the caret's line with the caret in the right column — Monaco's and CodeMirror 5's technique — with focus living there, `aria-hidden` on the canvas, `role="textbox"` / `aria-multiline` / an accessible name / `aria-describedby` key help on the textarea, and a polite live region. That one decision buys IME composition (a composed string arrives whole), dead keys, a mobile keyboard, the paste event and a screen reader that announces the line you are on, all at once. Tab was a **keyboard trap** (WCAG 2.1.2) and is not any more: it still indents, but Escape arms an escape hatch — CodeMirror's own rule — the live region says so, and the next Tab releases focus (Shift+Tab always does); the test presses Escape, presses Tab and asserts focus is gone. Also added, because a keyboard-only user cannot cross a file without them: Ctrl+arrow by word (through `EditorWord`, the same boundaries double-click uses), Ctrl+Home / Ctrl+End, and Shift with all of them. The second harness (`npm run datagrid:editor:compare`) opens **CodeMirror 6** and the Ranger editor in one browser, gives them the same document and the same 19-step key script, and prints where each caret landed: **19 of 19 steps agree**. It earned itself immediately — a plain arrow with an open selection used to collapse it *and* move on a character, where every text field in every OS collapses to the edge and stops; three rows went red at once, the fix was six lines and `ScriptEditorTest` holds it now (44 checks). The bench's timing half is honest about being two different pipelines (an EVG display list handed to WebGL against CodeMirror's DOM view) and exists for one column: **how much of an insert is the full re-lex**. At 2 000 lines it is 1.6 ms of 4.1 ms, at 37 lines it was 300 µs — which is the measured answer to when Lezer-style incremental parsing stops being premature, rather than a guess. CodeMirror and esbuild are installed under `web/editor/compare` and are a bench dependency, not a dependency of anything that ships
- **A code editor, on WebGL and on OpenGL, from one source** — the scripts got an editor: a JavaScript / JSX lexer (`JsTokens.rgr`), an editor panel (`ScriptEditor.rgr`) with a line-number gutter, a current-line band, selection, an Enter that keeps its indent and undo, and a page around it (`CodeEditorPage.rgr`) that is nothing but a header, the panel and a status bar. It paints into an **EVG display list**, so the same page runs three ways with no branch anywhere above the seam: `evg-webgl.js` draws it on **WebGL 2** in a tab with no host process (`npm run datagrid:editor:web`), `EvgGlPainter` draws it on **OpenGL** in a native SDL2 binary compiled through the C++ target (`npm run datagrid:editor:sdl`, which also opens a file from disk and writes it back with Ctrl+S), and `SoftPainter` draws it on the **CPU** for the PNGs in this repository. The text model is not a third one: `EditorBuffer`, `EditorSelection` and `EditorLayout` come from `gallery/text_editor`, and what is added on top is colour, a gutter and code-shaped typing. Every token is positioned by `layout.caretPixelX` — the same call the caret and the selection use — rather than by adding up token widths, because two ways of measuring one prefix is how a caret ends up half a character from the letter it belongs in front of; the native host then rasterizes from the same TTF the layout measured with, so tokens land exactly, while a browser's Canvas2D atlas differs by a fraction of a pixel per glyph and reads as slightly loose spacing along a long line (documented, and the reason the reference picture is the CPU render). The lexer is **deliberately not a parser**: it carries two states across lines (`/* … */` and a template literal) and handles JSX with two local rules — `<Name` is a tag, an identifier before a single `=` is an attribute — both of which are lies about `i < rows.length` and `x == y`, which is why both are in the test. ComponentEngine already builds a tree and reports errors when the script runs, and a second disagreeing opinion about what the source means is worse than none, so what the lexer gets wrong shows up as a colour and never as behaviour. Gates: `npm run datagrid:script:editor:test` (30 checks over the lexer, the block-comment state that outlives a line, and the editing model), `npm run datagrid:editor:web:test` (headless Chrome runs a script inside the page — click, type, Enter, undo, Ctrl+K — and checks that the scene reaching WebGL has text in it *in more than one colour*, which is the failure a unit test cannot see), and `npm run datagrid:editor:sdl:smoke` (20 frames through OpenGL headlessly, asserting `gl=yes` rather than accepting the CPU fallback). Written up in `gallery/datagrid/docs/CODE_EDITOR.md`, including what is deliberately absent and why wrap/folding and multi-cursor are decisions rather than tasks
- **The spreadsheet runs scripts, and the script prints** — `gallery/datagrid` gained a report engine: a JavaScript/TSX script that reads the workbook through plain function calls (`sheetRows`, `sheetText`, `range`, `cell`, `formatNumber`, `param`, `query`) and returns an **EVG document**. The script is evaluated by `ComponentEngine`, the TypeScript interpreter already in `gallery/pdf_writer`, so no new language, parser or evaluator was written for this; layout is `EVGLayout` and export is `EVGPDFRenderer`, both of which the PDF tools already use. The **preview is not a drawing of the report**: `ScriptPreview` takes the laid-out page's own `EVGDisplayList` — the same list that feeds the WebGL and SDL backends — and copies it into the grid's list with a scale and an offset, so gradients, rounded boxes and vector paths reach the screen without the preview knowing what they are, and `Ctrl+P` hands the same element tree to the PDF writer. Both halves measure with the **same faces**: `GridApp.init(fontDir)` gives its font directory to the script engine, because two font searches are two answers to "how wide is this string" and that is exactly how a preview stops agreeing with its print. The script **lives inside the .xlsx**: OPC allows parts a reader does not understand, so the source sits at `ranger/<name>.tsx` beside `xl/` with its content type declared, `XlsxLoader` reads every such part into `WorkbookModel.scripts` and `XlsxWriter` writes them back — Excel opens the same file as an ordinary spreadsheet and ignores it (it is not a macro and not `.xlsm`; Excel drops the part if Excel saves the file). A **database sheet is a sheet** — the database layer loads query results into the same `SpreadsheetModel`, so a report over a live table is the same report — and `query()` is a seam for the other direction: `GridScriptQuery` answers "no database is bound to this workbook" and a host subclasses it to bind one, which is the whole coupling between reports and databases. In the UI: a toolbar button, eleven `script.*` commands, and a preview that owns the keyboard while it is open (PgUp/PgDn pages, +/- zooms, Esc closes) but leaves the toolbar and the sheet tabs alive. Two layout rules learned the hard way are in the docs — a `View` is a **row** unless told otherwise, so an auto-width `Label` in one wraps at about a hundred pixels, and percentage columns must not add up to exactly 100 or the row's own padding wraps the last one onto a second line. The export always **re-runs** the script rather than printing the preview's cached pages, because a report of last minute's numbers is worse than one that takes a moment. Cost: the editor now carries an interpreter and a TS parser, so `grid_app_module.cjs` goes from ~1.9 MB to ~2.7 MB, and nothing is constructed until a script actually runs. `npm run datagrid:script:test` (36 checks: data API, pagination, PDF bytes, the database seam, and the script surviving a save/reload of the workbook it lives in), `npm run datagrid:script:smoke` (the same path through the app's own command ids and scene JSON), `npm run datagrid:script:artifacts`. Written up in `gallery/datagrid/docs/SCRIPTING.md`

- **Toolbar buttons for the database windows, because a shortcut is not a feature in a browser** — `Ctrl+D` is the bookmark and `Ctrl+Q` closes the window, and `Cmd+Q` on macOS cannot be intercepted by any page at all. The connection window and the SQL box now each have a button on the strip (a stack-of-platters icon and a `SQL` one, drawn from primitives like every other icon), running the same command strings the keyboard and a remote host use. The browser client also claims `d` and `q` as chords while the canvas has focus, so the shortcuts work where a page is allowed to have them. `GET /toolbar` on the window host returns the laid-out buttons (command, label, x, y, w, h) — the command table says what the grid can do, this says where a pointer has to go to ask for it, which is what lets a test click a real button rather than a guessed pixel, and the database window smoke now does exactly that. The connection window is also redrawn when the host reports back, so it shows the connection that was made rather than the question that was asked

- **A connection window for the database-backed workbook (Ctrl+D)** — until now the only sign of which engine a sheet was talking to was a line in the status bar. The window shows engine, data source and table as editable fields, and under them what was actually negotiated: rows loaded, the key columns that make the sheet writable (or the reason it is read only), what the engine can do for itself (`DBCapabilities.describe()`) and what Ranger had to do instead (`DBSession.lastFallback`). Connecting is the **host's** part, like opening a file picker: **Connect** leaves a request behind (`takeDbRequest()` → `"connect"` with driver, DSN and table) and the host opens the engine and calls `bindDatabase`; `web/serve.mjs` services it with `GridDbLauncher`, and a host that ignores it keeps the sheet it already has. Typing another engine into the field and pressing Enter switches a live workbook — DuckDB to RangerDB and back — which the window smoke now drives through the same events a person sends. Demo rows are only seeded into `:memory:`: pointed at a file, a table that is not there is reported rather than invented

- **Vela has a chart API: marks and channels, called rather than written** — `gallery/vela/src/VlChart.rgr`. A program that wanted a chart used to have to write a specification out as text, which is how `gallery/datagrid` builds its twenty chart types: three hundred lines of string concatenation, quote by escaped quote. The API writes the same Vega-Lite **value** instead, and `toSpec()` hands it to `VlCompile` and `VlRuntime` exactly as pasted text is handed to them — the fluent surface is a writer of specifications and not a second implementation of anything, which is AntV G2's idea and the reason none of this can drift away from the engine. Four things follow from that and from holding the data (`VlDataset`, an ECharts-style value beside the chart rather than inside it): a **view's encoding is inherited by its marks**, so an area and the line on top of it are two marks with one set of axes and one legend, resolved at emit time into a `layer` that states every channel in full; a **channel need not say what it is** — a column of ISO dates is temporal, of numbers quantitative, of anything else nominal, read off the rows (Observable Plot's ergonomics); a **column the data does not have is an error** rather than the empty axis Vega-Lite would draw and not mention; and what the API does not cover is **written out by hand into the same specification**, where the layer that knows refuses it by name (`a bin written with 'step' is not compiled here`) instead of the API keeping its own list of what the layer below supports. Checked three ways: `tests/chart_test.rgr` builds ten charts and compares the **Vega** each compiles to against a hand-written Vega-Lite specification, then runs each one (71 checks, and two matching refusals fail rather than pass); `tools/reference/chart_api.mjs` gives the six charts of `tools/vela_chart.rgr` — a file with no specification text in it — to the **official** Vega-Lite and Vega and compares the ink to a quarter of a pixel (**6 of 6**); and `tests/run_cpp.sh` requires the same 71 checks to pass and the same six charts to come out byte for byte from a `g++` binary with no JavaScript underneath. It also has a page of its own on the published EVG showcase — **Charts, called**, `gallery/evg/showcase/pages/chart_api.tsx`, the only page there that no specification was written for: each chart is printed with the calls that built it, and those lines are read out of the generating tool's own source at the markers around each chart's calls, so the code the page shows and the code that drew the page cannot drift apart (without the source the tool refuses to write the page). Getting code onto an EVG page turned up one thing worth knowing: **JSX text is tokenised as if it were code**, so a double quote in it opens a string and `x("region")` came out as `x(region )` — a line goes on as a string *literal* in an expression container instead, which is the one place the parser keeps a quote. Design, surface and what is still missing (interaction, a host API, composition beyond a layer) in [`gallery/vela/CHART_API.md`](gallery/vela/CHART_API.md). `npm run vela:chart`, `npm run vela:showcase && npm run showcase`
- **`gallery/rangersql` — a SQL parser, generator and dialect transpiler in Ranger, and RangerDB's SQL front end.** SQLGlot-inspired rather than a port: tokenizer → parser → one common AST → generator, with a `SqlDialect` answering only the questions engines disagree on. The AST is a flat arena addressed by int (`SqlAst.nodes[45]`, not `node.parent.args["expressions"][0]`) because a tree of objects with parent pointers is pleasant on a GC target and painful on Rust, C++ and Swift; a node carries a kind, its text, flags and `(role, child)` pairs. Expressions are precedence climbing — one table of binding powers instead of term/factor/comparison/conjunction — and comments are tokens the parser hands to the node they were written beside, so `SELECT 1 /* c1 */ + 2 /* c2 */, 3 /* c3 */` round-trips exactly. Covers SELECT with joins, CTEs, set operations, subqueries, CASE, CAST, window functions, `IN`/`BETWEEN`/`LIKE`/`IS NULL`, array subscripts and JSON operators, plus INSERT/UPDATE/DELETE. Measured against **SQLGlot's own `tests/fixtures/identity.sql`** (980 statements it regenerates character for character, vendored with attribution): 519 identical, 3 differing, 458 not yet parsed — with the remaining buckets named in the README (DDL is 175 of them) and the identical count asserted as a baseline so a change that quietly parses less fails. Cross-checked against SQLGlot itself by `tools/sqlglot_oracle.py`: of the 522 statements RangerSQL parses, SQLGlot reads 520 of its outputs as the *same query*, the two exceptions being optimizer-hint comment placement. `Sql.transpile` moves a statement between SQLite, Postgres and MySQL (`IFNULL` ↔ `COALESCE`, `LIMIT 10, 20` → `LIMIT 20 OFFSET 10`, `x::INT` → `CAST(x AS INT)`, backtick quoting, no `NULLS LAST` on MySQL). **`gallery/rangerdb/src/SqlFront.rgr`** plans a parsed statement into the `QuerySpec` / `DBMutation` RangerDB already executes, so `capabilities.sqlText` is now true and the contract suite's SQL section runs on RangerDB, SQLite and DuckDB alike (65/65 each, 94 for RangerDB with its engine-internal and front-end tests); the planner refuses rather than guesses, naming what was in the way. The whole library is ordinary Ranger with no host bindings: 12/12 targets compile, and the RangerDB suite including the SQL front end passes identically on JavaScript, Python and native C++
- **`gallery/rangerdb` — a database API with three engines behind it, and a DataGrid that edits through it.** The interface is a `QuerySpec` (table, columns, filter, sorts, groupBy, aggregates, offset, limit) rather than SQL text, because a `query(sql)`-only API forces every backend to own a SQL parser before it can answer anything — RangerDB would have had to be a SQL implementation before it could be a database, and the grid would be building strings to say "sort by column 3". SQL stays as an escape hatch and `SqlText.rgr` renders a spec into it for the engines that speak it, with every value travelling as a `?` parameter rather than as text. Results cross as `DataChunk`s — column vectors, a chunk at a time — which is what DuckDB's execution format already is, what a virtualised grid page wants and what a chart series is. Backends declare `DBCapabilities` and `DBSession` executes whatever they did not claim over the chunks that came back, so an engine is never asked to pretend. **RangerDB** itself is columnar and chunked: row groups of 1024, a scan that hands out the stored vectors when nothing is deleted, column pruning driven by the spec, streaming aggregation that never materialises the rows it sums, early scan stop for an unsorted `LIMIT`, tombstone deletes, and a whole-table text snapshot. Indexes, joins, a SQL front end, transactions and a WAL are listed as milestones rather than left as hidden gaps. Every call is synchronous on purpose (a spreadsheet repaints in a loop, Ranger has no generics for a `Task<T>`, and `async` is honoured by one class writer), so the asynchronous DuckDB driver is confined to a worker thread with the main thread blocking on `Atomics.wait` and collecting the reply with `receiveMessageOnPort`. One contract suite runs on all of them: `rangerdb` 53/53 + 19 engine-internal tests, `sqlite` (`node:sqlite`) 53/53, `duckdb` (optional `@duckdb/node-api`) 53/53; RangerDB's 72 also pass from the same source on JavaScript, Python and native C++, and the portable half compiles for 12/12 targets. `GridDbSource` makes a sheet out of a query and an `UPDATE` out of an edited cell — read-only without key columns, a refused value restored rather than left on screen, and sorting/filtering that re-runs the query instead of ordering the loaded page — verified over all three engines by `npm run datagrid:db:test` (93/93). **Ctrl+Q opens a SQL box inside the editor**: what is typed is parsed by RangerSQL and planned into the same `QuerySpec` a sheet built in code uses, so a typed `SELECT` is an ordinary editable database sheet (sort, filter, write-back and formulas all keep working); a statement beyond the planner is handed to the engine as text and the sheet says it is read only, and RangerDB — whose SQL is that planner — refuses it with the reason instead. The same thing is the `db.sql` command, so a host drives it over HTTP, and `datagrid:db:window:smoke` opens the box the way a person does (command, text events, Enter). Two bugs in the new code, found by the suite: binding a spec-driven sheet left the previous sheet's raw SQL in place, so the next sheet re-ran the old query; and `RangerDbBackend.lastError` was never cleared on success, so one refused statement made every later query look like a failure. Host backends now report their primary keys (`pragma_table_info` on SQLite, `duckdb_constraints()` on DuckDB), without which a sheet built from a typed query had no address to write an edit back to. `npm run datagrid:db:window` opens the ordinary WebGL editor window on a live database instead of an .xlsx — the host picks DuckDB if it is installed, SQLite if not and RangerDB if there is no host database at all, seeds a demo table when the one it is pointed at is empty, and `--db-dsn` points it at a real file; `npm run datagrid:db:window:smoke` drives that same host with no browser, posting the click/type/Enter events the browser posts and checking the scene it would have drawn. `npm run rangerdb:bench` times the same queries per engine and the README explains which columns are about the engines and which are about the API's row-at-a-time write path
- **The playground shows shapes, on twelve targets** — the browser bundle always carried every writer the `rgrc` CLI has, but the UI offered three (JavaScript, Kotlin, Swift 6). The target picker now lists **JavaScript/TypeScript, Python, Go, Rust, C++, C#, Java, Kotlin, Swift 6, Dart, PHP and Scala**, each with its own highlighter, and four `shape` / `case` / `group` examples come with it — the closed variant family, exhaustive `match`, methods on a family, and group capabilities — so the point of PLAN_SHAPES.md ("one source, each target's own representation") can be seen by switching the dropdown: a tagged object on JavaScript and Python, a native `enum` on Rust and Swift, an interface on Kotlin and C#, a variant on C++. LLVM is left out because it has no lowering for shapes (`case` over a shape case fails to match argument types), and Swift 3 because the Swift 6 writer supersedes it. Two things the wider target list exposed: **Java output was empty in the playground** — the Java writer leaves the requested output file empty and writes one file per class beside it, and the reader returned that empty exact match rather than the code; it now shows every file that has content, each under its own banner. And **Scala cannot compile `RangerProcess.rgr`** at all (a `for`-loop with `continue`), so the five process examples mark it unsupported and the picker greys it out with the reason on hover instead of dropping compiler errors into the output pane. The example and target are both in the URL now (`?example=shape-value&lang=rust`)
- **`rangercli`: Ranger programs from the command line, with no compiler present** — the engine's module model was already the whole interface between its two halves, so it became a file. `rg_build` writes a `.rgb` (it has the compiler in it); `rangercli` reads one and runs it, and contains the VM and the bytecode reader and nothing else. Compiled through the **C++ target** and linked with `g++ -O2` it is a **178 KB binary** — and the fastest tier-1 in the project: `demo.rgb`'s 209k instructions take **1 ms** natively against 36 ms for the same bytecode on the JavaScript-hosted VM (no JIT there — `RgJsJit` is a JavaScript host feature). Host I/O is small on purpose and every opcode is the ordinary Ranger operator of the same name, so the C++ build gets the C++ implementation without the engine knowing what a file is: `print`, `shell_arg` / `shell_arg_cnt`, `read_file` / `write_file` / `file_exists`, `wall_clock_ms`, `strsplit`, `trim`. The `.rgb` format is line-oriented **text** with opcodes written by **name** rather than by the enum's ordinal — an inserted opcode shifts every number after it, and a shifted number is a program that silently does something else. The writer refuses an opcode it cannot name and the reader refuses a name it does not know, which paid for itself immediately: the first build after `SPLIT` was added wrote it as `NOP`, and the CLI's own word count is what showed it. `examples/cli_args.rgr` and `examples/cli_wc.rgr` (reads a file, splits it, counts lines/words/characters) run identically under the native binary and under Node, apart from the character count — Ranger's string is UTF-16 code units on JavaScript and bytes on C++. `npm run engine:native`, `npm run engine:cli:demo`
- **An engine that runs Ranger source directly, `gallery/ranger_engine/`** — Ranger's `ComponentEngine` is written in Ranger and runs TypeScript; this is the mirror image, written in Ranger and running **Ranger**, with no target file in between: `node bin/rg_run.js program.rgr -report -jit=100`. The frontend is the compiler's own (parse → collect → analyze → typecheck), so name resolution, overloads and inference are inherited rather than re-derived, and `RgLower` turns the analyzed `CodeNode` tree into a module of bytecode in ~2000 lines. The machine is a **register machine with two banks** — numeric (`int`/`double`/`boolean`/`char`) and reference (strings, arrays, objects) — with the bank chosen at lowering time, because Ranger is typed: nothing is tagged, nothing is boxed, and a call takes a window of a shared register stack instead of allocating a frame. Tier 2 (`RgJsJit`) then translates a hot function's bytecode into JavaScript, one `case` per instruction with registers as plain locals, and hands the source to `new Function` through an operator whose template is per target — so the engine has no code generator of its own and the host does the machine code. A compiled body is generated in two forms — `direct(vm, a0, …)` taking its arguments as parameters, and a thin `entry(vm)` for calls arriving from the interpreter — because the first version measured **15× off compiled output on `fib` and the dispatch loop was not the reason**: four hand-written variants attribute 1.3× to the `switch(pc)` shape and **11.3× to reaching another function through the VM's argument buffers**. Self-recursion is now a plain call and a call to another function links itself the first time it finds that one compiled (an inline cache in the factory scope), which took `fib(24)` from 6.5 ms to **0.5 ms — 1.3× of compiled JavaScript**, with a chunked loop at 2.2× and Collatz at 5.6×; over the interpreter that is 29×, 19× and 8.7×. The two halves do not depend on each other: `RgBytecode` + `RgVM` + `RgJsJit` build to **33 KB** of JavaScript with no compiler linked in, against 2.9 MB for the engine with the frontend — which is the answer to "the compiler is megabytes, how small can a Ranger interpreter be". What does not lower yet (maps, lambdas, inheritance, traits, shapes, enums, try/throw, generics) is a **named bail** per function rather than a crash: the rest of the module still runs and `-report` prints the reason. Gated by `tests/ranger-engine.test.ts`, including a differential test that requires the engine and the ordinary compiled output to print the same lines — which is what caught a call lowered before its callee's signature existed, dropping the result of `def v:int (Back.doubled(n))` — and a signed `idiv` / `%` parity test across both tiers. Design notes and the tier-3 (WAT/wasm) plan in `PLAN_RANGER_ENGINE.md`
- **`gallery/pdf_writer/src/tools/jpeg_to_ppm.rgr`** — decodes a JPEG (baseline or progressive) and writes a lossless PPM, optionally running `scaleToSize` first. Lets the decoder and the resampler be scored without the encoder in the loop; that split is what localised the quantizer bug above to the encoder
- **The compiler compiles itself for C++** — self-hosting was only ever exercised through the JavaScript output. `-l=cpp` over `compiler/ng_Compiler.rgr` used to stop with **441 errors**; it now writes C++ that `g++ -std=c++17` builds into a working compiler, and that binary compiles the compiler again to JavaScript that matches the Node build byte for byte apart from one line (see *32-bit `int`* in `TARGET_NOTES.md`). Seven kinds of defect, six of them outside the C++ writer's own templates: a `systemclass` reached the output under its Ranger name (`std::shared_ptr<JSONDataObject>`); **JSON.rgr had no C++ template at all**, so every `@serialize` class failed; `pathname` was declared for es6 only; a UTF-8 byte over 127 is negative in a signed C++ `char`, so the parser's comment scanner stopped at the first em dash in `Lang.rgr`; four `@(weak)` fields in the compiler were the only reference to their object — free on JavaScript, dangling everywhere else; four out-of-range reads that JavaScript answers with `undefined`; and a C++ reference bound to a temporary. Gate: `tests/compiler-selfhost-cpp.test.ts` (codegen + `g++ -fsyntax-only`, ~20 s). Full account, including the `weak`-is-a-no-op-on-JavaScript hazard and what it looks like when it does **not** crash, in `TARGET_NOTES.md`.
- **The compiler compiles itself for Python** — the third target, from **72 errors**; the generated Python passes `py_compile`, runs, and compiles the compiler to JavaScript **byte-identical** to the Node build. Five defects, and the errors the compiler reported were not the hard part: **Python has no multi-statement lambda at all**, so a lambda with a body now becomes a named nested `def` hoisted above the statement that uses it (231 of them were syntax errors before), with a `nonlocal` line for the names it assigns — without that, `total = total + x` inside a `forEach` raises `UnboundLocalError`; the module-level entry point was written before the `operatorsOf…` helper classes, so any program using a collection operator died with `NameError` at import (it goes to `file_end` now); a `switch` over an **enum** fell through to a C `switch`, because only the int overload carried the Python `match` entry (Rust had the same hole); and a body whose only statement was an elided unused `def` was left with no suite at all, since a comment is not a statement. Gate: `tests/compiler-selfhost.test.ts`, plus `tests/fixtures/lambda_hoist.rgr` running the same nested-closure program on ES6, Python and Dart.
- **Python entries for the operators the compiler needs** — `read_file`, `write_file`, `env_var`, `sha256`, `normalize`, `path_dirname`, `install_directory`, `current_directory`, `is_tty` (the `*` fallback is the JavaScript literal `false`, which Python reads as a name), `clear` (`x.length = 0`), `array_extract` (`.splice().pop()`), and the `charbuffer` overloads of `length` / `charAt` / `substring` — Python passes a charbuffer through as a `str`, so the JavaScript String methods of the fallback applied to none of them.
- **The compiler compiles itself for Kotlin** — the sixth target, from **19** compiler errors and, once those were gone, **3490** `kotlinc` errors. The generated Kotlin now builds, runs, and compiles the compiler to JavaScript **byte-identical** to the Node build; the Kotlin build regenerates its own 57k-line source exactly. The defect worth the whole exercise was a **typo in a template**: `default` had `(block 2)` where the operator takes one argument, so every `switch`'s `else` branch came out with an **empty body**. In the compiler that is the flow parser's dispatch, whose default clears a `b_found` that starts `true` — the Kotlin build therefore declared the root node already handled, analysed **nothing**, reported success, and wrote the source back out as run-together tokens. It produced no error anywhere; finding it meant working backwards from an 813 KB file of concatenated identifiers to the fact that `writeClass` was never called on any writer. The writer also had **no lambda support at all** — a lambda-valued parameter was declared with an empty type (`fun forTree(cb : )`) and every lambda came out in the JavaScript arrow form, which is ~1000 unresolved references plus most of the 800 parse errors. Kotlin lambdas are now anonymous **functions** (`fun(a: T, b: Int): R { … }`) rather than `{ a, b -> … }`, because Kotlin forbids a bare `return` inside a lambda literal and the compiler's lambdas return values. Plus: `char` was mapped to Kotlin's `Char`, which is not an integer type and does not compare with an `Int` (150+ errors — every other target treats Ranger's `char` as a code unit, and the Kotlin templates already produced one); an optional in the middle of a path had no `!!` (~640 errors, the same hole Dart had, and Kotlin never smart-casts a mutable property); a property a subclass redeclares is an outright error rather than a second slot; a parameter is a `val` and the compiler assigns to two of its own; and a systemclass, an enum in return position, an optional function type, `reversed()`/`sortedWith()` returning read-only `List`, and `shell_arg` reading the `args` only `main` can see. Gate: `tests/compiler-selfhost.test.ts` (codegen + `kotlinc`).
- **A real JSON runtime for Kotlin** — the polyfill was a stub: `constructor(source: String) : this() {}` took the text and threw it away, so `from_string` answered an **empty object** and every getter after it read absent, while `to_string` returned Kotlin's map rendering rather than JSON. The object, the array, the parser and the serializer now all live in the polyfill, because Kotlin has no JSON in the standard library and the output has to build with a plain `kotlinc` line. `org.json` is no longer imported on top of it — nothing puts that package on the classpath. `tests/compiler-json.test.ts` runs the round trip on Kotlin alongside JavaScript, Python, C++, C#, Go and Rust.
- **The compiler compiles itself for Go** — the fifth target, and the only one that already reported **zero** compiler errors before any of this work and still would not build. The generated Go now passes `go build`, runs, and compiles the compiler to JavaScript **byte-identical** to the Node build; asking the Go build for Go returns the same 70k-line source it was built from. The defect worth the whole exercise was silent everywhere else: **an optional is a `*GoNullable` box, and `def` aliased it** — `def wr (file.getWriter())` pointed the local at the very box the `CodeFile` owns, so `wr = contentFork` thirty lines later replaced the **file's** writer. Every tag slice (the import lines, the polyfills, `file_end`) belonged to the writer that had just been dropped, so the Go build wrote files with no imports, no polyfill classes and no entry point — and reported success. The C# it generated had no `RgJson`; the Python had no `if __name__ == "__main__"`. A `def` of an optional copies the two fields through a temporary now. Four more were in the writer: a nested collection kept its Ranger spelling (`map[string]*[string]`, and `func r_has_key_string_[string](` — the map helpers are named after the type they serve); a `case` over a **system** union wrote the tagged-struct compare (`item.tag == interface{}_tag_string`) when a union holding a primitive is plain `interface{}`; the function *type* of a lambda ignored `@(optional)`, so a callback declared `func(*CodeNode, …)` met a lambda taking `*GoNullable`; and `(goset N)` wrote nothing at all for an expression, leaving `if (.has_value)`. One more only appears when the Go build generates Go: `findClass` unwraps without a guard, which is `undefined` on JavaScript and a **panic** on Go. Gate: `tests/compiler-selfhost.test.ts` (codegen + `go build`).
- **Go entries for the operators only a big program reaches** — Go turns a dropped template argument into a build **error** rather than a warning, because an unread local and an unused import are both failures. That is what surfaced `normalize`, `path_dirname` and `install_directory` having no Go entry (they fell to the `*` fallback, the literal `"./"`, which collapses the library search path *and* drops its argument), the plugin-host templates discarding the plugin handed to them, `length` over an array taking the JavaScript `.length`, `current_time_ms` naming `time.Now()` without importing the package, and three dead locals in `CLIProgress.printFailure`. Also: `cast` asserted on an already-concrete value (a type assertion is only legal on an interface — it goes through `interface{}` first now); `return` from inside a `catch` returned from the deferred `recover()` closure rather than the function; `error_msg` was the empty string, so a Go build reported "Unexpected compiler error" and nothing else; and `getInt` over JSON never matched, because `encoding/json` decodes every number as `float64` while the polyfill asserted `int` — it answered "absent" for a key holding `3`. `tests/compiler-json.test.ts` runs the round trip on Go alongside JavaScript, Python, C++, C# and Rust.
- **The compiler compiles itself for C#** — the fourth target, from **499 errors**; the generated C# builds with Mono's `mcs` with no error, runs, and compiles the compiler to JavaScript **byte-identical** to the Node build. Asking the C# build for C# gives back the same 1.98 MB source it was built from. All 499 were downstream of **`JSON.rgr` having no `csharp` template** — the shapes are `Dictionary<string, object>` / `List<object>` / `object`, with a hand-written reader and writer rather than `System.Text.Json` so the file builds on Mono and on .NET alike. The compiler then reported zero and `mcs` reported 188, two of which had been *silent*: a subclass field that redeclares a parent's field is a **second storage slot** in C# (`RangerAppFunctionDesc` redeclares eight of `RangerAppParamDesc`'s, the way JavaScript lets you), and a subclass method that redeclares a parent's method without `override` is a **second method** — every language writer redeclares `writeClass`, so `langWriter.writeClass(...)` called through the base type ran the *generic placeholder* and the C# compiler emitted `class X { /* static main */ }` for every target while reporting success. The writer now skips an inherited field redeclaration and emits `virtual` / `override`, which took 894 CS0108 warnings with it. Three more were C#-specific: a lambda parameter may not reuse a name live in an enclosing scope, so the implicit `item` / `index` of a nested `forEach` was 96 errors and the inner one is renamed now; a nested collection kept its Ranger spelling (`Dictionary<String,[string]>`); and `removeLast` wrote `Array.Resize`, which is for `T[]` and not `List<T>`. Gate: `tests/compiler-selfhost.test.ts` (codegen + `mcs`).
- **C# entries for the operators the compiler needs** — `read_file`, `write_file`, `create_dir` and `dir_exists` were **stubs that compiled to a comment**, so a C# build would have reported success and written nothing; `normalize`, `path_dirname` and `install_directory` fell to the `*` fallback, which is the literal `"./"`, so the library search path collapsed and no import resolved. Plus `env_var`, `sha256`, `sort` (`List<T>.Sort` is in place *and* unstable; `OrderBy` is neither), `clear`, `length`, `indexOf`, `remove_index`, `array_extract`, `double2str`, `current_time_ms`, the optional-boolean overloads of `unwrap` and `get`, and `str2int` / `str2double`, which returned a plain value where the operator declares an optional. Two existing entries were quietly wrong rather than missing: `to_charbuffer` used `Encoding.ASCII.GetBytes`, which writes `0x3F` for every byte over 127 — a source file with one non-ASCII character would have parsed as question marks — and `strsplit` split on `token[0]`, the **first character** of the delimiter, so normalizing CR LF left every LF behind and the C# build doubled the newline inside every multi-line string literal it read. A C# `create_polyfill` also lands *inside a class body*, where a helper is private to whichever class claimed the tag; the helpers added here go to `after_imports` as file-scope `static class`es.
- **The compiler compiles itself for Dart** — `-l=dart` over `compiler/ng_Compiler.rgr` stopped with **482 errors**; it now generates Dart that `dart analyze` accepts with no error, `dart run` executes, and which compiles the compiler to JavaScript **byte-identical** to the Node build (the compiler that comes out of that reproduces the file exactly). Four of the five Dart-specific defects were in the *writer*: a lambda with statements came out as the JavaScript arrow form `(a, b) => { … }` (Dart's `=>` takes one expression — 356 syntax errors); an optional in the middle of a path had no `!`, because the writer added one for the first segment only and Dart never promotes a field (751 errors); a local holding a lambda was declared with an empty type (Dart spells it `void Function(T)`); and an enum in a return or element position wrote its Ranger name. The fifth: every library search path collapsed to `"./"`, because `normalize` fell through to the `*` fallback — `normalize`, `path_dirname`, `install_directory` and `current_directory` now have real Dart entries. Gate: `tests/compiler-selfhost.test.ts` (codegen + `dart analyze`).
- **JSON for the Dart target** — Dart already has the three shapes in the language (`Map<String, dynamic>` / `List<dynamic>` / `dynamic`) and `dart:convert` reads and writes the text, so the only polyfills are the getters, which check the type before handing a value back and keep the distinction between a missing key and a present one on all six. `tests/compiler-json.test.ts` runs the round trip on Dart alongside JavaScript, Python, Rust and C++.
- **Dart entries for the operators only a big program reaches** — `create_dir`, `dir_exists`, `write_file`, `env_var`, `error_msg`, `sha256` (an inline polyfill: SHA-256 is not in the Dart SDK and the output has to run with a plain `dart run`), `reverse`, `sort`, `trimEnd`, `remove_index`, `array_extract`, `String + enum`, the three `&&` overloads that take an optional, the generic `case` over `string` / `int` / `double` / `boolean`, and the `charbuffer` overloads of `substring` / `charAt` / `to_string` / `to_charbuffer` — a `charbuffer` is `List<int>` on Dart, so the JavaScript `String` fallback did not apply to any of them. `shell_arg` read the `args` of `main`, which nothing outside `main` can see; the writer now copies it into a `__g_args` global, the same shape as `__g_argv` on C++.
- **JSON for the C++ target** — `JSONDataObject` / `JSONArrayObject` are `std::shared_ptr` handles (so an object pushed into an array and then filled behaves the way it does on JavaScript) and `JSONValueUnion` is a `std::variant`, so `case v x:JSONDataObject` lowers to the same `std::holds_alternative` every other closed family uses. Reader, writer and parser are polyfills: no library, no download. Covers `json_object`, `json_array`, `set` (all six value types), `push`, `keys`, `getStr`/`getInt`/`getDouble`/`getBoolean`/`getObject`/`getArray`, `getValue`, `array_length`, `isArray`, `asArray`, `to_string`, `from_string` and `print`. The generic `case` over `string` / `int` / `double` / `boolean` gained a C++ template alongside. `tests/compiler-json.test.ts` now runs the round trip on C++ as well as JavaScript, Python and Rust.
- **`sha256` needs no downloaded header on C++** — the template pulled `picosha2.h` from GitHub at build time, so every C++ program that hashed anything needed network access, and the compiler hashes. It is now an inline polyfill, verified against the standard vectors.
- **C++ `charAt` and `charcode` give an unsigned code unit** — `std::string::at` and `const char*` indexing give a *signed* char, so a byte over 127 read back negative and any scanner written as `c > 31` stopped at the first non-ASCII byte. Ranger `char` maps to `unsigned char` on C++ to match. Regression: `tests/fixtures/utf8_scan.rgr`.
- **The compiler generates Go too** — `RangerCompilerPlugin` is a systemclass and named no Go type, which was the single error standing between `-l=go` and the whole compiler. It now generates ~70k lines of Go; `go build` does not accept them yet, and the three defects it names (a nested collection type such as `[string:[string]]`, the Ranger spelling reaching a generated function name, and a `case` over a *system* union) are written down in `TARGET_NOTES.md` with the error counts for every other target.
- **C++ `cast` handles a class hierarchy** — the template was `std::get<T>`, which is a `std::variant` accessor; a downcast between classes (and a read through a `weak` field) now goes through `std::dynamic_pointer_cast`, chosen at compile time by the same helper.

- **`npm run jsengine:check` — C++ toolchain probe for `-cpp-single-thread`** — Apple's Command Line Tools `g++` is clang + libc++ and cannot compile the non-atomic `rg_ptr` prelude (`std::__shared_ptr` / `__gnu_cxx::_S_single`). `scripts/cpp-toolchain.sh` probes versioned GCC (`g++-15` … `g++`), reports which compiler will be used, and `jsengine:build` / the native bench scripts pick it (or fall back to atomic `std::shared_ptr` with a clear tip: `brew install gcc`).
- **`-native-fast-alloc` C++ prelude is portable to macOS** — the freelist used `malloc_usable_size` from Linux `<malloc.h>`; on Apple it now uses `malloc_size` from `<malloc/malloc.h>` (FreeBSD via `<malloc_np.h>`). Fixes `fatal error: 'malloc.h' file not found` when building the jsengine with Apple clang.
- **macOS jsengine build: portable `sed -i` and C++ trigraph escape in `string_view` compares** — BSD sed rejected the GNU-only `sed -i 's/…/'` used to collapse `.clone().clone()` (rust engine build aborted). The sized `string_view("??=", 3)` compare path now escapes trigraphs like `std::string("…")` already did, silencing Apple clang `-Wtrigraphs`.
- **Octane / jsengine timing uses sub-ms `performance.now`** — `Date` is TimeClip’d to whole milliseconds, so suite scores collapsed onto a few buckets (identical Richards/SplayLatency across targets). The engine now exposes `performance.now` (unclipped `liveClock`), `run.cjs` rewrites Octane’s `Measure` to use it, and `wall_clock_ms` returns fractional ms on C++/es6/Go (no more `duration_cast<milliseconds>` / bare `Date.now()`).
- **Kotlin on the TypeScript-engine run gate** — the full interpreter (`bench_main.rgr`) compiles with `-l=kotlin` (~40k lines) and, with `kotlinc`/`java` on `PATH`, answers the same Node benchmark cases (`npm run test:tsengine`). Fixes: typed `arrayListOf<T>(...)` instead of bare `[]`; `int32Wrap` and the array-index `< 2^32-1` bound rewritten in the double domain so signed 32-bit `Int` targets no longer see integer literals above `Int.MAX_VALUE`; oversized Kotlin int scalars emit `(NL).toInt()` as a safety net.
- **Swift 6 on the TypeScript-engine run gate** — the full interpreter (`bench_main.rgr`) compiles with `-l=swift6` (~39k lines) and, with `swiftc` on `PATH`, answers the same Node benchmark cases as Go/Python/C#/Dart (`npm run test:tsengine`). Fixes: rename reserved `guard`; drop `@main` in favour of top-level `__main__swift()` (avoids clashes with file-level `func ==`); map bitwise/`buffer_*`/`int_buffer` lengths to Swift `Int` (not `Int64`); emit `inout`/`&` for mutated Array/Hash/buffer params; treat buffers as `var`; initialize bare optionals with `= nil`; `int_buffer` is `[Int]`.
- **Dart on the TypeScript-engine gate** — the full interpreter (`bench_main.rgr`) compiles with `-l=dart` (~40k lines) and, with the Dart SDK on `PATH`, answers the same Node benchmark cases as Go/Python/C#. Wired into `npm run test:tsengine` and `TARGET=dart` in the native bench build script. Fixes along the way: double literals no longer use `getParsedString()` (CRLF sources were injecting `\r` and garbling every later double), static calls keep the class name (`EvHandle.fromBody` not `this.fromBody`), nullable receivers get `!` on call expressions, `null`/`true`/`false` method names rename like C#, and Dart templates cover `file_exists` / `file_mtime` / `buffer_read_file` / `random` / `str2int` / `atan2` / optional `if` / `empty` plus a non-shadowing `rg_pi` for `M_PI`.
- **Dart target for Flutter-ready packages** — `-l=dart` generates idiomatic Dart (`T?` null safety, `List`/`Map`, top-level `main`, `import '…';`) aimed at shared application logic a handwritten Flutter UI can import, not widget trees. `-pubspec` writes `pubspec.yaml` (`-name=` `-version=` `-description=`); `-flutter` adds a Flutter SDK dependency. Registration covers `Lang.rgr`, `RangerDartClassWriter`, LiveCompiler, VirtualCompiler, conformance, `npm run test:dart`, the syntax-app matrix, and `examples/dart_flutter_logic/`. See `PLAN_DART.md` and `TARGET_NOTES.md`.
- **Dart golden: `gallery/ts_parser`** — the ~10k LOC TypeScript/ES5 parser compiles with `-l=dart -nodecli`; the `-d` demo AST is identical to the JS reference. Adds host ops (`shell_arg*`, `read_file`), `$` escaping in Dart string literals, `npm run tsparser:compile:dart` / `tsparser:run:dart`, and `npm run test:dart:tsparser`. Operator-reference docs include Dart in `docs/tools/lib/compile.mjs` `TARGETS` (so pages such as string `at` list Dart after deploy). `at` on Dart uses `.substring(i, i+1)` — the `*` fallback `s[i]` is a code unit, not a string.
- **Language switches for Kotlin, Python, Rust and Dart** — `if_kotlin`, `if_python`, `if_rust`, `if_dart`; `if_swift` also emits under `-l=swift6`.
- **Docs: target maturity** — README, `targets/overview`, FAQ and `TARGET_NOTES` record the TypeScript-engine gate (Go / Kotlin / Python / C# / Dart / Swift 6 vs Node when toolchains are present) and stop calling C# unmaintained.
- **Target fixes found by the syntax app** — the matrix of `tests/syntax_app/` went from 82 of its 238 cells rejected by the compiler to 43, and from 41 cells that compile, run and print the expected output to 57. Java moved from 17 rejected units to 3 and from no unit running to seven; Python from 5 rejected to 1 and from 2 running to 6; Rust from 7 rejected to 3, C# from 8 to 3, Scala from 10 to 6, Kotlin from 6 to 4, Swift 3 from 6 to 4. The work is in `compiler/Lang.rgr`, so it needs no rebuild of the compiler, and `tests/syntax_app/TARGET_FIXES_TODO.md` records what is done and what is next.

  - **`for` over a hash map was JavaScript only.** Both shapes carried one template, so every program that walked a map answered `Could not match argument types for for` on the other thirteen targets. Both now cover the list.
  - **Four operators wrote source no target could parse.** `regex_test` and the three `iso_*` operators fell back to a Ranger-spelled call — `RegexMatch.testIgnoreCase(a b)`, a space where the target needs a comma — for every target with no entry of its own. `pushString` fell back to the JavaScript `.push()`, `rawbytechar` and `strfromcode` to `String.fromCharCode`, and `join` to `.join()`. `regex_test` now has Go, Python, C++, PHP, Scala, C# and a corrected Java entry and **no `*` fallback**: a target with no regular expression engine fails the match at compile time, which is where the absence belongs.
  - **The same program printed different answers.** `replace` changed the first occurrence on four targets and every occurrence on the other seven — it now changes every occurrence, which is what the trigraph escaping of `ng_RangerCppClassWriter.rgr` has always assumed. A double reaching a string printed `1.500000` on Go and C++, which both fixed six digits; both now write the shortest form that reads back as the same value. `to_string(boolean)` printed `True` on Python. `to_int(double)` truncated toward zero on Go instead of flooring, and refused an untyped constant. `indexOf` on a string gave the empty string rather than `-1` on PHP. `/` between two integers is real division, and Java, C#, Scala, Swift, Python and PHP were left with their own `/` — Java refused the program outright. The C++ `r_optional_primitive` left `has_value` uninitialised, so a failed `str2int` read back as a value.
  - **Operators that existed on one or two targets** now cover the list: `sort`, `reverse`, `remove`, `error_msg`, and the entries for `M_PI` / `fabs` / `tan`, `to_double(int)`, `cast`, `insert`, the string `indexOf` family, `to_lowercase` / `to_uppercase`, `charcode`, `throw`, the four `if`-over-an-optional-number forms, and `switch` / `case` / `default` on Python 3.10 `match` and Rust `match`.
  - **Two wrong templates.** `if!` carried a Rust entry that was a macro — so it wrote Ranger source — with the else branch spelled the Rust way and no parentheses around the condition; the target-independent `*` macro was already correct. `!!` on Java called `java.util.Optional.get()` while `unwrap` on the same target wrote the value itself.
  - **`reserved_words` had no `java7`, `csharp` or `scala` section**, so a Ranger name that is a keyword of those languages reached the output unchanged: `def double (fn:int …)` wrote `LambdaSignature1 double = …` on Java, which is what the lambda section failed on. All three now list their keywords. PHP deliberately gets none: its writer emits every function as a class method, PHP 7 allows a reserved word there, and a variable is `$name` and never clashes, so a section would rename `list`, `empty`, `clone`, `match` and `print` across some fifty files for no gain — measured, the PHP column is identical either way.
  - **`tests/syntax-app.test.ts` is excluded from the default Vitest config**, next to `compiler-llvm.test.ts`, and keeps its own `npm run test:syntaxapp`. A file that runs for two minutes starves the reporter under `singleFork`; the run then ends with `Timeout calling "onTaskUpdate"` and the files after it never run.
  - **`ranger-vscode-extension/compiler/output.js` is rebuilt.** It is a second copy of the compiler that only `introspection.test.ts` loads, and nothing in the build or in CI regenerates it. It had fallen behind `compiler/Lang.rgr` far enough to stop parsing the operator definitions, and four of its thirty-seven tests were failing before this branch. `npm run compile:langserver` brings it back; all thirty-seven pass.
  - **The test harness looked for one output file.** The Java target writes one file per class and ignores `-o`, so the whole java7 column of the matrix read `compile-error` while Java in fact compiled. `tests/helpers/syntax-app.ts` now accepts any file with the extension of the target, prefers the one holding `main`, hands every `.java` in the directory to `javac`, and runs the TypeScript of the repository rather than whichever `tsc` is first on PATH.

### Changed

- **The Rust jpeg output is clippy-clean: 1395 warnings → 0** — the fourth PLAN_RUST_IDIOMATICITY round closes every remaining warning class with emission fixes, and names four allows for shapes that mirror the Ranger source itself. Structurally: an argument slot or an already-delimited operand tells the next operator emission it needs no parens of its own (a one-shot flag the expression walker consumes), self-delimiting templates (bit ops, casts, format!) are declared so the walker never double-wraps them, and the bit operators became writer customs that parenthesize once — `(oldVal | (1 << bit))`, not `((oldVal) | (((1) << (bit))))`. Literal positions carry no casts (indexes, ranges, `vec!` sizes, u8 stores, f64 casts — with macro-wrapped literals unwrapped before the test), `int / int` emits `x as f64 / y as f64`, comparisons are idiomatic (`x == false` → `!x`, `s != ""` → `!s.is_empty()`, literal strings compare as `&str`, `a >= lo && a <= hi` → `(lo..=hi).contains(&a)`), a borrowed or mut-reference parameter passed straight through goes bare (`x`, not `&x`/`&mut x`), a class without a constructor body returns its struct literal as the tail, `strfromcode` inside a format! argument stays a `char`, and `substring` drops a literal-zero start instead of emitting `x - 0` or a deny-level `skip(0)`. The named allows, each commented in the generated header: `clippy::manual_clamp` and `clippy::collapsible_if` (user statement sequences), `clippy::too_many_arguments` (user arity), `clippy::upper_case_acronyms` (user type names), `unused_assignments` (the init-discipline family). The image stays byte-identical at both gate sizes; the clippy-clean shapes are pinned by greps on the flagship program in `codegen-rust.test.ts`

- **The Rust output reads on: `pos += 1`, tail expressions, slice parameters** — the third PLAN_RUST_IDIOMATICITY round (clippy on the jpeg output: 1395 → 512 across the three): **`x = x + e` is `x += e`** — and `-`, `*`, `/` — when the target is a plain scalar path (no cell, no optional, no weak segment) and the right side starts from the same path, with anything less plain falling through to the full assignment machinery (297 warnings, 273 compound sites on the jpeg output); **the last `return x;` of a body is the tail expression `x`** — the body walk marks its final statement, the return custom drops the keyword and semicolon on exactly that node, a bare tail `return;` disappears, and a constructor ends in `me` (all 85 `needless_return` warnings); and **a borrowed collection or buffer of scalars is a slice** — `&[i64]` / `&[u8]` / `&[f64]` where the signature said `&Vec<T>` (clippy's `ptr_arg`), safe because `&Vec` coerces to `&[T]` at every call site and borrowed-to-borrowed chains pass the slice straight through. The image stays byte-identical at both gate sizes. What remains is parked with reasons in PLAN_RUST_IDIOMATICITY.md: double parens need precedence-aware emission (the walker's parens are load-bearing for `a * (b + c)`), snake_case sits behind the `allow` and collides with `@serialize` field names, and borrow hoisting needs a loop-invariance analysis. Coverage: `tests/fixtures/rust_slice_params.rgr` + extended `codegen-rust.test.ts`

- **The Rust output prints like Rust: `println!("numbers {}", numbers.len() as i64)`** — the second round of PLAN_RUST_IDIOMATICITY, all template-level: **string concatenation is `format!`** — `print` flattens a whole `+` chain into one `println!` with the literals inlined in the format string, and a chain in any other position becomes a single `format!` (`"rows: " + n + " first: " + name` → `format!("rows: {} first: {}", n, name)`), replacing the `[&*a, &*b].concat()` and `[a, (b.to_string())].join("")` forms entirely, with an explicit `to_string` of a scalar dropped since `{}` is already Display; **an integer-literal index takes no cast** — the new `(idx N)` template op emits `arr[0]` where every index used to be `arr[(0) as usize]`, applied across the element, buffer and vec-init templates; **a Copy element read takes no clone** — the new `(cloneif N)` op keeps `.clone()` for String and struct elements and drops it for scalars, in `itemAt` and the `for` loop; and **a free `fn main` is a crate entry** — a file-level `fn main` lands on a class as an ordinary method, and no crate main was emitted, so `rustc` stopped with E0601; when the program declares no `sfn m@(main)`, the writer now emits `fn main()` calling it. clippy on the jpeg output: 1395 → 899 across the two rounds, the image byte-identical at both gate sizes throughout. Also recorded on the way: **Go now prints doubles in their shortest form** (`strconv.FormatFloat(x, 'f', -1, 64)` instead of six fixed decimals, matching the reference targets — the syntax-app numeric section on Go went run-error → **ok** in the recorded matrix), and the Go for-over-object-array gap of `tests/syntax_app/known_gaps.md` is closed by the earlier `treeReferencesVRef` fix — the probe is deleted and the shared `render()` of the syntax app uses the natural `for rows row:CheckRow i` form again. Coverage: `tests/fixtures/rust_format.rgr` + `codegen-rust.test.ts`

- **A read-only Rust method takes `&self`** — ranked first in PLAN_RUST_IDIOMATICITY and closed there: the writer has carried the full receiver machinery all along (per-method mutation detection, a same-class call graph, transitive propagation), and it never fired because of one truthiness fault — `def dm:boolean (get directMutations methodName)` binds an optional, `if dm` compiles to a presence check, so `false` counted as `true` and all 136 methods of the jpeg output took `&mut self`. Fixing it exposed three precision holes, each now closed: a read operator on a member collection (`itemAt items 0` is a `has_call` on the vector) counted as a write — reads on plain collections, strings and buffers no longer do, while the mutating operators and any user-class receiver still do; mutation through a collection operator (`push labels s` — never an `=` node) was invisible — the detector now reads the same mutating-operator list the template engine uses for LHS marking; and an uninitialized member collection carries `is_optional` on its desc, which forced `&mut self` though the Rust field is a plain `Vec`, never `Option`. The jpeg output lands at 41 of 136 methods on `&self`, `rustc -O` clean, byte-identical at both gate sizes. Also from the ranked list: void functions drop `-> ()` (84 clippy warnings), a parameterless method drops the `(&mut self, )` trailing comma (42 sites) — clippy 1395 → 1310 — `use std::rc::Weak;` is emitted only when a `@(weak)` field exists, and thirteen unconditional `print ("DEBUG …")` statements are gone from the Rust writer, which wrote them to stdout on every compile. Coverage: `tests/fixtures/rust_receivers.rgr` + `codegen-rust.test.ts`

- **The shared-class `Rc<RefCell<T>>` model is the Rust default** — `-rust-shared-classes` graduated from an experimental flag: a bare `-l=rust` build now applies the sharing verdict (value classes stay plain structs; the classes the analysis proves shared take `Rc<RefCell<T>>`), which is what lets the object-sharing programs of the docs — and `weak` back references — compile and run on Rust out of the box. The new flag `-rust-value-classes` restores the old all-value model, and `-rust-shared-classes` is still accepted as a no-op for compatibility. Turning the default on surfaced two writer gaps the serialize round trip caught: a strong optional field of a shared class was unwrapped mid-path without borrowing the cell (`back.one.as_mut().unwrap().name` does not compile — the segment now appends `.borrow()` for a read, `.borrow_mut()` for a write or call receiver), and an expression receiver — an unwrap, an element read, a call result — of a shared class was called without a borrow (`(self.one.clone().unwrap()).toDictionary()`; the receiver now borrows mutably, with `new` kept as the one shared-class expression that is still a plain value). The conformance gate holds under the default: the flag-less Rust `jpeg_scaler` build writes the reference image byte for byte at both test sizes, and the `@serialize(true)` round trip runs on Rust with the same output as ES6 and Python

### Added

- **Syntax test app, compiled to every target and measured** — `tests/syntax_app/` holds one Ranger program that uses 203 of the 207 core operator names of `compiler/Lang.rgr` — every one of them except the four that do not work — together with classes, inheritance, method override, `extension`, `record` in both of its construction forms, `Enum`, a plain `trait` and a generic `trait @params`, lambdas in five positions, optionals, the four buffer types, custom operators (macro, direct template with a `*` fallback, `*` and `+` overloaded on a class, an Enum matcher, a block operator, an `operator type:` block) and the collection methods of `lib/stdlib.rgr`. `npm run test:syntaxapp` compiles the app — and each of its twelve sections on its own, so a rejected section does not hide the others — to all fourteen targets the CLI accepts, then builds and runs the output with `node`, `tsc`, `go`, `python3`, `rustc`, `g++`, `javac`, `php` and `lli` and compares what each one printed with the output of the reference target.

  The result is a matrix asserted against `tests/syntax_app/target_matrix.json`. The comparison is two directional: a target that gets worse and a target that gets better both fail until the record is updated with `npm run test:syntaxapp:update`, so an improvement is recorded rather than lost. `tests/syntax_app/TARGET_REPORT.md` is the readable form, with the operators each target could not match and the first failure of every cell that is not green.

  Writing the app surfaced seventeen defects, each pinned by a short program in `tests/syntax_app/gaps/` that the test compiles on every run so that a fix cannot pass unnoticed: `last_index` expands to the unparenthesised `(array_length x) - 1` and never compiles; `make` drops its fill value on ES6 and prints `undefined`; `nullify` does not count as a mutation, so the JavaScript writer emits `const` and the program throws; `empty` cannot be assigned to a typed optional; the elvis operator is documented as infix and is not; `for` takes neither an array of arrays nor — on the Go target — an array of objects; an array of function values cannot be declared; inheritance is one level deep and a subclass constructor calls `super` with the parameter names of the base; strings have no ordering operators and no `string + boolean`; `[T].has` in `lib/stdlib.rgr` calls `indexOf` without a receiver; `[T].contains` does not bind its block parameter; Go compiles `if!` with one block without negating it; and the Python writer emits nothing under `def` for an empty method body. They are written up in `tests/syntax_app/known_gaps.md`.
- **The Go `jpeg_scaler` writes the reference image, byte for byte** — running the Rust conformance gate against Go surfaced two writer faults and the target's `to_int` gap, all three now fixed: **double literals were emitted as source slices** (the Go writer's `WriteScalarValue` Double case called `node.getParsedString()`, which re-reads the source file at the node's recorded position — under macro-shifted positions the slice lands on unrelated bytes, so `1.0` came out as fragments of neighbouring lines and the generated program did not parse; the case now formats `double_value`, with a `.0` suffix on whole values so Go types them as `float64`); **the `for`-loop item binding was dropped when the body only used a field path** (`go_for_bind` asks `treeReferencesVRef` whether the loop body mentions the item, and the walk never matched a namespaced path's root — a body reading only `tag.tagName` was judged not to reference `tag`, so the binding line was omitted and `go build` stopped at `undefined: tag`; the walk now matches the first segment of any `ns` path); and **`to_int` truncated on Go** where the reference targets floor — same fault as the Rust one above, fixed the same way: `int64(…)` is now `int64(math.Floor(…))`. With the three fixes the Go build of `gallery/pdf_writer/src/tools/jpeg_scaler.rgr` is byte-identical to the ES6/C++/Rust image at both test sizes, making four targets that agree to the byte; timed on the same machine the Go binary lands within ~1.2× of the two ahead-of-time natives (C#, Swift, Kotlin and Scala still truncate `to_int`; recorded in PLAN_RUST_OWNERSHIP.md). Regression coverage in `tests/codegen-go-writer.test.ts`

- **JSON for the Python and the Rust target** — `lib/JSON.rgr` declared no `python` and no `rust` template in any of its operator blocks, and `systemclass JSONDataObject` / `JSONArrayObject` / `JSONValueUnion` named no type for either target. A template block is part of the match, so the absence was not a fallback to some generic form: every program that touched a JSON object stopped in the type check with `Could not match argument types for json_object`, and `@serialize(true)` could not work on either target. The [FAQ answer on `toDictionary` / `fromDictionary`](https://terotests.github.io/Ranger/docs/faq/#how-do-i-write-an-object-to-json-and-read-it-back) showed that message in place of the Python and the Rust tab. Both targets now hold `print`, `getStr`, `getInt`, `getDouble`, `getBoolean`, `getObject`, `getArray`, `keys`, `isArray`, `asArray`, `getValue`, `array_length`, all six `set` variants, `push`, `json_object`, `json_array`, `from_string` and `to_string`, and `lib/stdlib.rgr` holds the union `case` for both:

  - **Python** maps the three shapes onto `dict`, `list` and `object`, and reads and writes the text with the `json` module of the standard library. A getter is an inline lambda that checks the type of the value, so no polyfill is needed: `getInt` rejects a `bool`, because `bool` is a subclass of `int` in Python and `{"ok": true}` must not read back as an integer.
  - **Rust** has no JSON type in the standard library, and the target writes code that `rustc` builds with no crate. The compiler adds the enum `RJson` as a polyfill, together with a reader and a writer for the text. A JSON object is a `std::collections::HashMap<String, RJson>` and a JSON array is a `Vec<RJson>`, both spelled in full so that the output needs no `use` line. The trait `RJsonValue` converts an argument of the union type `JSONArrayUnion` to the variant that fits it, which is what `push` and the `set` of a union value need.

  `tests/fixtures/json_ops.rgr` builds an object, writes it as text, reads it back and reaches every value in it. `tests/compiler-json.test.ts` runs the generated program on JavaScript, on Python and on Rust and compares the three outputs, so a missing template can not pass as a compile-only success. `tests/compiler-serialize.test.ts` adds `python` and `rust` to the target list and runs the round trip of `serialize_roundtrip.rgr` — nested object, object array and object hash — on both

- **The flag-on Rust `jpeg_scaler` writes the reference image, byte for byte** — the last divergence of the conformance gate was hunted with staged instrumentation (per-block reader positions, then per-stage checksums: decoded pixels, scaled pixels, code tables, quantized coefficients, bits written), each round fixing the first stage where the Rust run left the ES6 run. Every fault was a compiler fault, and three sat in the flag-off Rust writer all along: **`clear` was silently dropped from every Rust program** (the template said `rust ( custom )` where the dispatcher expects `( (custom _) )`, and the handler behind it emitted JavaScript `.length = 0;` — a re-parsed Huffman table therefore kept its old symbols, which was the two AC-refine decode errors); **a pre-evaluated mutable argument was never written back** (the borrow-conflict path cloned `self.dcYCodes` into `__arg_2`, passed `&mut __arg_2` and dropped the result, so the encoder ran with empty code tables); and **`to_int` truncated on Rust where JS, C++, Python and PHP floor** — the two differ on every negative quotient, exactly the encoder's quantization of negative coefficients (C#, Go, Swift, Kotlin and Scala still truncate; recorded in PLAN_RUST_OWNERSHIP.md). The sharing analysis also learned to see a local defined inside a while or an if body (the function-level lookup cannot; the walk now resolves through the node's own desc), the `at` macro's paren-wrapped expansion, `for`-loop iteration variables, and raw `new` pushes into shared-element vectors. Verdict on the program: 16 `value` classes, 6 shared — exactly the codec's mutable state. Final run: 0 decode errors, 1434621 bits written = the ES6 count, 180280 bytes out, `md5` equal to the C++ image. The wrong-image fault of PLAN_CODEGEN_OWNERSHIP is closed on Rust under the flag

- **The Rust flag survives its first conformance gate, and the gate paid for itself** — `-rust-shared-classes` was run against `gallery/pdf_writer/src/tools/jpeg_scaler.rgr`, the largest program in the repository, with the C++ image (byte-identical to the ES6 image) as the reference. Six real faults surfaced and are fixed: a constructor field initializer of a shared class was not wrapped; a self-referential field kept its `Box` where the `Rc` already provides the indirection, in the type, the assignment and the unwrap; `push` through a cell borrowed shared where a write needs `borrow_mut` (the first operand of every mutating operator now carries the LHS flag through the template engine); and three holes in the sharing analysis itself — the short form `return dcTable0` was not read as returning stored state, the `at` operator is a macro whose expansion hides `itemAt` behind an extra pair of parens, and mutation through a call argument (`decodeDCFirstBlock(reader buf …)` mutates `buf`) was invisible until `computeSharingMutations` propagated per-parameter mutation through call chains to a fixpoint. The two `__self_rc` edges are closed too: the hidden parameter is transitive through self-calls, an unnameable receiver is a compiler error that says to bind the receiver first, and an assignment whose right side reads a shared cell pre-evaluates it (`a.name = c.name` with `c` an alias of `a` used to panic `RefCell already borrowed`). The decoder's verdict is 18 `value` classes and 4 shared — exactly its mutable state — and the run went from 4332 Huffman decode errors and a 625-byte image to 2 errors and 8413 bytes, `rustc` clean, with every flag-off output still byte-identical. What remains is bit-level: two symbols across two progressive AC-refine scans, recorded in PLAN_RUST_OWNERSHIP.md; the flag stays experimental until that closes

- **Returns, optional fields and element reads follow a shared class on Rust** — the remaining produced-or-consumed surfaces of `-rust-shared-classes`: a shared class in a return position hands out the `Rc` (`writeRustReturnType`), a strong optional field is `Option<Rc<RefCell<T>>>`, a call result or strong-optional unwrap that already carries an `Rc` is taken as one, and the sharing analysis learned the two events that make those surfaces matter — a named value stored into any object graph, and a function that returns stored state (`return (itemAt items 0)` hands the caller an alias of the stored element). A field read now borrows shared while a write or a method receiver borrows mutably, so two reads of one cell can overlap — the aliasing probe used to panic with `RefCell already borrowed` and now prints `yy`, the same as the ES6 output: store an object into a list and an optional field, read it back through two getters, mutate through one name, and every name sees the change, on Rust. The jpeg verdict is unchanged (21 of 22 classes `value`), and without the flag every output stays byte-identical

- **`weak` works on Rust for the first time, behind `-rust-shared-classes`** — the two gaps between the flag and the parent–child program are closed. A method of a shared class that uses `this` as a value takes a hidden first parameter `__self_rc : &Rc<RefCell<T>>` and every call site passes the receiver's Rc alongside the `borrow_mut()` (safe: the callee only clones or downgrades the Rc, it never borrows the cell again), so `c.parent = this` emits `Some(Rc::downgrade(__self_rc))` — a live back reference where the old output built a fresh cell around a copy of self, dead on arrival. A collection of a shared class carries `Rc<RefCell<T>>` elements (`kids:[Child]` → `Vec<Rc<RefCell<Child>>>`), and a weak read upgrades to the Rc itself with no second cell and no `RefMut` type error. The parent–child program of the docs — adopt, then read the parent's name back through the child's weak field — compiles with `rustc`, runs, and prints what the ES6 output prints. That is finding 4c of PLAN_CODEGEN_OWNERSHIP, open since the ownership work began. Without the flag every Rust output stays byte-identical; what the flag does not cover yet is listed in PLAN_RUST_OWNERSHIP.md

- **A shared class can be `Rc<RefCell<T>>` on Rust, behind `-rust-shared-classes`** — the experimental other half of the sharing analysis: every field, parameter and local of a class the analysis marks shared takes the `rust_needs_rc_wrap` mode the writer already had for weak-wrapped values, and a def whose initializer is already `Rc<RefCell<T>>` now clones the Rc instead of wrapping a second cell around it — `def b:Counter a` is the language's aliasing form, and both names must reach one cell. The program the docs define the object model with (`def b:Counter a` … `b.add(1)`) compiles with `rustc` for the first time and prints `a 1`, the same as every other target. Without the flag every Rust output is byte-identical to before. What still stands between the flag and the parent–child `weak` program is documented in PLAN_RUST_OWNERSHIP.md: `this` as a value inside a shared class (the `shared_from_this` question in Rust form) and collection element types following the class

- **The compiler names the classes that need reference semantics on Rust** — `analyzeClassSharing` (`ng_StaticAnalysis.rgr`) runs after the ownership fixpoint and decides, per class, whether some object of it is ever aliased and held: a parameter of its type `moved`/`shared`, an alias a name then mutates through, a stored object read into a mutated local, or the target of a `weak` field. `-strict-ownership` prints the verdict per class (`ownership[rust] class Counter -> Rc<RefCell> (aliased and mutated in main)` / `-> value`) on every target. Measured on `gallery/pdf_writer/src/tools/jpeg_scaler.rgr`: 21 of 22 classes stay `value`, and the single exception — `BufferChunk`, a linked-list node aliased from a field into mutated locals — is precisely the pattern the Rust struct model breaks on. No output of any target changes; this is the diagnostic half of PLAN_RUST_OWNERSHIP step 2, staged the same way the C++ `const&` work was

- **Rust reads the ownership summary: a proven-borrowed object parameter is `&T`** — the immutable-borrow marking had always excluded object types, because nothing proved where such a parameter went, so every read-only object argument paid a whole-struct `#[derive(Clone)]` copy at every call. The interprocedural summary now proves it (`borrowed` = no store into any graph, no return, no storing callee down the chain), and the mutation pass already guards the rest, so `applyOwnershipToRustBorrows` (`ng_StaticAnalysis.rgr`, run for the rust target in `VirtualCompiler.rgr`) upgrades such a parameter to the `&T` mode the writer already has. `fn sumValue(…, mut a : Node, mut b : Node)` called as `sumValue(root.clone(), child.clone())` becomes `fn sumValue(…, a : &Node, b : &Node)` called as `sumValue(&root, &child)`. On `gallery/pdf_writer/src/tools/jpeg_scaler.rgr` the removed clone sites are the ones inside the pixel loops (`setPixel(x, y, c.clone())` → `setPixel(x, y, &c)`); `rustc -O` accepts the output before and after, and the binary writes a byte-identical file. A `moved` or `shared` parameter keeps the owned mode. PLAN_RUST_OWNERSHIP.md holds the measurements and the staged next step — the Rust object model itself, which is what still makes an object a value on Rust and a reference on the other eleven targets

- **Interprocedural ownership summaries (the "Phase B" the code comments promised)** — an argument passed to a resolved call is no longer undecidable: the pass records a pending edge per (caller parameter, callee, argument index) and `resolveCallEscapes` runs the edges against the callee summaries to a fixpoint. A parameter handed down a chain of read-only functions stays `borrowed` — which is what keeps the `const&` win on real programs — and `k.adopt(p)` where `adopt` stores its parameter now reads `moved (call adopt.p)`. Escalation is monotone (`borrowed` → `moved` → `shared`, or → `unknown` when the callee itself cannot be decided), so the fixpoint terminates; a recursion cycle of readers stays `borrowed`. Measured on `gallery/pdf_writer/src/tools/jpeg_scaler.rgr`: 110 functions, 256 of 256 parameters decided (254 `borrowed`, 2 buffer parameters the decoder really does store into `this.data`), zero warnings — before, the same program printed one warning, and it was the `blockIdx` false positive below. What stays open is documented in PLAN_OWNERSHIP_SOUNDNESS.md: a two-step escape through a local collection, an argument passed to a received lambda, a receiver stored via `this`, and a meet over virtual overrides

### Fixed

- **The borrowed `const&` parameter could alias the storage the callee mutates (C++)** — the ownership inference turns a `borrowed` object parameter into `const std::shared_ptr<T>&` (3.3.1). `borrowed` means the parameter does not escape the callee; it does not mean the caller's argument expression names storage that stays put. Passing a member field bound the field itself, so a callee that reassigned the field read the new object where every reference-semantics target reads the call-time one, and passing a member-collection element was a use-after-free once the callee grew the collection (AddressSanitizer: `heap-use-after-free`, in a program whose callee only reads its parameter and pushes one element to a member list). The signature keeps `const&`; the call site now decides: a local, a parameter, `this`, or a fresh temporary binds directly, and any other argument — a field, a collection element, an `unwrap` of either — is wrapped in a call-time copy `std::shared_ptr<T>( … )` that pins the object for the whole call (`cppNeedsCallTempCopy` in `ng_RangerCppClassWriter.rgr`, applied in `writeFnCall` and `writeNewCall`). The `jpeg_scaler.rgr` output is byte-identical before and after — no call site there passes a field to a borrowed parameter — and `tests/fixtures/ownership_alias_call.rgr` holds the aliasing case: JS and C++ now print the same text, and the ASan run is clean

- **The ownership inference missed most of the escape forms it claimed to see** — five separate holes in `walkForEscapes` (`ng_StaticAnalysis.rgr`), each one classifying a stored parameter as `borrowed`: the value of a `set`/`put` was read from child 2, which is the key (`set slots "x" p` escaped the string `"x"`, not `p`); the short form of a member store (`last = p`) was not counted while the long form (`this.last = p`) was; a store through a local alias (`def q p` … `this.last = q`) was invisible; so was a store behind `unwrap`; and the escape-via-call marking only understood the normalized `(call …)` node, which at analysis time exists for almost no real call — measured on `gallery/pdf_writer`, the only parameter it ever flagged was `blockIdx:int`, a primitive that can never carry ownership, flagged because `buf.get(…)` collides with the system name `get`. All five are fixed; the walk now reads the same `has_call` / `hasFnCall` / `hasNewOper` shapes as `walkForTransitiveWeak`, and a primitive argument is never flagged. `tests/fixtures/ownership_escape_forms.rgr` asserts each form

- **The `@serialize(true)` reader needed a lambda** — the generated `fromDictionary` read an object array and an object hash with `arr.forEach({ ... })`. The callback is the one construct that neither the Python nor the Rust writer emits: Python got a multi-statement `lambda` (`SyntaxError`) and Rust got the JavaScript arrow form (`expected one of ), ,, ., ? or an operator, found =>`). `ng_RangerSerializeClass.rgr` now writes an index loop over `array_length` / `getValue` for an array and over `keys` / `itemAt` for a hash. The loop uses the operators that every target already declares, so the reader no longer depends on the callback support of the target. A side effect on the JavaScript output: `fromDictionary` is no longer `async`, because the callback was what marked it so — `await` on the return value still works, and `dist/api.d.ts` states `T` in place of `Promise<T>`

- **`try { } { }` wrote JavaScript for the Python and the Rust target** — neither had a template, so both fell through to the `*` form and the output held `try { ... } catch(e) { }` verbatim. Python now writes a `try:` / `except Exception:` statement, and each suite opens with `pass` because a generated catch block is often empty and a Python suite needs a statement. Rust has no exceptions, so the compiler writes the try block and states in a comment that it does not write the catch block

- **The Rust file header sat after the first `use` line** — the writer put `#![allow(unused_parens)]` and the four other inner attributes into the class content, while an `(imp "...")` from an operator template writes its `use` line into the import slice, which is ahead of the content. An inner attribute has to precede every item of the file, so `rustc` rejected the output of every program that used a hash map with `an inner attribute is not permitted in this context`. The header now goes into the `before_imports` slice

- **A Rust systemclass reached the output under its Ranger name** — `writeTypeDef` and `getObjectTypeString` in `ng_RangerRustClassWriter.rgr` did not read `systemNames`, so a `JSONDataObject` parameter compiled to `dict : JSONDataObject` and `rustc` saw an undeclared type. Both now name the type that the systemclass declares for `rust`

- **`get` on a Rust hash map gave `Option<&T>`** — the operator is typed to give an optional `T`, and `HashMap::get` hands back a reference and takes one, so `scores.get("Alice".to_string())` did not compile at all. The template is now `get(&key).cloned()`

- **A Rust hash field was constructed as `None`** — the constructor of a class initialized an array field with `Vec::new()` and an optional field with `None`, and a hash field fell into the second branch, so a `[string:T]` field made `rustc` report `expected HashMap<String, T>, found Option<_>`. A hash field now takes `HashMap::new()`

- **A Python class field with no value read an undefined attribute** — `writeVarInitDef` wrote `self.one` with no assignment for a field that is neither an array nor a hash, and the constructor raised `AttributeError` the first time the class was used. The field now takes `None`, as a local variable already did

- **A Python `(typeof N)` template wrote the Ranger type name** — the Python writer inherited the generic `writeTypeDef`, which writes `node.type_name`, so the union `case` produced `isinstance(item, JSONDataObject)` in place of `isinstance(item, dict)`. The Python writer now maps the built-in types and reads `systemNames`

### Known gaps closed

- **Python has no JSON support at all** (listed under 3.3.1) is closed by the entry above. The lambda gap of the `operator type:[T]` block stays open: `.map()`, `.filter()` and the other callback operators still emit a multi-statement Python lambda. The `@serialize(true)` reader no longer depends on that support

## [3.3.1] - 2026-08-01

### Fixed

- **A recursive macro hung the compiler with no diagnostic** — macro expansion renders the template to Ranger source and walks the result again (`buildMacro` + `WalkNode` in `ng_parser_std_match2.rgr`), so an expansion that reaches the same call site again never terminates. Unguarded this was not a stack overflow and not an error: the compiler simply never returned, and had to be killed. The root context now carries an `active_macros` map; re-entering a call site already being expanded fails immediately:

  ```text
  [FAIL] Macro expansion of operator 'selfmac' is recursive: expanding it
         reaches the same call site again, so it never terminates.
  ```

  The key is the operator name **plus the source position**, never the name alone. `if ... else` and `if!` are themselves macros that emit `if` — they lower to the three-argument `if`, which is not a macro — so a nested if/else legitimately re-enters the same operator while the outer expansion is in flight. A name-keyed guard would reject nearly every real program, the compiler's own source included. A depth ceiling of 512 remains as a backstop for a cycle that keeps producing fresh positions and so never repeats a key.

  For the same reason there is no static "does this macro name itself" check: self-naming macro templates are legal, so such a check is false-positive by design. `tests/macro-recursion.test.ts` covers the direct cycle, the indirect one (`macA` → `macB` → `macA`) and the legitimate nested-if case, with explicit timeouts because a regression here would hang the suite rather than fail it

- **Two `to_string (value:int)` operators, one of them dead and the other emitting `to_int`** — `Lang.rgr` declared the same signature twice. The first (`to_string _:string`) wins every match, which the second (`to_string cmdIntToString:string`) shadowed entirely: `cmdIntToString` appears nowhere else in the tree, and removing the block leaves the emission for go, csharp, es6, python, cpp, rust, kotlin and swift6 byte-identical. The winning block's `ranger` template emitted `(to_int x)` rather than `(to_string x)` — a copy-paste error, not a deliberate lowering: `to_int` has no `(value:int)` variant at all, so the rendered Ranger would not type check. `ranger` templates are not confined to a Ranger-to-Ranger build: `ng_LiveCompiler.rgr` forks a context with `targetLangName = "ranger"` to render expressions back to Ranger source for polyfill identity, so they run during a normal compile to any target

- **Python had no template for nine operators** — `M_PI`, `fabs`, `tan`, `random` (both variants), `wait`, `file_exists`, `dir_exists` and `create_dir` could not compile for the `python` target. Added, and verified by running the generated Python rather than only compiling it (`tests/compiler-python.test.ts`), which the environment allows for Python but not for Kotlin or Swift

### Known gaps (Python)

- **Every operator taking a lambda emits invalid Python** — `.map()`, `.filter()`, `.reduce()`, `.find()`, `.count()`, `.groupBy()` and the newly added `.any()` / `.all()` compile successfully for `python` and then fail at runtime with `SyntaxError: invalid syntax`. The backend emits the Ranger callback body as a multi-statement Python `lambda`, and a Python lambda holds a single expression:

  ```python
  out = operatorsOf.map_2(a, lambda item:
    return item * 2;          # SyntaxError
  )
  ```

  Pre-existing and independent of the operators added above. Fixing it means hoisting callback bodies into named functions in `ng_RangerPythonClassWriter.rgr` — a codegen change, not a template. Until then, treat the `operator type:[T]` block as unavailable on Python

- **Python has no JSON support at all** — 0 of the 34 operator template blocks in `lib/JSON.rgr` declare `python`, and `systemclass JSONDataObject` / `JSONArrayObject` declare no Python type, so `@serialize(true)` cannot work there. Unlike C++ and Rust, Python has an obvious representation (`dict` / `list`), so this is tractable — but it is a full JSON backend, not a template gap

## [3.3.0] - 2026-08-01

### Added

- **`.any()`, `.all()`, `.slice()` on arrays; `.values()`, `.map_length()`, `.get_or()` on maps** — added to `lib/stdlib.rgr` as Ranger source in the `operator type:[T]` / `operator type:[string:T]` blocks, not as per-target templates, so they compile for es6, cpp, kotlin, swift3, swift6, go and rust without a template matrix. `get_or` replaces the `has` + `get` + `unwrap` triple. Note that `map`, `filter`, `reduce`, `find`, `count` and `groupBy` already existed in this same block and already worked on all seven targets — they are method-style (`items.map({ ... })`), which is why they are easy to miss when reading `Lang.rgr` alone

- **Operator coverage audit** — `tests/operator-coverage.test.ts` fails when an operator declares a `swift3` template but no `swift6` one and no `*` fallback, which is exactly the shape that let `@serialize(true)` ship broken for Swift 6 in 3.2.0. The check is one-directional on purpose: swift6 is a primary target and swift3 is legacy, so a newer operator existing only on swift6 is correct. It also pins the count of silent `not implemented` templates at its current 30 as a ratchet that may only go down

### Fixed

- **`keys` on `JSONDataObject` had no `kotlin` template** — any hash field (`[string:int]`, `[string:Child]`) in a `@serialize(true)` class failed to compile for Kotlin. Added a polyfill mirroring the existing `java7` one over the same `org.json` API. **The emitted Kotlin has not been run through `kotlinc`** — no Kotlin toolchain in the build environment — so this needs verification before it is relied on

- **`keys` on a map had no `rust` template** — `forKeys`, and therefore the whole `operator type:[string:T]` block, failed to compile for Rust

- **Seven operators lacked a `swift6` template** — `M_PI`, `fabs`, `tan`, `wait`, `file_exists`, `dir_exists` mirror their `swift3` counterparts verbatim (`file_exists` / `dir_exists` including their `FileManager` polyfills). `create_dir` got a real implementation (`FileManager.default.createDirectory(atPath:withIntermediateDirectories:)`) rather than a copy, because the `swift3` template is an empty no-op that silently skips the operation. `switch` on a generic condition also gained `swift6`, and `buffer_alloc` gained `swift3`

- **`random` had no Swift template at all** — neither `random:double ()` nor `random:int (min max)` could compile for Swift. Added `Double.random(in:)` / `Int.random(in:)` for swift6 and `arc4random` equivalents for swift3

- **`ceil` declared `:int` but emitted floating point** — `go` emitted `math.Ceil`, `cpp` emitted `ceil`, and so on, while the sibling `floor` truncated on every target. `ceil` now matches `floor` on swift3, swift6, cpp, kotlin, csharp, go, rust and java7, and gained the `kotlin` and `swift6` templates it was missing

- **Go's SHA-256 helper was named `_r_md5`** — renamed to `_r_sha256`; it calls `sha256.Sum256`

- **`rust` was missing from the `targets {}` block** in `Lang.rgr` although it compiles

- **`fn has:boolen` typo** in the array operator block of `stdlib.rgr`

### Known gaps

- **`@serialize(true)` does not work for `cpp` or `rust`** — not a template gap: `systemclass JSONDataObject` and `JSONArrayObject` declare no C++ or Rust type at all, so these targets have no JSON representation to serialize into. Needs a design decision, not a template
- Unary minus, `range`, `min`, `abs(int)`, `round`, `pow` and `log` remain unimplemented; see [PLAN_OPERATORS.md](./PLAN_OPERATORS.md) §5

## [3.2.1] - 2026-08-01

### Fixed

- **`@serialize(true)` did not compile for Swift 6** — 3.2.0 fixed serialization on ES6/TypeScript and Kotlin, but a `@serialize(true)` class still failed on the `swift6` target, and four of the five errors landed inside the compiler's own `JSON.rgr` rather than in user code:

  ```text
  JSON.rgr:48:12          [FAIL] Unknown type:  type ID : 0
  JSON.rgr:49:1            [FAIL] Could not match argument types for getValue
  JSON.rgr:50:5           [FAIL] Invalid types for lambda call
  extension WithSer:9:10  [FAIL] Could not match argument types for case
  ```

  Three operators on the generated `toDictionary` / `fromDictionary` path had a `swift3` template but no `swift6` one, and no `*` fallback, so operator matching failed for that target only: `getValue` and `keys` (`lib/JSON.rgr`) and the union-narrowing `case` (`lib/stdlib.rgr`). Each `swift6` template mirrors its `swift3` counterpart verbatim — across the four operator files, 86 operators already declare identical `swift3` / `swift6` templates and the 13 that differ do so only for genuine Swift 3 → 6 API changes (`.characters`, `UnicodeScalar`, `Data(bytes:)`), none of which these three use.

  Reported with a three-target repro in [realtrainer `ai/RANGER_COMPILER_ISSUES.md` §D](https://github.com/terotests/realtrainer): the same two-file library compiles on ES6 and Kotlin and fails on Swift 6, isolating the cause to the annotation rather than to user code.

### Added

- **Cross-target `@serialize(true)` regression tests** — `tests/compiler-serialize.test.ts` now compiles the serialization fixtures for `es6`, `swift3`, `swift6` and `go`, plus `tests/fixtures/serialize/serialize_union_case.rgr` for the union-narrowing path. An ES6-only test cannot see a missing template on another backend, which is how this reached a release

### Known gaps (unfixed, same defect class)

- **`keys` on `JSONDataObject` has no `kotlin` template** — any hash field (`[string:int]`, `[string:Child]`) in a `@serialize(true)` class fails to compile for Kotlin with 15 errors in the generated extension. Pre-existing, independent of the Swift fix
- **Seven operators still lack a `swift6` template** — `M_PI`, `fabs`, `tan`, `wait`, `file_exists`, `dir_exists`, `create_dir` (`compiler/Lang.rgr`). All fail on `swift6` today; none is on the `@serialize` path. `create_dir` additionally needs a real implementation rather than a copy: its `swift3` template is an empty no-op

## [3.2.0] - 2026-08-01

### Fixed

- **`@serialize(true)` array / hash / property types without the annotation** — the generator assumed every referenced class had `toDictionary` / `fromDictionary` and emitted the call regardless, so the compile failed inside `extension <Class>` with `Could not match argument types for push` / `for case` and never named the real cause. Hash-valued fields were worse: the field was silently dropped from the serialized output. A `@serialize(true)` class that references a class which is neither `@serialize(true)` nor implements the pair by hand now fails at the declaration:

  ```text
  [FAIL] Parent.kids: [Child] can not be serialized - class Child is not @serialize(true).
         Add @serialize(true) to Child, or implement toDictionary / fromDictionary in it.
      9 │     def kids:[Child]
                  ^── here
  ```

- **`@serialize(true)` with mutually referencing classes** — serializer extensions were generated one class at a time and `is_serialized` was set as each was reached, so whichever class came first in the dependency sort saw its peer as non-serializable and emitted primitive element code. All `@serialize(true)` classes are now marked before any extension is generated, which mutual references could not satisfy by sorting.

- **`@serialize(true)` element types with hand-written `toDictionary` / `fromDictionary`** — accepted as serializable; the generator now routes them through the object path instead of treating them as primitives.

- **Duplicate serializer extension when one file is reached by two import spellings** — a file imported as both `Child.rgr` and `domain/Child.rgr` was expanded twice, and with `@serialize(true)` the second expansion produced `method with the same name and parameter signature declared earlier` pointing at generated code. Fixed since 3.1.1 was published, released here; covered by a regression test.

- **`SourceCode` constructed with three arguments in `ng_CodeNode.rgr`** — `SourceCode` takes a single `code_str`, but `vref1`, `vref2`, `newStr`, `newBool` and nine sibling factories passed `(name 0 (strlen name))` or `("" 0 0)`, copying the shape of the adjacent `new CodeNode(code 0 ...)`. JavaScript discards the extra arguments, so this was invisible until the TypeScript API bundle was type checked, where it produced all 13 of the `TS2554: Expected 1 arguments, but got 3` errors that `build:dist:module` reported. Dropping the dead arguments makes `tsc` clean, so the step is `&&`-chained again and a real type error blocks the build instead of scrolling past

- **`file_mtime` C++ backend** — added missing `cpp` template in `Lang.rgr` (`stat()` + ms, matching es6 `mtimeMs`); fixes `game_runtime.rgr` hot-reload compile for `game_sdl` native binary

- **`TSLexer` UTF-8 / native C++ tokenization** — code-unit length vs byte `charAt` mismatch after multi-byte characters (e.g. em dash in comments) no longer desyncs the lexer; fixes native SDL parse failures on [`invaders.game.tsx`](./gallery/game_engine/scripting/invaders.game.tsx) and similar scripts

### Added

- **LPC spritesheet compositor (standalone MVP)** — Ranger-native `gallery/game_engine/lpc/` (`png_decoder.rgr`, layer blit, male walk demo); embedded `pack/demo-male-walk` (~28 KB); `npm run engine:lpc:build` / `engine:lpc:run`; design doc [`LPC_HEADLESS_SPRITESHEET.md`](./gallery/game_engine/LPC_HEADLESS_SPRITESHEET.md)

- **JPEG background loading for scripted games** — `GameImageLoader` + `GameRunner` asset queue (`resources()`, `backgroundImage()`, `onLoading()`); `splash_demo.game.tsx` smoke test; `game.d.ts` `ResourceDef` / `LoadingProps` types

- **In-process TS hot reload (Path A)** — `TSAstPatcher` (`ts_ast_patch.rgr`), `ComponentEngine.patchScript()`, `GameRunner` runtime options (`setHotReload`, `trackScriptFile`, `maybeHotReload`); `game_sdl --hot-reload` / `--no-hot-reload`; default on for interactive SDL, off for `maxFrames` smoke runs

- **SDL + native compiled games (Path B)** — `game_sdl_native_host.rgr`, `invaders_native_sdl_runner.rgr`, `build-game-sdl-native.sh`; `npm run engine:game-sdl-native:run:invaders` runs emitter output in an SDL window without `ComponentEngine`

- **`@serialize(true)` regression suite** — `tests/compiler-serialize.test.ts` and `tests/fixtures/serialize/`: round trip over primitives, nested objects, object arrays and object hashes; mutual references; hand-written `toDictionary` / `fromDictionary`; duplicate import spellings; and the three diagnostics above

### Changed

- **`prepublishOnly` no longer runs the whole repository suite** — publishing the compiler ran all 56 test files, including the gallery, game-engine and native-toolchain suites. Those need SDL2, `g++`, Cannon and game fixtures that ship with neither the repo nor the package, so `npm publish` failed on the publisher's machine for reasons unrelated to the compiler (missing `SDL2/SDL.h`, `gallery/game_engine/games/ylos/index.tsx` and `physics_race/index.tsx` are absent from the repository entirely). `prepublishOnly` and `.github/workflows/publish.yml` now run `npm run test:publish` — 44 files, 355 tests, ~90s — covering parsing, type checking and code generation for every target backend. `npm test` still runs everything, and `ci.yml` is unchanged.

- **`build:dist` now includes `build:dist:module`** — `dist/api.js` was not rebuilt by the release build, which is how it drifted behind `bin/output.js`

- **`dist/api.js` rebuilt from current sources** — the committed programmatic-API bundle predated several compiler fixes, so `require("ranger-compiler")` shipped older behaviour than the `rgrc` CLI. `scripts/patch-chain-desugar.js` now patches `dist/api.js` as well as `bin/output.js`, and `build:dist:module` runs it after `tsc`

## [3.1.1] - 2026-06-23

### Changed

- Version bump for npm publish — recommended for cloud CI and projects that install `ranger-compiler` from npm (e.g. koodisampo) instead of a sibling `../agent/Ranger` checkout
- Default `npm test` / `prepublishOnly` skips `compiler-llvm.test.ts` (experimental LLVM/WAT backend); run `npm run test:llvm` when working on native/WASM codegen

### Added

- **IsoDate stdlib** — `lib/IsoDate/` (`DateMath`, `IsoDateParse`, `IsoCalendar`) and `lib/IsoDateLib.rgr` for portable ISO calendar dates without host `Date`; see [ai/ISO_DATE.md](ai/ISO_DATE.md)
- **IsoDate compiler intrinsics** — `iso_add_days`, `iso_compare`, `iso_between` in `Lang.rgr` (Kotlin/Java `java.time.LocalDate`, ES6 UTC-safe helper)
- **IsoDate regression** — `tests/fixtures/iso_date_ops.rgr` in Kotlin compiler tests
- **Regex stdlib** — `lib/Regex/` (`RegexMatch`) and `lib/RegexLib.rgr` for string-pattern matching without `/literal/` syntax; see [ai/REGEX.md](ai/REGEX.md)
- **Regex compiler intrinsic** — `regex_test(pattern, haystack)` in `Lang.rgr` (Kotlin/Java `Regex`/`Pattern`, ES6 `RegExp`, Swift `range(of:options:)`)
- **Regex regression** — `tests/fixtures/regex_test_ops.rgr` in Kotlin compiler tests

- **JavaScript/TypeScript source maps (`-sourcemap`)** — `SourceMapBuilder` with VLQ encoding (`compiler/ng_SourceMap.rgr`); `CodeWriter` line/column tracking, `walkNodeStack`, and `outMapped()`; embedded `sourcesContent` for `.rgr` sources; `.js.map` + `//# sourceMappingURL=` on save; statement/expression mappings via `LiveCompiler.WalkNode` walk context; expression `names` from `node.vref` / parameter names; regression `tests/compiler-sourcemap.test.ts`; README section *JavaScript / TypeScript source maps*

### Fixed

- **Source map VLQ line breaks** — `buildMappingsString` no longer resets source/original relative state on `;` (fixes DevTools breakpoints on `.rgr` sources)
- **Source map original line** — `addMappingFromNode` uses `node.getLine()` from `sp` instead of stale `node.row`
- **Kotlin `floor` / `int2double`** — `int2double` emits `.toDouble()`; reserved parameter names escaped (`val`, `object`, …)
- **`proc_send` dispatch wrapping** — turn boundaries around handler calls; `ProcessRuntime.beginDispatchTurn` / `endDispatchTurn`

## [3.1.0] - 2026-06-02

### Added

- **`ProcessUiHost` notify suppress** — `beginSuppressUiNotify` / `endSuppressUiNotify` / `isUiNotifySuppressed` for batching parent↔child sync without re-entrant UI notify loops ([PROCESS_UI_NOTIFY.md](PROCESS_UI_NOTIFY.md))
- **Process view DTO regression fixture** — [tests/fixtures/process_view_dto_assign.rgr](tests/fixtures/process_view_dto_assign.rgr) (cross-class field assignment with method call on RHS)
- **Docs** — [PROCESS_UI_NOTIFY.md](PROCESS_UI_NOTIFY.md), [PROCESS_UI_VIEW_MODELS.md](PROCESS_UI_VIEW_MODELS.md); README `@process` quick start

### Fixed

- **Parser: assignment RHS method calls** — `row.field = this.helper(index)` no longer splits the call into invalid `=` operands ([PROCESS_UI_VIEW_MODELS.md](PROCESS_UI_VIEW_MODELS.md)); fix in [compiler/ng_RangerFlowParser.rgr](compiler/ng_RangerFlowParser.rgr) (`repairAssignMethodCallRhs`)

### Changed

- Version bumped from `3.0.5` to `3.1.0`

## [3.0.5] - 2026-05-29

### Added

- **`@process` messaging** — `proc_send` operator; `receiveMessage` on `RangerProcessBase`; `find_process` / `ProcessNameRegistry.findProcess`
- **TypeScript named paths** — `-typescript` emits `ProcessPath`, `findProcess` on registry, singleton `new` returns shared instance
- **`RangerProcess.rgr` in npm lib** — included under `dist/lib/` when running `npm run build:dist`

### Fixed

- **`build:dist:copy`** — `rm -rf dist/lib` before copy so new lib files (e.g. `RangerProcess.rgr`) land in `dist/lib/` instead of nested `dist/lib/lib/`

### Changed

- Version bumped from `3.0.4` to `3.0.5`

## [3.0.4] - 2026-05-12

### Fixed

- **Installed npm package library lookup** — The published compiler now also searches `./lib` relative to the compiler binary, so locally installed `ranger-compiler` packages can resolve `stdlib.rgr` and related bundled libraries correctly
- **Virtual compiler bundled file loading** — Bundled library files are now loaded from `./lib/` inside the packaged distribution instead of assuming a development-only `../lib/` layout

### Changed

- Version bumped from `3.0.3` to `3.0.4`

## [3.0.3] - 2026-05-12

### Fixed

- **TypeScript module build path** — `npm run module` now builds through the same explicit `dist/api.ts` path that the packaged API uses, instead of generating `compiler/bin/api.ts` and relying on a stale `tsconfig` include pattern
- **CI publish verification** — Release builds no longer depend on a globally available `tsc`; the repository now declares a local `typescript` dev dependency for consistent CI behavior
- **TypeScript config compatibility** — Updated the TypeScript configuration to remove deprecated settings that broke newer TypeScript runners in CI

### Changed

- Version bumped from `3.0.2` to `3.0.3`

## [3.0.2] - 2026-05-12

### Fixed

- **Rust and Swift string method codegen** — Added missing target templates for `startsWith`, `endsWith`, `contains`, and `replace`, so `tests/fixtures/string_methods.rgr` now generates output correctly for Rust and Swift 6
- **Compiler test diagnostics** — Shared test helpers now report the actual compiler failure when a compilation does not produce an output file, instead of masking the root cause behind a generic generated-file-not-found error

### Changed

- **Release automation** — Updated the publish workflow to trigger on release publication and allow manual dispatch, making retries and release recovery less brittle
- **Release metadata** — Version bumped from `3.0.1` to `3.0.2`

## [3.0.1] - 2026-05-12

### Added

- **Kotlin union-case dispatch** — `case item oo:SomeClass` now emits correct Kotlin `is`/`as` pattern for class, boolean, int, double, and string variants (in `lib/stdlib.rgr` and `compiler/stdlib.rgr`)
- **JSON.rgr ng version** — Updated `lib/JSON.rgr` and `compiler/JSON.rgr` to ng version with Kotlin and Swift 6 templates and four typed `set` overloads
- **TypeScript README example** — Added parsing example to `modules/ts/README.md`

### Fixed

- **Kotlin ternary operator** — Emits `if (cond) a else b` form instead of JavaScript `? :` syntax (`compiler/Lang.rgr`)
- **Kotlin `indexOf`** — Emits `.indexOf(x)` method call instead of invalid syntax (`compiler/Lang.rgr`)
- **Kotlin int/int division** — Casts operands with `.toDouble()` to avoid integer truncation (`compiler/Lang.rgr`)
- **Kotlin `open fun` warnings** — `open` modifier now only emitted when a class actually has subclasses (`compiler/ng_RangerKotlinClassWriter.rgr`)
- **TypeScript `instanceof` with structural types** — `typeof` in `case` context no longer emits `instanceof Record<string,any>` for mapped types; collapses to `Object`/`Array` at runtime (`compiler/ng_LiveCompiler.rgr`)
- **npm package bin path** — Changed from `bin/output.js` to `dist/rgrc.js` so the published package contains a valid binary

### Changed

- Version bumped from `3.0.0-beta.2` to `3.0.1`
- `prepublishOnly` builds dist and runs tests before every publish

## [3.0.0-alpha.1] - 2024-12-13

### Added

- **PLAN_3.md** - Comprehensive roadmap for Ranger 3.0 development
- **Compiler Introspection API** - Position-based type queries for IDE integration
  - `getTypeAtPosition()` - Get type information at cursor position
  - `getClassProperties()` / `getClassMethods()` - Inspect class structures
  - See [ai/INTROSPECTION.md](ai/INTROSPECTION.md) for documentation
- **AI Documentation** - Enhanced documentation for GenAI assistants
  - [ai/INTROSPECTION.md](ai/INTROSPECTION.md) - Introspection API guide
  - Updated [ai/QUICKREF.md](ai/QUICKREF.md) with introspection examples

### Changed

- Version bump from 2.1.70 to 3.0.0-alpha.1
- Updated test configuration to exclude ranger-vscode-extension tests from root

### Planned for 3.0

- File extension change from `.clj` to `.rgr`
- Web-based IDE with Monaco editor
- Simplified import system with auto-loaded standard library
- Language Server Protocol (LSP) for VSCode extension
- JavaScript source map generation
- Improved language targets: Python, Rust, Swift, C++

---

## [2.1.70] - Previous Release

### Features

- Cross-language compilation to JavaScript, TypeScript, Python, Go, Rust, Swift, C++, Java, Kotlin, C#, PHP, Scala
- Lisp-like syntax with object-oriented programming support
- VirtualCompiler for in-browser compilation
- VSCode extension (preliminary)

---

## Version History Notes

Ranger has been in development since 2016. Major milestones:

- **1.0** - Initial release with basic cross-compilation
- **2.0** - Introduced VirtualCompiler and improved type system
- **2.1** - Added more language targets and improved code generation
- **3.0** - (Current) Modernization with web IDE, LSP, and simplified tooling
