Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Goldberry

A fast and modern UI toolkit for Java. Linux, Windows, and macOS from day 1.

This book is the project’s decision log and developer documentation. It is not the design document — that lives at docs/ARCHITECTURE.md in the repository root and describes the whole system as intended. The book records why the design is the way it is, one decision at a time, and picks up the questions the design document leaves open.

Where things are

DocumentAnswers
docs/ARCHITECTURE.mdWhat the system is, layer by layer
book/src/adr/Why each significant choice was made, and what it costs
README.mdHow to build it, and what currently works

Status

Pre-M0. The Gradle multi-module skeleton, the JPMS module graph, and this book exist; nothing renders yet. The milestone ladder is in docs/ARCHITECTURE.md §16, and the README tracks progress against it.

Reading order

If you are new to the codebase, read docs/ARCHITECTURE.md §1–§5 first — positioning, the layer map, the native core, the backend SPI, and the rendering pipeline. Then read ADR-0002 through ADR-0005, which cover the four choices that shape everything else: CPU rasterization, SDL3-only windowing, the three-tree widget model, and CSS + KDL as the public contracts.

Status

What is built, milestone by milestone, against the ladder in docs/ARCHITECTURE.md §16.

What is not built is in TODO.md — deferred items, known gaps, specified-and-unbuilt surface, and the questions each of them is waiting on. This page is the other half: it says what works and what it cost to find out.

MilestoneStateIn one line
FoundationdoneThe build, the module graph, the toolchain and the decision log
M0 — SkeletondoneOne native library on four targets, two backends, a window at the right fractional DPI
M1 — Vertical slicebuilt, unprovenBlend2D rasterizes, HarfBuzz shapes, text lays out, and a frame’s cost is measured — on one machine. The three-platform evidence that closes it is scheduled at M5; showcase.yml reports what each leg’s 300-frame walk cost and asserts no budget, because none has been measured (ADR-0452)
M2 — Widgets & styledoneCSS, KDL, the three trees, input, motion — and every §3 control, select included
M3 — ShellstartedThe whole of §7, §9’s tray-icon, menubar, §5’s containers, the whole scroll family and §4’s fields — with the clipboard, text input, a focus trap and a third rank of every semantic hue that nothing had asked for. §3’s chip and §6’s breadcrumbs are built, which opens the nav package. The showcase is a menu bar, a bar and seventeen screens, two of them searchable sheets — all 1544 bundled icons, and the 1212 emoji of the face goldberry-emoji ships, each grouped by its upstream’s own categories with a chip row to choose one, and each tile opening a dialog of the glyph at five sizes (ADR-0386, ADR-0457) — in a window that opens maximized and stops at 640×480
M3.5 — the :natives sealdoneDrawing, layout and shaping are the toolkit’s own vocabulary; Blend2D’s, Yoga’s and HarfBuzz’s packages are sealed to :core by the module descriptor, and the last method closed with ADR-0290. A canvas hears input, draws an image, takes a caret and pastes one; a scene renders with no window
M4 — GPUstartedWindows present through the GPU by default and on the CPU where it cannot (ADR-0480); GPU layers in paint order, canvas3d, and video-view through a GPU layer (ADR-0484). docs/gpu-plan.md’s phases 1–6 have met their exits on Metal; the composited path has also run on Linux under X11, and the lavapipe lane has run twice and failed before its first test
M5 — HardeningstartedText editing depth, IME preedit, docs, the first release — snapshots have gone to Central since run 17, and no release has run, because there is no tag — and the three-platform frame evidence M1 is waiting on. The AccessKit bridge was this milestone’s last toolkit item and is on hold (ADR-0440)
Content modulesstartedThe first of the eleven is built whole: :html parses Markdown through md4c and HTML in Java, serves Markdown as HTML, and renders both as widgets — markdown-view and html-view, neither with an engine under it. :emoji is the second, and is a font rather than a widget: Noto Color Emoji’s COLRv1 build ships there, drawn from its paint graphs, and :core loads the face through a service (ADR-0384, ADR-0456). :media is the third: audio and video over FFmpeg driven from Java, with the operating system’s decoders behind the same SPI, published as an optional snapshot (ADR-0460, ADR-0493, ADR-0495). The web engine turned out not to be a module: web-view is a widget in :widgets where the window system allows a child window (ADR-0442). The other seven are unscheduled

Foundation

Done.

  • Multi-module Gradle (Groovy DSL), version catalog, convention plugins, JPMS module graph, JDK 25 toolchain, JUnit 6, licence disclosure, decision log.

M0 — Skeleton

Done. The bindings, the backends and a window, on every target.

The native library

  • The superbuild links on all four targets. Blend2D, AsmJit, SDL3, Yoga and HarfBuzz statically combine into one libgoldberry exporting exactly the symbols on the export list and nothing else — both Linux targets in CI’s manylinux containers, macos-aarch64 on an Apple Silicon runner, and windows-x64 under MSVC. The layout probe passes against the real library, and Yoga’s measure callback crosses in both directions including the YGSize struct-by-value return (ADR-0017), so the hand-written binding mechanism is proven end to end.
  • Windows closed the milestone: goldberry.dll builds, :natives:test passes against it with goldberry.native.required=true so nothing skips, and the golden images match — which answers the MSVC /INCLUDE: and .def branch of the export machinery and Win64’s 4-byte long at the same time (ADR-0041)

The bindings and the backends

  • Yoga’s node API is bound, and the callback is now driven by real layout passes rather than by a C probe written for the purpose (ADR-0029). SDL3’s lifecycle, error and version calls are bound and tested against the real library (ADR-0018). The backend SPI, the headless backend and the sdl3 backend are in :core, with fractional DPI correct by construction (ADR-0019) and background work on virtual threads that completes on the UI thread (ADR-0020).
  • The showcase opens a window and presents frames (ADR-0021), through a Window front door that names no backend and builds no event loop (ADR-0022).

M1 — Vertical slice

Built, unproven. Everything in the pipeline exists, runs and is measured; what is unfinished is the breadth of the 60 fps claim, not the pipeline — and breadth is a CI job rather than toolkit work, so it is scheduled at M5 with the rest of the hardening. M1 stays open until that job has run, because a milestone whose claim is “on Linux, macOS and Windows” cannot be closed on evidence from one Linux VM.

Rasterizing and shaping

  • Blend2D rasterizes the frame, HarfBuzz shapes the text. Frame no longer writes pixels by hand: it wraps the platform’s own buffer in a BLImage without copying it, scales the context by the display factor so coordinates stay logical and fractional edges antialias rather than snap, and blends with alpha that now means something (ADR-0031). The showcase paints through it. Shaping takes UTF-16 straight from a Java String, so the cluster indices point back into the caller’s own text (ADR-0032).
  • Text draws. Blend2D’s font chain is bound and a GlyphRun reaches the rasterizer: Font in :core owns a HarfBuzz font and a Blend2D one over the same bytes, shapes in design units and puts the size on the Blend2D font alone, so the font matrix is the only thing that converts (ADR-0034). The showcase draws two lines of Inter, and the tests assert where the ink landed — the inked span matches the measured width, which fails by a factor of 128 if either side of that crossing is wrong.
  • Yoga and Blend2D now meet: BoxPainter lays a flexbox tree out and fills the result, setting Yoga’s point scale factor from the display scale so computed edges land on physical pixels — the first code for which the fractional-DPI claim is a mechanism rather than an intention. Inter, JetBrains Mono, OpenMoji and Lucide’s 1544 icons are fetched at build time, pinned by checksum, and packaged into goldberry-core (ADR-0033)

Text in a layout

  • Text takes part in layout. A Paragraph shapes once and wraps with arithmetic over that one GlyphRun, so its measure function answers Yoga from inside a layout pass without shaping again (ADR-0036). A Box with text is a measured leaf: the showcase’s body wraps to whatever width the sidebar leaves it, and its siblings are positioned against the height that comes back. Two numbers are written down in that layout — the bar’s height and the padding — and everything else comes from content.
  • The cache and the benchmarks are done (ADR-0037): ./gradlew benchmark measures the text path, and the numbers say the upcall crossing is ~0.3 µs, a memoised wrap 0.02 µs, and shaping 56 µs — so ParagraphCache caches shaping and nothing else.

Threads, layers and damage

  • Painting is now multithreaded, and icons draw. Blend2D rasterizes a frame across up to four workers on any surface over 400×300, which takes a 960×640 paint from 0.47 ms to 0.34 ms and a 4K one from 6.0 ms to 2.3 ms; a threaded frame is asserted pixel-identical to a synchronous one at every worker count (ADR-0042). Blend2D’s path API is bound and Lucide’s 1544 icons reach the screen as stroked paths, all of them asserted to parse (ADR-0043). 481 of them were drawing the wrong shape until 2026-09-13, and all 1544 parsed the whole time: Lucide writes an icon as several <path> elements and lets each one open with a relative moveto, which SVG reads as absolute inside its own element and as relative once the compiler has joined them — so a third of the set traced its second subpath from wherever the first one’s pen stopped. SvgPathData anchors every subpath before the join and IconCompiler refuses one that is still relative, so the property is checked rather than asserted in a comment (ADR-0302). And a typeface is loaded once rather than once per size: FontFace holds the shaper and Blend2D’s face, so a second size costs 4.4 µs instead of 681 and no second copy of the file (ADR-0044).
  • A rebuild is not a restyle, and a wheel notch cost 78 ms before it was. The culling below was measured on a settled frame and that was the wrong frame: an icon view is slow while somebody is scrolling it, and a wheel notch on the icon sheet spent 66 of its 78 ms in the cascade. Element.update threw a node’s whole subtree’s cached styles away on every re-description — so a scroll moving by one notch, which re-describes two nodes, invalidated 4709 for a transform none of them can see. Three guards now: an identical widget is not a description at all, so the walk stops where a parent handed its children back the objects it was holding; a re-description that leaves type, id and classes alone cannot change what a selector matches anywhere below it, because those are three of the five questions StyleElement lets one ask and the other two live on the element; and what is left is Styled.restyle, which the whole catalog overrides twice, asked once per widget class through a ClassValue. 1556 elements re-resolved a frame became 4, and the cascade 66.6 ms became 4.2. It was never only this screen: every viewport in the gallery re-cascaded everything under it on every wheel event, and the sheet is where it was big enough to see. StyleCacheTest asserts each guard against Element.cachedStyle rather than against a colour, and each case fails without its guard. — ADR-0315, ADR-0070
  • A frame pays for what is on screen. The clip stopped rasterization and not the walk: a viewport with a thousand rows in it handed all thousand to Blend2D to be clipped away, which is fine for a fillRect and expensive for a stroked icon path and a shaped glyph run. Each render object now carries the Ink its subtree draws — its border box grown by the focus ring, the drop shadow’s four asymmetric outsets and an icon larger than the slot it is centred in, unioned over its children and through their transforms — and a subtree that cannot overlap the clip in force is not drawn and not walked. The subtree’s ink and not the box’s own, because flexbox lets a child overflow its parent and a transform moves one out from under it. The showcase’s icon sheet is 1544 tiles of which forty are visible: raster 18.0 ms → 4.4, a settled frame 22.2 → 9.0, and the wall of cards most of the application is pays 0.03 ms for the measurement. Scrolling re-measures nothing — a viewport moves by a transform on one box, so the thousand under it are skipped at the first rectangle that held — and the same pass reads Yoga once per node per frame where four separate walks each read it. boxesPainted/boxesCulled are counted for layersRepainted’s reason: a culler that stopped working draws the same frame four times as slowly, so no assertion on pixels could tell. The cache predicate had a hole and an existing test found it — the box comparison behind it did not look at flex-wrap, align-self, limits, overflow or elevated, so a row that starts wrapping moved every child without being called changed; all five are compared now, which also gives overflow and elevated the self-damage they never had. — ADR-0313, ADR-0114
  • Four symbols were added to the export list, the first since it caught its third local-symbol bug: bl_context_blit_image_d and bl_context_set_global_alpha for layers, then bl_context_clip_to_rect_d and bl_context_restore_clipping for the partial repaint. Nothing else was needed — the offscreen pixels are a PixelBuffer allocated in Java and wrapped with the already-exported bl_image_init_as_from_data, which is the principle the export list states in its own comment. BlendLayerTest is seven pixel assertions that cannot pass unless both really exported, and the ELF, MSVC .def and Mach-O branches are answered by the next CI run rather than by argument. — ADR-0071, ADR-0018

What a frame costs

  • The 60 fps claim now holds at the tail, not just the median. A 960×640 frame with a wrapped paragraph used to run at a 7.86 ms median and a 14.18 ms p95 — a factor of two in hand on the median and none at the tail. Pacing the loop to the display (ADR-0047) took that to a 3.13 ms median and a 4.28 ms p95, which is 3.9× of headroom where there was effectively none; the old numbers reproduce exactly when the pacer is turned off with -Dgoldberry.frame.rate=0, which is what they were measuring. Two thirds of that frame was work thrown away on frames the display never scanned out.
  • What remains of the claim is breadth, not budget: it is still one machine, and that machine is a VirtualBox VM. The milestone asks for Linux, macOS and Windows. Scheduled at M5, where the shape of the job is written down — most of the machinery is already there, since showcase.yml opens a real window on all three runners and paints three frames on each. What it does not do is resize, time anything, or assert a budget. Two of the three have since been built: each leg walks its window’s size for 300 frames and reports what they cost (ADR-0342). The third was built and taken out again, because the number it asserted had never been measured (ADR-0452) — see the frame evidence.
  • A window was laying its tree out twice per frame, once to paint and once to find out where it had painted, and nobody had noticed. HitTest.capture took a frame and a box tree and built a whole second Yoga tree to answer. HitTest.capture(RenderTree) reads the pass update already ran. — ADR-0069, ADR-0054
  • Painting is not what a frame spends its time on. Measured on linux-x64 at 960×640 under Wayland, over sixty frames: acquiring the buffer costs 130–400 µs, painting 0.6–3.6 ms (typically ~1.3), and presenting 1.5–21 ms (typically ~10). Present dominates by roughly an order of magnitude. What it is doing is now known — and it is not, as this entry used to say, mostly waiting on the compositor. SDL’s Wayland driver implements no window surface, so SDL_GetWindowSurface falls back to a hidden SDL_Renderer: every present is a copy into a streaming texture, a render pass, and a swapchain wait. At 960×640 that splits about 1.05 ms of copy, 0.7 ms of render-and-present, and 4.8 ms of blocking — three quarters of present is a block rather than work (ADR-0046). The largest of those is fixed: the loop was running at ~105 fps into a 59.96 Hz panel and throwing two frames in five away. Goldberry now asks SDL to hold each present until vertical blank, and where that request is ignored the loop paces itself to the refresh rate read off the window’s current display — SDL_GetDisplayForWindow and SDL_GetCurrentDisplayMode, with SDL_DisplayMode verified against the compiled library by the layout probe. Paced, present falls from 5.51 ms to 1.20 ms — the block does not shrink, it disappears, leaving exactly the CPU that was always underneath — paint falls with it from 2.25 ms to 1.61 ms, and the UI thread spends 165 ms of each second in the frame path instead of 862, showing the same frames (ADR-0047). What is left: damage tracking, now worth under a millisecond a frame; and owning the renderer, the only route to the zero-copy path (ADR-0031) was believed to have. M4’s composited window is that renderer: it owns the swapchain the frame is presented on (ADR-0479). Blend2D’s thread_count is a fourth, and only matters if paint ever becomes the bottleneck. — ADR-0031, ADR-0046, ADR-0047
  • Rasterization is now the frame, and there is nothing else of consequence left. With Blend2D pinned to one thread a 960×640 frame is about 320 µs, essentially all of it painting; threaded, it spreads over four workers. Two rounds of removing CPU work have made damage tracking and layer promotion the honest next target rather than one option among several. — ADR-0070, ADR-0069
  • Painting now dominates a frame, and half of that reversal is a driver change. Over 119 frames at 960×640 with text: buffer 0.18 ms, paint 5.10 ms, present 1.92 ms, total 7.86 ms median, 14.18 ms at p95, and 3 frames of 119 over the 16.67 ms budget. ADR-0031 had paint at ~1.3 ms and present at ~10 ms and concluded present dominated by an order of magnitude. Text is what moved paint; X11 rather than Wayland is what moved present, since these frames were measured on X11 after the Wayland run crashed the compositor. The like-for-like Wayland measurement is still owed, and nothing here made present faster. Blend2D’s thread_count was parked in ADR-0031 as “only matters if paint ever becomes the bottleneck”; on these numbers it has. Taken. Up to four workers, on any surface over 400×300. — ADR-0037, ADR-0031, ADR-0042

M2 — Widgets & style

Done. Every widget in docs/core-widgets.md §3 is built, and each was finished against both specification documents rather than merely made to appear. select was the last of them and is not described here: its list is a platform window, so it was built with M3’s overlays and is written up there.

The engines

  • The CSS engine is done, end to end. A hand-written tokenizer and parser for the §8 subset, matching right-to-left with backtracking, the four fixed cascade layers, custom properties and var() — ending at a ComputedStyle that carries typed values and nothing else (ADR-0049). Box.style(ComputedStyle) is the join the property split was stated for: layout properties land on the fields Yoga reads, paint properties on the ones Blend2D reads.
  • Nord light and dark ship as custom-property layers — two files whose only selector is :root, so switching a theme repaints widget rules that never mention a colour (§10).
  • Golden-image CI runs on all three platforms: six scenes driven through the whole pipeline, compared with a per-channel and an area tolerance, because Blend2D JITs its pipelines per CPU and bit-equality across AVX2 and NEON is not a promise anyone made (ADR-0050).
  • KDL 2.0 parses and inflates, including the §9 example document as a test, with a registry that refuses unknown nodes by position; and hot reload works for stylesheets and markup alike — strict on first load, forgiving on every reload, because a file being edited is broken more often than it is whole (ADR-0051).
  • All three trees now exist. Widgets are immutable records; the element tree persists across rebuilds and is what the cascade talks to, so :hover survives a parent re-describing its child; state lives on the element, setState mutates immediately and defers the rebuild, and ten calls in one handler cost one build (ADR-0052, which closes the gap ADR-0004 left open). The render tree is materialized as a Box tree per frame rather than retained (ADR-0053).
  • Five primitives ship — text, row, column, panel, spacer — and the parity invariant of §11 is enforced: each is a Java record, a KDL node and CSS-selectable by type, id and class, with a test asserting the Java-built and KDL-built values are equal. A golden image runs the whole stack, KDL to pixels.

The paint layer, the cascade and the layout properties

  • The paint layer can now draw what the design system asks for. border-radius, border, outline and opacity reach Box, and a rounded rectangle is built from four cubics through the already-exported bl_path_cubic_to rather than from a new Blend2D symbol — so the corner works on every target on the first CI run instead of the one after the export list found out (ADR-0064).
  • The cascade inherits, which closed a bug and a gap at once. A checkbox’s label rendered black on the dark theme, because StyleResolver inherited custom properties and nothing else: the label is a text child element no rule names, so it resolved to ComputedStyle.INITIAL’s black. button had never shown it, because it copies style.color() onto its child boxes by hand and bypasses the cascade. color and the typography now inherit down the element tree — and cursor deliberately does not, because it already inherits through the stack of painted rectangles (ADR-0057), and two mechanisms for one property disagree the first time a box has no element behind it. WidgetRenderer resolves styles on the way down and builds boxes on the way up, which is the shape inheritance forces. §1.4’s type scale ships, and it was the blocker’s other half: every typography token is a size, a line height and a weight, and all three inherit. font-family, font-size, font-weight and line-height reach ComputedStyle; a Fonts book caches faces by family+weight and fonts by (face, size), because a widget tree is re-rendered every frame and a heading at 20px would otherwise re-parse Inter sixty times a second.
  • A weight is a face, not an axis: Inter ships as a variable file and as its SemiBold static instance, because instancing wght needs symbols in both HarfBuzz and Blend2D and therefore three new export branches — the machinery that has caught the same local-symbol bug three times — while §1.4 specifies exactly two weights and Principle 3 forbids improvising a third (ADR-0066).
  • position and inset reached the cascade — §8 has listed them and YogaNode has bound them since the beginning, and nothing had needed a box that sits over its siblings rather than beside them.
  • flex-basis was implemented and taken back out: flex-basis: 0 gives equal cells and makes Yoga compute the track’s content size as zero, so an unconstrained bar collapses to its padding — explicit percentages are the form that works in both directions, and a property with no consumer had no business staying. And a segment’s hover became a wash: an opaque fill would paint over the pill, because segments are drawn after it, and clicking a new segment would paint the destination fill instantly and beat the animation to it — so the --gb-overlay-* tokens button.ghost uses do both states on both backgrounds, which took four tokens out of each theme. The cost is stated in §3’s row rather than discovered: a segmented control now has no width of its own and fills its parent when nothing gives it one, because its cells are proportions (ADR-0099). Still to come: select and custom image cursors

Binding — §9’s other half

  • bind is done, which closes the second half of §9’s wiring. A Property<T> is a cell with listeners and nothing else — get, set, subscribe — and set does nothing when the value is unchanged, which is what makes two properties mirroring each other settle instead of recursing. Bindings is the third registry beside Actions and Icons and is deliberately the same shape: markup names a path, the registry resolves it, strict by default.
  • A path is prefs.frost and nothing else — the §17 fork is settled at dotted paths, enforced by the registry, so bind="!prefs.frost" fails at inflation with the text quoted rather than producing a control that silently never updates (ADR-0062). The binding lives on the widget and the subscription on its element, so a bound node has no wrapper element and panel > text styles it exactly like an unbound one; a change marks the element dirty by the same route setState does, so three changes in one frame cost one build. text bind="user.name" works from KDL and from Java, with the parity test extended to cover it, and the showcase’s sidebar carries a line that follows a property nothing in the tree owns — set from a virtual thread, redrawn without anything reaching into the widgets.
  • Binding is one-way, which is a change to §9: a widget is handed the read-only Observable half of a property, so markup can read a value and not write it, and what the user did travels back up as an action — checkbox bind="prefs.frost" change="toggleFrost". A control is therefore controlled in the React sense: the tick moves when the application sets the property, not when the pointer lands. §9’s “one/two-way” is amended to say one-way, deliberately and on the record (ADR-0063).

Input, the pointer and the cursor

  • Pointer input routes. A box carries an opaque owner tag, so a rectangle on screen leads back to its element; hit testing runs against the snapshot taken while painting rather than a fresh layout, because a pointer event is about what the user can see. Dispatch is capture → target → bubble with consume(), :hover moves along the whole ancestor chain and only where it differs, :active follows the press, and focus walks up to the nearest focusable ancestor with :focus and :focus-visible kept distinct (ADR-0054). The sdl3 backend translates all of it — motion, buttons, wheel, keys and committed text — and GoldberryRuntime drives the router from a real window. §7’s remaining gaps are closed. The wheel arrives in lines, fractional and positive down, with SDL’s away-from-the-user sign and the “natural scrolling” inversion both undone at the boundary, so a widget never sees either (ADR-0056).
  • A press captures the pointer until the release, so a drag that leaves a widget still reaches it and :active cannot get stuck; an explicit capture outlives the release, for a gesture that does (ADR-0058).
  • The cursor rides on the painted box: cursor: pointer resolves through the cascade onto the rectangle, and hit testing reads it back off whatever the pointer is over — so inheritance is the stack of rectangles rather than the element tree, and it freezes during a drag (ADR-0057). And accelerators are bound per window, router.shortcut("Ctrl+S", ...), fired after the focused chain declines the key so a text field keeps its own Ctrl+A; letters and digits joined Key for exactly this, since a modified letter produces no text event anywhere. Tab and Shift+Tab traverse in document order.

Motion

  • The controls move. §1.7’s motion language ships: a frame Clock, the three duration tokens, the two easing keywords with a bezier solver that cannot overshoot, and CSS transition resolved by the cascade like any other property. Animated values live in a per-node overlay applied at paint and never written back into computed style — the sentence the whole design hangs off, because a cascade that saw the halfway colour as the node’s real one would diff that against the target and start again from it, giving a control that approaches its hover colour and never arrives. Retargeting starts from the current animated value, so a pointer leaving a button halfway through a fade returns from where the colour is rather than jumping. The whitelist is a closed enum — opacity, background-color, border-color, color — and transition: width 200ms is a dropped declaration with a warning naming it rather than a rule that silently never fires, because animating a width would run Yoga every frame of every transition. Colours interpolate in OKLCH, which is measurable rather than decorative: Nord’s danger red and success green have a channel spread of 54 at their sRGB midpoint and 109 at their OKLCH one. §1.7’s “press applies in 0ms, release fades out” needed no new mechanism — the timing that applies is the one on the style being moved to, so a zero duration on :active and a fade on the resting rule is the whole of it.
  • The frame loop stays idle: renderer.isAnimating() is what an application asks another frame on, so a window at rest costs nothing and nothing polls. And the virtual clock is what makes any of it testable — button-hover-midway.png is three buttons showing the start, the middle and the end of one transition in a single frame, which is a picture no wall clock can take (ADR-0067).

Density, and the metrics that turned out not to be fixed

  • --gb-density ships, at four controls rather than at thirteen — the cost is per control, so it is three edits now and ten later. Every control sizes itself from --gb-control-height; density-compact.css is a three-token :root block in the theme layer, because that layer is defined by what it holds rather than by what it is called and a fifth would differ from the fourth in name alone. Density.REGULAR ships no stylesheet: a default is the absence of an override, and a density-regular.css restating 32 would be one number in two files. Padding, gap and radius stay literal and are asserted to, because §1.3’s density row names heights and nothing else. Compact is below §1.3’s own 32×32 hit-target floor and that is the trade rather than an oversight — bounded by the glyph staying 16px, so it costs margin around the target and not a smaller target (ADR-0074).
  • Every metric in §3 is now actually fixed. Reported as “the knob is outside the pill when I resize the window”, and it was not a toggle bug: Yoga runs with CSS’s defaults, so every node had flex-shrink: 1 and a width: 36px was a preferred width a cramped row could take back. §8 lists flex-grow/shrink/basis and only grow was implemented, so there was no way to say otherwise. Measured at 40px of room: a switch’s pill 36 → 16 while its 16px thumb did not move, a checkbox’s glyph 16 → 10, a radio’s the same and drawn as an ellipse since border-radius follows the box, and in a short column a control’s hit target 32 → 13 — §1.3’s 32×32 floor gone. The reported symptom was the only one of the four visible at a glance. flex-shrink is implemented now with no native symbol and no new binding — YGNodeStyleSetFlexShrink was already exported and bound, so the gap was in the CSS engine alone — and the controls declare flex-shrink: 0 once over a type list, because the rule is “a control’s metrics are fixed” and a copy per control is how that stops being true.
  • The label deliberately still shrinks: text is the one thing in a control that should give, and a text that refused would push the glyph out of the window rather than ellipsing. Six golden scenes were sized by the bug — 300×132 for content needing 136, which fitted only because the options were being squashed. The test frames are deliberately absurd, because a regression here is a function of window size and a test at a plausible size is the one that cannot fail (ADR-0076).

The catalog, control by control

button

  • The catalog has started. button ships in :widgets — a Java record, a KDL node and a CSS type, with a test asserting the first two produce equal values; variants are classes because that is the one spelling Java, KDL and CSS can all use; the metrics are the design system’s in the toolkit-base layer and the colours are component tokens in each theme, because a hover lightens on Nord dark and darkens on Nord light (ADR-0059). It activates on a click — a synthetic event the router raises only when a press and its release land on the same node, so dragging off to cancel works — and on Space/Enter, ignoring repeats. The action half of §9 is wired: markup names an action and an Actions registry resolves it, strict by default so a typo fails at inflation rather than producing a button that silently does nothing. padding grew CSS’s 1–4 value shorthand and its four longhands on the way, because padding: 0 12px is the button’s own metric.
  • button is finished, not started: label, icon, or both — an icon is a Box now, which closes the question ADR-0043 left open, and it turned out to need no measure function because an icon is built at a size and that size is its intrinsic one. disabled refuses every route to the action, drops the button out of the Tab order and matches :disabled, which is the one pseudo-class a widget owns rather than the router. Markup names an icon against a registry for the same reason it names an action: an Icon owns native memory, and a document reloaded on every keystroke would leak one per reload.
  • Four golden images cover the variants on both themes, the five states side by side, and the icon layout — the check that catches a padding on the wrong edge, which no value assertion can.
  • button complies with its own metrics row (§3): radius 8, the design system’s focus ring — 2px --gb-focus at a 2px offset, following the radius, written once for every control rather than per control — and :disabled as 45% opacity rather than a colour remap (§2.1), so a disabled danger button still reads as dangerous where eight muted tokens had made every disabled button look alike. Removing the remap exposed that a disabled control still lightened under the pointer; CSS would spell the fix :not(:disabled):hover and :not() is not in §8’s subset, so PointerRouter refuses to set :hover or :active on a disabled widget — one choke point, every control, forever.
  • button is now fully compliant with its §3 row: body-strong was the last of the four things controls.css said it could not express. The theme tokens were also wrong and are now §1.4’s exactly — heading was 16 where the table says 15, body was 14 where it says 13, there were no line-height tokens at all, and docs/ARCHITECTURE.md §10.1 carried a different table with a label token at weight 500 that no shipped face can draw; §1.4 won and §10.1 records that it did.

checkbox

  • checkbox ships: three states with :indeterminate as its own pseudo-class, because two cannot describe three and folding mixed into :checked makes every rule that meant “the tick is showing” silently wrong; a tick and a dash drawn by the painter rather than by an Icon, since a widget is a value and an Icon owns native memory; a click target that includes the label; Space and deliberately not Enter, which belongs to a dialog’s default action. Its glyph is the first part — check-indicator is CSS-selectable and not KDL-constructible, a stated exception to the parity invariant rather than an oversight in it, because a part has no existence outside its parent and one ComputedStyle cannot carry two backgrounds (ADR-0065). The value is controlled in the sense ADR-0063 settled: a click on a bound checkbox whose handler does nothing moves neither the property nor the tick, and a test asserts exactly that.

radio / radio-group — the first composite

  • The first composite ships, which closes §7.2. radio and radio-group are the third and fourth controls, and the first widget that is a set rather than a control — so three things that were trivially true for button and checkbox stop being true.
  • Traversal: a group of six options is one Tab stop with the arrow keys roving inside it, which is what docs/design-system.md §7.2 asks for and what nothing could express, since moveFocus collected every focusable node in document order and a radio is one. Handles.focusScope() is the whole opt-in, and both halves are the router’s by the argument already written on Tab: which node an arrow reaches is a property of the group’s shape, and the radio the focus is on cannot see its siblings. Arrows are handled after the focused chain declines the key, so a slider stepping its value keeps its own. Both axes rove, because the group’s direction is the stylesheet’s and input cannot know which pair the user is looking at.
  • Where Tab re-enters is derived from :checked, not remembered — the decision the record is worth writing for. The obvious implementation is a stored roving position, and it is wrong in a way that only shows later: it is a second piece of state beside the selection, and the two disagree the first time an application sets the value itself, returning the user to the option they last looked at rather than the one that is on. No event would fix it, because a property being set does not know a router exists. Derived, the selection is the roving position; there is nothing to invalidate, nothing to leak when an element unmounts, and one test — focus leaves, the model changes underneath, Tab comes back to the new selection — that the stored version fails.
  • The invariant: “exactly one is on” is a fact about the set, so the group applies it on every build and selected is deliberately not a KDL attribute, since a document that could mark one option could mark two. A value no option carries selects nothing rather than guessing the first.
  • Selection follows focus through the application, not inside the widget: an arrow raises the change and does not move the tick, so a group whose handler does nothing moves the ring and stays put — ADR-0063 applied to a composite. The fromKeyboard half of the new onFocusChanged is load-bearing rather than decoration: a mouse focus deliberately does not select, or a press moving focus and the click that follows would each fire the change. Actions gains a valued binding, the first action told which one — Consumer<String> over the value the document already wrote, with a plain Runnable still resolving against it and a valued action refused for a press= rather than called with an invented argument. radio-indicator is the second part, which is where ADR-0065 asked that its argument be made again rather than assumed; it holds, and the circle needed no new drawing code — border-radius: 8px on a 16px box is one, through the four cubics ADR-0064 already ships, so no native symbol was added and Box.Mark.DOT finally has a caller. Five golden images across both themes, and one of them is what caught that options were stretching to the group’s full width: a column’s flex children stretch on the cross axis, so the focus ring and the click target ran out across empty space while .inline kept hugging its label — the same widget with two hit targets depending on a class, which no value assertion would have shown (ADR-0073).
  • radio is finished against both specification documents, not just built. Reading §1.3, §1.5, §2.1, §2.2 and §3.1 against what had shipped turned up five divergences, four of which checkbox shared — §3 gives the two controls one metrics row, so a rule true of one and not the other is a spec that has stopped being true. Both now carry §1.5’s small-control border-radius: 4px, which §2.2’s ring follows rather than drawing a square one beside button’s 8px; both change a surface on hover rather than only a border (§2.1’s “one surface step”); the group’s gap is §1.3’s 8 for related controls rather than the 4 it shipped with, and .inline takes 16 because side by side each glyph-plus-label is a unit and at 8 a label sits as close to the next option’s glyph as to its own. The fourth was a bug: :active was set on the single deepest element a press landed on, so no control had a working pressed state at all — see below. The fifth was §3.1’s check/dot scale, the last unimplemented row in that table, which needed the mark to stop being a mark. Two more golden images, one of them a frame 80 ms into a moving selection.

toggle — the first gesture

  • toggle ships, which is the first widget with a gesture. Everything before it responded to a click, a key or a focus change — all single events. A drag is a sequence, and a widget is a value rebuilt every frame with nowhere to keep one, so the Toggle that sees the release is a different object from the one that saw the press.
  • The router reports the origin, as PointerEvent.dragX(), by the argument already written on Tab and on arrow keys — the router owns what the widget cannot see — and because the interval a drag offset is defined over is exactly the implicit capture ADR-0058 already spans. It is NaN and not zero with no button held, because zero is a real answer (a press that did not move) and Math.abs(NaN) >= 8 is false, so an event with no gesture reads as “not a drag” through the arithmetic rather than through a guard. The rule is one comparison against half of §3’s travel: past 8px the value is the direction dragged, under it the value flips — so dragging right on a switch already on asks for on, which is what a naive “toggle on release” gets wrong. It is also the only control that acts on a release rather than a click, because a switch has no cancel gesture: dragging off it is the interaction. toggle-track and toggle-thumb are the fifth and sixth parts, the thumb by ADR-0073’s argument that the unit of independent movement is a node — a transform applies down its subtree, so a thumb drawn onto the track would slide the track with it. Where it travels to is the stylesheet’s, not Java’s.
  • The colour question took two wrong answers before the right one, both of which looked like a geometry bug: the thumb appeared to be breaking out of the pill, and measured off the image it never was — it is exactly concentric and 2px inside all the way round. What the eye read was the thumb merging with the window across those 2px. nord0 was identical to --gb-bg; nord3 was merely near it. Every dark value in Nord is near --gb-bg, so on a light accent pill there is no dark thumb that works, and the fix is not a thumb colour at all: the dark theme’s on pill is nord10 rather than --gb-accent, and the thumb is the same near-white in both states. That is the one place a control departs from the shared accent ramp, and the geometry earns it — a checkbox can use a light accent because nothing sits inside its fill, and a toggle cannot because something does.
  • All of it was caught by looking at a golden image and none of it by a test, which is now three occasions; a colour that equals another colour is a passing assertion, and a disc that is provably inside its container can still look like it is not (ADR-0075).

slider and fader

  • slider ships, with fader as its vertical class — the sixth control, and the first whose value is a number rather than a state. Every control before it has a value a stylesheet can name; toggle-track:checked toggle-thumb { transform: translate(16px) } is literally how a switch’s thumb moves, and that stops working the moment the value is 37.4. So the thumb is placed by flex ratio — fill, thumb, rest, with the grow factors carrying the value — and transform is not merely awkward here but unable: CSS percentages inside translate are a proportion of the moving box, so translate(50%) moves the thumb by half a thumb rather than to the middle of the track. The ratio yields the filled portion for free, as a box the cascade can reach. The second half is PointerEvent.local(), the direct sibling of ADR-0075’s dragX(): where an event landed inside the widget currently handling it, re-pointed per handler because dispatch bubbles — a press on the thumb targets the thumb while the slider wants the position along itself. The control snaps and clamps so no application has to, and each of the three rules is a choice: steps count from min (so a 1..10 slider stepping by 2 can reach 1), an arrow offers the next reachable value rather than the current plus a step (nothing snaps a value on the way in, because that would be the control overruling the model), and the ends are always reachable even when the range is not a whole number of steps. It is also the first control that relies on ADR-0073 putting scope traversal after the focused chain — and it consumes an arrow even when the value did not move, because a slider at its maximum still owns Right and letting it through would move focus off the control being adjusted (ADR-0079).
  • slider is finished against §3 rather than merely shipped. Its three optional halves — “optional tick marks and value label”, and fader’s “optional dB scale mapping” — look like three small additions and are three different things breaking.
  • A value label makes “the control is the track” false: [ track ──── ] 40 is one control and two boxes, and the value lives along the shorter one, so a pointer mapped along the control reads 88% at the far end of the track — drawn perfectly, reported nowhere. Handles.localPart() is the answer, naming a CSS type because that is the vocabulary a part already has (ADR-0065) and resolved by the router because a widget cannot see its own elements — dragX()’s argument for the third time. The fallback is on the rectangle and not the element, which is the case that actually happens: a part exists from the first build and has no region until the first paint, so the element-level check finds it and hands back a zero-sized box whose every fraction is 0 — for a slider, “the user asked for the minimum”.
  • The anatomy was renamed rather than extended: slider-track is now the full-height box the value is measured along and the 4px channel is slider-groove, because two boxes were doing one job under one name until a third thing joined the control.
  • Every existing golden is byte-identical, which is what says that was a refactor.
  • The marks needed two things that rule each other out — clear the thumb, and do not move the groove (a scale that pushed it up would put two sliders in one settings list at different heights for no visible reason) — so slider-ticks is height: 0 and each mark is moved clear by a transform, which costs no layout (ADR-0068). Each mark sits in a synthesized 0×0 cell it overflows out of, because a mark’s own 2px has no business being in the spacing arithmetic: spread five 2px marks directly and every centre is a pixel off the thumb centre it names. The cell is zero on both axes so a fader can flip the row to a column in the stylesheet alone. Marks are counted along the travel, not per step (twenty-one marks on a 0–100 slider stepping by 5 is a wall) and not at even values (on a decibel travel that is four marks huddled at the top).
  • format is a pattern and not a function, because §11 compares two records for equality and two lambdas are never equal; it is validated at construction, so %d against a double fails at inflation rather than out of a paint, and formatted in Locale.ROOT, because the default would draw 0,5 on a de_DE machine and the golden that failed would be unreproducible anywhere else.
  • Scale is a sealed interface of records — Linear and Decibels(floorDb) — for the same parity reason, and it places a linear gain at a position linear in dB: half gain is 6 dB down, which is 90% of the way up a fader and half way up a linear slider, and that is the feature. The bottom of the travel is silence exactly, a 0.001-of-full-scale discontinuity at one end, because the thing a fader must be able to do is go silent. SliderGeometryTest is a new kind of test here and the change is what needed it: the claims rest on geometric relations between two parts that no stylesheet states and no value assertion reaches, and two of its six assertions failed on the first run — Yoga adds padding to a box with an explicit height: 0, and a slider in a row collapses to its content width, so the test’s own scene was wrong (ADR-0080).

progress and spinner

  • progress and spinner ship, which are the first two widgets whose motion is not a transition. Everything that has moved so far moved between two styles the cascade resolved; §3.1 asks these two for a “sweep loop 1.2s linear” and a “rotation 900ms linear loop”, and §8’s subset has no @keyframes and is not going to grow one. §1.7 names AnimationController for exactly this and it was not built: a loop that never ends is (now % period) / period, with nothing to start, stop, dispose or leak. That is ADR-0073’s argument for the third time — a second copy of a fact the tree already holds disagrees with it — and here the stored version has a symptom the derived one cannot have: two spinners mounted a frame apart would turn at the same speed and never at the same angle, which looks wrong without looking broken. The controller’s real subjects have a lifecycle — toast reflow, and the opening → open → closing → removed sequence every overlay runs — and none of those widgets exist, so it is M3’s to build for M3’s problem. Two small seams: Paints.Context.nowMillis(), read once per frame so two spinners see one number, and Paints.isAnimating(), because §1.7’s idle loop would otherwise paint a spinner once and go to sleep in front of it — a property of the description, so a bar given a value stops asking. The sweep is a transform (animating a width would run Yoga every frame of a loop that never ends) and it turns at the ends rather than running off them, because the usual drawing needs overflow: hidden and nothing here clips a box: a bar that ran past its track would draw over its neighbours, and the wrap clipping exists to hide would be a visible jump once a loop. The spinner’s ring is a Box.Mark and its arc is three cubics through the already-exported bl_path_cubic_to — no symbol added to the export list, ADR-0064’s rule holding for the fifth time — and it is three quarters of a circle because a spinning circle is a circle (ADR-0081).

badge

  • badge ships, which is the first entry in §3’s table that is not a control — no focus, no value, no keyboard map, no states — and the first widget the design system lets use colour. §1.2 admits the aurora hues “only with semantic meaning”, so every widget so far has obeyed the never half; a status chip is the first one whose whole job is the only half. Which walks it straight into the other half of §1.2: every text/surface pair meets WCAG 4.5:1, validated in CI against both themes — a sentence that had nothing behind it, because no contrast check existed anywhere in the repository. It does now, and it found something on the first run. A filled chip cannot take --gb-text: white on --gb-warning is 1.35:1, on --gb-success 1.77 and on --gb-info 2.34, so three of the four hues need the opposite end of the palette from the one the dark theme is built on — --nord0 text on a theme whose every other text token is --nord6.
  • The foreground is a property of the fill and not of the theme, and because §1.2’s palette is theme-invariant the pairing is identical in both files. --gb-danger needs something that is not in the palette at all: it is 3.55:1 under --nord6 and 3.05 under --nord0, legible against neither, so the badge’s fill is --nord11 derived darker until it clears the floor — the one place a chip’s colour is not a palette entry, and the reason ADR-0087 exists. §3’s table gained a badge row before a single number reached controls.css (Principle 3), and every one of them is derived rather than picked: 20 is on §1.3’s ramp and is the height toggle-track already uses, so border-radius: 10px is §1.5’s full spelled the way that part already spells it. ContrastTest resolves every pair through the real cascade rather than parsing the CSS, so a rule that stops matching and a token that stops resolving both fail it.

knob — the first drag that is a rate

  • knob ships, which is the tenth control and the first whose drag is a rate. It looked like a slider bent into a circle – same min/max/step, same keyboard map, same bind and change – and almost none of the machinery transferred.
  • A slider’s value is a position: the pointer is somewhere along a track, the fraction it sits at is the answer, read fresh on every event with no history at all, which is why nothing keeps state and why the router only ever had to report where a gesture started (ADR-0079).
  • A knob has no track. §3 gives it “value drag 200px per full range”, so the value is where it started plus how far you have dragged – and “where it started” is exactly what nothing could answer, because a widget is an immutable value rebuilt from the model and by the second frame of the drag the value at the press has been overwritten by the value the drag itself asked for. So the router remembers a third thing about a gesture and it is not a point: Handles.gestureAnchor() is asked once on the press, deepest-first along the chain so a press on a part is anchored by the control that will handle it, and handed back on every event as PointerEvent.anchor(). NaN outside a gesture, which is dragX()’s convention and load-bearing – a widget reading “no gesture” as an anchor of zero would snap a knob to its minimum on every hover. That is ADR-0075’s argument one step further, and it is general: a splitter, a scrollbar thumb and a text-selection drag all want it, so GestureAnchorTest is written against a bare widget in :core.
  • The fine modifier is the gesture’s, not the event’s, and the reason is a bug that would never have looked like one: reading the live modifier rescales travel already covered, so pressing Shift 100px into a drag takes the value from half a range below where it started to a twentieth of one without the pointer moving – drawn perfectly, reported nowhere, and it reads as the knob slipping.
  • SDL_GetModState joins the export list, the first new symbol since ADR-0086, because pointer events carried no modifiers anywhere – not in PointerEvent, not in the SPI, not from SDL, whose mouse events have no mod field where its keyboard events do. Latching them from the last key event needs no symbol and is wrong in a way that lasts: a window that loses focus while Shift is held never sees the release and sits silently in fine mode.
  • Box.Mark gained start and sweep, making ARC the one mark whose geometry is not fixed by its kind – because it is the one that has to show a number – and no native symbol was added for the drawing, because Arc.addTo was already general and already fed by ADR-0064’s cubics; the rule holds for the sixth time.
  • Detents are magnetic and step is a grid, which is why both exist: a knob with a centre detent is not a knob with a coarse step.
  • The first drawing was wrong in a way only the golden could say. The dial was knob’s own background and both rings were stroked on the same box, so the track ran across the body at about 1.2:1 and the 270° of travel a user is meant to read was invisible – every value assertion passed. KnobDial exists because of it, and the knob went into controls-on-surface-* rather than being exempted from it. A second one the goldens did not catch and a test did: a gentle touchpad scroll did nothing on a stepped knob, because a touchpad reports fractions of a line, a stepped knob snaps everything it reports, and every wheel event computes from the current value rather than accumulating – so a third of a step rounded straight back, every time. A stepped knob now moves at least one step for any scroll at all (ADR-0089).
  • Then it was put in front of someone and two things were wrong that no assertion could have said. It read as a gauge: §3 asks for an “arc indicator” and between them the two documents say what the value is and never say which way the thing is pointing, so nothing on the dial turned and nothing about it suggested you could turn it. And the ring did nothing — a slider’s track is clickable, a knob’s ring is the same 270° of travel drawn round a circle, and it was inert. So Box.Mark gained a POINTER kind, a radial line at the value’s angle, drawn as a mark on knob-dial rather than as a part of its own — the first time that has been the right answer since CheckMark went the other way, because a part is a node when two things must be styled or moved apart and the pointer is neither.
  • Clicking the ring positions the value; clicking the dial grabs it. The boundary between them is not a constant: the control cannot know where the dial ends, because the inset is the stylesheet’s, so knob names knob-dial as its localPart() and “outside the dial” is derived from the geometry that was actually painted (ADR-0080 answering a question it was not written for). The jump fires on CLICKED and not PRESSED, which is the whole of what makes it compose with the drag: a press is the first event of both gestures and cannot know which one it is, the router synthesizes a click only when press and release landed on the same node, and the rest is Toggle’s 8px slop. Jumping on the press would also have fought the anchor, which the router reads before dispatching — a drag after a jump would continue from the value the jump replaced (ADR-0090).

segmented — the one the specification could not describe

  • segmented ships, which closes §3’s catalog for everything that does not wait on a popup — and it is the first control whose specification could not be built as written. It was billed as the cheap one: §3 says outright that it shares radio-group’s model and invariant exactly and “is radio-group with a different drawing”, and the model transferred without a line of thought. The drawing did not. §3’s row asks for “radius 8 outer, 0 between; 1px divider”, which is the joined-buttons look — and a per-corner radius, where ARCHITECTURE §8 resolves “one radius, not per-side” on purpose. There is no clipping either, so the usual escape of square fills inside a rounded clipping parent is not there: a square-cornered fill inside the bar paints over its curve, and the selected end of the control reads as a corner that lost its radius. So the bar carries the 8 and the segment is inset inside it at §1.5’s 4, with both numbers derived rather than picked — the 2px inset is what fits a 28-high segment in a 32-high bar, the same arithmetic toggle‘s padding comes from — and the divider goes with the joined drawing it belonged to, because segments inset on every side are already separated and a rule drawing one would draw it through the gap the inset made. §3.1’s row could not be built either, and for a better reason. It asks for a “selection indicator translate+width between segments”: width is not on §1.7’s whitelist and never will be, and the translate would have to name the distance from the segment being left to the one being arrived at — a fact about two boxes’ laid-out geometry. A stylesheet cannot write it, because segments are as wide as their labels; and a widget cannot compute it, because ADR-0080 already established where geometry is available, which is the router after a paint. So the fill is the indicator, on fast, which is what list selection already does — and the travelling version waits for tabs, whose row §3.1 says it borrows the effect from, and which will need a widget to be told where its own children landed last frame. That is a real feature with real costs and it belongs to the control that actually requires it (ADR-0081’s argument, for the second time). Both design-system.md rows were amended rather than left describing something that does not exist. What is new in Java is one line: focusScope() is HORIZONTAL where radio-group’s is BOTH, and that single difference is the whole of why these are two widgets rather than radio-group.segmented — a group has no axis because its direction is its stylesheet’s, a bar has one because it is a row and no class turns it into a column, so Up/Down are not its keys to take. ADR-0078 wrote that rule for menus and this is the first control outside one to use it. A segment is option — the node §3 writes for this control and for select — and it is a widget rather than a part: a document writes it, it takes the focus, and it means something on its own. Two smaller things fell out. option:checked:hover is the first two pseudo-classes on one compound anywhere in the repository; the selector engine always supported it and nothing had needed it, and here it is what keeps a selected segment selected-coloured under the pointer — checkbox and toggle spend a descendant selector on the identical problem because their fill is on a part. And flex-grow: 1 on a segment is the same question radio-group answered with align-items: flex-start, answered the other way: a group’s options are separate controls that happen to be listed together, while a bar is one object and its segments divide it (ADR-0097).
  • Then the indicator was made to travel, which ADR-0097 had deferred and was wrong to. That record argued a translate “would have to name the distance from the segment being left to the one being arrived at — a fact about two boxes’ laid-out geometry”, and every clause of it is true except the premise buried in the middle: segments are as wide as their labels. That was a choice, not a fact. Make every segment exactly 1/n of the bar and the distance to segment k is k times one segment — a proportion, not a length, and a percentage in a transform is resolved by the painter after Yoga has run (ADR-0068). Nothing has to measure anything. What was missing turned out to be somewhere else entirely: a value a widget computes in render arrives after the frame has observed the node’s style and started its transitions, which is why every Java-computed geometry in the toolkit — a knob’s arc, a slider’s fill ratio — is documented as not animating. So Styled.restyle exists: §8’s inline cascade layer, typed, applied after the cascade, after the style cache, and before the animation looks. Those three orderings are the whole mechanism, and each is a way it could have been wrong — frozen, snapping, or invisible to the subtree that inherits it. The anatomy underneath is segmented → segmented-track → [segmented-indicator, option…], and the track exists because two percentage bases disagree: Yoga resolves an in-flow child’s percentage width against its parent’s content box and an absolute child’s against its padding box, so a pill sized against a bar with 2px of padding is 4px too wide and drifts a little further with every cell. A track with no padding makes them one box, which is slider growing a track for the same kind of reason (ADR-0080). Three things fell out of it.

Structure, ergonomics and the showcase

  • JPMS encapsulates resources, and the first headless run found out. exports governs types; a file inside a package of a named module is invisible to other modules unless the package is opens. So the toolkit could not read the showcase’s own showcase.css, and the error blamed the file. The message now checks whether the owning package is open and names the missing opens line when it is not. The showcase opens its package to :core only — an unqualified open would hand its private types to everything on the module path as well — and this is a line every application will have to write, which is a papercut in “implement one interface and go” that nothing can remove. — ADR-0093

  • The showcase is a widget tree: bar, sidebar, wrapped prose and a row of buttons, with setState, theme switching, Ctrl+T, focus that survives a rebuild, and :hover that repaints itself.

  • An application is a root widget, and the showcase’s main is one line. It was 190 lines, and none of them were about the showcase: open a window, open a font book, build an element tree, a render tree and a router, hold three one-element arrays to remember the renderer and the theme and the density across frames, write the paint callback — flush, restyle if the theme moved, update, compute damage, choose partial or full, hand the damage back, capture the hit-test snapshot, ask for another frame if anything animates — and take it all down in an order that matters. Two of those lines are subtly wrong if reordered: a render object holds a Yoga measure callback closing over a paragraph closing over a font, so closing the fonts first reads unmapped memory, and the trailing Goldberry.shutdown() is the difference between a clean Wayland disconnect and a compositor unwinding a client that never said goodbye (ADR-0085). None of it is a decision an application makes differently, so all of it is Goldberry.launch’s now: an application implements Application — one required method, root() — and gets back a Host with repaint, restyle, title, shortcut, fonts and a named escape hatch to the window. restyle() is separate from repaint() and is the one piece of state the launcher keeps for the application: re-reading stylesheets() every frame would rebuild the renderer every frame, and never re-reading it would make a theme switch impossible, so the application says when. Alongside it, every widget is chainable — Attributed gives id, styled and keyed, Bindable gives bound, both self-typed so new Badge("3").styled("danger") is still a Badge — and a widget supplies the one line only it can, withAttributes. Containers take children as varargs, so List.of is gone from the showcase entirely. And an application’s CSS and markup are resources now: Stylesheet.resource and KdlParser.resource read files beside a class the way the toolkit reads its own, which is also how the badge row became the first thing in a window to come from KDL rather than from Java (§9 had test coverage and no window coverage) — ADR-0093

  • The showcase is five classes and two documents, and new survived a challenge. It was one 770-line class doing four unrelated jobs — the application lifecycle, the view model, the widget tree and three panes’ layout — with every screen a private method on one state object that also held the model. It is now Showcase (the Application: lifecycle, stylesheets, registries, accelerators), ShowcaseModel (properties, the methods that change them, and the two registries markup resolves against), and ui.Screen / ui.Panes / ui.Content. titlebar.kdl and sidebar.kdl carry everything declarative, which is the first time §9’s markup path has run in a window with all three registries live: bind=, change=, press= and icon= all resolve against what the model and the application register, and all three are strict, so a typo fails at inflation with a line and column. ui.Content stays in Java and the reason is the instructive one — its Undo and Reset buttons are disabled when the click count is zero, and §8’s markup has no expressions; a document that could evaluate clicks == 0 would be code in a data file with no stack trace. ShowcaseDocumentsTest asserts the shape rather than trusting the window: an empty sidebar.kdl inflates to an empty column and paints a blank panel, and the headless three-frame run would pass — so it checks that every control is there and that the bindings reach the model’s own properties, which a shape assertion misses (a bind= resolving to nothing still renders a control that never moves). On Column.of() against new Column(): new stays, and the deciding argument is that a public record’s canonical constructor cannot be hidden — the JLS requires it to be at least as accessible as the record — so of() could only ever be additive, two permanent public doors with no compiler help keeping them in step. The noise turned out to be depth rather than the keyword, and decomposition fixed it. Performance was measured rather than assumed: 20M allocations, new 45.2 ms against of 45.1 ms, identical within noise, because -XX:+PrintInlining shows the factory inlined (Box::of (10 bytes) inline (hot)) — the first attempt at that benchmark said 87 against 46 and was wrong, with a String.equals inside the loop. What did get named is the ambiguous overload: Slider had two five-argument constructors differing only in whether the fourth parameter was a double or an Observable, and Knob, Toggle and Progress had the same shape — now Slider.of, Knob.of, Toggle.of, Progress.of, following the of = bound convention the catalog already used — ADR-0094

  • Registries are generated, not reflected. (Superseded — see “A model is plain Java again” below.) Wiring a model to markup was fifteen lines of pure copying — one .bind(path, property) per property, one per handler, plus the Double.parseDouble a valued action needs — and the failure mode was the worst kind: a property that exists and is never registered inflates to a control that renders perfectly and never moves, with nothing pointing at it. The obvious fix is a runtime reflective scan, and §9 forbids exactly that (“no reflective #handler magic”) — rightly, since it would need the application’s package opens, cost start-up, and leave the same silent control. So @Bind, @Action and @Registry are read by an annotation processor that writes the calls a person would have written: ordinary Java you can open, step into and get a stack trace out of, with nothing on the runtime path at all. The refusals are the point — a private member the generated code cannot see (with the fix in the message), a @Bind on something that is not a Property, two members claiming one path, an @Action taking more than one argument or one the toolkit cannot parse, and an annotated member on a class that is not @Registry, which is the mistake with no other symptom at all. Eight processor tests cover those; the showcase proves the generation itself every build. Annotations are SOURCE-retained so nothing at run time can be tempted to read them — ADR-0096

  • A shortcut is built from enums, and Modifiers is a mask. An accelerator had one way in — Shortcut.of("Ctrl+S") — parsed at run time, so "Crtl+S" threw whenever the line happened to run. Modifiers had the same problem from the other side: four positional booleans, 23 call sites writing them out, and nothing to catch a wrong order. There is a Mod enum now with a real bitmask, composed as Mod.CTRL.and(Mod.SHIFT).and(Key.Z). Mod.CTRL | Key.A is not reachable: | is defined for the integral types and boolean and Java does not allow overloading it, and the spelling that would compile — Mod.CTRL.bit() | Mod.SHIFT.bit() into a method taking an int — is a mask with nothing checking it, where Key.A.ordinal() | Mod.CTRL.bit() would compile and mean nothing. So the mask is real and private to the arithmetic: bit() is for the SDL boundary and for tests, and and can only ever produce Modifiers or a Shortcut. Modifiers is one int with has/only/set on top, the four boolean accessors kept so no call site changed, and the four-boolean constructor demoted to a secondary one — it reads fine where all four are literals and is a trap where they are computed — ADR-0095

  • :core ships no widgets, and its own tests stopped needing any. text, row, column, panel and spacer were nested records inside a Widgets class in :core, for a reason that had expired: the widget tree, the cascade and the painter all had to be provable before there was a catalog to prove them with, and five primitives were the smallest set that made the parity invariant testable. Once :widgets reached thirty types with a package per control, they were the only widgets in a module that is not a widget toolkit — and core-widgets.md had specified their packages since v0.1 while the code had them in a different module inside one holder class. They are ordinary top-level records now, in the packages the document gives them. Attributes stayed, promoted to a top-level type: it is not a widget but part of the widget contract, and an application widget wanting an id should not have to depend on the catalog to hold three fields. The interesting half was the tests. Two of the five that moved could not: StyleCacheTest and BindingTest reach into Element’s package-private internals, which is right for a test of the element tree and impossible from another module — so they stayed and use local test widgets, the pattern DragOriginTest already established, and nothing in StyleCacheTest any longer looks like a fact about panel. BindingTest split along a seam that turned out to be real: reading bind= off markup is the catalog’s, and what an element does with a binding once it holds one is :core’s. The same 1,641 tests run; 25 of them changed module — ADR-0092

  • Every widget is provably a value, and now there is a test that says so. ADR-0004 rests on it and nothing checked it. ImmutabilityTest asserts the parts records do not give for free: that every widget is a record with no non-final field, that a container copies the children list it is handed rather than keeping the caller’s, that the list it hands back cannot be written to, that Attributes copies its class set, and that every chainable step returns a new widget rather than mutating the receiver — so handing one widget to two panes and styling one cannot restyle the other. Ten checks, all passing, which means the guarantee was already true and is now enforced. The one component that is mutable by design is called out rather than papered over: a binding is an Observable and a handler is a lambda, and what matters is that a widget cannot write through them (ADR-0063).

  • Two things landed that M2’s ladder does not name. (The first is superseded — see “A model is plain Java again” below, where the problem stops existing rather than getting a better answer.) The first is that an annotated member may be private again. ADR-0096 listed “annotated members cannot be private” as a cost and argued the fields belonged package-private anyway; the argument runs the wrong way round, because the toolkit was deciding a model’s encapsulation as a side effect of how it reads it, and an @Action only the markup calls has no business being part of a model’s API. A private member now gets a VarHandle or a MethodHandle, looked up once in the generated class’s static initializer through privateLookupIn — which needs no opens and no setAccessible, because the generated class is in the target’s own package and a module always opens its packages to itself. This is not the MethodHandles.Lookup alternative ADR-0096 rejected: that one resolved a name at run time, and this writes the descriptor the processor already verified, so a typo is still a compile error naming the field and the handle is access rather than discovery. An accessible member still gets nothing — a handle it does not need is a line of generated code a reader has to understand for nothing — so a mixed model gets a mixed file, which is honest. The processor’s test suite now runs its output rather than only compiling it, because “it compiles” stopped being the interesting half of the claim, and ShowcaseModel’s six properties and five markup-only handlers are private (ADR-0098).

  • A model is plain Java again. The binding schema had drifted into the shape it was meant to avoid: clicks.set(clicks.get() + 1) is clicks++ with three extra tokens and a heap object, and because the field was a Property, every read and write inside the model went through an accessor nobody chose to write. The showcase’s model carried eight of them. A model is now plain fields and plain methods — @Bind("app.clicks") private int clicks; and clicks++ — and the build rewrites that one putfield into a store that notifies, using the JDK 25 class-file API (JEP 484) on the model’s own compiled class. It has to be the declaring class: putfield is not virtual, so no subclass or proxy can see the write. The @Action half moved with it — one invokedynamic per action, bootstrapped by LambdaMetafactory, written into the model’s own class, which is byte for byte the call site javac emits for model::click. That deletes both the :processor module and the generated …Registry source file, and it deletes ADR-0098’s privateLookupIn along with the problem it solved: a call site inside the model reaches the model’s own private methods with no handle at all. Measured, medians per operation: a write nobody is watching 9.5 ns → 2.5 ns, a watched write 19.0 ns → 12.9 ns, constructing the model 23.8 ns → 2.8 ns; action dispatch unchanged, because it was a LambdaMetafactory call site before and is one now, and registry construction unchanged. The cost is honest and in the other direction: reading through a binding is 1.9× slower for a primitive, because a woven read boxes where a Property<Integer> already held a box. That is the right trade — a model writes on every event and the tree reads once per rebuild. The refusals moved with the rules: a static or final @Bind field, an array (only assignment is observed, so values[0] = x would notify nobody), a malformed path, two members claiming one name, an @Action taking two arguments or one the toolkit cannot parse, an abstract or empty @Model, a @Model extending a @Model — 41 weaver tests, each of them a rule that would otherwise have quietly stopped applying. The known gap is stated rather than hidden: a field assigned from a different class, such as a nested class of the model, is not observed. Lambdas are fine, and there is a test for that, because javac compiles them into the same class (ADR-0125, ADR-0126)

  • The binding schema fits a closed world, and a test says so. The brief asked for the class-file API, LambdaMetafactory, and a GraalVM native image — three requirements that contradict each other if the first two run at runtime, because a closed world has no class loading and no class generation. They do not contradict at build time, which is where the weaving happens, and LambdaMetafactory is used as an invokedynamic bootstrap rather than as a method call — the one form the image builder resolves when it builds the image. NativeImageComplianceTest parses the woven bytecode and asserts it: no Class.forName, setAccessible, privateLookupIn, findVarHandle, defineHiddenClass or Method.invoke; every bootstrap is LambdaMetafactory .metafactory; one call site per action. No image has been built — there is no GraalVM in this toolchain or in CI — so what is verified is the structural property and not an image that starts. The claim is that the binding layer is no longer the reason an image cannot be attempted, and not that the toolkit produces one; :natives and its FFM downcalls into SDL3, Blend2D and HarfBuzz are a separate and much larger question (ADR-0127)

  • An action is an assignment, and a value is named once. Two things the first cut of ADR-0125 left behind, both of which were the model still doing work on the toolkit’s behalf. The first: every action ended in a changed() that asked the window to repaint — a line with no meaning of its own, never wrong and only ever missing, whose symptom when missing is a value that moved and a window that did not. A @Bind field changing is the frame request now, subscribed to with Models.onChange(model, host::repaint), and fired once per change rather than once per write — so a button that sets a counter already at zero asks for no frame, where the old code asked every time. The showcase’s second callback went with it: onRestyle became two subscriptions to the two paths a stylesheet depends on, which is what made density worth binding even though nothing displays it. The second: nine public Observable<String> tab() accessors, which existed because a widget built in Java had no way into the registry a document already used — so every bound value was named twice and the two could disagree. Models.observable(model, "app.tab") is the same lookup bind="app.tab" does, and the weaver now caches the Bindings it builds so a path lookup while building a widget costs a map get. Actions is deliberately not cached, because applications extend it — the showcase adds the window’s own two — and a shared one would fail the second caller for doing what the first did. ShowcaseModel went from 320 lines to 246 and contains no plumbing at all (ADR-0128, ADR-0129)

  • A widget inflates itself, and the catalog is a list of names. Controls.inflater was 300 lines of inflater.register("button", (node, children) -> new Button(…)), nineteen times, none of it near the widget it built — so a widget’s markup contract lived in a different file from the record and the javadoc describing its attributes, which is two of §9’s three required forms in one place and the third somewhere else. It was also mostly repetition: node.argument().map(v -> v.asString()).orElse("") eight times, the String.valueOf change adapter three. Each widget now has a static Widget inflate(KdlNode, List<Widget>, Wiring) beside its record, Wiring carries the three registries and the readings that were repeated, and Inflatable.Catalog binds one wiring so the table is catalog.add("button", Button::inflate). A class rather than a Map, because the registration order is the order an unknown node is reported against. Primitives uses the same catalog, which makes §9’s “built-ins and application widgets register identically” literally true rather than nearly. Controls went from 443 lines to 204, and adding a widget is a method and one line instead of a fifteen-line lambda in a file about something else (ADR-0130)

  • The weaver is a jar with a main, and every build can call it. It has no dependencies beyond the JDK, so java -jar goldberry-weaver.jar target/classes is a complete integration — verified end to end against a class compiled outside this build. Gradle gets the goldberry.weave plugin, which hangs the weave off classes and testClasses so jar, run and every Test task reach through it and unwoven output cannot be consumed. Maven has no first-class plugin: exec-maven-plugin bound to process-classes runs it as it stands, which is the phase that exists for class post-processing, and the weaving page carries the XML. A real Mojo would be one <plugin> block instead of two <execution>s and is small work whose awkward part is that this repository builds with Gradle and would have to write META-INF/maven/plugin.xml itself — not built, and said plainly rather than implied

  • A widget package announces itself, and a model wires itself. The catalog ADR-0130 left in Controls was still nineteen hand-written lines naming exactly the widgets :widgets happens to ship — which does not survive a second widget module, where an application would merge two registries and keep the merge in step with both. A widget now carries @Markup("button") beside its record, and the build collects every annotated class in the module into a WidgetCatalog, patches provides into the module’s own module-info.class, and writes a META-INF/services entry for the class-path case. ServiceLoader finds them, which is the one discovery mechanism GraalVM already resolves at image build time — a scan would have been the runtime scan ADR-0127 spent the redesign avoiding. The wiring went the same way: Widgets.inflater(icons, model, this) reads the paths and action names off the models rather than being handed registries, and takes more than one because “open the menu” is the window’s action and not the view model’s — Showcase is itself a @Model now, and the Showcase.actions(model, openMenu, toggleHud) static that used to merge them by hand is gone. Controls is 136 lines and has no inflater at all; Primitives.inflater is gone entirely, because the structural widgets carry @Markup like everything else — which makes §9’s “built-ins and application widgets register identically” literally true rather than nearly. The migration found a real footgun: Widgets.inflater(actions, icons, bindings) bound to the varargs model-taking overload, compiled, and failed at run time reading Actions as a model. Eight tests caught it and an exact overload now exists (ADR-0131, ADR-0132)

  • A restyle is declared, and the window repaints itself. ADR-0128 moved the frame request out of every action; what it left behind was two subscriptions saying what one word could — and with exactly the property it was written to remove, in that they are never wrong and only ever missing, and when missing the symptom is a theme that changes and a window that keeps painting the old one. @Bind(value = "app.theme", restyle = true) is the whole declaration now: the weaver emits the call in that field’s setter, before the frame request, so a window has dropped its resolved styles by the time it is asked for the frame that will use them. And the subscription itself moved into the toolkit — Application.models() names the objects, and the launcher wires repaint and restyle after start, so an application says nothing about either. @Model(repaint = false) turns the frame request off for a model the UI does not show. A Property field cannot ask for a restyle and the build refuses one: the weaver rewires no writes to it, so there is nowhere to put the call — the only asymmetry between the two kinds of @Bind field, and the error says what to do instead (ADR-0133)

  • A frame is asked for by the value that moved, and a write is rewritten wherever it is. Two refinements that turned out to be the same shape of mistake. The first: @Model(repaint = false) was the wrong granularity, and obviously so once a real model was written — one model routinely holds both the gain a slider shows and the counter nothing shows, so a switch on the class has to be wrong about one of them. It is @Bind(value = "…", repaint = false) now, per value, decided in the build — a quiet field costs an instruction that is not there rather than a branch that is — and “off” means do not wake the window, not do not observe, which has its own test because the two are easy to conflate and the conflation would be silent. The second: ADR-0125 shipped a known gap where a write to a @Bind field from outside its declaring class was not rewritten, and the failure was silent. The weaver already made two passes, so pass one now records every model’s rewired fields and pass two rewrites writes to them in any class. The synthesised setter went package-private to allow it, and a write from another package is a build error naming both classes rather than an IllegalAccessError at the first click. The bug that found the implementation was mine: composing two transformingMethodBodies with complementary predicates silently drops every rewrite, because the second pass no longer sees the elements the first handed on — every notification test failed at once, which was the good outcome (ADR-0134, ADR-0135)

  • An application is values, actions, views — and there is now a page saying so. Nine records had changed how an application is written, each for a local reason, and none of them said what the result was; the showcase demonstrated the shape and did not explain it, and until now did not follow it either. ShowcaseModel is 125 lines of fields and four projections; ShowcaseActions is a record wrapping it with one method per thing a control can ask for. Each is the only shape that works rather than a preference: a record’s components are final and a bound field has to be assignable, so the values cannot be a record — and the actions hold one thing immutably and have no state, which is the half a record fits exactly. The showcase now has three models — values, actions, and the window itself — which is a useful proof that multi-model wiring is not a special case. The split costs private on the values’ fields, and that is stated rather than glossed: it is available, not required, and a three-field model should not bother. The guide is the deliverable — the four kinds of class, what each may know, widget state versus application state, and a “where does it go?” table — and it is deliberately not enforced by a test, because mechanically enforcing a recommendation turns it into a rule nobody agreed to (ADR-0136)

  • A model keeps its fields, an application is not a model, and the showcase runs again. Three corrections to the previous entry, one of them a real bug. The bug: Showcase.start built its inflater from a hand-written list of models while models() returned a different one, so app.toggle-theme was never registered and the window threw on its first frame — while every test passed, because the test had its own third list that happened to be right. The fix is that there is now one list: start builds the inflater from models(), and both tests take the application’s own objects rather than constructing parallel ones. The comment above that test already warned about exactly this failure and the test did it anyway, which is worth recording. The first correction: fields did not have to open up to the package. Nestmates share private access in both directions, so an Actions record nested inside the values reads a private field with an ordinary getfield — and the weaver now derives each setter’s visibility from whether anything outside the model’s nest writes to it, so a nested-actions model is exactly as encapsulated as one with no actions at all. ShowcaseModel’s fields and its synthesised setters are both private again. The second: @Model sat on top of implements Application, which put two unrelated roles on one class and was the only place the guide’s four kinds did not hold. The window’s two actions are a WindowActions record of Runnables now, so it knows what they are called and nothing about who performs them (ADR-0137, ADR-0138)

  • Actions are annotated as actions. @Model marked two different things: a class of @Bind values, and a class of @Action methods that operates on somebody else’s values and holds nothing at all. ADR-0138 had just made that mislabelling more visible by extracting a WindowActions record whose entire content is actions and whose annotation said “model”. There is an @Actions marker now, with three build-time rules — a @Bind field on one is refused (“a class that holds values is a @Model”), an @Actions with no @Action is refused, and carrying both markers is refused. A @Model may still carry actions, because that is the right shape for a model too small to be worth splitting and taking it away would make the second annotation a tax rather than a clarification. The name was taken, so Bindings and Actions — the two runtime registries — became BindingRegistry and ActionRegistry: a rename made to free a name, which is a bad reason, and an improvement for a better one, since one package held Bind, Bindings, Action and Actions where two were annotations on members and two were registries. Each family reads distinctly now. Two mistakes worth recording: the rename’s first pass ran over markdown as well as Java and produced “GitHub ActionRegistry matrix” in a dozen ADRs — a decision log records what the names were, and was reverted; and the same pass renamed a nested record’s own declaration, which the new exclusivity check then caught on the next build. The remaining wart is documented rather than hidden: a nested type called Actions shadows the annotation, so the showcase writes the fully-qualified name, and the guide recommends naming the type for its domain instead (ADR-0139)

  • select is built, and it is the last control in §3. The value model needed nothing new — it is segmented’s, which is radio-group’s, which §3 says outright — and everything else it needed had arrived in the last month: scroll for a list longer than the screen, host.popup for a panel measured and placed against a rectangle, and a way for a widget to ask for one. That last was the real blocker and the reason this control waited: opening a menu is something an application does, so Menus.open(host, …) is right (ADR-0106); opening a dropdown is something the control does, and there is no application code on a select bind="app.theme" line to hold a window with. BuildContext.host() is the door — Flutter’s Overlay.of(context), which ADR-0100 named as the wanted shape and declined to build against one consumer. There are two now, so it exists, and it is an Optional because a golden image and a widget test build the same widget with no window at all: no window, no popup, and the control draws its closed form rather than throwing (ADR-0140)

  • option moved, and the move cost one flag. §3 gives segmented and select the same child node; TODO had recorded that it would move when there were two callers, and refused to guess what the second one would want — “a model, possibly a tree node, a popup to render in”. Wrong in every part: a row in a dropdown is the same record as a cell in a bar. The whole difference is a stylesheet’s ancestor — segmented option against select-list option — and which of §3’s two keyboards the set has, which the specification states in as many words: a radio-group has “arrow keys move selection (roving focus)” and a select has “arrows, Enter/Esc”. Option.inAList() is that, and it also unlocks Enter, which every other control in the catalog refuses because it belongs to a dialog’s default action — a list is in a popup over everything and has no default action behind it. The flag was found by a failing test: with roving left on, the first Down in an open list chose a row, and choosing closes the list, so the second and third arrows had nothing to move (ADR-0141)

  • Two things fell out that are not about select at all. A press that dismisses a popup no longer also activates what it lands on — the rule the launcher already applied to the secondary button (ADR-0108) turning out to be the general one, and without it a control that opens its own popup cannot be closed by clicking it again. And Popup.focusOn(id) exists, because a control that has already chosen must open on the row it chose: a list that focused its first row would answer Down with the second option whatever the value was. Focused not “from the keyboard”, which matters more here than for a menu — a row focused from the keyboard in a roving set is chosen on the spot, so opening the list would report a change nobody asked for (ADR-0140, ADR-0141)

  • It anchors to itself by rectangle, not by id. SelectField is Located, so it is told where the last frame painted it and the state opens the popup there. Anchoring by id was the alternative and is worse: a select a document gave no id would need a generated one to open itself, and two in one window would then depend on that generation being unique. Six golden images and 49 tests, eight of them driving the real launcher — a click at a coordinate in the owner window, a second window opening, a click at a coordinate in that, and the value coming back through change. The gaps are stated rather than implied: typeahead works closed and not open, because a TextEvent goes to the focused row and there is no text capture phase for the list to take it in; the field is as wide as its current value, because no selector can measure a set of options; and multiple, autocomplete and tree are unbuilt, two of them waiting on text-input and tree rather than on a decision (ADR-0141)

  • Five things reported from the running window, and one of them was the frame itself. The showcase was painting at 10–15 ms with nothing moving, worse when a tour or a menu appeared. The suspects were all innocent — the popup’s second window, the veil, damage tracking — and the measurement said something much duller and much worse: one render of a settled screen cost 10 069 µs for 77 elements, and 56 of 72 styled elements missed the style cache on every frame. ADR-0070’s cache is keyed on the style a node’s parent handed down, by identity, which is what makes inheritance invalidate itself. But the style a parent hands down is not the one it caches: restyle runs after the cache, by design, and every widget that writes an inline value allocates a fresh ComputedStyle every frame whether or not anything moved — ScrollContent’s is resolved.flexShrink(0), unconditionally. So every node under a scroll re-resolved every frame, and in the showcase every screen is inside a scroll. A node now hands its children the same instance for as long as the value is equal, which is a flat record comparison against a re-resolve costing two orders of magnitude more: Controls 10 069 → 294 µs, Values 8 125 → 126, Text 2 607 → 50, Overlays 2 923 → 17, Tabs 5 036 → 22. The test asserts the mechanism rather than a duration — a widget whose restyle allocates, and its child handed the same object twice — because a timing test passes on a fast machine with the bug still in it (ADR-0142)

  • A menu outlived the window that owned it. Click on another application with the menu open and it stayed, on top, over the window you switched to. Light dismissal covered a press inside the owner and Escape, and neither of those happens when the user clicks somewhere else entirely — because there was no focus event at all, in the SPI or in the SDL translation. There is now, per window, which is what every platform reports; “the application lost focus” is a conclusion drawn from the whole set and the launcher is the only thing that needs to draw it. The check is deferred by 60 ms, and that is the mechanism rather than a fudge: opening a popup is a focus-lost for the window under it, followed by a focus-gained for the popup, so a menu acting on the first would close as it opened. Both outcomes have a test, and the second is the one that would have caught the naive version (ADR-0144)

  • A dropdown is as wide as what it drops from. A select stretched across a form opened a list as wide as the word “Dark”. No measurement of the content can fix that — it is a fact about the anchor — so host.popup takes a floor under the width, applied inside the two-pass measurement it already did. A floor and not a width: an option longer than the field still widens the list past it. Opt-in per call rather than a property of Placement, because it is false for the other two callers — a menu is as wide as its commands and a tooltip as wide as its text (ADR-0145)

  • A tab strip took its height from the tallest thing in it, and a menu icon from the corner of its box. Two drawing defects with one shape. Closing the last tab left the + — 24 square by design — as the tallest thing in the header row, so the strip shrank to it and the button rode high; the same rule was quietly wrong with tabs in it, measuring 30 where its tabs are 32, which is why the gallery’s controls images moved by two pixels. A header row is one control tall by definition and now says so. And an icon was drawn at its box’s origin, which is a no-op where the box is the icon and four pixels of misalignment where a stylesheet sized the box instead — item-lead is 16 square, the showcase builds its palette at 20, and the row with the icon read as the odd one out. Centred now, which changes nothing in the common case and is what a slot means in the other. Both are pinned by pictures, because both are facts about where something is drawn (ADR-0143)

  • The HUD says where the frame went, and the build checks it stays there. ADR-0142’s 34× regression lived here for a month with every test passing and a HUD on screen reading paint 12.4 ms — true, and useless: a total says a frame is slow and nothing about which part of it is. Finding the answer took a purpose-built probe and a counter compiled into the renderer. So hud grew four readings — build, style, layout, raster, one word for the set of them (readings="stages") — timed by five nanoTime calls in the painter and kept in the same 60-frame ring as the rate. They deliberately do not add up to paint: the hit-test capture and the frame’s setup are in the total and in none of the stages, and making them add up would mean a fifth reading nobody can act on. Two decimals for a stage against one for a total, because 0.0 ms cannot be told from a stage that is not running, and three ranks in the stylesheet because six equally bright numbers on one plate read as a wall. The showcase turns it on (ADR-0146)

  • And a frame now has a budget. FrameBudgetTest measures the showcase’s own tree at five resolutions from 800×600 to 4K, prints the table, and fails the build when a stage is over its ceiling — style measures 0.03–0.08 ms and is allowed 1 ms, which is useless against a 20% drift and exactly right against what happens: with ADR-0142’s defect put back it reports 10.1 ms and names the stage and the resolution. Ceilings rather than stored comparisons, because a test comparing against a recorded number fails on a slower machine and passes on a faster one that regressed. Two claims hold on any machine and are the sharper half: style and build do not grow with the pixel count, because the cascade runs per element and a 4K window has the same elements as a small one; and a settled render is two orders of magnitude cheaper than a cold one — 450–520× with the cache working and 11× without it. Zero Blend2D workers, because a threaded context queues its work and a loop around paint measures submitting a frame, which is how the first run reported a 4K raster as cheaper than an 800×600 one. FrameBenchmark stays: it measures the engine’s parts against each other, which is a different question from “is a real frame still fast” (ADR-0147)

  • A menu row wraps, and a wrapped label sits at the top of its row. Reported as “the item after the iconed one is vertically aligned to top”, and four rounds of measuring the rows could not reproduce it: every row is 32 tall and every label 8 from the top, at both densities and four display scales. The rows were never wrong. A menu row is a row of measured leaves, nothing stops those boxes shrinking, and a row squeezed narrower than its content does not clip its label — it wraps it. Two lines measure 32 in a 32-tall row, so align-items: center puts them at the top edge. The widest row wraps first, and the widest row is rarely the one with the icon — “Switch density Ctrl+D” is longer than “Switch theme Ctrl+T” — which is why it presented as something the icon had done. The label and the accelerator no longer shrink; the cost is option’s, taken for the same reason, that a label with no room overflows because nothing in this toolkit clips. The test asserts where the paragraph was painted rather than where the row was, and squeezes the menu to 160 logical pixels — the general sweep passes with the defect in place, which is what made it hard to find (ADR-0148)

  • A click on empty space re-resolved the whole tree. Reported as “clicking empty space keeps adding a lot of ms”, and the HUD from ADR-0146 is what made it findable: measured through the real launcher, 74 of 78 elements re-resolved per click and style sat at 12 ms a frame. :hover and :active apply to the whole ancestor chain — .card:hover .title has to work — so a click marks every node up to the root, and each of those threw away its whole subtree’s styles on the chance that a descendant combinator read the state. For a node near the root that subtree is the window. ADR-0070 said plainly that this was conservative on purpose; what nobody had was the number. StyleResolver now indexes, once, which pseudo-classes appear to the left of a combinator and on what type, so checkbox:hover check-indicator makes :hover on a checkbox reach down and nothing makes :hover on a column do so. A node with no CSS type reaches nothing, and getting that wrong is what made the first attempt change the measurement not at all: the hover chain is full of composition nodes, and treating them as “unknown, be conservative” is the same as not narrowing. 74 re-resolves per click → 3, style 12.4 → 2.5 ms, and what is left is transitions genuinely running rather than the cascade (ADR-0149)

  • The HUD says what its numbers are, and colours the ones in trouble. Three things were wrong with the breakdown as it shipped, all of them a correct number nobody could read: it never said the readings are means over sixty frames, so paint 2.1 ms reads as “this frame” and a spike looks like a plateau; it showed one total where there are two, so a slow frame could not be attributed to the toolkit or to the platform; and seven numbers in a row is a wall to scan. It is a column now, one reading a line, with frame beside paint and a caption under both. Every reading carries a budget — shares of a 60 Hz frame — and reports ok, near or over as a class the stylesheet colours, because §10 says a colour is a token and a widget that picked its own red could not be themed. Two readings are judged the other way round and both would otherwise cry wolf: the rate is a floor, and the frame interval is a target to sit at, since a vsynced loop measuring exactly 16.7 is success and a healthy window reading amber teaches a reader to ignore the colour. That needed one new hook, Styled.classes(FrameStats): the cascade reads a node’s classes before its render runs and the statistics only arrive in render, so a value cannot hold the answer in between (ADR-0150)

  • The budget table gained both 2Ks. DCI’s 2048×1080 and the monitor aisle’s 2560×1440 are 25% apart, and a table that picked one would be answering somebody else’s question (ADR-0147)

  • A frame can say what it did, and the first thing it said was that the cascade is slow per element. Twice now the answer to “why is this frame expensive” has been a count rather than a duration, and twice it meant compiling a counter into the renderer and taking it out again. -Dgoldberry.trace.frames=true is that counter kept: one line per frame that did something, with the four stages broken down further into cascade, identity, motion and boxes, plus how many elements were built, resolved and invalidated, what asked for a subtree walk, and how many paragraphs had to be shaped. Free when off — one static final boolean the JIT folds away — and a system property rather than a log level, because an isTraceEnabled() per element per frame is a diagnostic measuring itself. -Dgoldberry.trace.input=true is the other half: what asked for the frame (ADR-0151)

  • It found cascade 5.133 ms for three elements. With ADR-0149’s narrowing in place a click re-resolved one or two nodes and style was still 1.5 ms, because one resolve cost 1.7 ms. Two reasons, both structural. Every rule in every sheet was matched against every element — four sheets, some two thousand rules, asked about for a text node as readily as for a button. And custom properties are collected by walking to the root, running a full cascade at every level, so one node at depth ten was eleven cascades — twelve, because resolve asked for the properties and the declarations separately and both cascade the element. ADR-0070 cached the result of this term and never made the term cheaper, so every miss paid in full. Rules are now bucketed by the type their rightmost compound names, custom properties are cached per element on the same identity scheme the computed style uses, and resolve cascades once: one resolve 1.7 ms → 0.13 ms, a click frame’s cascade 0.48 → 0.26 ms, and a cold render of a whole screen — what a tab switch pays — 112 ms → 52 ms (ADR-0152)

  • And the HUD says it is in its own numbers. Three paragraphs a frame are re-shaped in the showcase and they are the HUD’s own readings: a string that changes every frame cannot be held by a cache keyed on the string. It cannot be taken out of the measurement without lying about the frame the window actually painted, so the caption reads this hud included — ADR-0101’s rule kept by being honest rather than by pretending (ADR-0152)

  • The frame interval is gone, and the display’s refresh rate is in its place. frame was counted between frames, and §1.7 makes the loop idle when nothing asks for one — so it measured how long the user had not touched the window. It collapsed the moment they stopped clicking and stayed low for the next sixty frames, because the ring is sixty long, and ADR-0150 had given it a colour: a window sitting still read red, permanently, which is how the showcase came to look broken at rest. SDL has no frame rate to offer instead — SDL_GetCurrentDisplayMode reports what the display does, and what a loop achieved is not a thing any platform knows. So refresh is asked for rather than counted: SdlVideo.refreshRate was already bound for the pacer and is now on the backend SPI, 0 meaning “the platform will not say”. Every budget is a share of one display frame rather than of a hard-coded 16.7 ms — paint a half, raster a quarter, style and layout an eighth, build a sixteenth — so a 120 Hz window judges its paint against 4.2 ms, which closes the gap ADR-0150 left in TODO. fps stays and is never coloured: it is worth watching while something is moving and it is not a thing the toolkit is answerable for. One more lesson recorded: a reading’s name is a CSS class, the first draft called this one display, and .display is §1.4’s largest type rank — so it rendered at 28px in the golden (ADR-0153)

  • A reading is a range. Every number on the hud was the mean over the ring’s sixty frames — the right thing for a budget to judge and the wrong thing to read a frame by, because it hides the shape of the cost and the shape is usually the question. Two windows both averaging 2 ms of paint are different animals if one never leaves 1.9–2.1 and the other ranges 0.2–14, and nothing on the plate could say which. Each duration is min / mean / max now, with the mean in the middle where the eye lands and where the budget is still judged — colouring by the max would paint every window red for one slow frame in sixty. FrameStats grew a Span, defaulting to a flat one built from the mean, so a source that keeps no window reports its one number three times rather than inventing a spread. The caption says the unit and the shape rather than the arithmetic — ms/frame · min / mean / max · last 60 — and this hud included is gone: true, true of every such diagnostic, and recorded in ADR-0152 where a reader who wants it can find it. The level and the text now read the same span, which is not tidying: the first draft judged styleMillis() while the row printed style(), and the over-budget golden came out with style 4.80 / 9.60 / 38.40 ms drawn as though it were fine (ADR-0154)

M3 — Shell

Started. docs/core-widgets.md §7 names two places an overlay can be drawn — “the in-window overlay layer or backend popup windows as appropriate” — and both now exist, with one widget on the first and the showcase opening one of the second.

The in-window overlay layer

  • The in-window overlay layer ships, and hud is its first occupant. Every window’s element tree is rooted at a window-root whose children are the application’s root — in flow, growing to fill the window — and whatever is floating over it, each an absolute box pinned to a Corner with two of its four insets undefined rather than zero, which is the difference between a plate in a corner and a scrim across the window. Three things follow from that one shape and each is the point: an overlay takes no space from the content, it is painted after it because a box tree has no z-order beyond document order, and adding one cannot re-parent the application — the root node is there from the first frame whether or not anything is floating, because a layer that appeared with the first toast would throw away every element’s state to show it. The list is a Property the launcher owns and the root watches through the binding() every widget already has (§9’s bind, pointed at the toolkit’s own state), since an element tree’s root widget cannot be swapped. host.overlay(new Hud(), Corner.BOTTOM_END) is the whole API and the handle it returns is the way out (ADR-0100).

hud, the first widget on it

  • hud is §7’s first widget and the first in the catalog that is about the toolkit rather than the application: 60 fps and paint 2.1 ms, read off a 60-frame ring Window.paint now writes unconditionally — two nanoTime calls a frame, where before every timing was behind LOG.isTraceEnabled() and watching a rate meant measuring a loop that was also writing a line per frame. The numbers travel down Paints.Context beside the frame clock, which is what lets a bare hud node in a document show live figures and lets a golden image show figures somebody chose.
  • It never asks for a frame: a rate display that requested one would report the frames it had itself caused, and would falsify §1.7’s “the frame loop is fully idle when no animation is active” for every window with one in the corner — so it reports the frames that were already happening, freezes with an idle loop, and draws dashes rather than zeroes when there is no loop at all, because a zero is a measurement (ADR-0101). The showcase toggles one from a HUD button or Ctrl+F, off by default, which is also what keeps a machine-dependent number out of §14’s image corpus.
  • The backend popup window is built — the other half of §7’s “in the in-window overlay layer or backend popup windows as appropriate”. Backend.createPopup(owner, spec) opens a real platform window parented to another and positioned in its coordinates, which is the one thing an in-window overlay cannot do and exactly what a dropdown taller than the space below its button needs. A popup is a window — it acquires a frame, presents, paces and closes by the same code, and its events arrive through the same pump under their own id — so Sdl3Window became sealed … permits Sdl3Popup rather than growing a boolean, and popups are in windows() because shutdown enumerates windows.
  • It returns an Optional and empty is a normal answer: popup support belongs to the video driver, not to the request. All four desktop drivers declare it — cocoa included, which was worth checking — and SDL’s dummy, which every headless test here runs under, does not; so the refusal is a branch CI runs on every platform and the fallback is the in-window layer, clipped to the window. Two things the tests found rather than assumed. SDL_WINDOW_TOOLTIP alone does not stop a popup taking focus — NOT_FOCUSABLE is a separate flag, and §7’s “shows on keyboard focus, never focusable itself” is false without it; and 0x80000000 turned out to be the first constant in the toolkit with the top bit set, which the layout probe read into a signed int and refused, so a constant row’s value is now read unsigned (a size that is negative still means the table is being read wrongly). And a resize is a request: on X11 the window manager grants it when it likes, size() honestly reports the old one until then, and HeadlessPopup defers its resize the same way so that the fake is not the one place a caller who measures too early passes (ADR-0102).

A widget tree in a popup

  • A Popup is an element tree, a render tree and a pointer router of its own, in a window of its own — wrapped in the same Window the launcher uses, so the existing frame loop paints it, the existing dispatch delivers its events, and its pointer goes through its own router. What is shared is the renderer: the stylesheets, the font book and the frame clock, read live rather than captured, so a popup is themed by the cascade of the window that opened it, restyles with it and animates on the same tick. What is not shared is the tree — a popup’s contents are a root, not a descendant, so nothing inherits into them and no descendant selector reaches them (ADR-0103).
  • Light dismissal needed input the router will not deliver. §7 gives a popover “light-dismiss on outside click/Esc”, and neither reaches a widget: an outside click usually lands on nothing, and Escape belongs to no control in particular. Window grew one package-private InputWatcher, called before routing; the launcher watches the owner window and the popup watches its own for Escape, because once a menu has focus the key goes to it. A press inside is deliberately not watched — that is someone choosing an item.
  • A menu is anchored to a rectangle from the last frame. Host.anchor(id) answers from the same HitTest capture the router is fed, because where a button is is a fact about the frame that was painted (ADR-0080). By id rather than by element because that is how §7’s tour asks for it and because an application holds ids; a popover anchoring to itself will want the element form.
  • Closing a window closes its popups, and that is not tidiness. The event loop runs until windows() is empty and SDL destroys a window’s popups with it — leaving this side holding dangling handles and entries in the window map, which is a process that never exits. Both backends close a window’s popups first, headless included, because that is where the bug would otherwise pass.
  • The showcase demonstrates both, one button each: Menu opens a real platform popup under its own button and is free of the window’s bounds, and HUD floats a hud in the window’s own layer, clipped to it and needing nothing from the platform.

popover, and the three things it is made of

  • A popup measures its own content, and the second pass is the interesting one. RenderTree.measure lays a tree out with no surface — two floats rather than a LogicalSize, because “undefined” is what has to be expressible and a size refuses NaN. The trap is that Yoga lays a root out at exactly the available size when that size is definite: there is no parent for it to be “at most” of, so a bound and a target are the same number, and measuring a menu against its window returns the window. That happened twice, once per axis — 960×640, then 960×108 — and both times it looked like a placement bug. So the measurement is nothing definite, then a second pass with the width pinned only if the natural width overflows, where a definite width is now what is wanted and a paragraph wraps at it. The same trap caught the widget: Popover.render grew to fill its window, and a growing root fills a definite available size (ADR-0104).
  • Placement is three rules and no state: preferred side, flip only when it does not fit and the opposite side does — not when the other side merely has more room, which would be a menu nobody can predict — and then shift along the cross axis, which keeps the popup attached to its anchor’s side while sliding it along. Too big for the screen either way and it clamps to the near edge, so the top of a long menu survives. It opens no window and reads no display: an anchor rectangle, a size and the rectangle to stay inside go in, a point comes out, and every case of it is a test rather than a screenshot.
  • The rectangle it must stay inside is the display’s work area, not its bounds. SDL_GetDisplayUsableBounds excludes whatever the desktop reserved, and the difference between the two rectangles is exactly the taskbar a menu would otherwise open underneath. BackendWindow gained workArea() and position(), both Optional because some drivers will not say; the launcher translates the first by the second so placement works entirely in the window’s own coordinates. HeadlessBackend has a pretend desktop of 1920×1040 — 40 pixels reserved, so a test that confuses the work area with the display’s size fails — and its windows can be moved about on it, because a placement policy is only interesting near an edge.
  • The keyboard belongs to the open popup. Its router focuses the first item after the first frame, and keys the owner window receives are forwarded to the topmost popup before the owner’s own router sees them — Window.InputWatcher.keyPressed returns a boolean now, and true takes the key. Not belt-and-braces: whether a popup has the platform’s keyboard focus is per-driver, so without forwarding an arrow would move the selection in the window underneath the menu on half the platforms.
  • popover is the panel and not the opening. §7’s floating surface — background, border, radius, padding — as a widget, so it is themeable and writable from a document; where it goes and when it goes away is Host.popup, which serves tooltip, select and menu equally and is not a popover. The showcase’s Menu button opens one with host.popup(content, "menu-button", Placement.BELOW), and on X11 it comes out 125×108 — its own content’s size — directly under the button that opened it.

tooltip, and the timer under it

  • The event loop grew a timer. EventLoop.after(delay, action) runs something on the UI thread later and shortens the next pump so the loop wakes for it — the loop’s, because the loop is the thing that is asleep and a delay implemented by sleeping elsewhere fires on time and then waits up to a second for the pump to notice. Two consumers are named in the specification (a tooltip’s delay, a submenu’s hover intent) and a toast’s timeout is the third.
  • A tooltip is an attribute, not a widget. §7 attaches one to any widget, so the text rides on Attributes beside id, class and the key — the three things every widget carries and none decides. The router says when the hovered or focused node moved and opens nothing; the launcher owns the window and does the rest. The target is found by walking up from the hovered element, because a tooltip on a button has to survive the pointer being over the button’s label, which is a different element and the one a hit test reports (ADR-0105).
  • Adding a component to Attributes broke every wither, silently. id(), classes() and key() each rebuilt the record and dropped the new field, so .tooltip("Save").id("save") lost its tooltip — no error, nothing in a log, and a test already written that failed for what looked like a timing reason.
  • It is never light-dismissed. A press would close it in the same gesture as the click on the thing it describes, taking the next tooltip’s timer with it. It also cannot end up under the pointer, by construction: it is placed outside the anchor’s rectangle and the pointer is inside it, above or flipped below.
  • And it closes when the pointer leaves a button that was clicked, which is the same sentence’s other half and was a separate defect (ADR-0308). §7 shows a tooltip “on hover and on keyboard focus”, and the fallback asked only whether anything was focused — but a click focuses things, so after one the target was still the button, pointingChanged found it unchanged and returned early, and the tooltip sat there until something else took the focus. The fallback now asks PointerRouter.focusedFromKeyboard(), which is the distinction the router already kept for :focus-visible (ADR-0054): a tooltip follows the focus ring. The first draft of the regression test passed against the unfixed launcher, because its target was not focusable and the click therefore focused nothing — which is the whole mechanism, missed by the test written to catch it.
  • And it closes when its anchor goes away, which it did not for a long time. Launcher.pointingChanged is the only caller of hideTooltip, and the only thing that reaches it is the router noticing the hovered node moved — so a click that rebuilt the tree unmounted the anchor and told nobody, and the tooltip hung over replaced content until the user moved the mouse. The router now enforces for hovered the rule ADR-0180 already enforced for focused — it never holds an element that is not in the tree — and re-resolves what the pointer is over against the frame just painted (ADR-0303).
  • A menu is a widget; opening one is a call. Menus.open(host, anchor, menu) measures the panel, places it, opens a platform window, wraps every command in “and close the stack” and hands each row with children a way to open its own submenu. It is not a method on the widget because opening needs a Host, and a widget holding the window it is drawn in would be describing its own surroundings — so the opener rebuilds the tree and supplies what only it knows, which is what radio-group does to its radio children (ADR-0106, ADR-0073).
  • A nested item is the submenu syntax, so there is no submenu node to forget and no way to write one that is not a submenu. Submenus open beside their row — Placement.AFTER, flipping near the screen edge — after 150ms of hover intent, and close their siblings as they open, so travelling down a menu past three rows with submenus leaves one open rather than three.
  • A submenu is anchored inside a popup, which needed a second anchor. Host.anchor answers from the main window’s geometry and knows nothing about what is in a popup, so Popup gained one that translates by its own offset — without which a submenu opens at the right place relative to the wrong origin.
  • A menu is a vertical focus scope. Up and Down move between rows; Left and Right are deliberately not traversal, because in a menu they mean “close this submenu” and “open that one”. Escape was already the popup’s.
  • The tick column is always built, checked or not, so a menu’s labels line up the moment one row becomes checkable rather than shifting sideways.
  • The accelerator is displayed and not registered. §8 asks for both; a shortcut has to work while the menu is shut, and a menu is built when it opens and thrown away when it closes. Registration needs something that owns menus for longer than one opening — which is what menubar needs too, and why neither is here.
  • Driven by four tests that post real clicks into the popup’s own window through the real launcher and frame loop: a command runs and closes the stack, a hover opens a submenu beside its row, a command inside the submenu closes both, and a disabled row does neither.

Context menus

  • The two halves of one feature sit on opposite sides of the module boundary, and the seam is one sentence wide. Only :core can notice the right-click — it has the router, which knows what is under the pointer, and the window, which is where a popup goes — and only the catalog can turn a name into a menu, because opening one means wrapping every item so that choosing it closes the stack. So Host.onContextMenu hands over the name and the point, and Menus.contextMenus(host, map) is the line an application writes (ADR-0108).
  • The name rides on Attributes, beside id, class, the key and the tooltip — which is what “any widget” has to mean, including one in an application’s own module.
  • The press is taken. InputWatcher.pressed returns a boolean now and learned which button and where, so a right-click that opens a menu does not also reach what it landed on: right-clicking a button opens its menu rather than pressing it.
  • Anchored to the pointer, not to the widget, so two right-clicks in one list open two menus in two places. An unregistered name is logged rather than thrown, because a press= typo is found when the document loads and this one is found on a right-click, where throwing takes the window down.

tabs, the first widget of §5

  • Adding and removing needed no API. The strip reads its selection through bind and reports three things — change, close, new — and the application answers all of them, which is radio-group’s shape extended to a set whose membership changes. A strip whose close handler does nothing keeps its tab, which is the visible form of “the model did not change”. There is no addTab, no internal list, and so no second copy of the thing the tabs are of (ADR-0107, ADR-0063).
  • A tab takes a colour, which is the first value of its kind in the catalog. colour="#bf616a" is application data — a tab coloured after its project — and a stylesheet cannot know it, because there is no selector for “the tab whose project is red”. Written through restyle, so the stylesheet still decides what the colour means: controls.css puts it on the label and on the underline, and a tab given none is styled entirely by the theme. The syntax is CSS’s, via a new CssColor.parse(String).
  • Content is lazy by omission: only the selected tab’s widgets are built into elements at all, so nine background tabs cost nine headers. The cost is stated where somebody will read it — a tab’s content is rebuilt when it is selected again, so a scroll position or a half-typed form belongs in the model.
  • The underline is a box, and the golden image is what said so. The first version wrote border-bottom and currentColor; §8’s subset has one border covering all four edges and no currentColor, so both declarations were silently dropped — every number in the layout correct and the underline simply not there. It is a 2px box pinned across the header now, and the rule under the row is another across the list, which is segmented-indicator’s anatomy for the same reason. Fifth time a golden image has caught something no assertion did.
  • One Tab stop, and the × is not in it. A focusable close affordance would make nine tabs nineteen stops between the strip and the content; Delete on the tab is the keyboard’s answer. The + is focusable, because adding a tab is a destination the roving selection should reach.
  • Two new marks — CROSS and PLUS — rather than two icons: at eight to ten logical pixels inside another control, an icon’s metrics and lookup buy nothing.
  • The showcase’s strip is dynamic, which is the third reason that pane is in Java: KDL can write three tabs, not “however many the model has”.
  • And then three things were wrong with it, each a different lesson (ADR-0109). A tab added or closed did not appear until the window was resized — not a tab bug: the showcase held its tabs in a plain List, and a plain list is not something a widget can subscribe to. Everything else in that window is a value reaching a bound widget and needs no rebuild; this is the first thing in it that changes the shape of the tree, so it is a Property now and the pane that builds the strip watches it, exactly as it already watched the two other structural changes. The resize was a red herring twice: it made the tabs appear because it rebuilt the tree for another reason, and it made the bug look like a layout problem.
  • The + was 28 wide and 20 tall, and a mark is drawn to fill its box — so the cross had a long arm and a short one. One number now. The margin that would have spaced it from the last tab is not in §8’s subset either, which is the third property this widget has reached for and not found.
  • Tabs arrive and depart on the frame clock, which is the toolkit’s first enter/exit animation and could not be a transition: an arriving tab has no two styles to move between, because its element did not exist last frame; and a departing one has already been dropped from the application’s list, so without something holding on there is nothing left to animate. So Tabs has state — which tabs are arriving, which are leaving, and what the leaving ones last looked like — and each tab is handed are you animating and how visible are you now as functions, because Tab is public and its phase is not a type anyone outside the module can name. Opacity and a 6px translation only: a tab that animated its own width would run Yoga every frame and reflow the row beside it.
  • Making Tabs stateful put two tabs nodes in the cascade, one inside the other, so every rule applied twice. The model node is a composition node now and the node it builds carries the appearance, the CSS type, the attributes and the focus scope.
  • A window is a title bar and five screens, one per tab: Controls (§3’s controls whose value is a state), Values (§3’s controls whose value is a number), Text (§2’s paragraph and the buttons that act on the model), Overlays (§7’s two places something can float), and Tabs (§5’s strip gaining and losing tabs). core-widgets.md asks for exactly this — “a gallery app exercises every widget in every state in both themes… a widget isn’t done until it’s in the gallery” — and what existed was a sidebar: one document holding every control there was, which worked at four and was failing at eleven (ADR-0110).
  • A screen is a file, so adding one is a file and a line, and the gallery knows nothing about what is on any of them.
  • Three screens are documents and two are Java, and which is which is the point. Not appearance — what §8’s markup cannot say: Text is Java because Undo and Reset are disabled when the click count is zero and markup has no expressions, and Tabs is Java because its list changes while the window is open and KDL is data. Everything else is bind= and change=.
  • The gallery’s own selection is an ordinary bound value, so Ctrl+1…Ctrl+5 and the strip are two ways to set one property rather than two copies of a selection. Its strip is deliberately fixed — a gallery whose chrome could be closed is a gallery you can break — and the Tabs screen is where a closable, addable strip is demonstrated instead.
  • Only the selected screen exists, which is §5’s lazy content doing the work: four of the five are not in the element tree at all, so a five-screen window is as cheap as the one-pane one it replaced.
  • Six golden images of the example, which is the first visual coverage the showcase has ever had — before this, a screen that rendered blank passed every test it had. Two things had to be fixed for them to mean anything: :example’s test JVM did not know where the native library was, so every one of them skipped — a green build that checked nothing — and the Values screen has a spinner on it, whose rotation is a function of the frame clock, so against the system clock the image failed by 113 pixels and a channel delta of 144. The renderer takes a virtual clock now.

Five faults in one window, and none of them was the same bug

Reported against the gallery, and worth listing because the interesting one had been there since text was first painted (ADR-0111).

  • A text box was painted outside its padding. BoxPainter drew a paragraph at the box’s own origin and wrapped it at the box’s full width — but Yoga sizes a measured leaf as its content plus its padding, so every one of those pixels ended up on the right and the bottom with the text hanging off the top-left corner. Magnified 3×, a tooltip’s glyphs were visibly outside their own plate. Nothing had hit it because every other widget in the catalog puts text in a child box — button, option, badge — for Option’s reason: Yoga never lays out a measured node’s children, so a control holding its own text could not also hold an icon. Every other golden image is byte-identical after the fix, which is the evidence it touched exactly the case that was broken.
  • A popup’s rounded corners were black. What is outside the radius is nothing, and nothing was being presented as an opaque buffer nobody had cleared. Popups are SDL_WINDOW_TRANSPARENT now and their frame is cleared to transparent — the surface format was checked rather than assumed, and X11 hands back ARGB8888 for these windows, so the alpha survives the blit.
  • The cursor changed the moment a tooltip appeared, and it was not the router: headlessly the same sequence keeps both the hover and the pointer cursor, which is what pinned it on X11 — mapping a window near the pointer makes the server report a leave for the window underneath. The launcher swallows an exit that arrives within 250ms of opening a tooltip, bounded rather than flagged so that a driver which sends no spurious exit does not have the user’s real one swallowed instead.
  • Warning spam — ease-out is not one of §1.7’s easings (they are ease-enter and ease-exit) and background is not transitionable (background-color is). Two rules got both wrong and the engine said so once per node per frame.
  • align-self is not in §8’s subset, so the + in a tab strip sat at the top of its row. The row centres its children instead; adding the property would be a 20-component record change in two records for one +, and is recorded rather than done.

What the pointer being somewhere means

Two faults in one menu, and they are opposite halves of one question (ADR-0112).

  • A submenu did not close when the pointer left the row that opened it, because ADR-0106 handed onOpenSubmenu only to rows that had one — the wrong half of the relationship. A submenu is closed by the pointer moving to a sibling, and most siblings have no submenu of their own. Every row is handed onHovered now and the menu decides what arriving means: open one, or put away what the row above opened. Both go through the one intent timer, because they are one gesture. The rename is the point — onOpenSubmenu described what the caller wanted, onHovered describes what the item knows.
  • The first row always looked hovered. A menu focuses its first row as it opens so an arrow key has somewhere to start, and it did so through a call that reports the move as the keyboard’s — so item:focus lit it. Focus and the highlight are two things: moveFocus takes a fromKeyboard flag now, and the highlight is item:focus-visible, which is what §2.2 defined that pseudo-class to mean. Open a menu with the mouse and nothing is picked out; press Down and the row it lands on lights up.
  • A tooltip takes body rather than caption, with 8px and 12px of padding. §1.4 gives caption to secondary text under a control, where the reader has the control for context; a tooltip is the only text on screen at the moment it is read.

A menu’s geometry, twice made per row and belonging to the menu

  • A submenu opened on top of the border of the menu it came from. ADR-0106 anchored it to its item, which is right for one axis and wrong for the other: an item’s right edge is a few pixels inside the menu’s — the panel’s padding and its border — so the submenu’s left edge landed inside the parent’s frame. The anchor is two rectangles now, x from the popup and y from the row, with a 2px gap: far enough that the two panels do not share an edge, near enough that a pointer crossing it does not leave both menus (ADR-0113).
  • Every row in every menu was indented by a tick column, whether or not anything in that menu could be ticked. The rule — a column that appears with the first tick shifts every label sideways — is right within a menu and had been applied to all of them. A menu reserves a column when anything in it is checkable, and then every row has one; which needs checked to have three states, because “unchecked” and “not a checkbox” had been the same value. Boolean: on, off, and not a checkbox at all.
  • And that was still not the whole of it. A row’s icon was drawn after the tick column rather than in it, so a row with an icon sat further in than the rows above it — which the showcase’s own menu shows, having both an icon row and a checkable one. There is one leading part now, item-lead, with one width and three possible contents: a tick, an icon, or nothing. No menu anywhere shows a tick and an icon on the same row, because the tick is the row’s state and the icon is its identity.
  • And a row that leads somewhere now says so. A submenu row was drawn exactly like a command; the chevron is a painter mark beside CROSS and PLUS rather than Lucide’s chevron-right, because an icon owns native memory that must be closed exactly once and a menu is built and thrown away every time it opens.
  • The menu has golden images, five of them, where it had none — a menu is drawn in a window of its own and appears in no other picture in the corpus. Both faults were visible the moment there was one.

The wheel, settled

docs/design-system.md §2.4 has asked for “pixel-precise wheel/trackpad deltas with line fallback” since it was written, and docs/ARCHITECTURE.md §17.1 has carried it as an open disagreement for as long: SDL exposes no pixel axis, and going around it to Wayland and macOS is what ADR-0056 declined. scroll is the first widget that has to care, so the question came due, and reading the header again is what answered it — SDL_MouseWheelEvent carries two numbers per axis and the toolkit was reading one of them.

x/y are fractional, which is where the smoothness is. integer_x/integer_y are SDL keeping the running fraction itself and emitting a whole click when it crosses one. Neither is derivable from the other, and the difference is not academic: a trackpad dragged slowly reports a long run of values that each truncate to zero, so a control that truncates per event never moves at all, however far the user scrolls. The integer_* pair is the fix for exactly that, and it has been declared in the layout probe and unread since the bindings landed.

So PointerWheel and PointerEvent now carry ticksX/ticksY beside the deltas — passed through from SDL rather than derived, negated on the same axis and for the same reason. A distance reads the float; a step reads the int. Every path with no accumulator of its own truncates, so the pair is always populated and never a lie. What a line is worth in pixels is the scrolling widget’s, not the event’s — and not a token, because nothing lets a widget read one.

Knob was not changed. §3 calls its wheel a rate and ADR-0089 built it as one, so it is not a detent consumer; the reader of detents is select’s, when it lands. The two tests worth having are the two that could not be written before: a detent whose sign disagreed with the fraction beside it would send a stepping control one way and a scroll view the other, and a detent arriving under a 0.125 fraction is the case no function of one event’s floats can produce.

And then a notch turned out to be one line rather than three. Two reports, a sentence apart — “scrolling is slower than the system one” and “two scrolls on the page work opposite to the wheel” — and both are the same mistake made twice: a wheel event counts detents, a viewport moves in lines, and the conversion was written in prose and never in code. ScrollViewport.LINE has said “three of these is the conventional notch” since it shipped and the handler multiplied by one, so the toolkit moved 20px where the desktop moves 60; its own test agreed with it, because the test asserted LINE and the code multiplied by LINE, and two copies of one misreading are not two witnesses. LINES_PER_NOTCH is the number now, on the wheel and not on the keys — an arrow still means a line, which is what makes --gb-scroll-line a token about lines rather than about detents.

The second report is text-area, the one scrollable thing in the toolkit that is not a scroll: the text in it is a paragraph rather than a subtree, so it holds its own offset and takes the wheel itself. It negated the delta and passed it as pixels, so one notch moved the document one pixel up — and beside the scroll in the showcase’s Markdown screen, that is an editor and a preview scrolling opposite ways at wildly different speeds. AreaEditor.scrollBy(dy) is scrollByLines(lines) now, because the unit belongs to the event and the conversion belongs to the side that knows how tall a line of this control’s text is. Four lines above the bug sat WHEEL_LINES = 3, declared and never referenced. TextAreaTest had no wheel test at all, which is how a control that scrolled backwards a pixel at a time survived; it has four. — ADR-0314

The icon sheet, virtualized

  • A grid is a list of rows, and a list already virtualizes. [ADR-0309] traded the sheet’s virtualized list for a masonry because a masonry reflows and the list chunked names into rows of a fixed seven — and priced the trade honestly at 4709 elements. Two records of toolkit work later, what was left was the part that is genuinely about having 4709 elements: box-building, layout and the hit-test snapshot, on every frame, whatever is on screen. ADR-0309 had already written the sentence that undoes it — “a masonry of equal-height tiles is a reflowing grid in reading order” — and a grid of equal-height rows is a list of equal-height rows. The masonry was never doing the reflowing; columnsFor is, from a width the screen measures itself. It was doing the chunking, which is four lines. The sheet is a ListView over 221 rows now, virtualized at a 76pt pitch the stylesheet agrees with, rows padded out with empty cells so a part-full last row divides the width the way a full one does. 4709 elements became 711, opening 414 ms became 106, a settled frame 22.2 ms became 5.1, and a wheel notch 78.6 became 16.8. The screen has no budget of its own any more — it had 8 ms of style where every other screen had 1, because it did not meet the wall’s. What is not fixed is written down with its measurement: the render tree matches children by position, so a virtualized window that shifts by a row mismatches every row after it and rebuilds a YGNode and a measure callback for each; matching by the element instead was measured at 9.9 ms → 3.7 and moved a card’s bottom border by a pixel on another screen, which is not a thing to ship on a performance argument. — ADR-0316, ADR-0213, ADR-0309

scroll, and the geometry it needed

The one widget book/src/TODO.md named three times in three unrelated entries: a menu taller than the work area loses its bottom, a tab strip wider than its window overflows it, and select over a realistic option list cannot be written at all. Built as three nodes, each one idea — a scroll viewport that clips and takes the input, a scroll-content that is translated, and whatever was written inside.

The offset is a transform, not a layout property. §1.7 already refuses to transition width and height because animating them would run Yoga per frame, and an offset expressed as top would run Yoga over the whole subtree on every wheel notch to move a box that did not change size. The transform costs nothing, and two things then come out right for free: the router inverts the matrix, so a row scrolled up by 200px is clicked where it looks; and ADR-0114’s clip is intersected in the same walk, so content moved out of the viewport is cut at its edge. flex-shrink: 0 on the content is the whole difference between a scroll view and a squashed one — Yoga’s default would have compressed the content to fit and left nothing to scroll.

The hard part was the geometry, and it is new machinery. Scrolling is arithmetic on two rectangles and a widget can measure neither: build and render both run before Yoga, which is ADR-0080’s finding and the wall ADR-0097 hit from the other side. Both of those found ways to avoid needing the number. This one cannot — the clamp is the widget. So PointerEvent and KeyEvent each gained bounds() and part(), two Extents the router resolves out of the snapshot the last paint left behind, reusing Handles.localPart()’s existing vocabulary: a scroll view names scroll-content, is handed its viewport and its content in one event, and the clamp is a subtraction. It is on KeyEvent too, because PageDown needs both extents while carrying no position at all, and a scroll view that only worked with a mouse would fail §1 outright. The measurements are one frame old, which is honest — the alternative is a widget that computes layout, and that is the thing three ADRs have now declined to build.

At its edge it lets go. A wheel or a key is consumed only when something actually moved, so a scroll at the top of a list bubbles — §2.4’s “inner scroller consumes until its edge, then chains to the ancestor”, obtained from the router’s ordinary bubble rather than from anything knowing an ancestor exists. pointerWheel now reports consumption, which keyPressed already did.

Seventeen tests, and the shape of them is the point: every one needs a painted frame before it means anything, because a scroll view that was poked directly would be testing a calculation nobody performs. The first version of them failed uniformly — six rows of text came out 94 tall in a 100-tall viewport, so there was nothing to scroll, which is exactly the bug the widget exists to fix seen from the test’s side.

The bars, and being told what you measured

§2.4’s overlay scrollbars, taken as written: a 6px thumb widening to 10 with a visible track on hover, full radius, accent while dragging, and a fade 800ms after the last movement. Dragging scrolls and a click on the track pages.

The thumb needed something that did not exist. ADR-0116’s extents arrive on the event that asks to move, which is enough to clamp and useless for drawing — a thumb whose length says what proportion of the document is visible has to be right before anyone touches anything. So Measured is the other direction: once per frame, only on a change, a widget is told what the last frame laid it out as.

The obvious worry is the loop — a measurement causes a rebuild causes a frame causes a measurement — and the answer is that the bars are absolutely positioned, so nothing the rebuild draws can change what was measured. The second frame measures what the first did and the router notifies nobody. The first attempt avoided setState entirely on exactly that worry and was wrong the other way: without a rebuild the extents never reach a build and the thumb never appears. The tests assert the convergence rather than the argument.

Two things that look like styling and are not. The bar has no padding, because the arithmetic runs against the viewport’s length and an inset track would let the thumb overrun the bottom by exactly the padding. And the fade is a clock rather than a transition, because no selector can express when — so the wake is flagged and the next frame stamps it, the way a tab’s arrival is.

Where it went

Three entries closed by doing. Every gallery screen is in a viewport — including the short ones, because a viewport over content that fits costs an element and a screen that is short at one window size is tall at another. A menu longer than the screen becomes a menu of the screen’s height with its items scrolling, capped by Menus rather than by the popup facility: :core has no widgets to wrap anything in, and more to the point a tooltip that scrolled would be a tooltip that should have been a dialog. And a tab strip scrolls its headers, with the rule left outside the viewport so a scrolled strip does not take its own underline with it.

The tab strip is where a bug in the viewport surfaced. It laid its content out as a column regardless of axis, and a horizontal viewport doing that stretches its content to its own width — so the measured overflow is zero, nothing ever scrolls, and the tabs spill out of a box that claims to fit them. Only a real horizontal consumer could have found it.

Still owed: scrollIntoView (which affix, tour and selecting an off-screen tab all want), the “always show scroll bars” reserved gutter, and the chevrons at the ends of a tab strip. All of it is in TODO.md.

affix, scrollIntoView and tour

The three things scroll was owed, and one more geometry facility to carry them.

affix is §1’s sticky child. Every geometry facility so far carried a size; this one needs a position, and one no widget can compute — whether a header has gone above its viewport is a comparison between where it was painted and where a node it cannot see has its edge. Located answers it, and the clip is what made it small: the obvious reading of “the nearest scroll view’s rectangle” is an ancestor walk with a cast, coupling the router to a widget in another module and answering nothing for a node inside two viewports. ADR-0114’s clip is already that rectangle, already computed, already on every region for hit testing. Nesting composes for free, and “nothing clips me” resolves to the window, so an affix outside any scroll view pins to the page rather than being a special case.

A widget told where it is must not move itself, or it is told a new position and moves again forever. The escape is structural: affix is a hole that never moves and a content node that slides under it — which is the same shape §1 needs for the hole anyway, so the constraint and the requirement turn out to be one thing.

scrollIntoView went the wrong way first, and that is the useful part. A Reveal widget wrapping whatever wanted to be seen reads better than a controller and broke two tab goldens and two motion tests the moment it went around a tab header: a wrapper is a box, and a box in a flex row changes how that row is sized. There is no node transparent to flexbox, so any widget that adds one to observe layout can change the layout it observes. §1 words this as an API rather than as markup, and that is the load-bearing part of the wording — an API adds no node. Tab was already a Handles node and is now a Located one, with no parent gained.

tour is §5’s guided sequence: a veil, a card, Back/Next/Skip, Esc to skip the whole thing, and a target named by id that is skipped with a warning when it is not on screen. The veil is four rectangles rather than one with a hole, because §8’s subset has no mask — and the workaround turned out better than the thing it replaced: nothing covers the target, so it stays live and a stop that says “click here” can be obeyed without the tour arranging an exception to itself. It needed one small facility, Host.fill, which is an overlay inset on all four sides rather than two.

The showcase screen, which found the bug

A sixth gallery screen — a list with four sticky headers, jump buttons, and a tour that points at them. It is the one screen not wrapped in the gallery’s own viewport, because §2.4 bans nested same-axis scrollers and the screen that demonstrates the rule is where it has to be kept.

It found two real defects. Located fired only when a rectangle changed, and a header asked to scroll itself into view is in exactly the position it was already in — so the request was made and never heard. The cache now holds the widget as well as the rectangles, compared by identity: a rebuilt node hears again even if it has not moved, and a still window still notifies nobody, because an element that was not rebuilt holds the same instance. Measured had the same latent bug and has the same fix; nothing had hit it, because its one consumer is a scrollbar whose state is stable. Which is what a second consumer is for.

The other was only visible as a picture. Fifteen tour tests passed while the card was drawn down the whole left edge of the window and stretched to its full height: Insets is in CSS order — top, right, bottom, left — and the placement passed left and top, which anchors a box by its top and its bottom. Every assertion about what the tree contained stayed true, because the defect was two numbers in the wrong argument positions. TourGoldenTest is the answer, and it is the right kind of test for this widget rather than an extra one: which region is dimmed and which is lit is not a question a widget tree can be asked.

Eight things the running application said

The showcase had been rendered and not used. Running it produced a list, and two of the items were defects the whole suite was blind to.

A setState asked for no frame. Reported as “the scroll starts working on the second or third turn of the wheel”, and it was neither a scroll bug nor about the wheel. Two rules met and left a hole: §1.7 says the frame loop is idle unless something asks, and ADR-0052 says setState defers. Nothing connected them — handlePointerWheel did not repaint at all, and every other handler asked repaintIfRestyled, which is a question about :hover, not about state. So a widget that changed its own state waited for an unrelated event to paint it. Every stateful widget had this; it hid because most of them change a pseudo-class in the same gesture, and a scroll view changes none. The tree now tells its window when it goes from clean to dirty, once per transition. No test in the suite could have caught it — a widget test drives frames itself, so it is a frame loop that never asks whether anyone wanted one.

A pinned affix was painted underneath the rows sliding over it. AffixTest passed the whole time, because every assertion it makes is about a position and all of them were true. A box tree has no order beyond document order, the affix sits at index N, and the rows at N+1 are drawn afterwards — so a background cannot help. This is exactly why CSS puts position: sticky in the positioned layer, and Box.elevated is that rule at its narrowest: one bit meaning “draw me last”, no stacking context, no z-index, no ordering among elevated siblings. Layout is untouched, and the hit test reorders with the painter — a box drawn on top that was not clicked first would be a header you can see and point through.

The other six were smaller and mostly mine. The tour anchored to the layout rectangle rather than the painted one, so a target inside anything scrolled was described in the wrong place, and its card was aligned to the target’s left edge rather than centred on it — which put every card in the same place and made the sequence look static. The lit target had no edge of its own. --gb-text-subtle was a token this file invented, defined nowhere, logged as a dropped var() on every frame, and — once defined — produced a counter nobody could read: there is no third text rank in this palette, and the size carries the demotion instead. border-bottom is not in §8’s subset and was being dropped with a line in the log each time. And the tab demo’s panel could not fill, because it is inside a scroll, where the remaining height is nothing — correct, silent, and now an explicit height.

Two goldens came out of it, and they are the right kind of test rather than extras: affix-pinned because “the header is at the top of the viewport” and “you can read the header” are indistinguishable to any assertion about the tree, and tour-edge because a card clamped against the window’s edge is a placement, not a value.

Three more from running it

The jump buttons were never working. Reported as stopping after a few clicks; the first press happened to be a scroll forwards, which any measurement gets right. ADR-0120 says the thing that wants to be seen measures itself, so the section’s header did — and that header is inside an affix, so the moment its section starts scrolling away it is pinned to the viewport’s edge, by design, permanently. A reveal measured against it concludes the section has already arrived, however far away it is. Two rules, each correct alone, composing into a widget that can never ask to be scrolled to.

An affix now hands out its hole — the same-sized gap §1 already requires, which travels with the document precisely because it never moves itself. It is a door and not a policy: Affix forwards two rectangles and holds no controller, and the caller decides whether a section wants showing. The showcase’s SectionHeader went back to being a plain node, which is the proof the door is in the right place. Neither AffixTest’s eleven cases nor ScrollingScreenTest’s four could have caught it — the failing sequence is scroll away, then ask to come back, and every test asked to go somewhere new.

A second invented token. --gb-on-accent this time, on the tour’s forward button: used, defined nowhere, dropped every frame with a warning. A primary button’s foreground is not derivable from the accent — it is nord0 on dark and nord6 on light, because the fill flips which is legible — so it takes --gb-button-primary-text like every other primary button. Twice in two days is a pattern rather than an accident, so TokenClosureTest and ShowcaseTokensTest now check that every var(--gb-…) the toolkit or the showcase writes resolves under both themes. Verified by breaking one on purpose.

flex-grow on the wrong box. Five gallery screens carried it inside the gallery’s viewport, where a content-sized column means there is no remaining height to claim, and one screen carried it where it was load-bearing. A dead declaration sitting next to a live one is how it stops looking dead. The growth is the scroll box’s.

  • A jar binds at run time; an image is woven. ADR-0125’s weaving was mandatory: every module keeping a model applied goldberry.weave, and Models threw at the first sight of an unwoven class. The weaving was cheap and the mandatory was not — a consumer had to install a class post-processor before the first field notified, Maven had no Mojo to install it with, and an IDE’s green Run button did not run it at all. So the weaver became what it is actually for: the native image path. An ordinary jar reads the same annotations reflectively — a VarHandle per @Bind field, a MethodHandle per @Action — and Models picks between the two forms, which answer identically. The one thing reflection cannot do is see the write, so a sweep compares each field against what it last held: after every action a document dispatches (across every model, because an @Actions record writes to the model beside it), at the top of every frame over the models Application.models() named, and wherever Models.refresh is called. The showcase needed exactly one of those calls — a background job’s continuation — and it is the one visible cost. RuntimeAgreesWithWovenTest is what keeps the arrangement honest: the same model class raw and woven, driven through the same actions, asserted to publish the same paths, names, values, notifications and frame requests. The catalog half did not move: @Markup widgets have no runtime equivalent — finding them means scanning the path, which is what a provides exists to avoid — so WeaverMain grew --models and --catalog, and the catalog stays hung off classes for every build while the models wait for -Pgoldberry.nativeImage=true. The costs are written down rather than discovered: a model in a named module has to opens its package to the toolkit, the registry listing order differs between the two forms because getDeclaredMethods promises none, and an image now carries annotation metadata it does not read. And measured rather than asserted: BindingSchemeBenchmark runs both forms of one model class in one JVM, and a press one widget is watching costs 15 ns woven against 45 ns reflective, a read 1.3 ns against 10 ns, and a document reload the same either way (570 ns against 630 ns). The sweep scales at 14 ns per attached model and 9 ns per bound field per press — so ten models cost a button 140 ns against a 16 ms frame, and the slope is the thing to watch rather than the base.

    Generating the binding at run time was priced and rejected, and the pricing paid for itself. BindingCodegenBenchmark builds the option for real — a hidden class defined as a nestmate of the model, reading its private fields with a plain getfield — and it does the sweep’s read-and-compare in 0.60 ns against the boxed reflective 15.7. But asking the same VarHandle for an int and comparing two longs gets 5.2 ns with no new mechanism, which said most of the measured cost was this implementation’s own plumbing rather than reflection. It was: replacing the boxed comparison with one small class per primitive kind, the List walks with arrays, and a List.copyOf per action dispatch with a snapshot rebuilt on change took a press from 107 ns to 45 and a read from 32 ns to 10 — no new mechanism, no new semantics, and not one test changed. What codegen would still buy is ~4 ns a field, against making java.lang.classfile and defineHiddenClass reachable from the module every image is built from — and against the fact that it would make the sweep fast without making it unnecessary, which the weaver already does, one flag away (ADR-0155)

  • The showcase can be asked for a native image, and the metadata is traced. ADR-0127 claimed the binding layer was no longer the reason an image could not be attempted; :example:nativeImage is the attempt. The obstacle was never the binding — it is :natives, where a binding class takes a SymbolLookup obtained at run time and builds its handles from it, so none of the 184 descriptors is a build-time constant a closed world could fold, and Yoga’s measure callback is an upcall besides. Writing them out by hand would be 184 registrations duplicating what the binding classes already say, going stale silently. So :example:nativeImageMetadata runs the showcase headless under GraalVM’s tracing agent and writes what it saw into src/main/resources — source, because it is reviewed in a diff and packaged into the jar — and nativeImage builds over that, after weaveModels, which the build orders before jar so an image can never be made from classes bound reflectively. The image builds and runs on linux-x64 against GraalVM CE 25.2.4: 30.6 MiB, ~0.55 s to start, exit 0, with the FFM downcalls, both upcalls, the fonts, the icons, the stylesheets, the KDL and the WidgetCatalog service all surviving the closed world. Neither task is in CI and neither is wired into build; both fail with a download link when asked. Two things the first real run taught: the agent found two upcalls where this was written expecting one — SdlEventWatch.invoke beside Yoga’s measure callback — and collapsed 184 exported symbols into 55 distinct downcall descriptors, which is the tracing decision arguing for itself on its first outing; and the linker needs zlib1g-dev rather than the zlib1g a desktop already has, or a minute of analysis ends in cannot find -lz. And it logs, which took one hand-written metadata entry and taught the sharpest lesson of the exercise: the agent records how a lookup was made, not where the file will be. Logback asks a ClassLoader for logback.xml, so the agent files it as a classpath resource — but the image runs on the module path, where that file is at the root of a named module and, registered without one, is simply absent. Logback with no configuration ends with no appenders and prints nothing, not even its own status, so a perfectly working image looked like a dead one. There is a second metadata directory now, goldberry-example-manual, for what a human writes; the traced one is never edited because the next trace overwrites it (ADR-0156, the native-image page)

  • A layer is blitted into its own size, and every disabled control on a Mac stopped being twice as big. Reported from a 2x display. opacity is the only thing the stylesheets set on a disabled control (ADR-0077) and opacity is what promotes a subtree to a layer, so “disabled controls are huge” was “promoted subtrees are huge”. A layer’s raster is allocated in physical pixels — a raster kept at logical size on a HiDPI screen throws the detail away — while the frame it composites onto is in logical ones, and bl_context_blit_image_d draws one image pixel per context unit. At 1x the two spaces coincide and the arithmetic is right by accident; at 2x a 60-point square is a 120x120 raster drawn across 120 logical units. The composite now states its destination rectangle, derived from the raster rather than from the laid-out bounds so that a fractional scale’s rounded-up raster still lands one-for-one on the device. Every golden passed unchanged, which is what says this is a fix and not a rendering change. The lasting part is LayerTest’s new Scaled nest at 2x and 1.5x — and the gap it exposes, which is not closed: almost every pixel assertion in this repository is at 1x, where this whole class of bug is invisible (ADR-0157)

  • A full repaint is a full upload, and the resize stopped flickering black. Reported from a Mac: dragging a window edge flickered with black areas. Two mechanisms, each correct alone. ADR-0072 asks the backend whether the lent buffer still holds the last frame; a resize reallocates it, so the answer is no and the painter repaints everything. ADR-0046’s damage list is a different question — which regions changed — and the frame loop reported it either way. So the frame after a resize was painted in full and uploaded in part, onto a surface that was entirely new: everything outside the damage rectangles was whatever the compositor had there. At a steady size the two questions have the same answer, which is why every damage test, every golden and every headless run missed it. The clamp lives in Window rather than at the call site, because a painter reporting what changed has nothing to do differently and the window is the only thing that knows whether the buffer had valid contents. Both halves are now pinned — a frame after a resize uploads the whole window, a frame at a steady size uploads only what changed, and the second is what stops this being “fixed” by uploading everything always. Not confirmed on the reporting machine: the cause is reproduced headlessly on Linux and is unambiguous, but whether it is the whole of what a Mac shows during a live resize — which macOS drives from inside a modal run loop — is not something this repository can answer yet (ADR-0158)

  • The native image is one file, and getting there found two bugs that were not about images. It shipped as a binary plus lib/libgoldberry.so plus a launcher setting -Dgoldberry.native.library, so running the binary on its own failed — which is what a person does, because an image is supposed to be a program. Statically linking the archives in does not work, and the failure is not where it looks: linking is fine (-Wl,-u,<symbol> pulls the code in, given the -lstdc++ and -lm native-image does not pass), but Goldberry resolves every native function by name at run time, so the symbols must reach the dynamic symbol table — and native-image links with its own --version-script making everything unlisted local. --export-dynamic-symbol does not beat it and a second version script is refused outright. So the image carries the library instead: the classifier jar’s resource, embedded, and unpacked on first use by the branch NativeLibrary already had. One 41 MiB file, ~5 ms and a writable temp directory the cost. On the way: a named module cannot see a class-path resource, so the classifier jar — the mechanism a released application is meant to use — could never have worked on the module path, invisible because every module-path run here points at a local library instead; and deleteOnExit drains in reverse, so the unpacked library’s directory was attempted before the file in it and every run leaked an empty directory, on the JVM as much as in an image. Neither has a test and both live where this repository does not look — what caught them was building the artifact and running it with nothing beside it (ADR-0159)

  • A module’s own resources are declared, not traced — after the image crashed on the theme toggle. ADR-0156 wrote the cost of tracing down before it was paid: “a screen the run never reaches contributes nothing, and the symptom is an image that starts and then dies opening a menu”. It was paid on the first real use — the NORD_LIGHT theme is missing from the jar — and three more had the same shape: density-compact.css, JetBrainsMono.ttf and OpenMoji-black.ttf, each the far side of a control the 120-frame run never touched. Tracing harder would have caught those four and told us nothing about the fifth: a trace can be made longer, not complete. So :core, :widgets and :example each ship a reachability-metadata.json declaring their own files by glob — a set that is finite and known at build time, where a directory listing cannot be one screen short. Each module ships its own because native-image reads META-INF/native-image/** from every jar on the path, so an application building an image gets the toolkit’s resources without knowing it needs them, rather than discovering that Goldberry has two themes by shipping a crash. The image went 41 MiB to 43 MiB, which is the two fonts that were missing all along. The FFM and reflection metadata are still traced and ADR-0156’s warning still applies to them (ADR-0160)

  • A downcall handle is a constant, or it is not a call — and the image went from 42 ms a frame to 1.0 ms. The hud on the first properly exercised image read paint 37.5 / 41 / 53 ms, raster 34.7 / 36 / 52 ms, against a 16.7 ms budget: two and a half frames of work per frame, almost all of it Blend2D. The cause is a known and open GraalVM limitation — #8113 has “improve downcall performance (currently always unoptimized)” on its unfinished list — and a GraalVM engineer’s answer to somebody else’s SDL application dropping from 400 fps to 25 gives the workaround. Measured here before anything was changed: one trivial call costs 10 ns on the JVM and 4560 ns in an image, and an unbound handle built at run time is just as slow, so both halves of the workaround are load-bearing. A MethodHandle is a call only when the compiler can see which handle it is; the JIT gets there by watching the field, and an image has no second chance. A handle bound to an address never can be — the address does not exist until libgoldberry is dlopened. So Downcalls holds one unbound handle per signature (134 bindings share 56 of them), each binding keeps the MemorySegment it looked up, and :natives ships the native-image.properties that initializes that class in the builder — beside the --initialize-at-run-time=…NativeLibrary that moved there from example/build.gradle, since both are facts about the module rather than about an application. Sixty frames headless: 2.533 s before, 0.061 s after, with a control build — the same code, properties file moved aside — reproducing 2.55 s exactly, which is what makes the gain attributable. The image is now faster than the JVM over a short run, because the JVM spends its first frames compiling and an image has nothing to compile. The JVM loses nothing (9.81 ns bound against 9.27 ns unbound), and two invokeWithArguments(Object...) paths in SdlVideo and SdlCursors that boxed every argument became invokeExact on the way past. The same trap sits one level down and decided the naming: a handle has to be read by the method that calls it. A constant passed into a three-line helper costs 810 ns in an image against 8.9 ns when the helper names it itself, so the constants are named for signatures — INT__PTR_PTR_INT, in C’s words rather than JVM descriptor letters — and not for functions, which would mean deleting every shape-generic helper and inlining it at ~100 call sites. Nothing fails if the flag goes missing — the image builds, runs, paints correctly and is forty times slower — so DowncallsTest pins the naming scheme, DowncallBenchmark prints both numbers, and the control is written down (ADR-0161, the native-image page)

  • -Dgoldberry.trace.frames=all printed nothing at all. Found while measuring the above. ENABLED was Boolean.getBoolean, which is false for anything but true, while ALL_FRAMES looked for all — so the setting that asks for more output turned tracing off entirely and every counter guarded by ENABLED was skipped. all now implies true, and both readings are pure functions of the property value so that FrameTraceFlagsTest can check them without setting it — which is the only way to test a flag read once into a static final field (ADR-0101)

  • Every golden is now three goldens, and none of them is committed. ADR-0157 fixed a layer composited at twice its size on a 2x display and wrote down what it had not fixed: 37 of the 39 assertMatches calls in the repository were at 1.0, where the multiplication between logical units and device pixels is the identity and a conversion done twice, not at all, or in the wrong space draws exactly the right picture. A golden that matches is now drawn again at 2x and 1.5x its own scale, into a frame of the same logical size, area-resampled back down and compared — so the claim being checked is invariance rather than “this is the 2x render”, which is what makes it worth a hundred more PNGs of nobody’s review attention. The comparison lets a pixel find its match anywhere in the 3x3 neighbourhood around it, because the two things that differ honestly between scales — an edge Yoga rounded onto a different device pixel, and a glyph antialiased at a different resolution — are both sub-pixel, and a subtree at twice its size is not. It runs in both directions: “every pixel of the reference is still near where it was” says nothing about something that grew, since the reference’s ink is all still there with more around it. ClipTest, TransformPaintTest and IconPaintTest get the same check with no golden behind it, which is where the arithmetic actually lives. Nothing new was found — the whole corpus passed at both scales on the first run, 2215 tests green — and saying otherwise would misrepresent what this bought: ADR-0157’s bug was already fixed, and this is what stops the next one being invisible for a year. The cost is about 17 ms a golden (88 images, 2.16 s to 3.64 s). What keeps it honest is that its failure path runs: ScaleInvarianceTest rebuilds ADR-0157’s bug on purpose — a rectangle sized in physical pixels and drawn in logical ones — and asserts it is rejected, and does it again for a border thickened by the scale, which is the subtle end of the family and only a stroke wide (ADR-0162)

  • The model that had to outlive one opening turned out to be the one the author already wrote. ADR-0106 left two things unbuilt with one sentence explaining both — §8’s in-window bar, and the half of an accelerator that is registration rather than display — because “a menu is built when it opens and thrown away when it closes”. That is true of the popup. A Menu is a record: an ordinary value, and Menus.open builds a second tree from it to put in a window. Nothing was ever holding the description, which is a different complaint, and the fix for it is a widget that does. So menubar holds its menus, Accelerators walks them, and Ctrl+O runs the command a submenu three levels down names with nothing at all on screen — which is the central test, written that way deliberately, because anything that opened a menu first would be testing the part that already worked.
  • No markup was added. A bar’s children are items, and an item containing items is a heading that opens a menu — the nesting that has been the submenu syntax since ADR-0106. The showcase’s bar is written entirely in KDL beside the buttons that were already there, wired to the same actions, and its accelerators are live: Ctrl+K clicks the counter with the bar shut.
  • A heading is not a menu row, and the keyboard is why. Down opens where an Item moves; Right moves where an Item opens. Neither arrow is the widget’s — menubar is a horizontal focus scope where menu is a vertical one (ADR-0078), so traversal is free and the two arrows left over are the two a bar wants. A heading also has none of the three things that make a row a row: no tick column, no accelerator on the right, no chevron. Hovering a heading opens it only when a menu is already down, which is what every desktop bar does; hovering with nothing open would drop a menu on somebody crossing the bar on the way elsewhere.
  • F10 and a bare Alt, and the difference between them is in the type. §8 asks for “Alt-style keyboard activation”. A bare Alt is a modifier released with nothing in between, and a Shortcut here is a key plus modifiers — Key has no ALT to name, because Shortcut’s own constructor refuses one that can never fire. So the Alt half is not an accelerator at all but a gesture, recognised at the window from the raw keycode (ADR-0223); F10 is the companion binding on every platform that has the Alt one, and the one that survives a compositor which eats Alt for its own window switcher. Both open the first heading rather than focusing it, because there is no Host.focus and a binding that did nothing visible would read as broken rather than as missing — and both close an open bar, which is what every desktop does with the same key.
  • Three costs, written down rather than discovered. Host grew removeShortcut, and the map is keyed by the shortcut and not by who bound it — so a bar going away takes whatever is on Ctrl+O with it, including a binding made afterwards; a collision inside one bar is logged and the later row wins; and an accelerator that does not parse is logged and skipped rather than thrown, because it is a typo already drawn beside the row where somebody can see it.
  • Adding two methods to Host found a third hand-written stub. SelectTest and TourTest each carried a near-identical one, so the interface change would have meant editing both and writing a third. TestHost is the shared one, both extend it, and — like the real thing under SDL’s dummy driver — it opens nothing, which is the branch ADR-0102 says a control has to survive.
  • The first widget drawn through ADR-0162. Six goldens, each checked at 2x and 1.5x on the day it was written rather than after somebody reports a HiDPI bug. And the images earned their keep immediately: .open started as --gb-overlay-active against :hover’s --gb-overlay-hover — 16% against 8% — and the picture said the two are hard to tell apart, which is precisely the comparison a bar puts in front of somebody, since the hovered heading is usually the one next to the open one. It is the accent fill now (ADR-0163)
  • Two things §8 asks for are still not built and neither is a bar problem: a bare Alt tap, and Left/Right moving between menus while one is down — the open menu is a window of its own with its own focus, so the bar never sees the arrow, which is the same missing item-to-popup callback that has kept Left from closing a submenu since ADR-0112.

Five of §5’s seven containers

  • card, group-box, statistic, skeleton and collapse ship, and three of them ran straight into a limit worth writing down rather than rediscovering. A card’s elevation is an edge: §10’s subset has no box-shadow and nothing in this toolkit paints outside a box’s own rectangle, so a card is raised by contrast — --gb-surface-2 against the page, plus a border — which is the answer popover reached first and is the honest version of the same idea, since contrast is what a rasterizer with no shadow pass can express. (What changed since: both halves of that reason expired and the property is built — ADR-0310. The edge stays, and is still what tells a card sitting on another card apart; card does not wear an elevation yet.) A group box’s title is above the frame, not through it: a legend that breaks a border needs a notch the subset cannot express, or the page’s own background painted behind the words, which is wrong the moment the box sits on anything but the page — and a heading above a frame wraps at a small width where a legend through one breaks it. A closed collapse describes no body at all — not a hidden one, not one of zero height — so nothing is mounted and nothing subscribed, which is §5’s own reasoning and ADR-0004’s; the test for it is therefore an assertion about an absence, including that the author’s own widgets were never built.
  • The one loop in the canon is a function of the clock, not a transition. §1.7 rule 4 lets a skeleton shimmer and nothing else, and a transition runs between two states where a skeleton has one — so the pulse is computed from nowMillis(), which is spinner’s arrangement (ADR-0081) and is why a column of placeholders is in step by construction. A triangle wave folding at the halfway point, so the two ends meet and it does not snap once a second, between 0.45 and 1.0 rather than 0 and 1: a placeholder that fades to nothing flickers the layout empty, and one at full strength is indistinguishable from content. Reduced motion holds it at its dimmest, because a placeholder frozen bright reads as content that arrived and was blank.
  • Two things the tests found rather than assumed. A record component cannot be called children when children() is overridden: GroupBox described its parts from that method, which is also the accessor for the author’s widgets, so asking a group box what was in it returned its own chrome — caught by inflating one node and being told it had two, and the component is content now. And the skeleton goldens could never have matched: a widget drawing from the frame clock renders differently every run, so they need Clock.virtual() the way ProgressGoldenTest already did. The instant is pinned at the fold, 500 ms; 250 was the first guess and is a quarter of the way in rather than the peak.
  • statistic never formats and never infers. §5’s reason for a string is that a locale-aware number formatted inside the toolkit makes a golden that cannot be reproduced on another machine. And direction names the sentiment rather than the arithmetic — latency falling is success — so the caller picks it; a widget that read the leading - would colour a latency improvement red. The colour itself is a class on the delta, so it stays the stylesheet’s (ADR-0164)
  • statistic’s sparkline waits on canvas, and collapse’s accordion= is a rule about siblings and therefore the containing column’s.
  • A divider translates; it does not track. ADR-0164 said neither of these had a design question left in it. Each had exactly one, and neither was the predictable one. A slider reads its value straight off the pointer because the value is a position along a track — a divider cannot, because the pointer is somewhere inside a six-point bar and mapping that to a fraction snaps the divider so its centre jumps under the finger on every press. So it is knob’s arrangement instead: the divider reports its offset as a gestureAnchor and the new offset is anchor + dragX. This is the second widget to want an anchor for a reason that is not the knob’s — a knob needs one because its value has already moved by the second frame — which is the useful thing it says: the mechanism generalises past the case it was built for.
  • The position is a fraction and the minimums are pixels, and they have to be different kinds of thing. A divider a third of the way across stays a third of the way across when the window widens, which a stored pixel offset gets wrong; but “this list needs 160 points or its labels wrap” is a fact about content, and a fractional minimum would let a narrow window squeeze it to nothing. The clamp between them needs the measured length, which is ADR-0117’s channel. And the first pane is sized while the second grows, because flex-grow shares out the space left over after content — two proportional panes would land wherever their content put them and ignore the divider’s fraction entirely, and §10’s subset has no flex-basis to say it with instead.
  • A rotation has three brakes and only two of them work. §5 makes interval default to off and asks for a pause on hover, on focus anywhere inside, and under reduced motion — §1.7 rule 4’s canonical violation being a carousel that moves while being read. Hover and reduced motion are complete. Focus is not: the strip and the carousel’s own controls pause it, and focus on a widget inside a slide does not, because the cascade has no :focus-within and nothing tells a widget that focus landed in its subtree. That is a real gap — somebody who has tabbed into a slide is exactly somebody reading it — and it is in TODO.md rather than papered over.
  • One one-shot timer, rescheduled, rather than a repeating one: a pause is then a timer not scheduled, and needs no second mechanism to suspend. Every reason to stop is re-checked when the timer fires, because one already in flight when the pointer arrives would otherwise advance a slide past the moment it should have stopped. And a build found a wasted wakeup: at the last slide of a non-looping carousel, build scheduled a timer that would fire, move nothing and stop — fixed by folding “is there anywhere to go” into the same predicate as the three brakes, rather than testing it at the reschedule where the copy in build was missing.
  • Two small costs, both stated. EventLoop.Timer’s constructor is package-private rather than private so TestTimers can hand one to a stub Host — TestFrames has the same privilege over Frame, for the same reason — and the divider’s thickness is written in SplitPaneView and in controls.css, because the first pane’s size is computed against it and a stylesheet that disagreed would put the second pane’s edge out silently. SplitPaneTest pins them together.
  • The showcase’s Panels screen demonstrates all seven, still with no Java behind it: a split-pane and a carousel that keep their own state need no more wiring than a card does (ADR-0165)

Five reports from looking at it, and two of them were decisions being wrong

  • panel had no stylesheet rule at all, and §5 has always said it is a “plain surface: --gb-surface, border, radius tokens”. The widget drew nothing, so every document that wanted a surface invented one — and the showcase invented one that looked exactly like a card, which is how it surfaced: “in black theme I do not see any visual differences between panel and card”. A building block that draws nothing is not a building block.
  • --gb-surface-2 was never an elevation. ADR-0164 said “elevation is an edge” and then hedged by also stepping the fill, which is wrong twice: eight levels on the Nord dark ramp is not an elevation anybody can see, and on the light theme --gb-surface-2 is a step down from --gb-surface — which is #ffffff there — so a card built on it read as recessed. The token means “the second surface” and never promised otherwise. There are two tokens now that say what is meant: --gb-surface-raised and --gb-border-strong, the second an alpha over whatever is underneath, which is the only way to say “lighter than its own surface” in a subset with no colour functions — so it lightens on dark, darkens on light, and stays right on a card sitting on a page, on a panel or on another card.
  • A group-box holds its title now, and the report was the right question to ask of the old one: “what is the purpose of group box? I thought I should group elements with title and border.” ADR-0164 put the title above the frame, because a fieldset’s legend through a border needs a notch the subset cannot express. The premise still holds and the conclusion did not: a heading floating over a bordered box is a heading and a panel, nothing about it says the two belong together, and an untitled one was indistinguishable from a card — so the widget had no purpose two existing widgets did not already serve. The border goes round both now, with the title as a tinted header row inside it.
  • carousel and collapse animate, and TabPhase became Phase. It was written for tabs and had nothing tab-shaped in it. Both new arrivals are arrival only and both for the widget’s own reason: a carousel builds only the current slide, so holding the outgoing one alive for a cross-fade would be building a slide that has been moved away from; and a collapse unmounts its body, so holding it for 160 ms after it was asked to go is the thing the widget exists not to do. Closing is instant and opening is not — asymmetric on purpose, because the thing worth animating is content appearing where there was none. Opacity and a small translation, never height.
  • accordion=#true inflates to a widget. §5 puts the flag on the containing column and is right to, since “one open at a time” is a rule about siblings. But column is the most-used container in the toolkit and statefulness is a property of the type rather than of the instance — so honouring the flag there would give every column in every document a State it never uses. It inflates to an Accordion instead, which reports column as its own CSS type: the document writes what §5 says, a stylesheet still sees a column, and an ordinary column pays nothing.
  • And chrome does not shrink. A title bar half its height on one screen, which is Yoga’s default: children shrink, so a window whose content asks for more height than there is takes it out of whatever will give — and a bar with a definite height is the most willing thing in the tree. If the content does not fit, the content is what scrolls (ADR-0166)

§4 opens with text-input, and two things underneath it did not exist

  • A field owns its caret and the model is told. Every control before this one has a state the application can hold — one bit, one number, one key. A field’s is a caret, a selection, an undo stack and a scroll offset as well as its text, so the edit lives in the element and each value goes up through change=. A bind= value is the initial text and an override, which needs two tests rather than one: the value must differ from what the field holds — or an application’s own change handler would reset the caret to the end on every letter — and it must have changed since the last build, or an unbound field’s constant value= would overwrite whatever had been typed. It also has to be in build rather than in didUpdateWidget, because a binding firing does not replace the widget.
  • The editing rules are a value, and forty-five tests need no window. TextEdit is (text, anchor, caret) and every operation returns a new one — so undo is a stack of states rather than a log of inverse operations, and nothing has to know how to reverse a word delete. A run of keystrokes is one Ctrl+Z by one comparison, does this change start where the last one ended, from which “a caret move breaks the run”, “a click breaks it” and “a value from the model breaks it” all fall out with no rule written for any of them. Editors that coalesce on a timer split the undo when you pause mid-word; this cannot.
  • The field names intents; it does not build edits — and the reason is the bug the first version had. A password draws bullets, so the caret and selection it draws are offsets into those, and a field applying edit.backspace() to what it was drawing deleted a bullet and left the password a row of them. The seam passes move(LEFT, byWord, extend) instead, and the one rule a masked field has lives in one place: a row of bullets has no words, so Ctrl+Left goes to the start rather than stepping by an amount that says how long they are.
  • A caret blinks on a timer, not on the frame clock. isAnimating asks for a frame every frame, which is right for a spinner and wrong by two orders of magnitude for something that changes twice a second: a focused field would run the loop at the display’s rate for as long as a form was open, and §1.7’s idle loop would be false for every window with one in it. One one-shot timer, rescheduled — carousel’s arrangement — at two frames a second.
  • SDL_StartTextInput was never called. It was not on the export list, so on a real SDL window the TEXT_INPUT event had never once arrived — SdlEventBuffer could read it, Window routed it and KeyboardTest exercised it, all against the headless backend. It is per window and off by default because asking is what raises an on-screen keyboard, so it follows focus rather than the window.
  • The clipboard was the SPI’s last named hole, left out by ADR-0019 until something wanted it. Text only, for a stated reason — images and files are a transfer negotiation rather than a value — and SDL_free is bound beside SDL_GetClipboardText because that string is the caller’s to free with SDL’s allocator. A backend without one reports Clipboard.none(); the headless one is a real in-memory clipboard, so a copy/paste test tests the widget.
  • The golden found what no unit test asked about. An absolutely positioned child here is placed against the border box while the clip is the padding box, so the first character of every field was drawn under the padding and clipped away — visible in all six fields of the Forms screen at once, and invisible to every test that checked what the field held (ADR-0167)

Six reports from using it, and four were defects

  • A drag selected nothing, ever. The field guarded its whole pointer handler on button() == PRIMARY, and PointerRouter.pointerMoved builds its event with a null button — a motion is not a button event. So every drag was thrown away before the switch could look at it. The button is the press’s question now, and what a motion carries is dragX(), which is NaN when nothing is held and is how the router already says “not a drag”.
  • The caret was as tall as the control — an 18-point line with a 32-point caret through it, which reads as a terminal cursor. It takes the font’s line height now, as does the selection highlight, and both are centred by the field’s own align-items exactly as the text is.
  • A field was --gb-surface-2, which is still not a direction. On the light theme that is one rung off the page, which is what “the fields are too pale” was — and it is the same defect ADR-0166 corrected for card, in a new place, found the same way. --gb-surface-sunken is --gb-surface-raised’s opposite and is an alpha over whatever is underneath, because a fixed value has to pick one background to be right against and a field has three. The first attempt proved it: --nord2 on dark is exactly --gb-surface-raised, so a field on a card vanished into it.
  • The placeholder rule applied and could not be seen. --gb-text-muted is two rungs from --gb-text, which inside a filled field is not a difference — so an empty field looked like a filled one. --gb-text-placeholder is its own token, and its alpha is set by §1.2 rather than by taste: the first value tried was 2.4:1 on light, and the shipping ones are the lowest that clear 4.5:1 against the worst surface a field sits on, landing at about half a value’s contrast. ContrastTest cannot measure either token — both are translucent, which is the trap it keeps button.ghost out for — so a test of its own composites them explicitly.
  • A limit nobody can see reads as a fault. The screen’s first field has max-length=40; pasting into it a few times stops taking characters, which is exactly what that means and is indistinguishable from a broken field. Clipping a paste rather than refusing it stands, and the screen says so now.
  • The fields moved into cards, because a form is a set of groups rather than a list of lines — and a card is also what shows a field is a well (ADR-0168)

field, form and the validation model, and a notification two widgets wanted

  • A field is silent until you leave it, and live from then on. §4 asks for validation “on blur and on submit”; the second half of that sentence is in no specification and is what every good form does — once a field has complained it re-checks on every keystroke, so the message goes the instant the value is fixed. A field that validated as you typed would call an email address invalid after the first letter; one that waited for a second blur to forgive you is one you have to leave and come back to.
  • Blur is onFocusWithin, and it had two consumers before it was written. Nothing told a container that focus had entered or left its subtree. The router now walks both chains and tells only the difference, so a move between two controls inside one field is silent — which is what lets a field with three controls behave like a field with one. The second consumer is carousel, whose third brake ADR-0165 recorded as a gap needing “:focus-within in the selector engine, the matcher and the router’s focus bookkeeping”. It needed none of that: a carousel does not want to style itself on focus-within, it wants to be told.
  • A field reads its control’s bind=, one level down, so nothing is written twice and there is no new channel. A field around an unbound control validates nothing — required included — because the alternative is failing forever and gating a form on a control nobody can satisfy.
  • The fields find the form, through BuildContext.findAncestorState’s first consumer since it was written. TabsState looked at it and said it “looks the wrong way”, which was right for tabs and is exactly right here.
  • :invalid is a real pseudo-class, which is the one addition §1’s list of states asks for by name — unlike select.open, which is a class because the subset had no word for “expanded” and inventing one for a single widget would be inventing a language.
  • A validator returns a message rather than a boolean, because a field that goes red without saying why is one somebody has to guess at, and and reports the first failure because a message slot is one line.
  • submit carries nothing, and that is a departure from §4’s “typed event with bound values”: bind= reads from the application’s model, so an event carrying them would hand an application its own data back — and binding() is an Observable rather than a path, so the toolkit cannot name them anyway. A FormController submits, because a Save button is usually outside the form.
  • Two reports from looking at a real form, and both were geometry. The validation message appeared beside the control rather than under it, and the Save button lined up with the labels rather than with the controls. The first is structural and the fix says why: §4 asks for a label column and a message below in one sentence, and those are a row and a column — so a flat field of label, control and message can only be one of them. A field is two boxes now, field-label beside a field-body that is always a column. The second falls out of the same idea: an action row is a field with no label, so the empty label still occupies the column and nothing has to know how wide it is. align-items: baseline on the row went too — a baseline is a property of a line of text, and asking a column for one put two fields on top of each other. Both passed every test that existed; FieldGoldenTest is the pixel coverage they should have had.
  • Two things the screen reported by their absence, and both are closed. Markup can name an object now — controller= and validator= resolve against a fourth registry, Named, which exists because the other three each refused the job and the binding registry refused it in words that settled the question: “a value that cannot change is not something to subscribe to”. And a container can hand focus down: Handles.delegatesFocus() turns the router’s walk round, so a press on a label that finds no focusable ancestor takes the first focusable descendant instead (ADR-0169, ADR-0170)
  • A card has had no edge since the day ADR-0166 gave it one. The CSS shorthand splitter broke on any whitespace, so border: 1px solid rgba(255, 255, 255, 0.2) became seven fragments and the whole declaration was dropped — and --gb-border-strong is rgba(…) by design, because an alpha over whatever is underneath is the only way to say “lighter than its own surface” in a subset with no colour functions. The warning was printed on every run of the showcase and nothing was reading it. It was found by running the application and looking at the log, which is how ADR-0166’s own defects were found (ADR-0170)

text-area, which turned out to be text-input and two ideas

  • The model needed two helpers, not a rewrite. TextEdit was written without a line in it about how many lines there are, and the editing, the undo history, the clipboard and the caret’s blink are all text-input’s unchanged.
  • A column is an x. Up keeps the column, a column is a position rather than an offset, and a run of Up/Down has to survive a short line in the middle — which is why the x is captured once per run and held. It cannot live in the model: a TextEdit has no font, no width and no layout.
  • A selection is one rectangle per visual line, because a run of wrapped text is not a rectangle. That is why Paragraph’s measurements take a line’s range: they were written for this one widget early.
  • The three parts are shared rather than copied, public in a package the module does not export — ADR-0065’s “styleable and not constructible” has meant package-private only because one widget owned its parts, and JPMS can say the real thing when two do.
  • Two things about the render order, both found by looking. render runs before Yoga, so a box does not know its width — the first version wrapped at one point before anything was measured and put every word on a line of its own, which the Forms golden showed at once. And a measurement has to ask for a frame: text-input records its width and requests nothing, because its width only decides how far it has scrolled, and a text-area doing the same would show its first guess until something unrelated repainted (ADR-0171)

message, and the sentence §1.2 had nothing behind it

  • The kind is said twice, which is the whole of the widget. §7 asks for four kinds and says the icon is not decorative — §1.2 forbids colour as the only carrier of meaning — so a kind sets a glyph and a hue. The glyphs are four new Box.Mark kinds rather than four Lucide icons, for tab-close’s reason with one more on top: an Icon is native memory that must be closed exactly once, and a banner is described afresh on every build.
  • The theme’s own claim about its hues was untrue, and measuring it is what found out. Both files documented --gb-danger as “what a label, an icon or a border is drawn in”; this is the first widget that draws one, and five of the eight hue/surface pairs are below §1.2’s 3:1 floor — the dark theme’s danger at 2.46:1 and the light theme’s warning at 1.28:1. A hue has three ranks now: itself, -fill for words on top of it, and -line for a stroke on the page. ContrastTest gained a second sweep, so the rule has a check under it rather than a sentence.
  • It is stateful and holds one timestamp, which is what §3’s entrance costs: a newly mounted element starts no transition, so an arrival is a function of the frame clock and needs a beginning. One correction to collapse and carousel came with it — they decide at build time whether they are animating and nothing rebuilds a banner, so this asks the Phase and actually goes quiet.
  • The exit works, and the trick is the order. “A banner goes away because the application stopped describing it, so there is nothing left to fade” looked airtight and was a false choice: the × starts the fade in the widget while the description is still in the tree, and tells the application when it is over. No owner needed, which is what a lone banner has not got. Phase carries a duration now, because §3 asks for 160ms in and 100ms out.
  • §4’s error summary is drawn at last — Message.summary(errors), empty when nothing is wrong, which is the register [ADR-0169] built and nothing consumed.
  • A gallery image is the second frame now. The Overlays screen’s first picture showed four banners at zero opacity, holding their space and drawing nothing, because the gallery painted the frame before every arrival starts. That is half of the entry ADR-0171 filed under text-area.
  • The showcase has a ninth screen, and it is Java on purpose. Everything worth seeing about a banner is a change — it arrives, and it goes — and both are the application’s doing, which is the same fact that keeps bind= off the widget. So Notifications has four buttons that spawn one and a × that actually removes it, where a dismiss= in a document can only report; the four resident banners at the top of it are the four kinds, and §4’s summary is under them. NotificationsScreenTest presses the buttons, because a golden cannot.
  • Four banners in a column touched, because a column has no gap of its own and nothing had stacked two blocks with borders before. 12px in the showcase’s own stylesheet, where every other gap on that screen is — and a note for toast, which stacks them with no container an author could write (ADR-0175)

dialog, and the two mechanisms it was waiting on

  • A modal is two different things and only one of them is code. The pointer’s modality is geometry — the scrim is a filling overlay, and a filling overlay takes every press wherever it draws, which tour’s veil discovered and which needed nothing new. The keyboard has no position, so its half had to be said out loud: Handles.isModal(), read by the router as one sentence — while something modal is mounted, the focused node is inside it.
  • The trap is enforced where focus is set, not at the routes that move it. The routes are Tab, a press, a roving arrow, a control focusing itself and whatever asks next; a trap that covered four of five would be no trap. Nothing is registered when a dialog opens, so a dialog removed by any route at all gives the keyboard back.
  • Host.focus(id) exists, which three separate TODO entries were waiting on, and the rule that makes it useful is the fallback: a node that cannot take focus resolves to the first focusable thing inside it. A dialog’s panel is not focusable, so without that the one caller that most needed this could not have used it.
  • The roles are values and the order is the theme’s. Esc, Enter and §7’s platform button order all need the dialog to know which button is which, so a DialogAction carries one — and two affirmatives is refused when the dialog is built, because Enter cannot be a coin toss. The order itself is one CSS declaration: the bar writes neutral, dismissive, affirmative, and a Windows theme reverses it. A widget that read the operating system to lay itself out would be a widget whose goldens differ per machine.
  • Both keys bubble. Handles.onKeyCapture’s own doc says “where a dialog swallows Escape”, and this dialog deliberately does not: a text-area keeps Enter and an open select keeps Esc by consuming them, and the control is the reason the dialog is open.
  • Closing runs before the application is told, which is §1.7’s overlay lifecycle and message’s order from the same day — so a handler that removes the overlay immediately still gets the fade, and no application writes a line about the animation (ADR-0176)

toast, and §7 is complete

  • The value is not a widget, which is the one place in the catalog that is true. §7 draws the line itself — a message is part of the layout and a toast is “something that just happened” — so a Toast is a record raised through a controller and there is no node an author can write. A document describes a screen; a toast is an event, and a screen that described one would raise it again on every reload.
  • The stack is the widget, and holding the list is the point. Both things ADR-0175 filed as impossible for a lone banner fall out of owning a queue: a toast can outlive its own dismissal long enough to fade, and something finally knows what a notification’s siblings are.
  • “Queued” is a cap of three, which is a judgement §7 does not make: four in a corner is a wall nobody reads and one at a time makes a burst take half a minute. A departing toast gives up its place immediately, so the stack briefly holds four rather than making a burst stutter.
  • The hover-pause is a pause. Host.after gives a timer and no way to ask how much of it has run, so resuming needs a clock — and the only clock a widget has is the one render is handed, which the stack reports back on every frame. A toast you glanced at for two seconds gets its remaining three, not another five.
  • The corner decides three things — where the stack sits, which edge a toast slides in from, and which end of the column is newest — because they are one decision. The last is a CSS rule (column-reverse for the top corners) rather than a list the widget reverses and then has to reverse again for the keyboard (ADR-0177)

A dialog that would not fade, found by being asked for it

  • isAnimating answered !closing && phase.isRunning(), so the instant a dialog started closing it stopped asking for frames — and a widget nobody asks to repaint does not fade: it stood still for 160ms and vanished. Two flags were doing one job. closing means input is off from the moment an answer is given (§1.7’s “no ghost clicks”); only a second flag, there is nothing left to draw, may switch the animation off.
  • Every golden passed, which is the part worth keeping. A golden drives render by hand and never asks whether the frame loop would have, so the four dialog images were pictures of an animation that never ran in a real window. The two tests that would have caught it assert on isAnimating directly, and they exist now.

The sibling reflow, and §7 is finished

  • Only the older toasts move, which is not obvious and is not the widget’s doing: a toaster is a corner overlay, so the column is anchored along the edge it is against, and controls.css puts the newest toast at that end. The column is therefore anchored by its newest member — so a hole in the middle leaves everything between it and the corner exactly where it was, and the older half comes in to close it. The corner decides which way, as it already decides which edge a toast arrives from.
  • The ordinary case moves nothing, and that is correct. A stack whose toasts share a timeout loses its oldest first, and the oldest has nothing older to move. The reflow is what a dismissal from the middle looks like — an action button, a clear(), a burst with uneven timeouts.
  • The translate runs backwards. Yoga has already put the survivor where it belongs; the transform puts it back where it was for one frame and then lets go. Same shape as the arrival, and they compose on two axes rather than taking turns — a toast can still be sliding in when the one beside it is dismissed.
  • The two numbers are read, not invented. The height of the hole comes from Measured and is banked every frame, because by the time it is wanted the toast is gone; the gap comes from toaster { gap } through the channel ADR-0177 opened for the frame clock. A toast dismissed before it was ever painted has no height, and that reads as no hole rather than a hole of nothing — the check is on the height, because the gap alone is a real number.
  • The AnimationController §3 names did not appear. Phase already does the start and the end; the interruption — a second toast going while the first reflow runs — turned out to be three lines of arithmetic rather than a mechanism. ADR-0081’s finding one level up. The overlay enter/exit sequence is the specification’s one remaining subject.
  • The golden had to be taken in a real window, and that is the first decision above making itself felt: every other picture in ToastGoldenTest is of a column on its own, which is top-anchored, so it would photograph the newer toast moving. Overlay placement is not assertable as a number, which HudGoldenTest found first (ADR-0178)

What a popup measured, said out loud

  • The measure step was never observable, and Host had always claimed it was. Measure, place, open — “and each is separately observable” — but a caller that needed to know how big its content came out had no way to ask, and Placement clamps anything taller than the work area to the near edge with everything below it silently dropped.
  • So both callers who needed the number worked around it, differently. Menus guessed — rows times an assumed 34px, rounded up so it erred towards wrapping a menu that would have fitted — and kept a second copy of --gb-menu-item-height to do it. select did not try at all, so a list with more options than the display is tall lost its bottom: the same defect menu had before ADR-0118, still shipping in the control §3 most expects to be long.
  • Host.Fit is the report, taken between the measure and the place: handed what the content measured and the room it has, answering with what to open. Returning the content unchanged is the ordinary answer and costs nothing; returning anything else costs a second element tree, which is the right way round — nearly every popup fits, and only the one that did not pays.
  • The facility asks rather than decides, for two unchanged reasons: whether long content should scroll or be clamped is a fact about the content — a tooltip that scrolled would be absurd — and :core has no widgets to wrap anything in anyway. Reporting in :core, policy in :widgets, which is the fence the modules already draw.
  • One policy now, held once. Fitted is what both callers answer with, beside the Scroll it builds. The 8px margin came out of Menus and was never anything to do with menus: a panel flush against both edges of the screen looks cut off even when it is not.
  • A twenty-row menu measures 667px where the estimate said 696 — close enough that the guess was never wrong on a full-height display, and 29px of menu needlessly wrapped on a short one. The end-to-end test opens that menu into 240px of work area through the real launcher, and fails at 667 without the fix (ADR-0179)

The keyboard, given back — and the stale pointer under it

  • Element.unmount tells the element tree and nothing else. The router is not a listener, so a dialog closing left PointerRouter.focused pointing at an element that had left the tree: unmounted, still receiving key events, still keeping its whole dead subtree reachable. “Focus is not restored when a modal closes” was the half of that anybody could see, and it was on the list; this was not, because nothing had looked.
  • So it is two rules, and the first is not about dialogs. The router never holds an element that is not in the tree — a switched tab, a shortened list and a closed dialog strand the same pointer — and then, if there is somewhere to put the keyboard back, it goes there.
  • refocus() runs once a frame from updateRegions, beside notifyMeasured and notifyLocated, and is public where they are private: the question is about the element tree rather than the painted frame, and a test that closes a dialog without drawing anything still needs the answer.
  • One slot, and it is the first state the focus trap has ever held. TODO was right to flag the cost. Everything else about the trap is a question about the tree asked fresh — which is why a nested dialog gives the first one back for nothing — and this cannot be: what had focus before a modal opened is a fact about the past. So it is written at exactly one moment, never overwritten by focus moving inside a modal, kept rather than spent when a nested modal closes, and allowed to go stale on purpose.
  • The ring goes back with the keyboard, because §7.2 keeps :focus and :focus-visible apart and restoring one without the other would either lose a ring the user was looking at or conjure one under a pointer nobody moved.
  • The two popup entries turned out to be wrong about the cause. A probe through the real launcher — a widget logging every focus change, a menu opened over it and closed — recorded no focus loss at all. A popup has its own tree and its own router and touches neither of the owner’s, so a select’s field keeps its focus and its ring for as long as the list is up. What may still be missing is the platform’s window focus, which the headless backend cannot show. Those entries say that now instead (ADR-0180)

How small and how large, which three widgets had been writing around

  • §8’s subset gained min-width, max-width, min-height and max-height, and dialog has the two numbers §2 has asked it for since it was specified. It was never only about dialogs: toast’s 360 is a width and controls.css says outright that it is one “because the subset has no max-width”, a tooltip has no maximum and so runs a long one onto a single line, and popover takes minimumWidth as a Java argument doing a declaration’s job. Three widgets writing a width where they meant a maximum is a missing property rather than three choices.
  • One value, not four components. Limits, beside Insets. The Insets argument applies — the four are only meaningful together, and Box and ComputedStyle would each have grown four where they now grow one, across 45 positional reconstructions. The extra argument is that these are the same question asked four ways: a caller handling three of them has a bug nobody would find, and one value makes that impossible.
  • Undefined, not zero, because a minimum of zero constrains nothing but a maximum of zero is a box that may not exist. “No limit” and “a limit of none” cannot share a spelling.
  • The scrim lost its padding across, and that is the whole trick. §2 wants “80% of the window” and a percentage resolves against the containing block — a dialog’s is the scrim, which fills the window, so 80% means what §2 says only once the scrim stops insetting it. Measured rather than assumed: with the 24px still there the dialog came out at 330 in a window where §2 permits 339. Down the page the padding stays, because nothing else keeps a tall dialog off the top and bottom edges.
  • All four dialog goldens changed, and the change is the spec being applied: the dialog wanted 85% of the window and is capped at 80%, so its message wraps. That is what a maximum does, and it is the first evidence the property is real.
  • Of the four consumers waiting, one wanted converting. tooltip has a maximum now — 320, a judgement rather than a specified number, because without one a sentence of help text is a ribbon across the window. The other three were not consumers: toast’s width is a design argument its own note already made (the same 360 on every toast is what makes a stack read as a stack, and a maximum gives the ragged pile back), popover’s minimumWidth is a runtime measurement no declaration can express, and text-area’s max rows is built and is a row count.
  • The 24-component record has a test rather than a refactor. One failure mode follows from a positional constructor that long — an argument in the wrong slot, compiling and running and wrong in a way no golden shows. RecordWitherTest asks every wither on Box and ComputedStyle to set its component to the value it already holds and requires an equal record back, which catches a wrong slot, a wrong read and a doubled component alike, needs nothing per component, and covers whatever is added next the moment its wither exists. Verified by planting a width/height swap the compiler cannot see. The structural answer — group the components until no argument list is long enough to get wrong, which is what Insets and Limits already do — would turn box.width() into box.layout().width() across the toolkit for a benefit this already has (ADR-0181)

A set of things, and a way to give one back

  • A toast’s plate is its own dismiss affordance. §7 gives a message a × and a toast an action button and nothing else, and that was followed exactly — which left a Duration.ZERO toast with no action removable only by clear(). What the missing × meant is that a toast does not need a second affordance competing with its action on a 360×40 plate. The click was already being swallowed and doing nothing.
  • select multiple= is built, and change is a toggle in that mode: the set is the application’s, so asking for a value it already holds can only mean taking it out. One channel keeps a chip’s × and a click on a chosen row from being two ways of saying one thing. The order is the options’ rather than the model’s, so removing a chip and putting the value back does not move it to the end of the row.
  • A popup’s content may now change while it is open, which the toolkit could not do: a popup is an element tree with its own build schedule, so a setState in the widget that opened it reached nothing in the popup’s window, and showing it something new meant closing and reopening. ElementTree.update reconciles from the root, so elements, state and focus survive.
  • §4’s free-text autocomplete is built. The field raises the query through change, the application answers by handing back a list, and choosing reports through the same channel — so “the field’s text is never rewritten without the user choosing” falls out of the shape rather than being enforced. Option.inAList() is what makes the panel right: arrows move the focus and Enter commits, where follow-the-focus is a select’s behaviour and would rewrite the field under a user who is only looking.
  • A field that learns where it is asks for one frame, and that was a real bug rather than a test artifact: a field focused with suggestions in hand offered nothing until some unrelated frame rebuilt it, because the rectangle arrives after the paint and §1.7’s idle loop was never going to ask for another one. The rebuild is asked for only on a change and only when something is waiting, so it settles in one frame (ADR-0182)

A combobox, which is a select you can type in

  • §3’s autocomplete is built, and the editor is a real text-input. The sentence says “makes the closed control an editable text-input” and it is meant literally: everything an editable field needs — the edit model, the undo history, the clipboard, the caret, IME — already lives there and has rules in it, and a second editor grown inside select would be a second copy of those rules with the first drift going unnoticed.
  • One Tab stop. The field stops being focusable when it holds an editor and delegates focus instead, which is field’s mechanism for its own reason: the thing that takes the press is a sibling of the thing that should end up focused. Two keys change meaning with it — Space types a space, because §3 lists it as a way to open a closed control and a combobox is not one, and a click opens rather than toggling, because a click in a combobox is a user putting the caret somewhere.
  • One nullable string does all the work. What the user has typed is handed to the TextInput as its value, and follow overwrites the field only when the offered value changes — so typing is never fought, Esc restores by setting it back to null, and choosing clears it for the same reason. No new rule was needed anywhere.
  • Refusing is a blur-time decision, because that is when a half-typed value stops being an attempt and starts being an answer. Heard through onFocusWithin rather than onFocusChanged, since the thing that has the keyboard is the editor inside the field.
  • The editor is drawn as the select’s interior, checked rather than assumed: left alone it brought a text-input’s border, fill, radius and focus ring inside the select’s own (ADR-0183)

tree, and the last of §3’s select line

  • tree is built in a first cut, and it had to be: §3’s select tree= takes “a tree’s model”, and §3 also says a tree shares list’s item-factory — but list is not built either, so the model was defined here and list will have to agree with it.
  • The id is the whole model. §3 asks for expansion retained “by node id, not by index”, so TreeNode requires one, the state holds a set of ids, and the row uses it as its reconciler key. One decision paying three times: a model re-sorted under an open branch leaves it open, and leaves its focus alone.
  • A chevron is drawn before anyone knows what is under it, which is what makes lazy children possible at all — a node that had to know its children to decide whether to draw a chevron would make a directory tree stat the whole disk to draw its first row. The fetch happens in the toggle rather than in build, and a branch closed and reopened does not go back to the supplier.
  • The indent is a sized box, not padding, so the selection highlight still reaches the left edge; and a leaf keeps the chevron’s box and draws nothing in it, so a folder’s label and a file’s label at one level line up.
  • Right on an open row does nothing, deliberately. Rows are flattened depth-first, so the next row is the first child — the key falls through unconsumed to the vertical scope. Left on a closed row is the one that needs help and asks the host to focus the parent by name.
  • Not built, and filed: the checkbox per node with cascade and indeterminate, *, type-to-select, multi-selection, Home/End. And §2’s chevron rotate, which is two marks instead because §8’s subset has no transform on a mark (ADR-0184)

Three defects found by running it, and one still open

  • A multiple’s chip appeared and the row stayed grey. The list was re-described at the moment of the click, where widget() is still the description from before the application was told — so it drew the selection the list already had. The rule worth keeping: after reporting upward, a controlled widget knows nothing new until it is rebuilt, and reading widget() there is reading the past. The refresh moved into build.
  • An autocomplete took one character and went dead. The list focused its first row on opening and took the keyboard off the editor being typed into. So a popup that hangs off a field does not take focus — the arrows still reach it, because the owner forwards keys to whatever popup is open, which is a mechanism that existed for another reason and turns out to make this safe rather than a compromise. It opens on focus rather than on the click, because the editor consumes the press to place its caret.
  • A tree would not open with a mouse. Nothing handled a click: a row selected when selectable, and in a leaf-only tree a parent is not — so a click on “Europe” did nothing, and the chevron had no handler either. Every keyboard test passed.
  • All three were green in CI, which is the part worth keeping. Each lives in the seam between the widget and a running window, and every test drives the widget by hand. MenusTest drives the real launcher against the headless backend and nothing in §3’s select family does — closing that is worth more than the three bugs were.
  • The wither check now covers the catalog. WidgetWitherTest walks the compiled classes and asks every wither on every widget record to set its component to what it already holds; verified by swapping two same-typed arguments in Select.placeholder. Five widgets refuse the values it invents and are named in the failure message rather than skipped quietly.
  • Still open: a popup hangs when the application loses focus to another window. Closed. ADR-0144’s mechanism was wired and the fault was inside it: anyWindowFocused() counts popup windows, so a popup holding the platform keyboard kept the check true and the dismissal never fired. No popup of any kind is focusable now, which costs nothing — the owner has forwarded keys to whatever popup is open since ADR-0104, because SDL focuses POPUP_MENU windows on some drivers and not others (ADR-0185, ADR-0186, ADR-0189)
  • SelectLoopTest drives §3’s select family through the real loop, which the three defects above argued for, and it found a seventh on its first run: a click opened the list and closed it again in one gesture, because the press focused the editor — which opens it — and the click then toggled from a stale open flag. An editable control opens on one signal now, and the signal is focus (ADR-0188). What the harness still cannot reach is the platform’s window flags: reverting NOT_FOCUSABLE fails nothing, because the headless backend has none.
  • Still open: flex-wrap is not in §8’s subset. It is now, and the chips wrap (ADR-0192). The gap was in one place — Yoga had the setter bound and the enum written, and nothing above the native boundary could say it — so the work was one component on Box, one on ComputedStyle, one parser case and 48 positional reconstructions, which is the churn ADR-0181 grouped four properties to avoid. Where the wrapping goes had to be seen rather than reasoned about: on the field it drops the chevron onto a second line under the chips, which is a worse picture than the shrinking it fixes, and only the golden image said so. The chips have a box of their own now — select-chips, a part in ADR-0065’s sense — and a second golden holds five chips in a 220px field, because the existing one has three that fit and its javadoc claimed they wrapped.

tray-icon, and the first widening of the export list

  • §9’s tray-icon is built, and it is the first thing M3 owed that begins in goldberry.symbols rather than in a widget. Eleven symbols — nine tray calls, plus SDL_CreateSurfaceFrom and SDL_DestroySurface, which are how a painted BGRA buffer becomes an icon — took the list from 192 to 203, and the five SDL_TRAYENTRY_* values went into the constant probe with everything else. The one that pays for the probe is DISABLED: 0x80000000 is a negative int, and a mask assembled in one is wrong in a way nothing else would have noticed. SDL_UpdateTrays is deliberately unbound — SDL calls it from its own event loop, and this toolkit pumps events. (ADR-0191)
  • It is the first entry in the catalog Goldberry does not draw. A tray menu is a GTK menu, an NSMenu or a Win32 popup: the shell owns the font, the row height, the highlight and the click. So the parity invariant’s third clause has nothing to attach to, and a TrayIcon is a value like a Toast rather than a widget — Trays.show(host, tray) is what puts one on the desktop.
  • The menu it holds is an ordinary Menu, which is ADR-0163’s finding used a second time: what is short-lived about a menu is the popup and not the description, and a tray menu is the longest-lived opening there is. An author writes one description and shows it in a window, in a context menu, or here. What the platform has no vocabulary for is dropped with a warning — an icon, an accelerator, any widget that is not an item or a separator — because a tray that quietly ignored half a description would be a menu somebody kept editing without effect.
  • Absence is reported and no error string is read to decide it. A popup’s caller reads SDL’s not supported to tell a driver’s limit from a caller’s mistake; the tray has no such line, because the Linux path fails with Could not load AppIndicator libraries — an absence wearing the words of a failure. Every null is empty, logged at debug with SDL’s own words, and the showcase says tray unavailable on this desktop and carries on.
  • HeadlessTray is the only place a tray menu can be observed at all. There is no golden image of a GTK popup and nothing to hit-test, so choose("Recent/ report.pdf") is the click the shell would have delivered, applied in the platform’s order: a checkbox toggles before its handler runs, because SDL applies the click itself and the handler reads the result. A test that toggled afterwards would be asserting an order no platform uses.
  • It ran for real, which for this widget is the only proof available: the natives test created a live tray on this machine’s session under libayatana-appindicator, and the showcase puts one up on start — six rows, a submenu among them — and takes it down in stop, because a tray left behind is a picture in somebody’s notification area that the shell will not clean away.
  • Every row but Quit did nothing, and that was found by running it. A tray row is the only input in the toolkit that arrives with no event behind it: it is delivered from inside SDL_PumpEvents by way of SDL_UpdateTrays, so no pointer moved, no key arrived, and nothing asked for a frame. A jar-bound model is swept at the top of a frame, so a handler that set the theme set it where nobody was looking. Quit worked because closing a window is a platform effect rather than a model change — which is exactly the shape that makes this look like “the tray is broken” rather than “the loop is asleep”. Host.tray gives every row the window’s repaint now, and both the value and the widget layer assert it. The lesson generalizes: a source of input the frame loop cannot see has to say so itself, and this is the first one whose failure was silent.
  • The libayatana-appindicator is deprecated warning on Linux is the distribution’s, not the toolkit’s. SDL’s loader tries libayatana-appindicator3.so.1 and libappindicator3.so.1; the -glib successor the message names is not on its list, so silencing it is a change to SDL on a pinned commit.
  • Not verified on Windows or macOS. Those paths are SDL’s, are compiled, and nobody has looked at them. Said here rather than implied by silence.

Charts, which start two layers down

  • canvas is not built, and charts sit on it. content-widgets.md §3 builds the five chart widgets on the canvas primitive so they inherit the theme, the text stack, hit testing and the golden corpus — and §1’s canvas was never written. It has been blocking something shipped since M2: statistic’s sparkline is specified and absent for want of it (ADR-0164). So the order is canvas, the chart substrate, then the widgets.
  • The paint surface gained a state stack (ADR-0193), which is the first thing canvas needed and the second widening of the export list in this milestone. Every painter inside the toolkit knows what it set and unsets it; an application’s onPaint is not one of those — it runs inside whatever clip the tree established, and resetClip goes back to the whole frame rather than to the region before it, so a canvas inside a scroll would paint over the viewport’s edge. bl_context_save / bl_context_restore are exported now (205 symbols), Frame.save() / restore() sit over them, and the nesting is asserted rather than assumed. The export list’s own comment used to explain why the pair was unnecessary; it now explains why both calls exist.
  • The series palette is derived and measured (ADR-0194). §3 says “categorical series colors from aurora + frost hues”, and the word doing the work is derived: Nord used literally fails five of the six categorical checks — six of eight hues below the chroma floor, so they read as gray and stop doing identity work, and nord9/nord8 at ΔE 8.5 because the frost family spans 23° of hue and two of its members are 5° apart. What ships is eight slots re-stepped from Nord’s hue angles, with dark as its own steps rather than a flip, passing all six checks in both modes. The order was searched over all 40 320 permutations rather than chosen, because adjacent slots are what touch in a stack: Nord’s own numbering puts orange beside green at ΔE 0.8 under deuteranopia, which is two series nobody can tell apart.
  • The Grafana question is answered in docs/charts.md §3 — which of its features belong in a desktop toolkit, which are goldberry-plot’s, and which are dashboard machinery a toolkit must not grow (query editors, field overrides, auto-refresh, dual y-axes).
  • canvas is built — §1’s last unbuilt primitive, and the substrate the five chart widgets sit on. A Painter is a content slot on Box beside text, icon and mark, and paintOne hands it the frame translated to the box’s content corner and clipped to it, inside the save/restore pair above. So a painter draws in its own coordinates from (0, 0), cannot escape its rectangle however wrong its arithmetic is, and may leave the context in any state at all — which is what makes it safe to hand an application the toolkit’s own rasterizer.
  • Three guarantees, asserted in pixels rather than in calls. A painter that fills (-50, -50, 200, 200) paints its own 40×40 and nothing else; a painter that clips to a 2px sliver, translates and returns leaves the box drawn after it whole; a painter that throws propagates its exception and restores, so an application’s bug is a stack trace rather than a window that draws wrong from then on. A canvas laid out to nothing is skipped rather than throwing, because a collapsed split pane produces one.
  • It draws inside the padding, which is ADR-0111’s rule for text applied to the one content that is not text: canvas { padding: 8px } is eight pixels of surface, not eight pixels of drawing.
  • Markup writes one and names no painter. A canvas node inflates to a styled, sized surface that draws nothing, so the parity invariant holds — a document says how big it is and what it sits on, and the drawing is Java. Naming a painter from markup needs the registry indirection icon and action use, and the shape of that registry depends on whether a painter is a value or a method. Filed rather than guessed at.
  • sparkline is built, and statistic has its child. §11’s first chart and its smallest: no axes, no legend, no tooltip — a shape beside a number, read for its direction. It is one series, so it takes color, exactly like text: a palette is for telling series apart and there is nothing here to tell apart, so an application recolours one with the property it would already reach for and a trend inside a statistic can inherit the delta’s hue from a rule. The palette arrives with line-chart, which is the first widget that has two of anything.
  • It scales to the data’s own range, not to zero. A series between 1000 and 1004 baselined at zero is a flat line that says nothing, and the shape of the change is the whole job. A flat series is centred rather than divided by a zero range, and the stroke is inset by its own half-width so a maximum is not clipped in half at the top edge.
  • Lttb is the first of §3.1’s borrowed algorithms. Largest-Triangle-Three- Buckets, and the reason is truth rather than speed: a hundred thousand points in a two-hundred-pixel sparkline is five hundred per pixel, and whichever one is drawn last wins. The test that matters puts a single 20× sample at index 4237 of 10 000 and asserts both that LTTB keeps it and that every hundredth sample — “just take fewer points” — misses it entirely.
  • The marker is a disc and there is a test that says so. SVG’s A, which is what Blend2D’s path takes, cannot draw a full circle in one segment, so it is two half-arcs; getting that wrong gives a square, a wedge or nothing, and all three look plausible at 200px. The corners of its bounding box are what tell them apart.
  • statistic’s note turned out to be right. It said, while it was waiting, that a sparkline would be “one more child at the end of the column” — and that is exactly what it was, with no other change. That sentence is an assertion now. The gap has been open since M2 (ADR-0164).
  • Two goldens: canvas-dark, a themed surface with a border and a radius from CSS and two bars from a painter — the picture that says the two halves compose — and sparkline-dark, filled and marked, in --gb-accent.
  • The axis substrate is built: Ticks and Scale. Ticks.extended is §3.1’s Wilkinson algorithm in Talbot, Lin and Hanrahan’s 2010 extension, which scores candidate labellings on simplicity, coverage, density and legibility and takes the best. It exists because nice numbers are not a rounding problem: 0…97 at five labels is either 0, 20, 40, 60, 80, leaving a quarter of the axis unlabelled, or 0, 12.5, 25 …, which asks the reader to do arithmetic to place a point — two failures pulling opposite ways, which is why one number cannot decide it. Legibility is a constant 1, and that is stated rather than dropped: the paper weighs font size and label overlap, which need a decided axis width this does not have yet.
  • Scale has no “inverted” flag, and that is the design. A frame’s y grows downward and a chart’s values grow upward; every bug in this area is remembering that in one place and forgetting it in another. So a y scale is linear(min, max, height, 0) — a swapped range — and the arithmetic never knows which axis it is. A flat domain maps to the middle rather than to an edge or a NaN, so the next four charts inherit the rule sparkline had to state for itself.
  • sparkline was moved onto it and the golden did not move a pixel, which is the check that says the substrate is the same arithmetic rather than a second opinion about it.
  • Twenty tests on eleven lines of algorithm, and the ratio is the point. The tick tests are ranges chosen to be awkward: 0…97, 1000…1004 (which must not fall back to labelling from zero, or the whole series sits in the last thousandth of the axis), negatives, and the same range at five magnitudes from nanometres to trillions, because a step that stops being round at some magnitude is a rounding bug. Plus a timing bound — it runs per axis per frame, so a millisecond would be a third of a frame’s budget.
  • The series palette is the theme’s, not the toolkit’s (ADR-0195). The eight values live in nord-light.css and nord-dark.css as --gb-chart-1…8 and are read through a new Paints.Context#color, because a chart is the one widget that cannot express its colours as CSS properties: a node has one color, a stylesheet cannot say “the fourth series”, and ADR-0065’s parts do not help because a canvas has no child nodes at all — its content is a painter rather than a tree. A Java table would have worked and would have taken colour away from the theme: two themes would share one palette, an application could not recolour one chart’s first series, and a third theme would be a code change. Now #revenue { --gb-chart-1: #b48ead } is an ordinary rule, and there is a test that says so.
  • It is the first thing on Paints.Context whose answer is per node, and the context is deliberately one object per renderer — which is why nowMillis is a field rather than a clock call. So the renderer sets currentElement before render and clears it in a finally, and the clearing is not tidiness: a canvas painter closes over the context and runs later, during the paint, so a context still holding an element would let a painter read a stale node’s tokens in a frame where the tree had changed under it.
  • line-chart is built — the first chart with axes, and the first widget in the toolkit with more than one of anything. It is two halves: a chart-plot that is a canvas, because a chart of a thousand points must not be a tree of a thousand nodes; and a chart-legend that is ordinary widgets, because a legend is text and a swatch — the two things the toolkit is already good at — and making them nodes means a stylesheet reaches them, the shaping cache serves them, and the entries wrap when the chart is narrow, which is what ADR-0192’s flex-wrap was added for. Drawing the legend inside the canvas would have re-implemented all three.
  • The legend is present for two series and absent for one, which is a rule and not an option: with one line the title names it and a box repeating that is noise; with two, colour is the only thing telling them apart, so identity must never be colour alone.
  • What happens in render and what happens in the painter is the design. The cascade and the text stack are only available in render, so the tick labelling, the series colours and the shaping of every label happen there — a chart that shaped its axis inside the painter would re-shape five unchanged numbers sixty times a second, at 56 µs each (ADR-0037). The painter gets the size, so it decides where the gridlines go and how wide the gutter turned out to be: measured from the shaped paragraphs, so an axis reading 1,000,000 reserves more room than one reading 5 and nobody wrote a number down.
  • Axis labels are formatted in the root locale, which is hud’s rule with a stronger reason: a golden image of a chart formatted in the machine’s locale is a test that passes in one country. Decimals come from the step rather than the value, so an axis stepping by 0.5 labels 1.0 and not 1 — a column where one label has a decimal point and the rest do not reads as ragged.
  • The golden found a defect immediately. The last x label read Su: it is centred on its point, the last point is at the right edge, and half of it hung outside the clip. Edge labels are pulled back inside the plot now — nudging beats dropping them, because the two ends of an axis are the labels a reader most wants.
  • §3.2’s inline KDL data works, via option’s precedent. The inflater builds depth-first and hands a factory children that are already widgets, so series and point are registered nodes that draw nothing — exactly what select’s options are, and for exactly that reason. The alternative was teaching the inflater that some children are data, which is a change to the one mechanism every widget goes through, for a case two widgets have.
  • All five of §11’s charts are built. area-chart and bar-chart are modes of the same chart-plot, because the axes, the gridlines, the gutter measurement and the label-collision rule are the same for all three and three copies would be three chances for a chart whose gridlines are a pixel off its labels. ChartParts holds the two rules every chart shares — what its children are, and how §3.2’s inline data is read — for the same reason.
  • A bar and a band start at zero and cannot be talked out of it. A bar encodes its value as a length, so a baseline at 90 makes a 3% difference look like a doubling; line-chart is the only one of the five that may zoom its baseline, because a line encodes by position rather than by area. A negative bar hangs below the zero line rather than being drawn upside down, which is the one thing every naive bar renderer gets wrong.
  • An area chart is stacked, always. Overlapping translucent bands are the classic unreadable chart: three series make seven possible colours on screen and none of them is in the legend. Stacked, the bands add to the total — which is what a reader assumes an area chart means anyway. So the choice between the two is real: line-chart for separate quantities, whose total is meaningless; area-chart for parts of one.
  • Bars sit in a band and lines sit on a point, which decides where a label goes. Getting it wrong puts every bar chart’s labels half a band to the left, and it looks like a rounding error rather than a category error.
  • donut-chart refuses two slices and refuses nine, at construction, which is where dialog refuses two affirmative buttons and for the same reason. Two is a ratio and reads better as progress; nine has arcs too narrow to compare and more parts than there are distinguishable hues, and bar-chart answers the same question at forty categories. It also always has a legend, unlike the axis charts: an axis chart with one series is named by its title, and an arc has nowhere to write a name.
  • The ring starts at twelve o’clock and goes clockwise, because that is where a reader’s eye starts; the maths starts at three o’clock if nobody intervenes. The gap between slices is taken out of each slice rather than drawn over it, so a slice’s area stays its share.
  • Four goldens, one per chart, and each caught something a test could not have asserted: the clipped Su, the stacking order, the band-versus-point label offset, and the arc direction — largeArc set wrongly draws the complement of a slice, which is exactly wrong rather than obviously wrong.
  • The showcase has a Charts screen, which is the first place all five are on one wall — and it is what asked for masonry (ADR-0196). A donut is square, a statistic is three lines and a line-chart is whatever height it was given; in equal rows every card is as tall as the tallest beside it and the short ones sit in acres of surface.
  • masonry reads the frame before. Nothing can tell a widget how tall a child will be before it is laid out, so each card reports what it came out as through Measured, the state banks it, and the next frame puts each card under the shortest column. First frame round-robin, second frame right. It is allowed here — where Measured’s third rule forbids it in general — for one reason: the columns are equal width, so a card’s height does not depend on which column it is in, and the number being reported is stable under the thing it causes. Which is also why columns is a count and not a list of widths, and why there is a test that four frames produce one layout rather than a comment claiming they do.
  • It is a widget the design documents do not have. §5’s containers were complete without it; recorded in ARCHITECTURE.md §17.1 rather than resolved by editing a document that is the authority.
  • The screen found three defects that no assertion would have. A legend’s entries touched, because §8’s gap takes one length and the two-value row/column form parses as nothing; the lowest y label was cut in half when a chart had no x labels, because it is centred on the baseline; and --gb-chart-1: var(--gb-warning) did nothing, because a custom property may hold another var() and CSS resolves those at use time — reading the raw tokens saw “not a colour” and fell back silently. StyleResolver exposes a substituted read now, which is what ADR-0195’s mechanism should have done from the start.
  • And it closed an open TODO. The gallery goldens never fed hit-test regions back between their two frames, so every self-measuring widget saw a first-frame answer for ever — text-area wrapped as though it were narrow in the Forms image and the entry said so. A masonry cannot be photographed at all without it, which made the gap concrete enough to close; the Forms picture is now the one the running application shows.
  • The interaction layer charts.md §3.1 lists was next, and the hover half of it is built — see What a chart does when a pointer arrives and A donut under the pointer. What a chart says when it has no numbers is built too — see What a chart says when it has not got the numbers, and so are thresholds, the java.time axis, interpolation, log scales, soft bounds, point markers and the shared crosshair. Only the gradient fill is outstanding, and it is waiting on a native symbol rather than on a decision.

Two things found by scrolling the wall of charts

  • A chart did not move with the panel it was on. Scrolling the Charts screen slid the cards, the headings and the axis labels, and left the five plots where they were laid out — clipped by a viewport travelling over them. One line: paintCanvas moved the painter’s origin to the box’s content corner with Frame.transform, and that call assigns rather than composes (ADR-0068 chose that: the walk accumulates the matrix in Java, and each box assigns the answer, so a run of untransformed boxes costs no native call). A scroll moves its content with a translate, so the canvas replaced the scroll’s matrix with its own. paintOne now takes the ambient matrix and the canvas composes onto it (ADR-0197).
  • The clip in the same method was already right, which is why the symptom was a chart standing still rather than one that vanished: a clip lands in the context’s current user space and Blend2D intersects, so the two halves of one method disagreed about which space they were in. Both are now written down beside each other.
  • Nothing could have caught it. Every canvas test paints at the root, and a golden is captured at scroll offset zero — where ScrollContent puts no transform on the context at all, because an unscrolled viewport should not. CanvasPaintTest now paints a canvas under a translate and reads the moved rectangle; it fails on the old code with the square exactly where it was laid out.
  • The gallery had two orders in it, and one of them was a crash. The strip lists eleven screens; Showcase held its own copy of the list for the Ctrl+1… accelerators, and charts went into that copy instead of choosers rather than after it. So Ctrl+8 selected the ninth tab, and the loop asked a nine-element digit list for its tenth entry — an IndexOutOfBoundsException in start, which is a window that never opens. Screen.GALLERY is the one order now: the strip is built through Screen.inGalleryOrder, which throws when the tabs and the list do not name the same screens, and the accelerators are bound from it.
  • Ten digits, eleven screens, said out loud rather than papered over. Ctrl+0 is the tenth and the eleventh has none; which screen goes without is the order’s decision, and it is the tour screen, which is opened by name anyway. GalleryOrderTest runs each bound accelerator to find out which screen it picks, because a key bound to the wrong screen is invisible in a picture — both tabs render correctly.

What a chart does when a pointer arrives

  • A crosshair, a marker per series, and a readout beside it on line-chart and area-chart; a band highlight on bar-chart, because a hairline down the middle of a group of bars points at the gap between two of them. This is the first half of charts.md §3.1’s interaction list (ADR-0198).
  • The plot’s geometry is one arithmetic used in two directions. PlotGeometry turns a point index into an x and an x back into a point index, and the test that matters is the round trip over every point count from 1 to 40 in both modes — because a crosshair two pixels left of the point the pointer chose is a chart that looks broken at one window size and fine at every other. It also settles where bars differ from lines in one place: a bar owns a band and a line passes through a point, which decides the label offset, the crosshair and the pointer mapping at once.
  • The pointer resolves against the frame that was painted. The gutter is measured from the shaped axis labels, so the geometry is only known inside the painter — and a pointer event carries a rectangle and no text stack. So the painter leaves its answer in a PaintedGeometry and the handler reads it, which is ADR-0054’s rule one level down: the toolkit already routes a pointer against the frame the user was looking at when they pointed.
  • The readout is painted and the legend is not, and the difference is not taste. A legend wraps, is selected by a stylesheet and is in the same place every frame, so it is nodes (ADR-0192’s flex-wrap was added for it). A readout is positioned in plot coordinates, flips side at the middle of the plot, and must not participate in layout — as widgets it would need absolute positioning against a gutter children() cannot see, so it would read a geometry one frame late to produce a node that must not be laid out. Its text is still shaped by the text stack, in render, where the hovered index is known: one point’s worth of strings, and the cache serves the repeats.
  • It takes hud’s tokens, through ADR-0195’s Paints.Context#color. A floating overlay over content the reader is looking through it at is a HUD, and a chart inventing a fourth surface token would be one the theme cannot restyle with the rest.
  • Clicking a legend entry isolates its series, and clicking it again puts them all back — §3.1’s “the one interaction Grafana users reach for first”. The entries that are not isolated are dimmed rather than dropped, because a legend that changed width as you clicked it would take the way back with it.
  • Isolation is an index, not a set of hidden series. Unhiding a set requires remembering what you hid, and a chart showing three of eight series has a legend that no longer says what the picture is. Isolating rescales the axis, which is the point of asking for one series: a flat line at the bottom of a chart scaled to a bigger one has nothing to read.
  • The isolated series keeps its own colour, which is why the series list is never filtered: the index is the palette slot (ADR-0194), so filtering would redraw an isolated fourth series in the first slot’s hue and its own swatch would then disagree with it.
  • Three charts became stateful and the box tree did not change. A hovered point and an isolated series are state, and a widget is a value — so ChartPlot is stateful above the canvas, and line-chart, area-chart and bar-chart are stateful above both halves, because the click lands on the legend and changes what the plot draws. What they build is a ChartView carrying the chart’s own CSS type, id and classes, so every rule in controls.css still lands where it did and all four chart goldens are unchanged, to the pixel. A stateful widget occupies an element and no box.
  • Driven through the real router in tests, which render, lay out, paint — the step a hover cannot work without — and then dispatch. They compare pictures to pictures rather than coordinates, because the hovered index is deliberately not readable from outside: hovering draws something, two positions over one point draw the same thing, the gutter draws nothing, and leaving clears it. Three new goldens say what it looks like.
  • RoundRect is public, with a comment saying why: a canvas painter needs a rounded rectangle and the alternative was a second derivation of ADR-0064’s four cubics in :widgets. No new symbol crosses the native boundary.
  • Still owed from §3.1: thresholds, log axes, java.time axes, null handling, interpolation, soft bounds, gradient fills, empty and error states, a shared CrosshairGroup across charts, hover on donut-chart, and §3.5’s keyboard operation — arrow keys walking the crosshair, which now has somewhere to keep its index and no keys bound to it.

A donut under the pointer, and every chart under the keyboard

  • A donut reads its slices now, which closes the hole ADR-0198 left in its own parity row. The hovered slice keeps its colour, the others fade, and the hovered one’s name and share go in the hole (ADR-0199). The share rather than the value, because a part-to-whole chart is about the proportion and a reader who wanted the raw number wanted a bar chart; <1% rather than 0% for a sliver, because a readout must not contradict a visible arc.
  • The hole is where a donut’s readout belongs, and it is the one placement decision in the five charts that needed no arithmetic: an axis chart’s readout has to be placed, flipped and clamped, and a ring has already reserved an empty circle that cannot cover the data and cannot be clipped by the box. Only what fits is drawn — a hole is a circle and text is a rectangle, so a long name is left out and the share is not.
  • A ring needs no banked geometry. DonutGeometry follows from the box alone, so unlike PlotGeometry — whose gutter is measured from shaped labels and has to be left behind for the pointer — it is computed fresh on both sides. Which is why its test needs no renderer at all.
  • The gaps between slices belong to a slice. The painter trims a sliver off each arc so they do not touch; a hit test that respected those slivers would put a ring of two-pixel dead wedges through the chart, and a pointer crossing one would drop the readout and pick it up again. There is a test that walks 720 angles and finds a slice at every one.
  • Every chart can be read without a pointer, which is §3.5’s first item and the one it is most insistent about. A plot with data is a Tab stop and takes the same focus ring as every other control; Left/Right walk, Home/End are the ends, Escape lets go and is consumed only if it cleared something, so it still closes the dialog the chart is sitting in. An axis clamps at its ends and a ring wraps, because a line has two ends and a ring has none.
  • Up and Down are deliberately left alone. A chart is very often inside a scroll, and a focused widget that consumed the vertical arrows would swallow the keys that move the page — the same class of theft §2.4 bans nested scrollers for. Two arrows reach every point.
  • Tab does not move between series, which is a refusal of one sentence of charts.md §3.5: Tab is the focus traversal and a composite is one Tab stop with roving arrows inside it (ADR-0073), and the readout already names every series at the point rather than one at a time. Recorded in ARCHITECTURE.md §17.1 rather than quietly not done.
  • Writing the keyboard found a defect that has nothing to do with charts. A key handler computing “one to the right” was reading the crosshair index off the widget, which is the description the last frame was built from — so two arrow presses between two frames both stepped from the position before either of them and the crosshair moved once. That is a key repeat on any machine dropping frames. A step is relative now and only the state applies it; absolute positions stay absolute, and the callbacks answer whether anything changed so nothing has to consult a stale copy to decide whether to consume a key. The rule generalizes to any widget whose keys move a position it reports upward.
  • The keyboard and the pointer are held to one answer, in pixels. The test that matters asserts End and a pointer at the right-hand edge produce the same frame — not two descriptions of one intent. It was also the test that caught the defect above, by pressing an arrow twice without a frame in between.

What a chart says when it has not got the numbers

  • A chart with no data says so, where it used to draw five gridlines and five labels over nothing — every one of those numbers invented. An empty grid is not a neutral picture: gridlines are an assertion about a scale, and asserting one over no data is the same class of untruth as a bar chart baselined at 90 (ADR-0200).
  • Three states and one of them is derived. LOADING and FAILED are the application’s to say — only it knows whether a query is in flight or came back angry — and empty is not: a chart whose series are empty is READY, and the widget notices. A fourth state an application had to declare would be one that can disagree with the list beside it.
  • It keeps the box, and that is why the chart owns this at all. An application can write loading ? spinner : chart in a line; what that costs is the height. chart-message takes the plot’s flex-grow, so a chart in a 156px card is 156px while it loads, and a masonry of cards whose charts came and went as their queries resolved would reflow the wall twice per panel. Asserted by measuring the laid-out height in all three states rather than argued.
  • The message is widgets and the hover readout is paint, which looks inconsistent until you ask the question that decides it: does it participate in layout? A readout is placed in plot coordinates and must not affect the box; a message is centred, wraps, and is the content.
  • Two more strings the toolkit writes rather than the application — No data and Loading…, after Field.REQUIRED_MESSAGE and for its exact reason: an application that passed an empty list has supplied no words. Both are overridable. No spinner: §1.7 keeps the frame loop idle when nothing animates, and a dashboard’s charts are all waiting at once.
  • A hole is not a zero, which is charts.md §3.1’s sentence and now three ways of drawing one (ADR-0201). GAP is the default because it is the only one of the three that invents nothing; CONNECT interpolates the interior holes, which is the straight segment for a line and the same shape filled for a band; ZERO says the value was zero, which is right for a counter and a lie everywhere else.
  • And it was a live defect rather than a gap in a feature list. Math.min propagates NaN, so one missing reading made Series.min() answer NaN, the axis found its domain was not finite, fell back to 0…0 and collapsed the whole chart onto one line. A single absent sample destroyed the picture, silently, because no test had a hole in it.
  • A null is read as a hole rather than refused. List.copyOf rejects nulls, so before this a series read out of a nullable column had to be converted by its caller — and the obvious conversion is orElse(0), which is exactly the answer the policy exists to prevent.
  • One place applies the policy. Gaps.resolve produces the substituted values and the runs of consecutive drawable indices, so a polyline, a band and a bar read one answer and no mode can quietly disagree about where a hole is. LTTB runs per run, because downsampling across a hole would invent a segment through it.
  • A hole in one series is a hole in the whole stack. A band’s y is a running total, so an index where one component is missing is an index where the total is unknown; drawing the bands above it as though the missing one were zero would put them at a height nobody reported.
  • Nothing is dropped silently. A run of one has no segment, so a line draws a dot and a stacked band draws its cross-section a pixel wide — the objection LTTB exists to answer, applied to one point rather than to a spike in a hundred thousand. The area golden showed the dropped Monday before the fix.
  • The policy is the chart’s, not the series’. One picture, one convention: two series treating their holes differently is a chart nobody can read without being told which line is which kind, which is the argument that gives a chart one x axis and refuses it a second y.
  • Four new goldens, and the assertion that matters is that three of them differ. Null handling wired up but never applied would pass every unit test about the arithmetic and draw one picture for all three policies.

Three things the wall of charts said about holes

  • The showcase has the argument on it. The Charts screen’s last card draws the same twelve readings twice — three of them missing — under GAP and under ZERO, with a line of prose under each. One card and not two, because a masonry places by column height and could not be promised to keep a pair together; and because the wrong picture is only obviously wrong beside the right one. On its own, a line diving to the baseline looks like data.
  • The data was chosen to show both shapes a hole makes. A two-sample dropout leaves a hole between two segments; a single reading with a hole on either side leaves a dot. The first draft had no lone reading in it and the caption promised one, which is the sort of thing a screenshot catches and an assertion does not.
  • A lone reading was nearly invisible, twice over. It was drawn as a disc of the stroke radius — a couple of pixels, indistinguishable from the gridline behind it — and when it fell on the last index it sat exactly on the plot’s right edge with half of itself outside the box. So the one rendering that exists to stop a reading being dropped was dropping it. It is a disc of the stroke width now, pulled back inside the plot at the ends, which is the rule the x labels already follow and for the same reason.
  • And the axis did not have to cover the data. Ticks.extended scores a candidate labelling on four things and coverage is only one of them, so the nicest labels for 12…36 are 10, 15 … 35 — which stops short. The scale was the labelling’s, so the last point was drawn above the top gridline, inside the headroom by luck rather than by rule, and a 4px disc there hung over the edge. The scale is the union of the labelling and the data now; the gridlines stay on the round numbers, which is what every chart a reader has seen already does. Six goldens moved, all of them by a few pixels of scale.

A limit drawn across a chart, and the grey it turned out to be

  • Thresholds are built — charts.md §3.1’s lines and shaded regions, in one of four semantic levels with no way to pass a colour (ADR-0202). That refusal is the decision: the series palette exists to keep the things being compared apart, and a limit is a statement about them, so a threshold from the palette would read as one more series and steal a real one’s hue. It reads --gb-<level>-line, which is the rank §1.2 added for a stroke drawn on the page rather than for a label or a fill.
  • A band is a wash and its edges, and that was a measurement rather than a taste. The first version was a wash alone, and this theme’s warning hue at 16% over the dark surface computes to (76, 76, 76) — exactly neutral grey. A band whose semantic colour a reader cannot perceive says “something” rather than “warning”. Each finite edge is drawn at full strength now, and the test asserts both halves: that the edges are in the warning hue, and that the wash really is the grey that made them necessary.
  • A threshold is part of the domain. The axis stretches to reach it, so “we are a long way from the limit” is a reading a chart can give. One that only appeared once it had been breached would be a warning light that comes on after the fire.
  • A band goes under the data, because a warning that hid what it was warning about would cost you the reading you came for. NaN is refused at construction with a message naming NullPolicy: it is the one place in this area where a NaN means something specific, and a limit that is missing is not a limit.
  • The showcase’s p99 latency card has an SLO band on it, which is also the only colour on that screen that is not from the series palette — the decision, visible on the wall.
  • The three axis charts now have six components, and the next §3.1 item makes it seven. ADR-0202 records that the next one should bundle everything that is not the data into a ChartOptions, with the withers kept as the public surface; doing it in the same change as the feature would have hidden the feature.

The same mistake twice: where a pointer is inside a scrolled box

  • Scroll a panel and a chart stops highlighting, which is what running it said. Every pointer event still arrived — hover, press, click, the cursor, all of it — and the crosshair simply never appeared once the panel had moved.
  • localTo subtracted the layout origin from the window point. A hit-test region holds the rectangle a box was laid out in plus the inverse of the matrix it was painted with, because a scroll moves its content with a transform and Yoga never saw it (ADR-0054, ADR-0068). Region.contains maps the pointer through that inverse; the router’s where inside did not. So a control 300px down a scrolled panel was told the pointer was at y = -290: every widget asking “am I inside” got no, for the whole length of the scroll, while every one of them still received the event.
  • Two arithmetics for one question, which is the shape this codebase keeps finding: PlotGeometry exists because a crosshair and a painter must not each work out where a point goes, and Scale exists because a value becomes a position in exactly one place. This was the same defect one level up, in the method that had no second reader until a chart arrived.
  • It is the second time in a week that something ignored the ambient transform. paintCanvas assigned its matrix over its ancestors’ (ADR-0197) and this dropped the inverse; both were invisible until a widget that reads geometry was put inside a scroll. The pattern worth remembering: anything that mixes a window coordinate with a layout coordinate is wrong unless it says which space it is in.
  • The chart was not the only casualty. text-area places its caret from local().y(), so a text area below the fold put the caret on the first line; anything measuring vertically inside a scrolled panel had the same answer. A vertical scroll leaves x alone, which is why a slider — which reads fractionX — looked fine and hid the bug.
  • Guarded at both levels. LocalUnderTransformTest asserts the arithmetic in :core with a hand-built region, which is where the defect is; ChartInputTest scrolls a real viewport with the wheel and asserts a scrolled chart highlights exactly what the same chart highlights on its own, compared over the plot’s own pixels because the two windows differ everywhere else. The first version of that test passed without the fix — the scroll bar reacts to the same pointer, so the frame changed for a reason that had nothing to do with the chart.

A time axis, which is where the points go and not how they are labelled

  • content-widgets.md §3.1’s java.time axis is built, and the decision it turns on is not the labelling (ADR-0203). Every chart’s x has been the point index: a metric scraped every 15 seconds that missed four minutes had exactly one step of gap, the same step as every reading that was on time. That is a picture of a schedule nobody kept, and relabelling the index would have left it there.
  • The ticks step in java.time, and that is the whole of why the class exists. Ticks is Wilkinson’s algorithm for numbers and a nice number is a round multiple; time has no round multiples. A step of 2 592 000 000 ms is a month only in a year with no February in it and has drifted five days by December; a step of 86 400 000 ms is a day except on the two days a year a zone changes offset. TimeTicks picks a rung — the steps a clock is read in, 1 through 30 seconds, up to decades — snaps to a boundary of that rung’s own unit and advances with ZonedDateTime.plus.
  • The DST case is a test because nobody writes one. A day step across Berlin’s spring-forward is 23 hours and still lands on local midnight; a month step over a year lands on the first of twelve different-length months. Both are asserted, and both are what stepping in milliseconds gets wrong in a way that looks like an off-by-one.
  • The zone is the application’s and the format is the root locale, which land on opposite sides of a question that looks like one question. A locale changes how a number is written and a zone changes which number it is: an axis in the machine’s language is an unfamiliar picture and an axis in the machine’s zone is the correct one. times(list) reads the machine’s zone; a test passes UTC.
  • A sampling gap is not a hole, and the two compose. The axis makes an unscraped stretch wide; the line still crosses it, because both ends are readings that happened. An application that means “nothing was measured in between” writes a NaN and NullPolicy breaks the line — two mechanisms, two meanings, and a test that says so.
  • A bar chart ignores it. A bar has a width and sits in a band, and bands of unequal width are a different chart; half-applying the axis would put labels where the bars are not.
  • The first version of the axis labels read 09:00, 09:30, 09:45 — it dropped colliding labels one at a time, which keeps two neighbours and loses the one between them, so a reader cannot tell what the spacing is. It strides now, like the categorical labels, with room for the end labels being clamped inward.
  • ChartOptions earned itself first. Everything about a chart that is not its numbers is one record now — the state, the null policy, the limits and what the x means — which is what ADR-0202 said the next feature should find rather than a seventh component on three charts.
  • The showcase’s p99 latency card is a real time series: its ninth scrape is twenty minutes after its eighth, and the axis is twenty minutes wide there.

A curve is a claim about what happened in between

  • Interpolation is built — linear, smooth and step, with LINEAR the default because it makes the weakest claim and a chart should not make a stronger one unasked (ADR-0204).
  • SMOOTH is monotone cubic, and the point is what it refuses to draw. A Catmull-Rom or a natural spline through 0, 0, 100, 100 dips below zero before it climbs and overshoots above a hundred after — which is what those splines are for, and wrong for data: on a percentage the overshoot is not inaccurate but impossible, and it lands exactly where a reader is looking because it lands where the interesting thing happened.
  • Two limits, and the second is the one that was missing. Fritsch–Carlson’s α² + β² > 9 circle scales a pair of tangents back; a local extremum needs a flat tangent, which the circle does not give. Averaging the secants at the top of 1, 9, 2 gives +0.5 and the curve reaches 9.0013 on a series whose maximum is 9 — a chart drawing a number nobody recorded, at the one point a reader is looking at.
  • The test found it, which is the reason it is written the way it is: every assertion samples the curve densely and checks the bounds rather than inspecting the coefficients. A property one missing if away from being false is not one to argue from the algorithm.
  • STEP holds forward. A value read at 09:00 is what was true from 09:00 until somebody looked again, so the horizontal comes first and the jump lands on the next reading. Holding backwards would say the new value was already true before it was observed, which is the one direction the data cannot support.
  • One emitter, used by a line and by both edges of a band. A smooth band whose underside was straight would be thicker than its own numbers wherever the top bulged; the underside is the same curve reversed, which for a cubic is its control points in reverse order. The showcase’s Bytes served stack is smooth, which is the case that would show a mismatched pair.
  • Curves is public and pure — no renderer, no natives, no path. The painter asks for tangents and control points and does the drawing, so goldberry-plot gets the arithmetic without the widget.
  • And it composes with everything already there, because the tangents are computed on the pixels the painter is about to draw: an unevenly sampled series on a time axis curves correctly for free, a GAP run curves per run, and an isolated series curves alone.

A log axis, and the readings it cannot take

  • §3.1’s log axis is built, and it is the first thing in the toolkit that is not affine (ADR-0205). Every scale — the sparkline’s included — has mapped a domain onto a range linearly; Scale.log maps log10(value) instead, as a flag rather than a subtype, because every caller wants a scale and none of them wants to know which kind.
  • Wilkinson is the wrong algorithm again, for the reason it was wrong for time: it scores how round a number is against how evenly the labels cover the range, and on a log axis those pull apart completely — 1, 10, 100, 1000 is the only labelling anybody wants and it is, in value space, wildly uneven. LogTicks labels decades, strides them when there are too many, and subdivides by the 1-2-5 mantissas when there are too few. Not every integer, which crowds the bottom of each decade where a log axis has least room.
  • The subdivision is the one nearest the target, not the first to reach it. The first rule here turned 1…1000 at five labels — four whole decades — into 1, 5, 10, 50, 100, 500, 1000: a decade axis made into a half-decade one to gain a label it did not need. Caught by the first test written against it.
  • A zero has no logarithm, and the chart says so. Gaps.positiveOnly turns a non-positive reading into a hole and NullPolicy draws it as one, so the line breaks there rather than sliding off the bottom of the picture. That is the honest rendering of “there is nowhere to put this”, and it is why a log axis is opt-in rather than something a chart could choose when its numbers span enough decades: choosing it costs data, and only the application knows whether the zeroes matter.
  • Only line-chart draws one. A bar and a band encode their value as a length from zero, and zero is infinitely far down; a chart drawing one anyway would have to pick a bottom, and every choice is a number nobody gave it.
  • A series with nothing positive in it falls back and keeps its data. The first version filtered and then discovered it had nothing left, and drew an empty grid — worse than either honest answer. Scale.log refuses a non-positive domain and a paint pass must not turn that into an exception, because a query can return zeroes.
  • Measured rather than asserted by eye: the six quiet readings of a series that spikes four decades get two rows of a 156px plot on a linear axis and thirteen on a log one. The test compares the multiple rather than the difference, because what the axis promises is proportional.

The last three of §3.1, and the one that needs a symbol

  • Soft bounds stop a flat series rendering as noise (ADR-0206). An uptime reading 99.94, 99.97, 99.91, 99.99 auto-scaled fills the plot with the difference between 99.91 and 99.99 — a mountain range made of eight hundredths of a percent, shouting loudest exactly when the news is good. softAxis(99, 100) draws the flat line near the top that it is, and an outage still pushes the axis down to meet it, because “at least this far” is what soft means. A hard bound does not move, and data outside it is clipped — the correct rendering of a promise that was wrong. Asserted rather than argued: the same readings use more than three times the vertical room auto-scaled that they use bounded.
  • Point markers are AUTO by default, and this changed every sparse chart in the toolkit. A dot appears when its neighbours are more than four marker-widths away — in pixels, because what makes a dotted mess is how close the dots are on screen rather than how many there are, so the same chart shows dots at seven readings, none at seven hundred, and shows them again when the window is widened. Deliberate: a dot per reading is the difference between a measurement and a trace, and the goldens moved with it.
  • A crosshair can be shared. CrosshairGroup is a mutable holder an application owns — a ToastController’s shape (ADR-0177) — and what travels is the point index, so the charts in one are assumed to be sampled together. Every chart in the group draws the line; only the one under the pointer draws the readout, because a dashboard with six floating boxes on it, five about a chart nobody is pointing at, is worse than no linking at all.
  • Which needed the crosshair and the readout to stop being one condition. paintHover returned early when the readout was null, so a linked chart drew nothing at all; and the hover marker’s ring colour was read off the Readout, so a chart drawing markers without one crashed. Both were the same assumption — that a chart draws a crosshair exactly when it has something to say — and it held right up until two charts shared one.
  • The showcase links its two seven-day charts. Requests per day and Bytes served are the same week, which is what a group needs; pointing at Thursday on either puts the crosshair on Thursday on both.
  • A leak is tested for rather than reasoned about. A chart that stayed subscribed would hold the group’s listener list — and through it the last window’s charts — alive; CrosshairGroup.listenerCount() exists for that test and nothing else. Writing it also found that the test harness was never unmounting its element tree, so dispose had never run in any of these files.

The fill that needed a wider library

  • charts.md §3.1 is complete, and its last row was the only one that could not be built out of what the export list already had (ADR-0207). Every drawing call on that list takes its colour as an rgba32 argument, because that is what the toolkit’s own painter has ever needed; a gradient is an object with stops that has to exist while the fill happens, and it reaches a context as state. So the first commit of a chart feature was six symbols and two layout rows.
  • The sixth symbol is the interesting one. bl_context_fill_path_d — the plain fill, with no _rgba32 suffix — is the only styleless drawing call bound and the only way a ramp reaches a path. The other five build a gradient and put it on the context and take it off again, and taking it off is not optional: a gradient left set would be drawn by whatever reached for the styleless fill next, somewhere else in the frame entirely. That is globalAlpha’s rule and the opposite of its mechanism — an alpha has a neutral value to go back to and a fill style does not, so restoring one means choosing one.
  • The OKLCH in the deferred entry turned out to be vacuous. A fade between two alphas of one hue is the same curve in every perceptual space. What actually makes a fade correct is premultiplied interpolation, which Blend2D does, and repeating the colour at the far stop, which the caller must: 0x00000000 is transparent black, and a green fading to it goes through grey on the way out. BlendGradient.fade is a constructor rather than two lines at each call site for exactly that reason.
  • Fill.NONE is the default, so nothing changed. A line chart draws no fill, which is what a line chart already was; an area chart reads NONE as SOLID, because a band with no fill is not a band. Every existing golden is untouched. Three values rather than an opacity number: an opacity is a number a caller could want any value of, and a fill is a choice between two conventions.
  • A ramp is anchored to the data rather than to the plot. Under a line it runs from the furthest point of that run from the baseline back to the baseline; in a band it runs across the band’s own extent. Anchored to the plot instead, two series of different magnitudes would be drawn at different strengths and a stack’s lower bands would be half gone before they started — which is what the test asserts, by measuring that both bands still reach their own colour somewhere.
  • The fill follows the curve the line was drawn with, because it is built from the same run of points — after smoothing and after downsampling. One built from the raw values would show its own straight edges through a smoothed line.
  • A gradient is sampled at the pixel’s centre, so the pixel sitting on the start point is already half a pixel along the ramp. The native tests assert near a stop’s colour rather than equal to it; the exact form would be an assertion about Blend2D’s sampling grid rather than about the fade.
  • goldberry-html and goldberry-vector start one commit further along. Both entries in TODO.md named these symbols as their own first step, which is what made the width worth taking for one row of a chart table.
  • The showcase’s p99 latency card fades, and it is the one thing on that screen that could not be drawn before. It thins out before it reaches the SLO band, so the limit is still read against the data rather than through it.

The keyboard’s right-click

  • A context menu opens from the keyboard now (ADR-0208), which is the half of ADR-0108 that did not ship and left the catalog with one entry a pointer was the only way into — in a toolkit whose §2.2 says everything must be reachable.
  • Key.MENU and Shift+F10, both rather than either: the first is SDL’s SDLK_APPLICATION, and the second is the companion binding everywhere and the only one on a keyboard that has no menu key. Bare F10 is left alone, because it is the menubar’s (ADR-0163) and an application with both would open a context menu where it meant to activate its bar.
  • It anchors to the focused element’s painted rectangle, because there is no point to anchor to — so the menu hangs off the bottom of whatever has the focus ring. The element-wise anchor the TODO entry said “does not exist” turned out to have existed since ADR-0111, where the tooltip path built it.
  • One walk, two callers. “A right-click on a button’s label is a right-click on the button” and “the menu key on a focused button is that button’s menu” are the same rule, so they are the same method — a second copy would be a second chance for the two to disagree about which ancestor wins.

The rest of a tree’s keyboard

  • §3’s Home/End, * and type-to-select are built (ADR-0209), which is three of the five things ADR-0184 shipped tree without. They waited for one reason and it is the same reason for all three: each needs to know about rows the focused one cannot see, so each is a callback the tree hands down — the shape Left’s move-to-parent already had.
  • Home/End mean the flattened list, not the viewport: End in a scrolled tree goes to the last row of the model, and the focus ring asks the scroller to follow (ADR-0120).
  • * opens the siblings and not the descendants, which is the reading that makes it useful and the one that does not hang a lazy tree by fetching its whole model on one keystroke. A lazy sibling’s supplier runs exactly as it does for a branch opened by hand.
  • * and the typeahead both arrive as text rather than as keys. * is a character whose key differs by layout — Shift+8, a numpad key, or neither — and asking for the key would be asking for the physical position, which §7.1 says this toolkit does not answer. A typeahead wants what was typed for select’s reason (ADR-0141).
  • Typing moves the focus and chooses nothing. Enter is what chooses, which is select’s split and ADR-0063’s rule; a keystroke committing a value in a controlled widget is the thing that rule exists to prevent.
  • Type-to-select matches visible rows only, which is §3’s own wording: a search that opened branches to find its match would be a search, and a lazy tree cannot have one without fetching everything.
  • A test that could not fail was found writing these. TestHost.focusRequests() hands back a copy, so a test making several moves and clearing it between them was clearing a list nothing was writing to. forgetFocusRequests() is the fix, and it keeps the record on the host where it belongs.

A tree checks and selects two different things

  • tree’s last two leftovers are built (ADR-0210), and §3 owes it nothing further: checkable= puts a box on the rows and selection= is none/single/multi with the Ctrl/Shift semantics.
  • Multi-selection was recorded as blocked on list and that reading was too strict. tree defined the node model itself for exactly the same reason (ADR-0184) and wrote down that list will have to agree with it; the selection models are the shape every desktop list has, which is what makes that a small promise to make on list’s behalf. The same debt, taken knowingly and in the same place.
  • The checkbox needed a question answered first, and it was in the design document rather than in the code. §3 spends the word checkable twice — on select tree= it is which rows are an answer, and on a standalone tree it “adds a checkbox per node”. Both ship, under two names, and the word doing two jobs is now recorded in ARCHITECTURE.md §17.1.
  • Selecting and checking are two values through two callbacks. The selection is where the reader is; the checks are what they have marked. A file manager where those were one thing could not copy six files, because opening the seventh folder would clear the list.
  • A cascade parent is derived and never stored. A stored parent bit goes stale the moment one child is unticked, and the row then claims “all of these” while showing one that is not. A lazy branch nobody has opened reads its own membership — fetching a model to draw a checkbox is the one thing a lazy tree must not do, which is what mayHaveChildren exists for.
  • Clicking a mixed branch asks for all of it, which is Checkbox.Value.toggled()’s rule getting a second caller rather than a second copy — and the box itself borrows check-indicator, so there is one tri-state mark in the toolkit and not two kept alike by hand.
  • The whole set is reported even when it holds one. A Shift range runs over the flattened visible rows, which only the tree can see, so an id on its own would be an answer the application could not turn back into a selection. The three-argument constructor unwraps it again, so select tree= and every existing caller see the String they always saw.
  • The anchor does not move under Shift, so a run of shifted presses sweeps from one end rather than growing from wherever it last stopped — which is what makes an over-shot range recoverable without starting again.
  • Two tests failed first by assuming the widget remembered its own selection, which is exactly what ADR-0063 says it must not. Rewritten to apply the reported set back between presses, which is what an application does and what turns them into tests of the loop rather than of one call.
  • Every existing golden is byte-identical, because all three defaults are unchanged. The new one is a cascade tree with a partly-ticked branch — the one state a picture is the only proof of, that the mixed mark is a bar and not a greyed tick.

The release that arrived in another window’s space

  • Every popup on macOS could be opened and hovered and not chosen (ADR-0211). The press landed on the row and the release did not, so no click was ever synthesized — a dropdown, a menu and a suggestion panel all unusable, and the toolkit’s own halves all correct.
  • SDL promises coordinates in the target window’s space and does not deliver them here. Cocoa_SendMouseButtonClicks rewrites them only when the event’s NSWindow is not the key window; a mouse-up goes to the key window, and a NOT_FOCUSABLE popup can never be one — which it is by ADR-0189, and for a reason that stands. So the press arrives in the popup’s space and the release in the owner’s, both carrying the popup’s id.
  • And the owner-space value is stale, which is worse than a fixed offset: nothing updates it while the pointer is over the popup, so a release reports where the pointer was before the popup opened.
  • The bounds check is the detector and the desktop is the answer. A coordinate inside the window it was delivered to is taken as given — every event on every other platform, and most of them here. One that falls outside has its space in doubt, and only then is SDL_GetGlobalMouseState asked.
  • A popup’s desktop origin is its owner’s position plus the offset it was asked for, because SDL_GetWindowPosition on a popup reports the display’s coordinates on some drivers and the parent’s on others. Two readings that are not in doubt rather than one that is.
  • Every window is reconciled, not only popups. A top-level window’s coordinates are already inside its own bounds, so the branch never fires for one — and a rule that named popups would stop being checked the day something else needed it.
  • A test can push a pointer now. SdlEventBuffer gained writeMouseMotion and writeMouseButton for writeWheel’s reason (ADR-0061): all three branches run under the dummy driver against the real translate. What no test here reaches is SDL’s attribution — the dummy driver refuses popups outright, and the behaviour is a property of a real NSWindow.
  • A drag off a control still cancels its click. The desktop reading agrees the pointer is outside; only the magnitude changes.

A list, and the models it was owed

  • §10’s list is built (ADR-0212), which leaves table as the only entry in that section — still deferred, still on virtualization.
  • It was built to settle a debt as much as to fill a gap. tree took ADR-0184’s rule twice: the widget that needs a model first defines it and writes down that the other will have to agree. So Selection lived in panel.tree while §3 called it “list’s selection models”. It has moved, and nothing about its shape changed on the way — which is the evidence that the promise was a small one to make. Checkable stayed, because §10 gives a list no checkbox and a model with one consumer belongs to that consumer.
  • An item is the application’s own type, and three functions describe it: identity says what it is, factory says what it looks like, and text says what it reads as. Three lambdas rather than an interface to implement, because an interface would make the trivial list — strings drawn as text — the one that cost the most to write. ListView.of(List<String>) is that case in one call.
  • text is optional and its absence turns type-to-select off, which §10 asks for in as many words. The half that is not obvious is that the row must then not consume the keystroke: one that swallowed text it could not use would stop a field elsewhere from ever seeing one.
  • §10’s item context menus are named on the row, and that is the one place they can be. A menu is a name on a widget the launcher finds by walking up from an element — a right-click walks up from what is under the pointer and the menu key from what has the focus (ADR-0208), and the row is the only node on both paths. So ListRow carries an Attributes, which no other part in the catalog does.
  • A row’s focus name is scoped by its list’s id. host.focus takes a name global to the window, so two lists over items with equal identities would each answer to the other’s Home. This is tree’s behaviour improved rather than copied; what is left of the collision needs two lists, both unnamed, holding an item with the same identity.
  • The focus ring stays, where a dropdown’s row has none, and the difference is which thing the arrows move. In a select they move the value, so the highlight is always where the keyboard is and a ring would be a second marker for one place. Here they move the focus and Enter chooses, so those are genuinely two rows and need two marks — ADR-0063’s split, drawn.
  • A none list is still walkable and still does not eat its clicks. §10’s none says what may be chosen, not what may be read: rows nobody can select are still content a keyboard user has to reach, and a row that consumed the click would stop a button the item-factory put on it from ever being pressed.
  • The class is ListView and the CSS type is list, because a widget record named List would shadow java.util.List in every file that built one — including its own, whose model is a java.util.List.
  • The showcase’s Choosers screen gained it, above the select tree= section rather than below, which is also the order that reads: “a tree instead of a list” means more after a list. Every tree golden is byte-identical, because only the enum’s package moved.

Ten thousand rows, and the two spacers that hold them up

  • §10’s virtualization is built (ADR-0213), which is the promise ADR-0212 shipped an API shape for and could not test. A list of ten thousand builds about twenty rows.
  • The window comes from [Located], the facility affix opened: clip.top() - self.top() is how far into the model the viewport has reached, because self is where the list has been scrolled to rather than where it was laid out.
  • The spacers are the whole safety argument, not a detail. Every facility that hands geometry to a widget carries the same warning — what it triggers must not change what it reports — and a list that built fewer rows would be shorter, be told a new position, and oscillate at the frame rate. A spacer’s height is rowCount × rowHeight, so the column adds up to the same total however the window moves and the measured node never moves. An arithmetic identity rather than a rule anyone has to remember.
  • It takes a row height rather than a flag, because the height is the one thing the widget cannot find out: a stylesheet resolves --gb-list-row-height and no widget can read a resolved custom property. It is also where the precondition becomes obvious — index × height is only a position if every row is that height, so a list with rows of varying height must not virtualize.
  • Home, End and the typeahead are what virtualization breaks, and putting them back is most of the work. All three move the focus by name, and a name resolves against the element tree — so a virtual list asked for its last row was asking for a row that does not exist, and End did nothing at all. The move is two steps now: widen the window, then focus.
  • And the second step is a retry rather than a delay. The frame loop fires its timers after the platform pump, so whether the repaint a setState asked for has been drawn yet depends on the pacer. Host.focus returns whether it found anything, which is what turns a race into a recoverable one; two attempts, so an id naming no row stops rather than re-arming for ever.
  • The tests drive real painted frames, like affix’s, because the window does not exist until Yoga has run and the router has captured the result. Setting the row height back to zero fails seven of them, which is how the assertions were checked for being load-bearing rather than decorative.
  • What it costs: the focused row can be scrolled out of existence. Wheel far from the ring and the focused row leaves the window, is unmounted, and the router drops it. Arrow keys are unaffected, because the ring asks the viewport to follow; only pointer-scrolling away and then pressing one loses the place.

A table, which was waiting for a list all along

  • §10’s table is built (ADR-0214), and §10 is complete. It leaves ARCHITECTURE §17’s deferred list, which had it behind virtualization — correctly, as it turned out, though not for the reason the entry gave.
  • Most of the work was writing the specification. The entry was one sentence — “deferred; it awaits the virtualization work” — and design-system.md §3 had no metrics row for it at all. §5 requires a spec and a metrics row and gallery coverage before code, in that order, so all three came first.
  • What it was waiting for was list. A table’s rows are a list’s rows with more than one thing in them, so Table builds a ListView whose item-factory returns a row of cells, and the selection models, the typeahead, Home/End, the item context menus and the ten-thousand-row window are inherited rather than written twice. TableTest asserts the seam and leaves the rest to ListTest, which is the whole argument for composing.
  • A column’s width is a number or a share, and flexbox already had both. A fixed column will not shrink; a weighted one is flex-grow over a zero basis, because over auto a column of long strings would quietly outgrow its weight. One function sizes a header and the cells under it, which is the cheapest guarantee that they come out the same width.
  • Sorting is the application’s, and the click reports what the sort would become rather than which column was hit: which way a second click goes is a rule about tables and not something every application should restate. A table over a database sorts in the query, which is why the widget does not.
  • A caret slot is kept on every sortable header, drawn or not — and the golden image is what found that. Without it, sorting a column takes 16px away from that column’s own label at the moment the reader clicks it, so every header the sort visits shuffles its text. Every assertion in TableTest passed while that was true; the picture did not.
  • Box.Mark.Kind.CHEVRON_UP is new, a third chevron for the second one’s reason. Here the two are not decoration but the value: a caret pointing the wrong way says the column is sorted the other way, which is a lie a rotation would have made easy to ship.
  • No rule between the rows, and one under the header. A grid of lines is furniture competing with the data in it, and the row height and the hover wash already say where a row begins. The line that stays is a boundary between two kinds of thing rather than between two of a kind.
  • The showcase has a Collections screen, which is where the ten-thousand-row list and the sortable table live. Its own screen rather than a section on Choosers, because the two things worth seeing are scale and sort: the first needs a viewport of its own to be scrolled through, and the second needs somewhere to keep the state the sorting is done in.

The rule that was written and never drawn

  • table-head shipped with border-bottom and drew nothing (ADR-0215). §8’s subset has one border and no per-edge longhands, so the engine did what it promises — logged at debug and carried on — and the golden was accepted with the line missing. §3’s metrics row asks for that line.
  • It is the fourth time. TODO.md has recorded it since ADR-0109: border-bottom, currentColor and margin were each reached for and not found, “all silently ignored… and nothing warns when a declaration is dropped”. menubar documents the same wall in a comment in the stylesheet itself.
  • The rule is a node now — table-rule, a box one pixel tall with a background, which is separator’s answer to the same problem and the only one the subset allows.
  • And the toolkit’s own stylesheets are linted. SupportedPropertyTest resolves every rule the catalog and the showcase ship through the real cascade and fails on anything reported as unsupported. The asymmetry is the point: an application naming a property before it exists must not stop a window opening, but the toolkit was being held to that same lenient standard against itself. (The example was box-shadow until ADR-0310 built it; backdrop-filter is what it is now.)
  • It asserts the behaviour rather than a copy of it. No list of supported properties to drift — it attaches an appender and reads what the cascade actually said, so a property added to the engine tomorrow needs no edit here.
  • Custom properties had to be excluded, and finding that out was the check working. --gb-accent: … reaches the same branch and is logged the same way, because custom properties are the resolver’s rather than ComputedStyle’s (ADR-0049) — so the unfiltered version reported 158 failures on a healthy tree.
  • And the check checks itself: a third test feeds it border-bottom and asserts it is caught, because a change to the log’s wording would otherwise make the other two pass by seeing nothing at all.
  • Two live instances in the whole tree, and that was all: this one, and a padding-bottom in the showcase’s own sheet.

The corner that was written and never drawn

  • group-box-title asked for border-radius: 7px 7px 0 0 and got four square corners (ADR-0216). §5’s frame is 8px round with a 1px edge and the header fills the top of it, so the header’s top corners are the frame’s less the border and its bottom ones are square. The engine resolved one radius per box, dropped the declaration whole, and two square shoulders spilled out of the frame’s curve. select text-input { background: none } was the second line in the same log, doing nothing for the same kind of reason.
  • A radius is four numbers now. Corners over CSS’s 1-4 shorthand, in CSS’s order. This is the change ADR-0215 declined to make for border-bottom, and the difference is that a rule under a table header is expressible as a node and a corner is not — nothing in this toolkit clips.
  • One drawing serves both, and the uniform case emits exactly the point sequence the single-radius painter always did. Two goldens changed by 31 pixels each — the two with a group-box in them — and every other golden is byte-identical, which is the assertion that a hundred controls did not move.
  • background: none is transparent and background-color: none is not, which is CSS’s own division: none turns off the layers in the shorthand, and the longhand takes a colour. It is what border: none has always done one property up, and it is why the field inside a select had a fill it was told not to have.
  • The lint reads values as well as names now — and had to start running the real cascade to do it. Every colour in the toolkit is var(--gb-something), so raw declarations handed to ComputedStyle reported 164 failures on a healthy tree; the check builds a probe element per selector, chained by parent so select text-input is a text-input inside a select, and resolves it through StyleResolver. The leftmost probe has no parent, which is what makes it :root and how the theme’s custom properties reach the chain.
  • The mistake was not that the log was too quiet. A dropped value has always warned, one level above the dropped property that started ADR-0215, and it was read exactly as often. What catches it is a test.
  • ADR-0097’s parked question is unblocked: SegmentedTest said the bar’s inset grid should be revisited “the day a per-corner radius exists”. It is not revisited — the control draws correctly — but it is now a choice rather than a limit.

A key given back by whoever took it

  • A menubar going away could unbind an application’s own Ctrl+O (ADR-0220). The window’s map was keyed by the shortcut alone, so giving back what the bar took removed whatever was on those keys — including a binding made after the bar was mounted.
  • A binding is (action, owner) now, compared by identity, and there are two ways to give a key back: by key, which removes whatever is there and is what an application means; and by key and owner, which is a no-op when somebody else holds it and is what a widget means.
  • The bind side did not change. Two commands on one key is still an authoring mistake where the later one wins; the loser simply cannot take the winner away with it any more.
  • A displaced binding is not restored, deliberately: that needs a stack per key, and a stack needs an answer for what happens when the middle of it leaves.
  • menubar is the only owner in the toolkit, which is exactly what the entry predicted when it was filed.

Four entries that were one missing callback

  • An item could tell its menu one thing — “the pointer arrived on me” — and four TODO.md entries were all the sentences it could not say (ADR-0219): a keyboard Right waited out the pointer’s 150 ms hover-intent, Left closed nothing, Left/Right did not move between a bar’s menus, and nothing marked the row whose submenu was showing.
  • MenuSignals is the sentence: hovered, open, back, forward. Each says what happened to the row, not what to do about it — which is how Left means “close this submenu” in one menu and “the menu on the bar’s left” in another without the row knowing either.
  • A delay is for a pointer. Hover-intent stops a submenu dropping out of one travelling past three rows; a keypress has travelled past nothing, so open() cancels the timer and opens in the same frame.
  • A bar hands its root menu a Siblings and a submenu gets none, which is the whole of why Left goes back one level inside a branch and along the bar at the top of one. It wraps, and skips a separator or a disabled heading.
  • Menus grew an object. Three of the four fixes need state that lived nowhere — which row’s branch is open, and which menu is above this one — so an open menu is an OpenMenu rather than five parameters passed down a chain of statics.
  • The mark is read in pixels. A popup’s tree is in another window, so the test moves the pointer out of the parent menu and compares the row’s own pixels before and after: the only thing left on it is the mark. Four of the five new tests fail against the old code.

The paste that took the window down

  • Paragraph.of refused right-to-left text and a text-input does not choose its text (ADR-0218). A user pasting Arabic lost the window: the paste succeeded, the field held the text, and the frame that tried to describe it threw. The last crash on TODO.md.
  • A paragraph never refuses text now. Bidi text is shaped with the direction forced to LTR, so the glyphs come back in the order every measurement here assumes. The glyphs are right — joining comes from the script, which is still guessed — and the order is mirrored.
  • Wrong in exactly one way. Widths, wrapping, carets, hit testing and selection all come off the same prefix sums, so a click lands where the caret is drawn. What is wrong is the reading order, which is the thing that needs run splitting.
  • It says so twice: isBidiApproximate() for a caller, and one warning per distinct string for a reader. The alternative to a crash should not be a silence.
  • Font.shape(text, direction) is the seam the real fix will use, because bidi run splitting is “shape each run in its own direction”.
  • The test fails against the old paragraph, which is what says it tests the crash rather than the fix.

The bar that was drawn the other way

  • segmented draws §3’s row now — “radius 8 outer, 0 between; 1px divider in --gb-border” (ADR-0217). ADR-0097 declined it on two grounds: per-corner radii did not exist, and nothing clips. ADR-0216 removed the first, and the second turned out not to need answering — clipping was only ever needed to cut a square fill to the bar’s shape, and a fill that rounds its own outer corners already is that shape.
  • The bar’s padding is its border’s width, which is the whole of the new arithmetic: 1px puts the track on the bar’s inner box, and the 7 the segments and the pill carry is the bar’s 8 less that border. Concentric, which is what makes a fill lie flat against a rounded edge instead of poking through it.
  • The radius is the stylesheet’s and the corners are Java’s. Which cell is at an end of the row depends on a count, and no selector can count segments — the same argument ADR-0099 used for the cell width. Corners.inRow is in :core because button.square’s joined buttons and tabs are the next two callers.
  • The divider came back as a node and out of flow. In flow it would take a pixel of the row, and the row is a grid — (100% - 3px) / 4 is not a percentage anything can name, and the travel depends on every cell being exactly 1/n. The two beside the selection fade rather than blink, because the pill takes base to reach them, and all of them are painted under the pill so a moving fill never has a line drawn across it.
  • A new golden, segmented-unset, is the only image that shows a hairline at all: with three segments and the middle one selected, both dividers are beside the selection. That is correct and it is exactly why the image exists.
  • The focus ring left the bar’s edge. ADR-0097 recorded as a coincidence that a 2px ring at a 2px offset landed on the border when the segment was inset by 2; with the segment against the inner edge the ring sits just outside the bar and takes the segment’s own corners.
  • Two tests were counting past the track’s parts — children().get(index + 1), one past the indicator — and there are n parts now. Both find an option by type instead, which is what they meant.
  • The showcase is a menubar, a bar and seven screens (ADR-0222). ADR-0110’s rule for what went where was one screen per widget family, and it did not survive the catalog reaching fifty-one widgets: Controls, Values and Text were three tabs you had to visit in turn to see one screen’s worth of chrome; Overlays and Notifications were the two halves of one comparison with a tab between them; and twelve screens against ten digits left two of them with no accelerator at all, which ADR-0110’s own note had admitted.
  • Every screen is a Wall — a heading, a line of prose, and a masonry of cards. A type and not a convention, because it was a convention first and four screens had already drifted off it: one had its heading inside the wall, one had no prose, two disagreed about whether the caption was .caption or .prose.
  • A document supplies the cards it can and Java appends the rest to the same masonry. Panes.wallOf refuses a document whose root is not a masonry, and that check is the load-bearing one: a column wrapped round it during an edit is a perfectly good document, nothing throws, and the screen quietly grows a second wall laid out against different columns. The Java cards are exactly the five things markup cannot write — an expression, a list the application edits, a set that is toggled, a filter that hands options back, and series data.
  • The window opens maximized (ADR-0221), which is a default false predicate on Application and a SDL_WINDOW_MAXIMIZED flag beside the size rather than an enormous size instead of one. WindowSpec refuses maximized-and-not-resizable, because SDL silently drops the flag there and both readings of a warning would be wrong. The layout verification caught the missing GB_CONSTANT in goldberry_shim.c on the first run, which is the check doing exactly what it is for.
  • The theme is one fact in two spellings, written in one place. app.theme is a name because three controls pick from a list; app.light is a boolean because Toggle.resolved reads source.get() instanceof Boolean and falls back to its own flag otherwise — a switch bound to "light" never moves. Both are assigned in pickTheme and nowhere else, and a test walks every route to the theme asserting the two agree after each.
  • Set.of is now banned from anything a golden image prints. Its iteration order is randomized once per JVM, so the Collections tree’s caption — String.join(", ", checked) — came out “buckland, weathertop” on one run and the other way on the next. A thousand pixels of caption failed the image at random; a LinkedHashSet fixed it. Three consecutive --rerun-tasks runs are what confirmed it.
  • Scrolling keeps §2.4’s nested-scroll ban and stops being a special case. It used to be the one screen the gallery did not wrap in a viewport; it is now a card in a two-column wall, and the screen fits without scrolling at all. The wall is what made the ban affordable rather than awkward.
  • Section names are one word each, because a section’s name becomes its #section-<name> and its button’s #jump-<name>, and #jump-bag end is not a selector. The slug helper that had appeared to cope with the others went with them.
  • SectionHeader is public and is the screen title. text.screen-title did the job for eleven screens and stopped being honest: a class is something any node can wear, a heading is a kind of node, and being an element type is what lets one rule say “a heading inside an affixed section takes a surface”. ShowcaseTypographyTest asserts its rank through the cascade, which is the one place that can see it — every golden image is drawn with the single-font renderer and is blind to every typographic rank there is.
  • The content is Middle-earth. Not decoration: a table of nine companions with a Kindred column that repeats and a Leagues column that does not shows a sortable header doing something Row 1…Row 6 cannot, and names of wildly different lengths are what a layout has to survive. No text is quoted — the prose is written for the purpose.
  • Eleven golden images at 1200×900, replacing twelve at 900×560, plus gallery-basic-narrow at 720: a masonry’s columns are a count and not a media query, so two columns at 1200 are two columns at 720, half as wide and twice as tall, and a card with a minimum width would overflow rather than wrap.
  • WindowActions survives, and the reason is the interesting one. It was deleted when the menu bar started holding its handlers directly — and put back, because overlays.kdl presses app.open-menu by name and only a registry can turn a string into a call. The two halves of §9 are now visible side by side in one window.
  • Two new test classes: WindowSpecTest in :core for the flag and its refusal, and ShowcaseShellTest in :example for the three bands, the seven screens, and every row of the menu bar including the one that is honestly disabled. Neither is a thing a golden image can show — every picture is drawn at a size the test chose, so a window that opened 200px wide would look identical in all of them.

The key that could not be a shortcut

  • A bare Alt tap opens the menu bar (ADR-0223), which is §8’s “Alt-style keyboard activation” itself rather than the F10 that had been standing in for it since ADR-0163. The entry that tracked it had already written the design — “key-release tracking with a nothing-happened-in-between rule, at the window level” — and got one thing wrong by omission: where the keycode can still be read.
  • Key names no modifier, deliberately, so Alt reaches the router as Key.UNKNOWN and is indistinguishable there from every letter that arrives as text. Window is the last component holding a platform keycode, which is why the recogniser lives there and is fed before the InputWatcher and the router both — a key a popup swallows still has to spoil a tap.
  • A new package, input.tap, beside input.key rather than inside it: ModifierKey is the four modifiers seen as keys that can be tapped, and ModifierTaps is the detector and its owner-keyed registry. The first thing in input that is a recogniser rather than a value or a dispatcher.
  • The rule is stated as what spoils it, because that is the half that has to be exhaustive: another key, an auto-repeat, a second modifier, a pointer press, a wheel, a focus change — and, deliberately not, pointer motion. Alt+F must not read as a tap of Alt followed by an F, and the window switcher’s Alt must not open a menu on the way back.
  • Host grew modifierTap/removeModifierTap with ADR-0220’s ownership and no unowned overload, because the only reason to bind a tap is a widget that will have to give it back. menubar now holds two kinds of registration and returns both; F10 and Alt both toggle, which is a behaviour change to F10 and the right one.
  • Three test classes. ModifierTapsTest states the rule against the detector, ModifierTapWindowTest drives the real launcher and asserts each interruption separately — a detector that is correct and unwired looks exactly like one that is absent — and MenusTest taps Alt through the real window and the real popup, twice, and then proves Alt+F leaves the bar alone.

The click that acts on what it landed on

  • A right-click selects the row it is over before the menu opens (ADR-0224) — every file manager’s gesture, and one the toolkit had left to applications because it “has no notion of what select means for an arbitrary widget”. It still has none. The widget under the pointer does, and what was missing was a moment.
  • The launcher’s existing walk is the moment. It already goes from what the gesture landed on up to the nearest widget that named a menu; it now remembers the deepest Selects it passed and asks it once, immediately before opening. So a right-click on a cell targets its row, by the same rule that makes a right-click on a button’s label a right-click on the button.
  • Nothing is asked when no menu opens, because a selection that changed with nothing to show for it is a gesture with no visible cause. The keyboard’s menu key shares the walk and therefore shares the rule (ADR-0208).
  • The rule that makes it worth having is the one an application writing this by hand gets wrong: a row already in the selection leaves it alone, so right-clicking one of five chosen files opens a menu about the five rather than collapsing them to one.
  • Selects is one method in input.handler, and the first thing in that package that is a request rather than a report. ListRow and TreeRow implement it in four lines each; table inherits it, because a table is a ListView whose item-factory returns a row of cells.
  • Twelve tests. Six in :core against the real launcher — who is asked, that the deepest wins, that only one is, that it happens before the menu and not after, that nothing happens when no menu opens, and that the menu key does the same — and six in :widgets for what a list’s and a tree’s rows do when asked.

The one widget nothing would ever have spoken

  • A toast says it is a live region (ADR-0225), which is §7’s phrase and a claim Role and accessibleName cannot make between them. Every other widget in the catalog is announced because something happens to it — the focus lands on a button, a reader walks onto a row — and the reader’s own cursor is the event. A toast has none: nobody focuses it, nobody has to click it, and it is gone in five seconds.
  • Semantics.live(), answering OFF, POLITE or ASSERTIVE and defaulting to off, so no existing widget changed. Role gained STATUS — a region that reports what just happened, which is neither a GROUP (a boundary with content in it) nor a DIALOG (somewhere the user is until they leave).
  • ASSERTIVE has no consumer, deliberately: interrupting is for something that must be dealt with before anything else, and a toast is dismissible and transient by construction. It exists because a vocabulary of two would make “polite” look like a default rather than a choice.
  • Rarity is a test rather than a convention. SemanticsSweepTest asserts that ToastBox is the only class in the catalog overriding live(), so a widget that later decides it deserves interrupting has to go there and say why.
  • Nothing announces anything yet. The bridge is M5, exactly as for every other widget’s role and name. What changed is that the remaining work needs no decision from the catalog — a toast raised today already carries everything an announcement would read.

The animation no picture could have caught

  • AnimationSweepTest (ADR-0226) — the second sweep, after SemanticsSweepTest, that enforces something no golden can show. A golden drives render by hand and never asks whether the frame loop would have, so a widget that answers isAnimating with false while it fades produces perfect pictures of an animation that never runs.
  • Two rules. A widget holding a Phase declares isAnimating — structural, and scoped to things that implement Paints, because a State and a value record may both hold a phase and neither is asked for a frame. And every declaration of isAnimating has a test in its own package that names the method, which catches the animations a Phase does not describe: a tab’s transition is a number, a scrollbar’s fade is an idle clock.
  • The second rule is deliberately weak about what is asserted. An arch test cannot tell a good assertion from a bad one; it can tell that there is one, which is the difference between finding this late and not at all.
  • It found a gap on its first run. ScrollViewport and ScrollFade had no isAnimating assertion anywhere. ScrollFadeTest now covers §2.4’s fade curve and both ends of the frame contract — that it keeps asking through the idle period and the fade, that it stops once the bars are gone (the opposite failure, and just as real), and that bars held open by the pointer are still rather than moving.

The word the tree did not have

  • Widget.nothing() (ADR-0227) — a widget that describes no box, no space and no selector. Every build has to return a widget, so a widget with nothing to show could only draw an empty box (which takes no room of its own and is still a child, so a column with a gap puts the gap round the thing that vanished) or be described away by its parent (which moves the decision to the application, which is what bind= exists to spare).
  • No new branch anywhere. The mechanism was already there: a node that is neither Styled nor Paints and has no children contributes no box, which is how every composition node works. What was missing was a name. A singleton leaf and a static method — a method rather than a constant because a static final on Widget holding one of its own subtypes is a class-initialisation cycle.
  • It is still an element, holding its state, its place in the reconciler and its subscription. That is the point: a widget that describes nothing this frame and something the next is one node whose value changed.
  • message bind=, which was the entry that asked for all this. A blank value is no banner; text stays the fallback for no binding, not for a blank one, because an empty error property means there is no error. The dismissed case converged on the same word and stopped leaving a gap behind it.
  • field-message is deliberately not converted. Switching its empty styled box for a nothing changes the spacing of every form — five golden images say so — which is a design decision about §4’s “message slot” rather than a bug fix.

The frame loop that never slept

  • collapse and carousel ask their Phase now (ADR-0228). §1.7 promises “the frame loop is fully idle when no animation is active”, and it was false for any window with an open collapse on it and for any window with a carousel at all — that one reported an animation from its first frame and never stopped.
  • The bug is one substitution. Both handed their moving part a function of the clock and decided at build time whether there was an animation, so isAnimating answered “were you built in a state where you could move” rather than “are you still moving”. A phase settles itself on the frame that finishes it; a DoubleUnaryOperator closing over one cannot say whether it has. message was already built the right way.
  • Two things the entry had not predicted. A section shut half way through its arrival keeps an ENTERING phase that nothing will ever read again, so CollapseSection guards on open. And CarouselTest’s own animating() case asserted the bug — written against the implementation rather than against §1.7.
  • The wasted frame went with it. A separate entry recorded, as harmless, that every clock-driven arrival costs one frame because the renderer asked isAnimating before drawing. A phase learns it has finished by being read, and reading happens in render — so asking afterwards is one line and one frame of every animation in the toolkit.
  • A new IdleLoopTest, asserting on the renderer rather than on a part, because the renderer is what the frame loop asks. It also writes down the two legitimate reasons a loop stays awake that made it hard to write: a CSS transition starts on the frame that observes the changed style, and opening a collapse rotates a chevron under one.
  • AnimationSweepTest fired on this change, naming both widgets the moment they gained a Phase component — the sweep from ADR-0226 doing its job on the first real change after it landed.

The rank that was missing, not the rank that was unused

  • A semantic hue has four ranks now (ADR-0229): the hue as a fill, -fill for words on it, -line for a stroke on a surface, and -text for words on a surface. The names say what each is for rather than how it was made.
  • The survey that asked for this found the opposite of what it expected. Six rules drew ink in a bare hue and only one was a line — field:invalid’s border, which now takes -line. The other four were words, and §1.2’s floor for words is 4.5:1 where -line is derived against 3:1. Pointing them at -line would have moved them from clearly wrong to quietly wrong: --gb-danger-line is 3.53:1 on the dark theme’s surface.
  • The worst measurement was 2.04:1 — statistic-delta.up in the light theme, a green nobody can read. The HUD’s over-budget red was 3.95:1 on its own plate.
  • The HUD gets its own two tokens, identical in both themes, beside the --gb-hud-text and --gb-hud-bg that already were: its plate lies over the application’s colours, so a theme-varying hue is wrong on it — the light theme’s -text red is a dark red, and a dark red on a near-black plate is an absence rather than a warning.
  • Eight golden images changed, each of which had been recording a colour below §1.2’s floor faithfully for months.
  • And the survey became a lint. noBareHueDrawsInk reads controls.css for color:/border-color: set to a bare hue. ContrastTest’s opening note refuses to parse CSS, and rightly — for a contrast claim. For a coverage claim only the source can answer, which is why they are separate tests.

A notification, an event, and the difference between them

  • PointerRouter.onPointingChanged takes a list of listeners (ADR-0230) and hands back a Subscription. The entry that tracked it said a second listener needed “a decision about what it means for two things to react to one hover”, and the decision is that there is nothing to decide: what is delivered is a notification, not an event. Nothing is passed, nothing can be consumed, and each listener reads the router for itself.
  • An event would be the thing worth refusing — one carrying a target, or consumable — and is what ADR-0105’s objection was aimed at. The rule is written down now: if the thing delivered can be consumed, one listener; if it is only a nudge to go and look, a list.
  • The slot had a bug nobody had noticed. A setter named onPointingChanged reads like a registration and behaved like an assignment, so a second caller silently dropped the first — a tooltip that stops appearing, with nothing anywhere saying why.

The menu that stayed where the window used to be

  • A popup is placed again after a resize (ADR-0231), which is what Popup.move had been waiting for since ADR-0104.
  • Half the entry’s premise was wrong, and finding out which half is most of the work. A popup sits at an offset from its owner, so moving the window carries it along — the platform does that. A resize moves the thing it was anchored to, and nothing told it.
  • An id is worth more than a rectangle. A popup opened against an anchor id re-resolves it against the frame the resize produced, so it follows a heading that moved; one opened against a caller’s rectangle keeps that rectangle, because the caller said where.
  • It happens at the end of the next paint, not in the resize handler — the part that is easy to get wrong and impossible to notice. anchor(id) answers from the capture the last paint produced, which during the resize handler is still the old window’s: re-placing there would put every menu back where its heading used to be.
  • The test fails by exactly 200 pixels without the fix, and writing it found a trap worth recording: a run bounded by --frames finishes in whatever wall-clock time the machine takes, so a callback scheduled 300ms out can arrive after the loop has gone and read a live-looking anchor() from a dead launcher. It schedules by turns of the event loop instead.
  • What is still open is written into the entry: a window move does not re-clamp, because there is no BackendEvent.Moved; and a popover does not follow a scrolling anchor, because the anchor is what would have to report it.

The modal that trapped the keyboard and let the mouse through

  • Modality is one flag (ADR-0232). It was two mechanisms, and Handles.isModal said so in as many words: “the pointer is not this flag’s business” — a dialog is unreachable by mouse because its scrim covers the window. That is modality by geometry, and a widget that declared itself modal without a scrim trapped the keyboard and let every click through.
  • The rule now: while a modal is mounted, the pointer reaches its subtree and its ancestors, and nothing else. The ancestors are the point rather than a loophole — a scrim is the panel’s parent, and a click on it is what closes the dialog. An ancestor is on the path from the modal to the root; a button in the application is neither on it nor inside the modal.
  • Enforced in elementAt, the one place every pointer entry point resolves a target, so presses, releases, wheels and hovers obey it together — a control behind a dialog that lit up under the pointer would claim to be pressable when it is not.
  • The paint-order rule is written down too. The topmost painted region taking the pointer was already true and unasserted; it is on elementAt now with a test that fails if it stops being.
  • Found once per frame, beside the regions, which is ADR-0054’s rule applied: input is answered against the frame the user can see, so the tree that frame came from is the tree to ask — and one walk per paint rather than one per mouse move.
  • The test’s overlay deliberately does not fill the window, which is what makes it a test of the rule rather than of the geometry: a filling scrim takes every press whether or not anything is modal.

The submenu that took its parent with it

  • Escape closes the innermost popup; a press outside closes the stack (ADR-0233). The two gestures mean different things and the launcher had been running the same code for both, so opening File → Recent and pressing Escape closed the menu as well as the submenu — and there is nothing to reopen it with but the mouse.
  • Finding which handler was at fault was most of the work. A Popup watches its own window and closes only itself, which is correct and never runs: since ADR-0189 no popup holds the platform keyboard, so Escape arrives at the owner window, whose watcher dismissed everything.
  • The innermost popup is not always the one that goes. A tooltip is lightDismiss(false) and refuses, so the walk looks past it rather than stopping — otherwise Escape would do nothing with a menu open underneath. dismissedByInput reports whether it closed, which is what makes that expressible.
  • Focus loss still closes everything, because the application is no longer in front and there is no chain to step out of.

The controller that turned out to be a timer and an ordering

  • The overlay lifecycle survey is done (ADR-0234), and the answer is two objects rather than one controller. §1.7’s opening → open → closing → removed was a specification with no subject until the widgets it describes existed; they do, and the table of how each of the seven arrives and departs is what settles it.
  • The arrival needs nothing shared. Phase is already the whole of it — a beginning stamped on the first frame that draws, a duration and a settle — and six widgets use it without wanting more.
  • The departure was the same code twice. dialog and message each held two flags, a timer and six lines, and independently got the same four rules right: idempotence (two handlers on a save dialog is two saves), two flags that mean different things (using one for both is why a closing dialog once never faded), stop-drawing-before-telling (it matters for one frame), and gone-at-once with no host or under reduced motion.
  • Departure is that, and it is still not an AnimationController. ADR-0081 refused one for spinner, ADR-0178 refused one for a toast’s reflow; this is what was left after both. It drives no value, interpolates nothing and owns no clock — it owns a timer and an ordering, which is the part that was duplicated.
  • toast and tab are deliberately not converted. A toast’s departure ends when its stack’s queue says so and a tab’s ends inside render; forcing them through this would be ADR-0092’s warning about generalising from two examples that already agree.
  • The refactor is behaviour-preserving, which the dialog and message suites — golden images included — say by passing unchanged. DepartureTest is eleven cases: one per rule, and one per way a rule was once broken.

A wrong reason, repeated in four places

  • “Nothing in this toolkit clips” was false (ADR-0235). option, select-value, ProgressFill and a TODO.md entry all said it in almost the same words; overflow: hidden has shipped since ADR-0114, is read by Yoga and the painter, reaches hit testing, and is used by text-input, text-area, scroll and four CSS rules.
  • So clip a menu row and be done — except it does not work, and why is the finding. Box.text is a measured leaf: narrowing the box it is in re-measures the paragraph at the narrower width, so the label wraps instead of overflowing and there is nothing left to clip. That is why ADR-0148’s fix was flex-shrink: 0 rather than a clip.
  • Three attempts, all recorded. Clipping the row cropped the showcase’s 20px icon in its 16px column (a golden caught it in one run); a shrinking clip box around the label reintroduced the wrap ADR-0148 had fixed; adding align-items: center to that box fixed a different bug found on the way — a Box.of() wrapper defaults to Yoga’s column, where align-items is the horizontal axis — and did nothing about the wrapping.
  • The missing property is white-space: nowrap, not text-overflow. With it a clip works and an ellipsis becomes reachable; without it no arrangement of overflow and flex-shrink can cut a label, because the label is never too long for the box it is in.
  • No behaviour changed and four comments did. Shipping overflow: hidden where the build stays green only means no golden covers a label that long — which would have risked turning an overflow into a two-line wrap with nothing demonstrating an improvement. Two TODO.md entries keep their subject and lose their reason.

The wheel that stopped at the first thing that could hear it

  • A knob consumes what it moved, and nothing else (ADR-0236). Two TODO.md entries had been holding this open since ADR-0089 from either end — “a knob inside a scroll view is still untested” and “Kind.WHEEL had exactly one consumer, and it showed” — and they close together, because they were the same fact stated twice.
  • There were two rules for one event, and only one of them was written down as a rule. ScrollViewport had answered it in ADR-0116 — “returns whether anything actually moved, which is what the caller turns into consuming the event, and therefore what decides whether an ancestor scroller gets a turn” — while Knob.wheel consumed everything it was handed. With nothing above a knob for an unconsumed wheel to reach, the difference could not show. Put a knob in a list and it shows at once: pinned at its maximum it swallowed every upward scroll and the list stopped dead under the pointer.
  • The comparison is against what ask would pass on, not against the raw arithmetic, and that is the detail that decides whether the rule is right or merely plausible. A stepped knob two from its end on a grid of five still moves those two — snap(clamp(98 + 5)) is 100 and differs from 98 — where comparing the raw 103 against the maximum would have thrown the last part-step away.
  • Only the direction with nowhere to go chains. A knob at its maximum still takes a wheel that turns it down, so a control being used does not let the list lurch out from under it halfway through. And a knob nobody is listening to is not a place a scroll stops: disabled, or a null onChange, means the value cannot change, so the event is not consumed.
  • KnobChainingTest is the first test in the catalog to drive a wheel through a real bubble between two widgets — four cases through the real router against painted regions. The arrangement is what took the work: the list is scrolled off its top before every case, because the direction a knob at its maximum rejects is the one that scrolls a list up, so against a list left at its top the viewport’s own edge rule would have refused the wheel and the test would have passed before the fix for a reason that had nothing to do with it.
  • Three of the new assertions were checked against the old code by neutralising the range comparison and re-running, which is the only thing that distinguishes a test of this shape from one that never ran.
  • What it turned up and did not close: a disabled control swallows a wheel outright, because PointerRouter.dispatch returns before the chain is built when the target sits in a disabled subtree — so the scroll above it never gets a turn. Measured, not deduced. That cut is ADR-0059’s, it is right for a click and wrong for a wheel, and whether it should be per event kind is a decision about the router rather than about knob. It is in TODO.md.

The shape that was a function of the last motion

  • The cursor now follows the frame, not only the pointer (ADR-0237). A TODO.md entry had carried its own fix since ADR-0057 — “re-run cursorAt after each paint against the last known position” — with the condition “it is worth doing when something can actually change that way”. Something can: cursor: not-allowed ships on every disabled control, and the sequence that reaches it is a button disabling itself in its own press handler while the person deciding whether to click holds still.
  • What was missing was a place to remember where the pointer is. The router had three position fields and all three are gesture-scoped: pressOriginX/Y span a press-to-release and are NaN outside one, which is precisely what made them useless here. The fourth outlives a gesture and is set from every entry point that carries a position — moved, pressed, released, wheeled — so a window whose first event is a click is not left with nowhere to ask about.
  • NaN means “we do not know”, twice: before the pointer has ever arrived, and after it has left, which is another window’s pointer or none at all. Both skip the recompute rather than asking about a point the pointer is not at. The capture freeze is reached through rather than around, so a repaint during a drag does not thaw the shape mid-gesture.
  • Measuring it turned up a second half the entry did not name, and a comment that denied it. :hover and :active had exactly the same staleness, while mark said “a control that was hovered before it became disabled does not keep the state — which is a real sequence, because a button commonly disables itself in its own press handler”. It did keep it. Clearing is not suppressed, but nothing called it: updateHover returns early when the element under the pointer has not changed, so the wash survived every later move within the control and went away only when the pointer left it — on the exact sequence the comment named as the reason it was safe.
  • So it is one defect and not two. Fixing only the cursor would have shipped a control drawing its hover wash while its cursor said not-allowed, which is more confusing than either mistake alone, and §2.1 already says a disabled control must not light up — no decision left to defer.
  • restate() is mark(…, true) and nothing else, because mark already knows the rule: a set on a disabled element becomes a clear, so re-asserting what the pointer is over sets the state where the control is live and takes it away where it is not, in one call with no second branch. No ENTERED or EXITED is emitted — nothing entered or exited anything, and a tooltip opening because a list repainted would be a worse bug than the one being fixed.
  • This runs once per frame rather than once per motion, which makes the edge-triggering in setCursor and setPseudoClass load-bearing in a way it was not before. A 120 Hz repaint over a still pointer is 120 comparisons and no platform calls, and a test asserts it.
  • Eight cases, three of them checked against the old code. mark’s comment is now true, which matters more than it sounds: it was describing an intention as an achievement, and that is the kind of comment that stops the next person looking.

The gesture that was never about the control it was over

  • A wheel chains past a dead control (ADR-0238), which closes the entry ADR-0236 had opened one commit earlier. A disabled knob in a scrolling column stopped the list dead under the pointer, and no widget could fix it: dispatch returned before the chain was built when the target sat in a disabled subtree, so the scroll above was never offered the event.
  • The cut is ADR-0059’s and its argument is about the thing being aimed at. A click on a disabled button must not become a click on the row holding it, and a disabled control still hit-tests so a click cannot fall through to what is painted behind it. Both stay. What the argument does not cover is a wheel, which is not aimed at a control at all — it is aimed at whatever scrolls, and a browser, GTK and Qt all deliver it to the scroller. Nobody puts the pointer on a dead control in order to scroll; they put it on the list, and the dead control happens to be under it.
  • So the disabled cut is per event kind, for one kind. A press, a release and a click are refused exactly as before; a wheel builds the chain and drops its disabled prefix. The dead subtree still handles nothing — the trimmed chain never reaches it — and what changes is only who gets a turn afterwards.
  • The prefix is a fact rather than an assumption. The chain is deepest-first and isDisabled walks up, so it is true from the target to the outermost disabled ancestor and false at every step above; dropWhile is exact in one pass. A wholly disabled tree trims to nothing, which is the old behaviour reached by the new route.
  • isInput is untouched. Taking WHEEL out of “the user doing something” is a one-character diff and the wrong one: the same predicate decides whether the disabled subtree is skipped at all, so the disabled knob would have started turning.
  • Why nothing caught it, which is the part worth keeping. DisabledPropagationTest has covered “the wheel is refused too” since ADR-0077 — against a disabled form holding a button and nothing above it. With no live ancestor, “the subtree refuses the wheel” and “the wheel is swallowed” produce identical logs. The old assertion is unchanged and still passing, because what it actually claims is still true.

The floor nobody was standing on

  • §1.2’s non-text half is measured (ADR-0239). ContrastTest has enforced 4.5:1 for text since ADR-0087; the other floor — 3:1 for anything that is not text — had nothing behind it, and ADR-0088’s argument that the accent ramp did not need to move rested on exactly that unenforced number.
  • The open question had a simpler answer than it looked. “What counts as the background of a mark drawn onto its own box” is its own box: a mark takes the color of the element it is drawn in, and that element supplies its own background. For every mark in the catalog the same rule sets both — a checked tick is --gb-checkbox-mark-checked on --gb-checkbox-bg-checked, both from check-indicator:checked. So a pair is one ComputedStyle’s two properties, and this stays a cascade test like the sweeps beside it.
  • Three sweeps, because there are three shapes of question: a mark against the box it is drawn in (nine pairs), a ring against the surface behind it (the focus ring and the spinner, each on all three surfaces), and a control against that surface.
  • The last one is a maximum, and that is the part that took thinking. A control offers two means of being identified at once — a fill that differs from the surface and an edge around it — and §1.2 asks that some means clears the floor, not that every one does. Measuring the two separately was the first version and it reported --gb-border failing on every surface in both themes, which is a decorative divider doing exactly what a 1px separator is meant to do. The maximum tells the two roles of one token apart without needing two tokens.
  • Nineteen pairs are below the floor, and they are recorded rather than fixed. KNOWN_FAILURES is empty because ADR-0088 fixed the seven text pairs it found; the same move is not available here, because every one of these is a theme colour and sliding a ramp changes what the toolkit looks like — a design decision with a golden-image tail rather than a test’s to take. They sit in three exact-set lists on KNOWN_FAILURES’ terms, each carrying its measurement, so none can be parked quietly and any that gets fixed fails the test until it is taken out.
  • The worst is §2.2’s focus ring, below 3:1 on all three surfaces of the light theme (1.74, 2.00, 1.64) — the one mark in the system with no second means of being seen. Twelve of the nineteen are control boundaries, and their shape is one fact: --gb-checkbox-bg is --gb-surface-2 in the dark theme, so an unchecked box on a group-box differs from its backdrop by nothing at all.
  • The class comment stopped saying “the exemption list is empty.” It was true of the text sweep and is now only true of the text sweep, which is the same kind of overstatement ADR-0237 had just finished correcting in mark.

The ring that had no picture of itself

  • --gb-focus follows the accent on the light theme (ADR-0240), which pays the first and worst of the nineteen debts ADR-0239 recorded a commit earlier. §2.2’s ring was 1.74:1 on --gb-bg, 2.00:1 on --gb-surface and 1.64:1 on --gb-surface-2 — below §1.2’s floor on every surface the theme paints.
  • It was the one to fix first for a reason that is not the size of the number. A focus ring is the only mark in the system with no second means of being seen: a control that is hard to make out still has its label, its shape and its position, and a keyboard user who cannot see the ring has nothing.
  • The cause was a ramp left behind rather than a colour anyone chose. Both themes set the ring to their accent — except the light theme’s accent had already moved down the Frost ramp from --nord8 to --nord10 for contrast, and the ring kept the pale one. Setting it to --nord10 gives 3.50, 4.03 and 3.31, and it is a palette value rather than an invented one, which the theme files’ own two-tier doctrine asks for.
  • The gap it exposed is the more useful half. Changing a shipped colour moved no golden at all — not because the change is invisible, but because every focus golden in the catalog is NORD_DARK: segmented-focus, menu-focus, menubar-focus. §2.2’s ring had no picture of it on the one theme where it was broken, which is why nothing caught it and why nothing would have caught it coming back. segmented-focus-light is new and is the catalog’s first.
  • ContrastTest’s exact-set lists worked on their first use. Emptying the token without emptying RINGS_BELOW_FLOOR failed the build, which is exactly the property ADR-0239 built them for: a pair that gets fixed fails the test until it is taken off the list.
  • Sixteen non-text pairs remain, in MARKS_BELOW_FLOOR and BOUNDARIES_BELOW_FLOOR. They are control fills and one accent-on-border pair, they move goldens in bulk rather than one at a time, and TODO.md carries them as a single entry for that reason.

The guarantee that stopped where the extensibility began

  • A theme can be audited by whoever wrote it (ADR-0241). ContrastTest has measured the two themes the toolkit ships since ADR-0087, and §10 lets an application replace every alias token — so §1.2’s promise stopped exactly where §10’s extensibility began: the toolkit guaranteed legible colour, handed the application the means to replace all of it, and then had nothing to say.
  • css.contrast is a new package in :core, and exported. :core because a theme is, and because an application should not have to depend on the widget catalog to find out its colours are unreadable. Its own package because it is neither a stage of the engine nor a value type — it is a question asked about a resolved cascade, which is the shape ADR-0172 gave the other four.
  • The pairs are found by convention rather than listed, and this is the decision the entry did not anticipate. A hard-coded list of the toolkit’s own pairs would check a custom theme’s overrides and miss everything it added. The design system already names pairs consistently, so the rule is every --gb-<name>-bg with a matching --gb-<name>-text — and an application following the same convention for --gb-mycard-bg is checked for free. The surface pairs are stated beside it, because --gb-text on --gb-bg is the one relationship the convention cannot express.
  • Two details decide whether it works on a real theme. Values are substituted, for ADR-0195’s reason: a theme written the ordinary way says --gb-badge-warning-bg: var(--gb-warning), and reading raw tokens would decide that is not a colour, skip the pair, and audit a real theme as having nothing to check. And a translucent pair is skipped rather than scored, because what it composites over decides the answer — --gb-hud-bg is #1c212ae6 and is the shipped example, with a test saying so, because the rule is only credible if the toolkit’s own tokens are subject to it.
  • Both shipped themes audit clean at seventeen pairs each, asserted as a count as well as a set: a sweep that quietly stopped finding pairs would otherwise pass by measuring nothing at all.
  • ContrastTest lost its private arithmetic and its two literal floors. They are Contrast’s now, so the number CI asserts and the number an application audits against cannot drift apart — an audit and a sweep that disagreed would be worse than either alone.
  • It deliberately does not cover the non-text floor. NON_TEXT_FLOOR is exported and unused here: which token is a mark is not something a naming convention can tell, and the sixteen non-text pairs below the floor stay TODO.md’s.

The unit that meant one number everywhere

  • em is the element’s own computed font size (ADR-0242), which closes an entry open since ADR-0066. CssLength.Context was always the right shape — (fontSize, rootFontSize), one read by each unit — and nothing ever built one per element: WidgetRenderer holds a single instance for the whole tree and hands it to every ComputedStyle.of call, so em was one constant at every depth.
  • Two passes, because CSS has one exception. 1.2em on font-size means “a fifth larger than my parent”, since the value being computed cannot be its own input; on anything else it means “a fifth larger than my own text”. One pass with one context cannot say both. font-size is resolved first against the parent’s size, everything else against the size that produced.
  • It needed no plumbing. The parent’s size is parent.typography().size(), already passed in for inheritance — the fix is entirely inside the method that had been given everything it needed all along. The undeclared case needs no branch either: a node that says nothing has whatever it inherited, which is exactly what em should resolve against.
  • Measuring it turned up a number the entry did not mention. CssLength.Context.DEFAULT is (16, 16) and Typography.INITIAL’s size is 13. So 1em was not the parent’s size, not the element’s own, and not any size the toolkit actually renders text at — two constants with no relationship and nothing making them agree.
  • Transform was the same bug in a second place, and had said so in a comment naming the gap: it reached for Context.DEFAULT directly. It takes a Context now, threaded from ComputedStyle.with, which had one all along — two public call sites, both in ComputedStyle.
  • No shipped rendering changed, and that was checked rather than assumed. Not one em or rem appears in nord-dark.css, nord-light.css, controls.css or the showcase’s sheets, and the golden corpus passes untouched.
  • One existing test changed meaning and was rewritten. “em multiplies the font size in force” passed Context(20, 16) with no parent and asserted 30 — the old semantics, on an element whose computed size was 13. It declares font-size: 20px now and asserts the same 30 for a reason that is true.
  • What is left is rem, which reads the configured root size rather than the root element’s computed one. They agree unless a root declares a font-size, and nothing in the catalog does; recovering it needs a third thing threaded down, because a node is handed its parent’s style and not the root’s.

The warning that was a stream

  • A missing token says itself once (ADR-0243), which closes an entry open since ADR-0121 and applies ADR-0216’s answer one stage earlier in the same pipeline. A stylesheet is static, so a var() that resolves to nothing cannot resolve on the next frame either — but a style is resolved per element per invalidation, so one missing token reported itself sixty times a second for as long as the screen it was on kept moving. Two of them survived long enough to reach a user, which is what a log nobody can read costs.
  • The mechanism is ComputedStyle’s, and the field is not. That one is static, because a record with static factories has nowhere else to put it, and it needs a public forgetReportedDrops() so tests in two modules can clear it. A StyleResolver is an object, built per stylesheet set and living as long as its renderer — so once per resolver is once per stylesheet, which is what the entry asked for and comes out better three ways: a theme swap reports again (what the new theme is missing is news), a test is isolated by constructing its own, and nothing leaks between unrelated sheet sets in one JVM.
  • Keyed by property and element type, which refines the entry’s “per property”. The same token failing on button and on text is two facts, and which types it reaches is the blast radius somebody debugging it wants — bounded either way, because a stylesheet has finitely many declarations and a tree finitely many types. A cycle is keyed by the property name alone, because a custom property referring to itself is a fact about the property and not about whichever element asked first.
  • substitute and expandVar stopped being static, so the cycle report could reach the field. Neither had a caller outside the class, so the change is invisible.
  • The assertion is a counter, and the comment says why. Only slf4j-api is on the classpath, so there is no appender to read the log back from, and a logging backend bought for one assertion would be a dependency this does not need. reportedDrops() is package-private for exactly one test.
  • descend walks depth, not siblings, which two of those tests got wrong first and which cost a NoSuchElementException to find out. They build a fresh window > type tree per case now.
  • The drop itself is unchanged. Only the report is once — making the drop conditional would be a stylesheet that behaved differently on the second frame.

The property the document already claimed

  • align-self resolves and reaches Yoga (ADR-0244), which closes an entry open since ADR-0111 and takes one of the two things stack is blocked on.
  • The entry was wrong twice, in the toolkit’s favour. §8’s layout list reads align-items/self/content and the sentence naming what is unimplemented said only flex-basis — so the document claimed this worked, and what was missing was the implementation rather than the sanction. And Align.AUTO was already waiting: the enum’s own comment says “AUTO only means anything for align-self”, a value that existed for a property that did not.
  • The price the entry quoted was real and already insured. 47 positional argument lists across two records — 22 withers on ComputedStyle, 25 clean sites on Box — and alignItems and alignSelf are the same type, so a swap between them compiles, runs, and is wrong. RecordWitherTest has existed since ADR-0181 for exactly this: it asks every wither to set its component to the value it already holds and requires the record back unchanged, which no transposition survives. Its premise is that no two components of one type hold equal values, so the fixtures give alignItems FLEX_END and alignSelf CENTER.
  • The clean sites were scripted, one identifier per argument, inserted at a fixed index; the four carrying inline commas were edited by hand. A test written the last time somebody paid this price is what made that safe.
  • Five layout tests, four of which fail against the old code, asserted against Yoga’s own output rather than against the record — the property is one line in RenderObject and the whole risk is whether that line runs, so a test reading box.alignSelf() back would pass on a box nothing laid out. auto is asserted indistinguishable from saying nothing, and beside it a check that a non-auto value really does move the child, because the reason those two agree must not be that nothing is wired at all.
  • No golden moved, because nothing in the catalog declares align-self yet. That is the honest state of a property added for the widget that will want it — the tab strip’s + is the case that found the gap, and changing it is its own diff.

Three answers, and one of them was a question about CSS

  • --gb-surface-2 stays (ADR-0245). The entry had asked whether it should keep existing after three widgets mistook it for an elevation, and said that needed a look at what still reads it. The look found five readers and not one wants a direction: a default badge’s fill, a scrollbar on hover, a group-box-title band, a skeleton-bar and a collapsed split-divider. All five want a plate merely distinct from what is under it, which is what the token promises — the three that were wrong wanted “raised” or “sunken” and have their own tokens now.
  • The trap is asserted rather than described. ThemeTest holds --gb-surface-raised to never being darker than --gb-surface and --gb-surface-sunken to never being lighter, on both themes — and asserts that --gb-surface-2 takes opposite directions in the two files, up on dark and down on light. That is exactly why each of the three consumers looked right to whoever wrote it and wrong to everybody on the other theme. When a class of mistake has happened three times, the test to write is not one that checks the three fixed sites but one that checks the property they violated.
  • Text has a capture phase (ADR-0246), and the entry’s own condition for adding one was met. It had named the fix — Handles had an onKeyCapture and no onTextCapture — and refused to build it on spec, “because a capture phase is a routing rule and inventing one for a single consumer is how a router grows two”. select’s open list is the consumer: it lives in a second window with its own router and an option focused, so the letters stopped at a row that does not know what typing means.
  • It removes an asymmetry nobody had written down. dispatchKey has captured root-first and then bubbled since the beginning; textInput only bubbled. One event kind had a phase the other did not, for no recorded reason. SelectList now reads letters on the way down and calls the same typeahead the closed control calls, so n, n, n cycles the same options in the same order either way — one implementation rather than two that drift. Blank text is left alone, because a space in an open list means “pick this one” everywhere else.
  • start and end are taken, because they are not aliases (ADR-0247). The entry called them CSS’s aliases and left acceptance open; the word is what decided it. align-items: start is CSS — Box Alignment Level 3 — and Yoga has only flex-start, so this was not a toolkit picking one spelling among two conveniences but one dropping a declaration the specification allows and telling the author they had made a typo. It filled the Panels screen’s console for long enough to need deduplicating before anybody asked whether the declaration was actually wrong.
  • Two entries, applied after the enum’s own lookup, so a constant named START could never be shadowed by a mapping written for a different enum. left and right stay refused for a reason rather than an omission: they are justify-content only and are not start/end under RTL, so §2.4’s bidi support means the toolkit cannot promise they stay equivalent.
  • Two tests changed meaning and were rewritten, both in the group that exists because of this typo: its example of “a value the toolkit has not got” was align-items: start, and now has to be one it really has not got.

Two caches, and what each was actually comparing

  • Only the inherited half is handed down (ADR-0248). ADR-0142 stopped a node handing its children a new style instance for an unchanged value; what it compared was the whole record, including the transform — so a scroll moving an offset re-resolved every node inside the viewport on every frame of a gesture, for a change none of them could see.
  • The notion the entry wanted already existed. It said the fix “needs a notion of which properties inherit, which the cascade has and ComputedStyle does not” — but inheritingFrom is exactly that list and is two lines, color and typography, with a comment enumerating what is deliberately not there. What was missing was reading it twice, which is what inheritsSameAs does, beside it, so a property that starts inheriting has to be added to both.
  • The difficulty was not the comparison. stableStyle’s return did two unrelated jobs — the children’s cache key and what the node paints — so loosening it in place would have handed back an older instance carrying last frame’s transform and then painted with it: a scrolling viewport frozen at its first offset while every child cached happily. Two variables, because there are two jobs, and the test for it was written against the mistake. Folding the roles back together fails it, which was checked rather than assumed.
  • A rule that can name a type, does (ADR-0249). ADR-0152’s saving is that a rule for button is never looked at for a text, and it is worth what the stylesheet lets it be. The entry assumed the toolkit’s own sheets were type-first; measuring found 16 of 340 rules naming none, in two families rather than a scattering.
  • Seven were tour’s parts, and every one was matching a known type without saying so: the tour builds them from plain Text and Button widgets carrying a class, so text.tour-title matches exactly what .tour-title matched and lands in a bucket. Seven rules, one word each, and no golden moved — which is the evidence it changed what the cascade looks at rather than what it finds.
  • The other eight cannot be qualified and should not be: the typography scale is seven ranks an application puts on whatever it likes, which is what makes it a scale rather than a widget’s parts, and :root is the theme’s token layer. RuleBucketTest holds them as an exact set rather than a threshold, and reports the selectors rather than a count — the difference between a failure that names .tour-title and one that says a number went up. Beside it, an assertion that the sheet is large and nearly all of it bucketed, because a check listing eight selectors would pass against a stylesheet of eight rules.

The widget that turned out to be nine lines

  • stack is built (ADR-0250), which closes §1’s last core-group gap but image and an entry whose own final sentence had become “what stack still wants is stack” once ADR-0244 took its last blocker.
  • The first child stays in flow and the rest are position: absolute. That is the whole widget, and each half answers what the other cannot: something has to give the stack a size, because a box whose children are all out of flow is a box of nothing — and an overlay must not resize what it sits on. It also means a stack of one child is that child in a box, so wrapping an existing widget in one is a change that cannot move it.
  • It positions nothing, and that is the point. §1 asks for children “positioned by alignment or absolute insets” and both already worked: an absolute child with no inset is placed by the container’s align-items and justify-content and by its own align-self, and one with an inset goes where it says. This is the case ComputedStyle.INITIAL’s inset comment has been describing since before anything could reach it — “the difference only shows on an absolute node, where zero would stretch it and undefined leaves it where the alignment put it”. stack is the widget that finally shows it.
  • Ten tests, six of which fail against a stack that positions nothing, all against Yoga’s own output, because every claim a stack makes is a claim about where boxes ended up. The markup path goes through the real catalog rather than constructing the record: stack is in §1’s core list, so what is under test is the registration.
  • The tests wrap the stack in a row that does not stretch it, and that is load-bearing. The root box is always laid out at the frame’s size, so a stack tested as the root is 300 wide whatever its children do and every size assertion passes for the wrong reason — found by writing the assertions first and watching four of them come back 300.
  • No stylesheet rule ships for it, which is row’s and column’s arrangement exactly: a stack sets no colour, no padding and no gap, and where its overlays land is the application’s to declare.

Two things §2.4 said that nothing could hear

  • A widget may read a token (ADR-0251), and the entry asking for it was half stale when it was written. Paints.Context.color has read a resolved custom property since ADR-0195 — that is how a chart gets --gb-chart-1…8 — so what was actually missing was the same door for a number. length is it, deliberately still narrow on color’s terms: lengths and colours and nothing else, because both are values the cascade already parses and a general token accessor would invite a widget to reimplement the parser.
  • Reading it was not the hard half. The wheel arrives at onPointer, where there is no context to ask — so ScrollViewport reads --gb-scroll-line in render and banks it into ScrollState through the shape onMeasured already had. A frame late by construction, which is ADR-0117’s bargain unchanged: a paint always precedes an input, so a real window has spent that frame before anybody can turn a wheel. Guarded on the value having changed, because the callback sets state and a setState every frame is a rebuild every frame.
  • The override test paints twice and says why. The first paint banks the token; the rebuild after it is what puts the value on the widget the router hands the wheel to. Writing it with one frame is what found that, and the comment is there so the next reader does not “fix” it.
  • A nested same-axis scroller says so, once. §2.4 rules them out and nothing enforced it — and chaining means such a pair behaves reasonably rather than badly, so the ban cost nothing and the author heard nothing, which is the worst shape a rule can have. BuildContext.findAncestorState is the whole implementation: it exists for scrollIntoView and answers this with nothing added, which is why it is asked in ScrollState.build rather than by teaching the renderer about scroll views.
  • It is a diagnostic and not a refusal. The arrangement still works, because turning a canon rule into a crash is worse than the rule going unheard — the author’s problem was that nobody told them. Deduplicated by axis for ADR-0243’s reason: build runs per element per invalidation, and a document that nests in four places has one mistake rather than four.
  • list is unchanged and its entry stays open, which is the honest half. ListView.virtualized(h) takes the height as an API argument, and the number decides which rows to build in children() — so a value banked from render would be a frame late in the one place a frame late means building the wrong rows. The door scroll needed is not the one list needs.

The state a window could be put into and never asked about

  • A window can be maximized, restored and asked (ADR-0252). Application.maximized() was a creation flag and nothing else: it became SDL_WINDOW_MAXIMIZED and after that nobody involved knew whether the window still was one.
  • isMaximized() answers what the platform last reported, not what was last asked, and that is the question the entry left open. It follows from what ADR-0221 already established — maximized is a state rather than a size — and from the fact that every platform routes the ask through a window manager that may refuse it, delay it or grant it in part. A flag set on the way out would be a lie the moment one did.
  • The cost is stated rather than hidden: between maximize() and the event, isMaximized() is still false. That is a window which has been asked and has not yet agreed, and there is no third answer that is true — asserted by a test, because it is the kind of thing a later reader would “fix”.
  • It is also what makes the feature worth having. An application can learn that the user maximized it, which is what a “remember my window size” preference needs and which no amount of tracking one’s own calls can produce. HeadlessWindow.reportMaximized is the route that does not start with the application, and the test for it is the one that matters most.
  • And a window has a floor, which is the other half of “the user decides the geometry”. WindowSpec.minimumSize, Window.minimumSize(...) and Application.minimumSize() declare the smallest a window may be dragged to, and the window manager enforces it through SDL_SetWindowMinimumSize — so the pointer stops at the edge rather than the window shrinking and springing back a frame later, which is what clamping in a resize handler looks like. Default is no minimum, because a toolkit does not know what a window holds; the showcase declares 640×480, below which its sidebar and its pane stop being two things. A minimum larger than the opening size is refused, and --size= demotes it with a warning rather than failing to start (ADR-0304).
  • The export list and the C shim both had to learn the new names, and that refusal earned its keep immediately. SDL_EVENT_WINDOW_MAXIMIZED is 0x20A and RESTORED is 0x20B — derived by counting an unnumbered C enum from the last explicit value, which is precisely the arithmetic that is silently wrong. LayoutVerificationTest refuses a constant declared in Java that nothing verifies against the compiled library, and it checked both.
  • GoldberryRuntime’s switch is exhaustive over a sealed interface, so adding the event failed the compile until it was routed — the design working rather than an inconvenience.

The number three places had to agree about

  • --gb-caret-width ships (ADR-0253), which is the second component-token default to arrive since a widget could read one and the first that was an accessibility gap rather than a styling question: a thicker caret is a low-vision aid, and §13 lists that kind of switch.
  • The entry’s diagnosis was right and its phrasing understated it. A caret { width: 3px } is not merely ignored, it is overwritten — the caret’s box is computed from the shaped paragraph in the same render that positions it, so the cascade’s answer is replaced rather than consulted. width is the wrong spelling for this and a token is the right one; the token was not shipped only because nothing could read one.
  • Two controls had two copies of the number, and the second’s comment said it was the first’s — one constant with a comment where the compiler should be. widgets.form.Carets holds it and the token name, both controls read it, and a test asserts they agree.
  • There is a third consumer, and it is the one that would have made a fat caret wrong. TextInputState.laidOut reserves “the caret’s own width of room” so a field does not scroll short of showing it, hard-coded to 1. A three-pixel caret against a one-pixel reserve is a caret clipped at the end of the text — a failure that would have read as a text-rendering bug rather than as an unfinished token. laidOut takes the width now, which is free because it is already called from render.
  • Four tests, two of which fail against the old code, and the tests find the caret by its width rather than by counting children — a field’s anatomy changes, and an index into it is a test that breaks for an unrelated reason.
  • -Werror caught two dangling doc comments left behind when the constants moved, which is the check doing its job on a refactor rather than on new code.

The other door, and the number that was a bug waiting for a setting

  • A build may ask the cascade for a number (ADR-0254), which is the door ADR-0251 named and did not open. BuildContext.token is Paints.Context.length’s build-time twin, for the numbers wanted before there is a box to paint.
  • It was three lines, because the pieces were already there. Element implements both BuildContext and StyleElement, and ElementTree has held a StyleResolver since ADR-0149 so a node whose state changed could ask what the sheets say. What was missing was the method. WidgetRenderer.prepare is the genuinely new part, and it is about ordering: render hands the tree its resolver on the way in, which is a frame too late for a reader in build.
  • The stakes were higher than a repeated number. density-compact.css sets --gb-list-row-height: 26px, so a list written virtualized(32) against the regular density virtualizes on the wrong pitch the moment an application switches — the spacers and the window disagree with the rows. Repeating the number was a bug waiting for a setting to be changed.
  • The first attempt was a sentinel, and it cost a guard. rowHeight is already a tagged number — 0 means “do not virtualize” — so -1 for “ask the token” looked free. ListVirtualTest asserts that virtualized(-1) throws, and its name says why: “a negative row height is refused where it is written”. -1 is what a typo looks like. A boolean component instead, across seven constructor sites where double, boolean, Attributes in a row makes a transposition something the compiler refuses.
  • The first build of a tree has no cascade, found by measuring rather than assumed: the value resolved on every build except the first. A Stateful widget builds once inside the ElementTree constructor, before any renderer has taken the tree on — so a token there answers its default and the second build is the first that can see the stylesheet. Everything that reads one is expected to settle, and a virtualized list settles by construction.
  • The token must be declared at or above the list. ListView is a composition node whose state builds the list element, so the build that decides the row count runs one level above the node a list { … } rule would match. That is where it ships — :root, in both controls.css and density-compact.css — and it is written down because list { … } looks like it should work and will not.

The property four widgets were waiting for, and the one that had to stay out of the layout

  • A label that does not fit is cut, not wrapped (ADR-0255), which builds what ADR-0235 diagnosed a week earlier and deliberately declined to build: §8’s subset now has white-space: normal|nowrap and text-overflow: clip|ellipsis.
  • white-space is the whole mechanism, and it lives in the measure function. Paragraph.measureFunction(TextFlow) ignores the width Yoga offers under nowrap and reports the width the text actually wants. Everything else is a consequence: a box may now be laid out narrower than its own content, which is the state overflow: hidden and an ellipsis were always waiting for and which the toolkit could not previously reach. ADR-0235 recorded three attempts at clipping without it, all of which failed for the same reason — a box with text is a measured leaf, so narrowing it re-measures the paragraph and wraps it, and there is then nothing overflowing to clip.
  • text-overflow is a paint decision and never a layout one. An ellipsised line is drawn short and measured long, and a test asserts that the two flows measure identically. A paragraph whose measurement shrank because it had been truncated would be a box that shrank because it was too narrow — it would settle at a width nobody asked for or oscillate, and either way the ellipsis would decide the width it is supposed to be a consequence of.
  • The cascade carries two properties and everything below it sees one value. ComputedStyle gained two components rather than one TextFlow, and the split is CSS’s own: white-space inherits and text-overflow does not, and a bundle cannot be half-inherited. Both halves are what an author means as well — menu { white-space: nowrap } is a statement about the rows, and text-overflow on a container that draws no text would otherwise mark every label under it. So whiteSpace joins color and typography in inheritingFrom and in inheritsSameAs, which ADR-0248 warns has to be edited in the same breath or the style cache goes stale rather than merely cold.
  • Four widgets stopped overflowing: a menu row clamped to the work area, an option in a segmented bar whose cells are exactly 1/n of the track, a select-value in a field an application gave a width, and an autocomplete suggestion. Six comments that explained why they could not be cut are replaced by what they now do.
  • flex-shrink: 0 came off the menu label, which is ADR-0148’s fix being released rather than reverted: it stopped the wrap by stopping the shrink, and nowrap is a way of stopping the wrap that does not. The accelerator keeps its flex-shrink: 0 — a cramped row spends its missing pixels on the label, which has an ellipsis to say so, and never on the shortcut.
  • An anonymous label box inherits by hand. Box.style carries the flow onto a Box.Text exactly as it carries color, so text and select-value needed nothing; a menu item and an option build their label as a child box that no style is applied to, so their render passes style.textFlow() to a new Box.text(paragraph, argb, flow). That is the same inheritance one level below the cascade.
  • RenderObject rebinds its measure callback when the flow changes, not only when the paragraph does — and by equality, where the paragraph is compared by identity, because the cascade hands out a fresh TextFlow on every resolution. Yoga does not dirty a node when its measure function is replaced, which is the trap already recorded there for a changed paragraph.
  • Three of CSS’s five white-space values are deliberately absent. pre, pre-wrap and pre-line are all statements about collapsing runs of spaces and newlines, and a Paragraph never collapses anything — so pre-wrap is what normal already does here and pre is what nowrap already does. Naming them would be four spellings of two behaviours.
  • Font gained its first memo. ellipsisWidth() is asked once per truncated label per frame and the answer is a fact about the face and the size, so it is cached on the font rather than on the paragraph — where it would be memoised once per distinct string for a number that never differs between them.
  • Thirty-four tests, in six classes: the value’s one rule, the measurement under both flows, the ink under all three drawings, the cascade’s split inheritance, and a squeezed menu whose label is now narrower than its own text and still one line tall.

The property that was never Box’s problem

  • A line is placed by the paint, not by the box (ADR-0256). text-align: start | center | end resolves now, and nothing was added to Box — which is what §8’s own note had said was blocking it.
  • The note was right about three properties and wrong about the fourth. box-shadow needs a drawing Box has no field for, backdrop-filter needs a second pass over what is underneath, and letter-spacing needs the shaper to be told something before it shapes. (What changed since: two, not three. box-shadow needed no Box field either — it is a component of Decoration and a stack of rounded rectangles, and the drawing was the whole of the problem, ADR-0310.) text-align needs neither engine: Paragraph.paint is already handed the box’s width, because it has to be or the text could not wrap to it, and every TextLine has already measured itself. The two numbers were in the same method the whole time.
  • It is the third component of TextFlow, not a fourth thing to thread. white-space and text-overflow answer the too-wide question and this answers the too-narrow one, and all three are answered where the line’s width and the box’s are both in hand. It inherits, like white-space and unlike text-overflow — and it has to, because text-align is written on a container far more often than on the node that draws the text.
  • Per line, and clamped at zero. Per line is what text-align means — a centred paragraph centres each line rather than the block they make up, and the difference is the short last line. The clamp has two reachable causes: a nowrap line wider than its box would otherwise be pulled left by text-align: end, hiding the beginning to show an end the reader can already guess; and maxWidth is UNCONSTRAINED wherever a caller is measuring rather than placing, which without it is an infinite offset and a blank frame.
  • slider-value was the consumer, and it had been waiting since the control shipped. It is width: 40px by declaration, because a label that sized itself to its digits would resize the track under the finger setting it (ADR-0080) — which is exactly the condition that makes an alignment mean something. Left-aligned, 9% and 100% start in the same column and end four pixels apart. Four goldens moved and all four are that readout: slider-value.png and the showcase’s Basic screen in its three variants, where the whole diff is 274 pixels and every one of them is the 40% on the gain slider.
  • left and right are refused, for ADR-0247’s reason read the other way round: start and end are what Box Alignment defines, and left and right name sides of the screen. They coincide under LTR and part company under RTL, so accepting right as a synonym would be writing down an answer that is right today and silently wrong later. justify is refused because it is a respacing rather than a placement, and a paragraph shaped once has nowhere to put the extra advance.
  • The menu row’s spacer stays, and is not an alternative to this: a text-align places a line inside one box, and a menu row shares its room between five. Item.render’s comment now says which of the two each is for rather than saying the subset has neither.
  • Eight more tests, in four classes: the slack fractions, where the ink lands under each of the three keywords, the two clamps, that a cut line does not move, that alignment is per line rather than per block, that left, right and justify are dropped, and that slider-value resolves to end.

Four things the toolkit knew and did not say

  • A diagnostic is asked for, not logged (ADR-0257), which is the answer two TODO.md entries had already written down and two more were waiting for.
  • css.lint is SupportedPropertyTest with the test taken off it. The machinery worked and had one caller: itself. StyleLint resolves every rule through the real cascade, hands every declaration to the real ComputedStyle and returns Finding values carrying the line and column the parser saw — which a log line never did. Two sheets rather than one, and it is not ceremony: half of what a declaration means is what its var()s stood for, and a sheet linted without its theme reports every colour in it as a value the engine refuses.
  • ComputedStyle.applies is four lines, and the reason is a fact about the control flow. with returns this in exactly two places and both of them are failures — the default arm and dropped — while every success goes through a wither and every wither allocates. So identity is the answer, and it cannot fall out of step with the engine because it is not a copy of it. A list of supported properties would have been a second source of truth needing an edit every time the first changed. That is now a constraint on the class rather than an accident, written down in both places.
  • The two failures are deliberately not told apart. “No such property” against “no such value” means the engine reporting rather than being asked — a sink threaded through thirty switch arms in the frame loop — for a difference the author reads off §8’s list either way. What was invisible is that the rule does nothing.
  • An unresolvable var() is not a finding, and a test asserts the silence: it is already the resolver’s own report, once, which is ADR-0243’s shape. Two mechanisms for one fault disagree the day either changes.
  • The old test lost a hundred and sixty lines — a logback appender, a log-level override, two sentence-matching filters and its own two guard tests, which existed because a change to either log’s wording would have made it pass by seeing nothing at all. It gained two sweeps it could not afford: the light theme and the compact density. A var() that resolves to something legal in one theme and to nothing in the other is a rule that draws on one and not the other, and no golden of the dark theme could have shown it.
  • flex-grow inside a scroll says so now, and the entry had expected the hard version: “a diagnostic would have to know that a grow resolved against an unbounded main axis, which Yoga knows and does not report”. Nothing had to be asked of Yoga. ScrollContent.render is handed its children as boxes with flex-grow already resolved, and the content box’s main axis is the scrolling axis by construction — so it is a field comparison, and it catches a widget that set the growth itself, which no stylesheet rule would have shown.
  • A virtualized list says when its pitch and its rows disagree, by a simpler mechanism than the entry predicted. It asked for a Measured assertion on the first built row; what it got is the cascade, because list-row declares height: var(--gb-list-row-height) and that number is resolved before the row is laid out. Exact, free, and a frame earlier than a measurement.
  • That check has to see the mismatch twice, and ADR-0254 is why. The first build of a tree has no cascade, so a list reading the token answers the default on that build and the stylesheet’s value on the next — one frame of a real 32-against-26 disagreement under a compact density, which settles by itself. Reported naively, the form that cannot be wrong would have been the noisiest one. And that forced the second decision: the check runs on one row of the window, because twenty rows resolving the same height report twenty times a frame and “seen twice” could not then tell a frame from a sibling.
  • Both widget diagnostics are ADR-0251’s shape, down to the static report set and the forget beside it: a diagnostic and never a refusal, because turning a rule into a crash is worse than the rule going unheard. There are four such sets now, which is a pattern rather than a mechanism — the fifth is where somebody should extract it.
  • One thing found and not fixed, recorded in TODO.md: StyleElement documents type(), id() and parent() as “or null” and annotates none of them, inside a css package that is @NullMarked. Every implementation until now lived in an unmarked package, so nothing had noticed; the lint’s probe is the first written in a marked one and cannot say what the interface says. Its package is unmarked as a result, which is the wrong end to fix it from.
  • Twenty-five tests — one new class and two nested in existing ones: what the lint finds and the two ways it could report a healthy sheet as broken, a scroller whose child asks to grow, and a list whose two numbers do not agree, including the token form, which is quiet by construction and is the test that found the settling frame. Plus two in the rewritten SupportedPropertyTest, which are the sweeps the log capture had made too expensive to run.

The edge a measurement chose

  • Fifteen of §1.2’s sixteen non-text failures are fixed (ADR-0258), and the sixteenth is now impossible rather than undecided.
  • The entry filed them as one thing and they were three. ADR-0239 measured nineteen pairs below the floor, ADR-0240 fixed the focus ring’s three, and the rest were recorded as debt on the grounds that “every one of these is a theme colour … a design decision with a golden-image tail”. True of the tail, and the decision had already been taken: design-system.md §1.2 says every non-text pair meets 3:1, so fifteen of these were the code out of compliance with the document rather than the document owing an answer.
  • Twelve control boundaries were a gap in the palette. --gb-checkbox-bg is --gb-surface-2 on the dark theme, so an unchecked box on a group-box differed from its backdrop by 1.00:1 and the whole control was held up by an edge at 1.17. The edge was --gb-border, and controls.css argued for it — “the token for exactly that, and why this is not an invented colour”. The measurement refutes the sentence: a divider is chosen to be subtle and a control’s edge is the one thing §1.2 will not let be.
  • Nord has nothing to put there. Between --nord3 and --nord4 the palette stops, and against --gb-surface-2 those two are 1.17:1 and 6.39:1 — invisible, or a white ring round a dark control. --gb-checkbox-border is the midpoint of that gap on each theme, slid until it clears and no further, at 3.17:1 and 3.22:1. Deriving a value is not new (--gb-accent-fill, --gb-border-strong); justifying one with a number is.
  • Three marks missed by 0.02. A slider’s fill, a progress bar’s and a knob’s arc are all --gb-accent on --gb-border — one pair wearing three names, at 2.98:1. The light accent moved to #5c7ea8, which is 3.11. Every pair the accent is in moves the same way, because a darker accent on a light theme is further from every surface it is drawn on, so there was nothing to referee.
  • The sixteenth is arithmetic. The light theme’s slider track sits between a white thumb and a dark accent fill and has to clear 3:1 against both: the thumb needs the track’s luminance at ≤ 0.300 and the fill needs ≥ 0.688. No solid colour is both, so no amount of deliberation will find one. What has to change is what a light-theme thumb is, which is a sentence §3 does not contain — and it is the one genuine design decision in the original sixteen. It is recorded as impossible beside the list so nobody spends an afternoon sliding the track.
  • Twenty-two goldens moved, which is why this had waited. controls-on-surface-dark and controls-on-surface-light are the pair to look at: they exist because the glyph used to disappear on a panel, and they are now the images that show it does not.
  • The text sweep is untouched. KNOWN_FAILURES was empty before and is empty after, which is the check that the accent move did not buy the marks at the labels’ expense.

Two metrics rows and an attribute the spec already had

  • A badge with one digit is a circle (ADR-0259), which spends the last of ADR-0181’s four bounds. Three of them found consumers the day they shipped — dialog, toast and tooltip had each written a width where they meant a maximum — and min-width had none until now.
  • The minimum alone would have done nothing. A caption digit is about 7px, so 8 + 7 + 8 is 23 in a 20-tall box: a badge with §1.3’s default padding can never be round however large its minimum, and the version that only added min-width would have looked done and not been. Padding-x drops to 4, which is the legal step below 8 — 6 is the comfortable answer and is off §1.3’s ramp, which lists 2, 4, 8, 12, … and says “no off-ramp values” in as many words.
  • The minimum is the height, and the test says so rather than saying 20: equal width and height inside a full radius is what a circle is, so the two drifting apart is the failure worth naming and a test on the literal would pass while they did.
  • select-chip stopped sharing the rule. It holds a label and a ×, is never one character wide, and has nothing round about it, so it keeps the default padding and gains no minimum. A small loss of the “one drawing” property the shared block expressed, and the honest shape: they were never the same control.
  • A name is an attribute every widget has (ADR-0260). The entry said “§13’s semantics are M5’s”, and that is true of the AccessKit bridge and was never true of Semantics.role() and accessibleName(), which have shipped for milestones with a sweep enforcing them. What was missing was somewhere to put a name a widget cannot derive.
  • An icon-only control’s label is the empty string by construction, which is not an oversight in the widget: the icon is the whole of what is on screen. Button.accessibleName() returned it, so an icon-only button answered "" — a control a reader cannot announce, passing a sweep that only checked for null. It answers null now when nobody named it, which is honest and is distinguishable from being named with an empty string.
  • It sits on Attributes beside tooltip and context-menu, whose own comment is the argument: “a catalog where each control had to remember to carry one would have thirty chances to forget”. §13 asks for a name on everything, which is exactly the set Attributes covers — so two widgets read it today and the rest carry one already.
  • core-widgets.md was ahead of the code: §3 already documents name= on both button and segmented, so this is the spec being implemented rather than amended. §3’s badge row is the one that moved.
  • A test asserts every wither carries the new field forward, because that is the failure a widened record invites and null is a legal name, so nothing else would have complained.

A rule about which states earn a second theme

  • Every focus golden has a light twin, and a test says so (ADR-0261). The entry did not ask for images — it asked for “a rule about which states are worth a second theme rather than one more image” — and the rule is narrow on purpose.
  • §2.2’s ring is the one mark with no second means of being seen. A hover has a wash, a checked control has a fill, a disabled one has its opacity, and each of those is drawn in colours some other golden already covers. A ring is only a ring, and --gb-focus resolves differently per theme, so a ring photographed on one theme is a ring nothing watches on the other. That is not hypothetical: the ring measured 1.74:1, 2.00:1 and 1.64:1 on the light theme’s surfaces, and the change that fixed it moved no golden at all.
  • FocusGoldenPairTest discovers its subject, reading the resource directory rather than a list, so a focus golden added next month is checked next month. Three assertions rather than one, because a discovering test has a failure mode of its own: the twins exist, the sweep found the four rings the catalog has, and no twin is orphaned. Checked against a deliberate break — with tabs-focus-light.png moved aside it fails and names the missing file.
  • Doubling the whole corpus was the alternative, and it is not a rule so much as the absence of one: thirty more files answering questions their siblings already answer.
  • The -focus naming convention became load-bearing and the test’s javadoc says so, because a name that carries meaning silently is the thing this repository keeps rediscovering.
  • It deliberately does not check the two images differ. Two identical pictures would pass, which is the case where a theme swap changed nothing — and that is what ContrastTest is for. The pair is the coverage; either alone is not.

A delay that was a constant, and a number nobody had built

  • A tooltip’s delay is a token now (ADR-0262), and both of the entry’s blockers had expired — one of them without ever being true.
  • The first was real and is gone. “Nothing above the cascade can read a resolved custom property” was answered by BuildContext.token, and the launcher holds an Element, which is a BuildContext.
  • The second was answered before it was asked. The entry wondered whether the design system should carry a duration that is not motion; design-system.md §3’s tooltip row says “delay 500ms show / 100ms move-between” in as many words. §7 does not say how long, which is what the entry had read, and §3 does.
  • So the code had built one of §3’s two numbers and not the other. pointingChanged scheduled the full 500ms for every target, including one reached from a tooltip that was already showing — so a user reading along a toolbar was served the whole sentence of hover intent at every button. A specified behaviour that was never built, hiding inside an entry about tokens.
  • BuildContext.duration is token’s sibling, and a third accessor rather than a general one for Paints.Context.length’s stated reason: lengths, colours and now durations are values the cascade already parses, and a general reader would invite a caller to reimplement the parser. It calls ComputedStyle.durationMillis — the ms/s reader transition has always used, made public rather than written a second time, because two parsers for one syntax disagree the day either grows a unit.
  • The delay is read off the target, not off the window, which is the only reading that lets a panel set it for what is inside it rather than being a global setting wearing a token’s clothes.
  • moving is read before the hide, because the hide is what makes it false. A test asserts the first tooltip in a row still waits the full delay, so the shorter number stays a statement about moving between rather than a faster tooltip.
  • Four tests, and the move-between one fails against the old constant — checked by putting 500 back. TooltipTest grew a two-target scene, because the case §3’s second number is about cannot be produced by one full-window node.

Three numbers in one row, and nothing watching

  • Found by reading a row rather than by building anything (ADR-0263). TODO.md’s popup-inheritance entry says a tooltip “wants the styling of the thing it describes”, so the tooltip’s own styling was read to see what it would inherit — and §3’s row disagreed with the rule implementing it in three of four places. Only one of the three had a comment saying so.
  • §3 said padding 6/8, and 6 is not on §1.3’s ramp — that section lists 2, 4, 8, 12, … and introduces it with “no off-ramp values”. The row could not be implemented without breaking a rule one section above it, so this is the document contradicting itself rather than the code overriding it. Amended to the shipped 8/12, which is two legal steps.
  • The radius and the type rank are decisions, and went to §17.1. §1.5 groups radii as 4 (inputs, small controls) · 8 (buttons, cards) · 12 (dialogs, popovers, frost panels) and names no tooltip in any of them, so §3’s 4 and the shipped 8 are both readings and neither follows. The type rank already had its argument written in controls.css — §1.4 gives caption to secondary text under a control, and a tooltip is the only text on screen at the moment it is read — and a good argument is not an agreement. Amending §3 to match the code would be taking a decision by writing it down, which is what §17.1 exists to prevent.
  • Nothing was watching, and that is the finding. SupportedPropertyTest asks whether a declaration does something, ContrastTest asks what colours measure, and no test asks whether a metric is the metric §3 pinned — so a row drifts a number at a time and each drift looks like the file it is in. TooltipMetricsTest asserts the shipped values with each one’s standing in its javadoc, because a test that asserted the document would fail today and have to be disabled, which is how a disagreement becomes invisible again.
  • It also corrects the entry it came from. A tooltip inheriting the anchor’s font-size would draw one on a display-ranked heading at 28px, so the popup-inheritance entry’s proposed answer is wrong for the widget it named; its subject stands and its example does not.
  • One of about thirty. Every other metrics row in §3 is unchecked the same way. A general test is the right shape and a markdown parser with opinions; per-widget, as BadgeTest.metrics does, is what the catalog has been doing.

The door a toast raised from deep in a tree wanted

  • Toasts.of(context) (ADR-0264), which is the Overlay.of(context)-shaped call TODO.md asked for by name and the last open half of the overlay-layer entry.
  • findAncestorState cannot do it, and the reason is worth knowing. A toast stack is mounted in the overlay layer, which is a sibling of the application’s root under window-root rather than an ancestor of anything inside it — so walking up from a deep widget reaches window-root and stops. The mechanism that answers this shape for scroll and form is the wrong shape here.
  • host() gives the window and Toasts.at knows which stack is on it, so the whole thing is a map from one to the other. The only real decision was where it lives, and it is not on Host: :core learning what a toast is would undo the reason Toasts, Menus and Dialogs are three classes in :widgets rather than three methods on the window. A general host.service(Class) is the same problem with the type erased, and one consumer is not enough to design one against — which is ADR-0140’s own rule, the one that made BuildContext.host() wait for select.
  • Weak on the key, so a window that goes away takes its entry with it; plain rather than concurrent, for the reason already at the top of that file. Last attachment wins, because handing out a controller whose overlay has stopped drawing is the one answer that is certainly wrong.
  • Empty is an answer twice: a tree with no window, which is most unit tests, and a window whose application never attached a stack. Neither is a fault.
  • Seven tests, including two windows not seeing each other’s stacks. One of them mounts the stack as well as attaching it, because TestHost.overlay records rather than builds — a controller registered by at alone is still detached and swallows what it is shown, which would have made the test pass by asserting nothing.
  • Menus and Dialogs are now visibly asymmetric with this, and neither has been asked for. When one is, the question to answer is whether the three share a mechanism rather than whether to repeat this map.

Two questions the catalog’s own entries had left open

  • Yoga measures an inset from the border box (ADR-0265). The entry asked “whether Yoga or the painter is the one disagreeing with CSS” and did not answer it; an hour of Yoga did. A 40×20 absolute child in a root with padding: 12px, errata at its spec-compliant default: with left: 0; top: 0 it answers (0, 0) where CSS says (12, 12), and with no insets at all it answers (12, 12), which is right.
  • So Yoga contradicts itself, and the painter is exonerated — it clips to the padding box, which is what CSS says, against positions computed against a different box. The disagreement is one path of two rather than a missing feature, which is a more useful thing to know than the entry hoped for.
  • The fix is priced and located rather than made. RenderObject applies an inset to its own node and has no reference to the parent’s padding, so the parent must push it down — and the same commit has to remove text-input’s and text-area’s compensation or it double-counts, with a golden tail across segmented, tour and scroll. Doing that inside an investigation is how a golden moves without anybody looking at it.
  • A null button is unequal to everything (ADR-0266), which is the onPointer guard entry closed on its own last sentence. It called the default “right for dragX’s NaN and quietly wrong for a null button”, and that is exactly the distinction: NaN is arithmetic, so the meaninglessness propagates and every comparison against it is false in both directions — a caller cannot act on it by accident. A null button is a reference, unequal to everything, so button() != PRIMARY is true for a move and the guard fires backwards: it keeps the press it was written for and drops every drag.
  • It reports and does not refuse. An input handler that threw would turn a lost drag into a window that falls over, on a mistake an application can make in its own widgets. Button.NONE was the other shape and fixes nothing — the guard still fires backwards, now against a value that looks deliberate.
  • Nothing in the catalog trips it. All nine button() reads are already inside a kind check — seven in a switch arm and Toggle’s behind a short-circuiting || — which is what makes a diagnostic affordable on the busiest event in the toolkit. Keyed by kind and node type, because a pointer event is read per event per handler.

A text scale, and two entries that were about something else

  • §1.4’s global text-scale token is implemented (ADR-0267). ARCHITECTURE.md §17 had it as “neither implemented nor gallery-enforced”; it is now the second of those.
  • The entry read as a gap in the tests and was a gap in the toolkit. “Text that clips at 150% scale is §1.4’s explicit gallery-enforced requirement and nothing enforces it” — nothing enforced it because nothing implemented it, so there was no way to ask for 150% text and nothing for an image to be of.
  • It scales the text and not the layout, which is the whole point. The factor is applied where a ComputedStyle becomes a Font and nowhere in the cascade, so a paragraph is shaped larger and a measured leaf grows around it while a height: 32px stays 32 — exactly the condition §1.4 asks components to survive. A control whose box grew with its text could not fail that test and the requirement would be vacuous.
  • Scaling in the cascade was the other design and is wrong twice. font-size: 1.2em resolves against a parent that would already have been scaled, so an em chain takes the factor once per level; and a padding: 0.5em would grow with it, hiding the clipping the feature exists to reveal. The button-height test is the one that would have caught it.
  • A switch on the renderer, beside reducedMotion — which is where §13’s other accessibility switches are, and the “settings mechanism” three other entries name as missing. §1.4’s 90–150% is a clamp rather than a refusal: a window that failed to open because somebody’s accessibility preference was 200% is worse than one whose text is as large as the system allows.
  • A line-height ratio is deliberately not scaled, or the factor would be squared — it already scales by the size scaling. A test asserts the resolved line box grows exactly once.
  • One is the default and no golden moved, which is what made it safe to build the mechanism before anything enforces the case. The whole corpus is the evidence.
  • The enforcement is left, and is now a decision rather than a mechanism. Since text-overflow: ellipsis shipped, some cutting is correct — so “no text is clipped” is no longer the assertion, and a golden of eleven screens at 150% would pin every one of those calls at once, in a picture, before anybody made them.
  • masonry’s entry read a record backwards. It said a responsive column count is “a layout pass that reads its own width, which is the loop ADR-0196 built the last-frame read to avoid” — and ADR-0196 is the last-frame read. masonry already banks every card’s height through Measured; reading its own width is the same door one step over, and Measured’s third rule holds because a column count changes the masonry’s height and not its width. What actually blocks it is that masonry has no row in core-widgets.md at all, so §5’s gate has nothing to have passed.

A tour card that says how tall it is

  • The card’s height is measured rather than estimated (ADR-0268), and the mechanism was already in the file. The entry said measuring “needs the measure-then-place machinery ADR-0104 built, which works on windows rather than on boxes”; TourStop already banks the window’s own rectangle from the frame before through Located, and the card is one node further in.
  • That is the fourth entry in this section whose stated blocker had expired or was never right — the tooltip delay, the popup-inheritance answer, masonry’s column count, and this. The entries are older than the mechanisms that unblock them.
  • The estimate survives with a narrower meaning: what the first frame decides with, before anything has been laid out and had a height to report. It is no longer the number for every frame, which is what put a card above its target when it would have fitted below.
  • It cannot oscillate, by construction rather than by promise. The card’s width is fixed at 280 and its content is the stop’s own title, body, counter and buttons — none of which depends on whether the card was placed above or below — so the height it reports is the same either way. That is masonry’s argument for Measured’s third rule rather than the scrollbar’s, and a test asserts it.
  • The test’s fixture is chosen so the two answers differ: below is 186, a 132-tall card needs 330 and fits in a 400 window, a 260-tall one needs 458 and does not. Any target where the estimate and the measurement agree would have passed against the old code — which the first version of the fixture did, at a target where the estimate already said “above”.
  • The first tour entry is verified and stands. A tour still cannot find the viewport its target is in: findAncestorState walks up from the element being built, and what a tour needs is a walk up from the target it names, which is a different question and one the tree cannot answer.

A tour that arrives, and a promotion that had already happened

  • The TabPhase entry was describing work done two records earlier (ADR-0269). It asked for the lifecycle to be promoted “when the second consumer arrives”; it is widgets.core.Phase, moved there by ADR-0166 — whose javadoc says “there was never anything tab-shaped in it” — with the closing → removed half extracted into Departure by ADR-0234. Six families use one or both, including toast and dialog, which are the two consumers the entry named as wanting it.
  • So the tour entry’s blocker was a door. “That is TabPhase again: the enter/exit lifecycle built for one widget, wanted by a third” — it is not built for one widget, and what was missing was a tour using it. Fifth entry in this section with an expired blocker, and the first where the expiry was hiding a second entry behind it.
  • Two phases, belonging to different things. The arrival is the tour’s — one phase for the whole tour, because a card that faded in again at every stop would be a sequence that restarts rather than advances, and a test asserts the second stop holds the same instance. The travel is the cut-out’s, restarted on every stop change.
  • One rectangle, interpolated. §3.1 asks for the cut-out to “translate+size” between stops and both fall out of interpolating a single rect — which is what keeps the ring, the veil’s hole and the card agreeing on every frame. Three separate animations over one geometry could only agree by accident.
  • beginTravel runs before the index moves, because anchorOf has to answer the stop being left — the rectangle the travel starts from. After the move it banks the destination as the origin and animates nothing.
  • The scale is deliberately absent. §3.1’s popover row is opacity, translateY and scale “from anchor origin”; transform-origin resolves against a box the painter measures, so a card scaling from its own centre reads as a pop rather than an arrival. popover has the same gap for the same reason.
  • AnimationSweepTest earned its keep. It failed twice within a minute of the phase being added: TourVeil held a Phase and never overrode isAnimating, and the tour package had no test naming it. Neither would have shown in an image — which is exactly what that sweep exists for.
  • The goldens did not move. TourGoldenTest warmed five frames on a system clock, which pass in microseconds, so a 160ms arrival would have been photographed at whatever opacity the loop caught — differently on every machine. It has a virtual clock now, advanced past the duration, which is GalleryGoldenTest’s answer to the same problem. Both tour images match unchanged.

Two overlay entries, an input chapter, and the frames nobody could see

  • A window move is an event now (ADR-0270). BackendEvent.Moved, SDL_EVENT_WINDOW_MOVED translated and deduplicated per position, and HeadlessWindow.moveTo posting the event a window manager would — so the whole re-clamping path runs in CI. The layout probe caught the constant being unregistered in goldberry_shim.c before anything else did, which is exactly the failure SdlEventType’s javadoc says it exists for: a wrong event number does nothing at all, and there is no error anywhere to notice.
  • A move re-places immediately; a resize still waits for the paint. The opposite of ADR-0231’s rule and for the same reason: a move produces no new hit-test capture and invalidates none, so the current one is the right one. No repaint follows a move either — nothing inside the window changed.
  • The scrolling anchor did not need Located. The TODO entry proposed it; the anchor turns out to have nothing to report, because anchor(id) answers from a capture taken every frame. What was missing was the question, so replacePopups runs at the end of any frame with a popup anchored by id — which is also the guard, since a popup opened against a caller’s rectangle has nothing to re-resolve.
  • A popup was anchoring to the wrong rectangle, and had been since before anything scrolled. Region.bounds() is the layout rectangle and painted() is where the box was drawn; both javadocs said a popup anchors to the first. A button inside a scroll sits in the flow hundreds of pixels from where it is drawn, so a menu opened from a scrolled list opened hundreds of pixels away from its button. Three call sites read painted() now. The two rectangles are identical for every box nothing transformed, which is why it took a scrolling anchor to show it.
  • late is a hud reading (ADR-0271). Both halves of “nothing reports a dropped frame” in one number: the frames the loop never reached, which leave no record in a ring that only holds frames that were painted, and the frames the platform refused after painting them, which were in the mean as though somebody had seen them.
  • pendingSince is what makes that number honest. The naive version — the gap since the last frame, over the display’s interval — reports a window nobody touched for a minute as three and a half thousand dropped frames, when it drew every frame it was asked for. §1.7’s idle loop is the common case, so lateness is counted from the moment a frame was asked for, not from the last one delivered. A window of sixty rather than a total, so it comes back down.
  • 60 ms can be told otherwise. -Dgoldberry.popup.settle= overrides how long a focus-lost is disbelieved for, clamped to 1–2000 ms — zero would act on the first of the focus-lost/focus-gained pair every driver sends, which is the exact bug the delay exists for. The default is unchanged and still cannot be derived.
  • The input chapter is gone, and one of its two entries was closed by arithmetic. SDL_GetModState costs 8.71 ns a call — 34.8 µs per second of dragging at 4000 events a second, 0.0035% of one core — measured by ModifierPollBenchmark on the same headless path SdlTest uses. The other entry had been answered by ADR-0115 two milestones earlier and was still sitting in the list under an introduction that said it had left.

The box a child is placed against, and two widgets that meant the other one

  • ContainingBlock, and ADR-0265’s fix made rather than priced (ADR-0272). An absolutely positioned child’s containing block is the padding box of its positioned ancestor, and Yoga implements one path of two: given no insets it lands on the padding edge and is right, given an inset it measures from the border box. RenderObject now takes its containing block’s padding, shifts the inset through one class, and puts that on the node.
  • On the style rather than on the answer, which is the part a post-layout correction could not have done. With left and right both given Yoga derives the child’s width from the two insets; shifting both makes that width the padding box’s. A correction applied after the pass could have moved a child and could not have resized one.
  • Only the edges the box named, because Yoga’s fallback for an edge with no inset is the static position, which already includes the padding. Defining an edge in order to correct it would replace a right answer with a placement nobody asked for. Percentages are declined on both sides of the sum and say so: a percentage inset resolves against a size the layout pass has not produced.
  • text-input’s and text-area’s compensations came out in the same commit, as ADR-0265 insisted they must — three lefts and three rectangles that each added their control’s own padding. Both controls still read that padding for the three things that are not placement: where the text wraps, the room the scroll offset leaves, and turning a pointer’s x into an offset into the text.
  • The golden tail was smaller than priced and pointed somewhere else. segmented, tour and scroll were the three named and not one of them moved, because their parents genuinely have no padding. What moved was tabs, where an underline pinned across a header with padding: 0 12px came out 24 points short of its own label, and toast, where an overlay pinned 12 points from a corner started counting from the application’s content box instead of from the window. Both mean the border box; both now say so through acrossBorderBox, in terms of the padding their own style resolved rather than a negative number written into a stylesheet three rules from the positive one.
  • No damage flag was needed, and that is a deleted flag rather than an assumption. One was written on the theory that a child moved by its parent’s padding has an identical box and would go unreported. Removing it broke nothing, twice over: sameAppearance compares the parent’s own padding, so the parent is damaged, and collectDamage reports any node whose remembered rectangle differs from its current one — a comparison of results, which does not care why.
  • YogaLayoutTest’s two tests still assert (0, 0), deliberately. They are about the compiled library; AbsolutePlacementTest is about the toolkit built on it and asserts (12, 12). The day Yoga fixes its inset path, the :natives pair fails first and names the correction that has to come out.

Six boxes over one string, and §4’s shortest specification

  • code-input is built (ADR-0273), and §4’s paragraph on it turned out to be two rules seen from four directions. CodeEdit is a string and a box count — no caret, no anchor, no undo stack and no per-box array — and the active box is min(filled, length - 1), derived rather than held.
  • So three of the four sentences are not implemented. “Focus lands wherever the first empty box is” is what the derivation says, every frame, with nothing to keep in step. “Typing advances” and “a paste of the full code fills every box at once” are one append, because committed text arrives as a string whether it came from a keystroke or a clipboard. And Backspace has no second case: the box the ring is on is always empty, so the box to clear is always the one before it.
  • No holes, and §4’s last sentence is why. Six boxes are announced as one textbox with the whole code as its value, and a code holding 12 and 56 with a gap between them has no honest string. An array of slots with a movable caret is the model a code field would need if a code had gaps, and it does not.
  • One departure, and it is from another widget’s rule. TextFilter rejects and never corrects, for a stated reason: a filter that rewrote what was typed would move the caret out from under somebody mid-word. CodeType drops per character, because neither half of that survives here — there is no caret to disturb, and a whole-value filter rejects a paste of Your code is 123 456 outright, which is the paste §4 calls “the thing users actually do”. The alphabets are TextFilter’s own, which had named this widget in a javadoc since it was written.
  • complete is guarded by a flag rather than by being full. A field that raised it whenever every box was filled would submit a form again on every rebuild. A Backspace clears the flag, so a mistyped code corrected and finished completes twice — which is right, because that is two codes.
  • The group gap is a widget, not a selector. §2 asks for “group gap 16 at the midpoint when length is even”, and §8’s subset has no :nth-child. The boxes go into code-group parts: the row of groups carries the 16 and a group carries the 8, so both numbers are written where they are read. An odd length is one group and the outer gap never applies. A pixel probe of the golden reads [16..56] [64..104] [112..152] [168..208] [216..256] [264..304] — eight, eight, sixteen, eight, eight.
  • The one control whose density is two numbers. --gb-code-box-width and --gb-code-box-height are both tokens, because §2’s row is 40×48 (36×44) and six boxes that narrowed without shortening would be a code on graph paper. Every other row in the catalog moves a height and nothing else.
  • It asks for no frames. There is no caret, so there is no blink timer — the focus ring on the active box says where the next character goes and §2.2 wants it instant. text-input had to build a 530 ms timer to keep §1.7’s idle loop true for a focused field; this one keeps it by having nothing that moves.
  • Fifty tests and five images. The editing rules are CodeEditTest’s, with no font and no frame; the images are for what no assertion can see — the 16 at the midpoint and not at every gap, a ring on one box rather than around six, and what a mask draws. The Forms screen gained a seventh markup card, and it is the only one on that screen showing a control that raises two events.

A month that has to be told what day it is, and a field that types

  • calendar and date-picker are built (ADR-0274) — §10’s month grid and §4’s typed date field, which are one pair: the picker’s popover holds §10’s widget unchanged rather than a month of its own.
  • An existing rule refused to bend, and the API is better for it. The first version read the clock, which is what every calendar API does. DeterminismTest failed it: ZoneId.systemDefault() has exactly one sanctioned caller in the catalog and it is TimeAxis (ADR-0203). The rule turns out to be exactly as true here — an instant is only a date in some zone — so the month is an argument, today may be null and null marks no day. A golden of September 2026 is the same image tomorrow, and in Auckland.
  • DateSelection is one value for three models, where list uses an enum and a separate set of rows. That split works for a list because its models differ only in how many rows may be chosen; a calendar’s third is not a count. A range of two dates is not two dates, and it is what makes §2’s “radius full on the selected day, range ends only” one CSS rule: the ends are :checked and the middle is not.
  • §2’s grid gap of 0 is load-bearing. A range is drawn by shading the days between its ends, and a gap would break that shading into seven stripes a week. The row says the number and not the reason; this is the reason.
  • Six weeks always, so a four-week month and a six-week one are the same height and nothing under a popover moves when it pages. §3.1’s cross-fade is two months at once — the incoming one in flow, the outgoing one absolutely positioned over it — because a single grid dipping to transparent is a dissolve to the background, which on a popover reads as a blink. That absolute layer lands correctly because ADR-0272 landed last week.
  • One Tab stop is one focusable node and a class. A FocusScope roves between focusable children, and forty-two cells would be forty-two Tab stops from anywhere the scope does not reach.
  • The arrows clamp to the bounds and not to the predicate. A bound is a window and a predicate is a rule inside it; a Right that skipped four days because a weekend was refused is a grid whose arrows lie.
  • A month header, which §10 does not ask for. It gives the widget only PgUp/PgDn, and §2’s “header row caption” is the weekday row — so a calendar built to the letter of both is one a mouse cannot page. docs/design-system.md §2 gained a row for it in the same change, which is the difference between an addition and an undocumented part.
  • The picker holds text and derives the date. §4’s “the typed field is the source of truth” built rather than quoted: holding a LocalDate and rendering it into the field has to answer what the field says while somebody is halfway through typing. The grid writes text into the field, exactly as a user would, so a value takes one path and is parsed in one place.
  • The one date syntax the toolkit writes is a range’s separator, an en dash with spaces, because no locale service answers “how does this language join two dates”. Parsing accepts a plain hyphen as well — which is why the separator cannot be a bare hyphen, since 9-1-2026 is a date in some locales.
  • A document’s change carries text and Java’s carries a value, because §9’s valued actions cross as a String. Found by the binding weaver refusing the showcase’s first handler, which is that check doing its job.
  • The Validator entry closed by composition. Validator.parsing is a rule over the parsed value expressed as a rule over the text it came from, and field needed no change at all — which is the evidence the second seam was never a type parameter.
  • Three sweeps caught three real decisions before any test did. DeterminismTest on the clock; TokenClosureTest on a --gb-accent-on that does not exist; and SemanticsSweepTest on two parts that overrode isFocusable to say false, which is what makes a type owe a role — the default already said what they meant. A fourth thing no sweep could catch: border-radius: full is not a value the engine has, §2 writes full and controls.css spells it as half the height, and two rules asked for it. A dropped declaration warns and fails nothing, so it was found by looking at the image.

A wheel that wraps, and a popover that was as wide as its field

  • time-picker is built (ADR-0275), and it is date-picker’s control with different things in the popover: the field, the affordance, Alt+Down, Esc, the delegated focus and the :checked affordance are one shared node now (PickerField), because §4 writes the two pickers in one entry and they differ in exactly one thing.
  • The reported popover width was the wrong rule borrowed. A date-picker opened its calendar with the field’s width as the popup’s minimum, which is select’s rule and ADR-0145’s argument — “a list narrower than the control it hangs off reads as a mistake”. It does not survive the move: a list’s rows stretch and a grid’s cells do not, so a floor produced a panel as wide as the field with the 224-point grid stranded in a 350-point panel. Both pickers ask for no minimum now, and both say so with a named constant rather than a bare zero — the interesting thing about the number is that it is a different answer from select’s to the same question.
  • A wheel and not a scrolling list. Sixty minutes in a viewport costs a scroll, a ScrollController and a Located cell per column, and gives a popover whose height depends on how much of a list it decided to show. Five rows centred on the value and wrapping at both ends is what every platform’s own time picker does: one height always, nothing to scroll into view, and 23 → 00 in one press rather than sixty rows back up.
  • The wrap is what makes the quiet neighbours honest. A column showing 58 59 00 01 02 says what comes next; a list clamped at 59 stops.
  • The arrows split by axis, and a calendar’s do not. A grid moves a cell with all four; a column set is a row of independent wheels, so Up/Down turn one and Left/Right choose which. §4 gives both pickers the sentence “arrows move within the grid” and means two different things by it.
  • The wheels report on every turn, where a calendar reports nothing until Enter. There is no “not yet” for an hour — the columns always show some time — and it is what keeps §4’s source-of-truth field in step rather than a step behind.
  • selected is a class and not :checked. A chosen date is one of a set somebody picked from; an hour is one digit of one value that nobody chose.
  • WidgetWitherTest caught a coupling. precision() was rebuilding the format so a seconds column got a field that could show one, which made precision(its own value) produce an unequal record — a DateTimeFormatter has no value equality. The format is null until somebody sets it now, and resolvedFormat() derives the default: it still follows the precision, and it follows because nobody pinned it rather than because a wither reached over.

A plane that is HSV, and §4 finished

  • color-picker is built (ADR-0276), and §4 is complete: text-input, text-area, field, form, the validation model, autocomplete, code-input and all three pickers.
  • The model is HSV, and §4 asks for OKLCH. The departure is the entry’s own two sentences disagreeing: a saturation/value plane is HSV — the axes are S and V — where OKLCH’s are lightness, chroma and hue. OKLCH chroma has a gamut boundary that varies with both of the others, so a rectangular plane over it has large unreachable regions: corners that clamp elsewhere, a cursor that cannot be put where it was clicked, and a colour that changes when the hue slider moves under a stationary cursor.
  • And the stated reason survives somewhere else, which is the test that it was a good reason for the wrong thing. “That is what §1.7 interpolates in” is about transitions, and every colour transition in the toolkit still goes through Oklch. This control interpolates nothing — dragging is not a transition — and a picked colour is an ordinary 0xAARRGGBB that fades like any other. “Round- trips to hex without drift” is kept, and asserted over 4,096 colours plus every 24-bit corner.
  • Two pieces of state, and the second is the point. The hex is the value, as §4 says. An HsvColor is kept beside it because the conversion is lossy exactly where people drag: every colour with no saturation is a grey with no hue, so a picker that re-derived it each frame would swing the hue slider to red at the left edge and lose it entirely at the bottom. withArgb keeps the hue a colour does not carry, which is CSS Color 4’s powerless-hue rule and what Oklch already does.
  • The closed control is a swatch and not a field, which is the one place this picker’s chrome departs from the other two — §4 says “a swatch button”, so it is one: focusable, Role.BUTTON, Space opens it, and its accessible name is the hex. The presets are deliberately not focusable: twelve colours would be twelve Tab stops in a popover, and §4 gives them no roving mechanism.
  • Everything it draws is painted rather than styled, because §8’s subset has no gradient. Three fills and no per-pixel loop — 32,000 pixels a frame in Java is a picker that makes the frame budget its problem — and white then black, not the reverse, which is the kind of wrong a picture catches and no assertion does. Five goldens for that reason.
  • alpha=#false refuses in both directions. A picker with no way to change alpha must not report one, or a bind= carrying #88c0d080 leaves the control showing a colour it cannot express and a form holding one nobody chose.
  • #88c0 is a colour, found by a test asserting it was rubbish: it is CSS’s four-digit #rgba form. The picker takes whatever the engine takes, because one that second-guessed it would refuse text a stylesheet accepts.
  • The affordance’s placement is scoped now. picker-toggle was absolutely positioned over its field’s right padding, which is right for the two pickers that have a field and lands on top of a 24-point swatch for the one that does not. Found in the first picture of the closed control.

chip, and the word that was doing three jobs

  • §3 gained a chip row and the catalog gained the widget (ADR-0305). badge was described as a “count/status chip”, select multiple had a select-chip part, and what neither of them was is the thing the word usually means: a small rounded label you can choose and take away.
  • One sentence separates it from badge, and everything else falls out of it: a badge answers what is true, a chip answers what you picked. So a chip is focusable, carries :checked, and has a keyboard — and a badge, which is a status on a wall, is none of the three. Growing badge a press would have made every status badge in every application a Tab stop the moment the capability existed.
  • The two share their hue tokens and part on the rest fill. One set of five semantic fills rather than two that have to be kept agreeing; but a badge’s --gb-surface-2 is a plate you read beside something, and a chip is a control at rest that has to be a findable target, so it takes --gb-button-bg.
  • A dot takes the foreground its own fill guarantees contrast against, which is the rule an image found. chip.success chip-dot { background: var(--gb-success) } reads correctly and draws a green dot on a green plate — the first golden of it came out with no dot at all.
  • Both delete keys dismiss, unlike tab’s one: a chip is commonly the last thing before a text field, where Backspace is what a hand reaches for, and binding one of the two would have made it a coin toss. It is the whole reason the × can stay out of the Tab order.
  • It binds. bind= drives selected the way it drives a checkbox’s tick, so the showcase’s filter row is three properties, three actions and no Java — and any number of the three may be on, which is the argument for a row of chips over a segmented.
  • §6’s first widget is built (ADR-0306). nav has been in §11’s package table since v0.2 with nothing in it; steps and wizard now have somewhere to land that is not panel.
  • The trail decides which crumb is current — the last one, written down on every build, which is tabs telling a tab it is selected. A document cannot say otherwise, because a document that could mark a middle crumb current would describe a path that does not end anywhere.
  • A current crumb is demoted silently rather than refused. A trail is built from a loop over a path, so every crumb gets the same press= and the last one is supposed to be inert; refusing it would make the ordinary way of writing one an error.
  • The … is the one part in the catalog that takes the focus. Every other — tab-close, select-chip-remove, chip-dismiss — is deliberately not a Tab stop because the keyboard reaches it through the control it sits in. This one has no such route: the crumbs behind it are not in the tree, so a pointer-only … would put part of a navigation path out of a keyboard’s reach.
  • Nothing is elided inside a name, which is §6’s own argument and the reason the overflow is a menu rather than a truncation: a shortened folder name still looks like a name.
  • The semantics are incomplete and say so. §6 asks for a navigation landmark containing links; Role has neither LINK nor a landmark, so the crumbs answer BUTTON and the row answers GROUP. Inventing the constants now would make a gap look closed — docs/gaps.md carries it until the AccessKit bridge, which is now on hold.

An eleventh screen, and the limit that was only ever about keyboards

  • The showcase has an Icons screen: all 1544 of them, a search field, and the name under each that a document writes in icon="…" (ADR-0307). Every other screen answers “what does this widget do”; this one answers a question a reader has while writing a document, and until now the only way to answer it was Lucide’s website.
  • Nothing lost its accelerator. Ctrl+1…Ctrl+0 mean exactly what they always meant and icons is reached by the strip, the arrows and Edit ▸ Go to. The machinery already allowed it — screenShortcuts has always bound what it can and stopped, and GalleryOrderTest has always said “ten digits, however many screens there are”. What had to change was a comment claiming a limit and one assertion encoding it.
  • The sheet virtualizes over rows, seven names at a time, and the icons are built lazily and cached: parsing 221 KiB of path data up front for a screen a reader may never open is as wrong as parsing it per frame.
  • Two numbers have to agree, and the first golden is what said so. A list-row is --gb-list-row-height — 32 — which is half a tile, so every row of the sheet overlapped the one below it until showcase.css re-stated IconsScreen.ROW_HEIGHT for #icon-sheet list-row.
  • BundledAssets.iconNames() has no order, which this found: it is the key set of a Map.copyOf, so the sheet first opened on book-lock, calendar-off, badge, list-start. The screen sorts; the method never promised an order and this is its first caller that needed one.
  • The application declares a widget, which the gallery had not shown before: IconTile is Widget.Leaf plus Styled plus Paints, three methods, and no permission asked of the toolkit.
  • And then the sheet was made to follow the window (ADR-0309). Rows of a fixed seven left a band of empty space in a wide window and clipped the last column in a narrow one, so the sheet is a masonry whose column count is as many tiles as fit — measured through Measured, one settling frame, seven at 1200 and four at 720.
  • A masonry of equal-height tiles reads across the row, which that widget does not promise: its warning about reading down a column is about cards of differing heights, and “the shortest column, and the emptiest of the equally short” places equal ones across. Asserted, because it is a consequence of a tiebreak rather than a contract.
  • The scroll’s content must not grow. icon-sheet had flex-grow: 1, which is what a box that should fill its viewport looks like — and it is a vertical scroll’s content, so it came out exactly as tall as the viewport, nothing overflowed, and no thumb was drawn. No golden could tell: content running past the bottom edge looks the same either way, and the thumb has faded by the time a picture is taken. That is why the screen has a driven test as well as two images.
  • And the cost was measured, which contradicted the first thing written about it. The claim was “expensive to open and ordinary to scroll”. The numbers say 4709 elements, 464 ms to open, and 3.7 ms of style on a settled frame against every other screen’s 1.0 — because the style pass is O(elements) whatever is cached, and ADR-0299’s cache stops the shaping rather than the walk. FrameBudgetTest now measures the whole sheet against a budget of its own, and the filtered sheet as a ratio — two letters take it to 1085 elements and about a third of the style cost, so the search field is the performance story rather than a convenience. The ratio is itself a correction: the first version asserted the filtered sheet against the wall’s 1.0 ms and failed under a full build at 1.25, because 1085 elements is still five times a wall’s and the number straddles the line.

A shadow, and the two ADRs it reverses

  • box-shadow draws (ADR-0310). <x> <y> <blur> [<spread>] <color>, one shadow per box, painted under the background and outside the border — which is the property §8 has listed since the first day and which ADR-0164 and ADR-0166 each named as an alternative and turned down.
  • Two of the three reasons it was turned down had expired. “Box has no field for it” and “nothing paints outside a box’s own rectangle” were both true when they were written and neither is now: the focus ring is drawn outside the border box and the damage rectangle has grown for it for two hundred records. The third — the rasterizer has no blur — is still true, and is the interesting one.
  • So the blur is a stack of rounded rectangles. One band per logical pixel, between four and forty-eight, nested and filled outermost first. That is the primitive Blend2D is fastest at, and at one-pixel bands it is a gradient.
  • The alphas are solved for, not read off the curve. Nested fills composite, so a point under the outer five bands lands at 1 - Π(1 - aᵢ) and not at the fifth band’s alpha. Reading the fade curve straight gives a shadow far too heavy in the middle with rings in it; the curve is the accumulated alpha and each band is 1 - (1 - Aₖ)/(1 - Aₖ₋₁). The profile is smoothstep, which is exactly 0.5 on the shape’s own edge — what a blur is — and flat at both ends, so the fade has no seam.
  • The bands under an opaque box are never built. The painter says whether the background will cover its own rectangle; when it will, the bands inside it are dropped. They are always a suffix, so nothing earlier changes. 0 2px 8px is eight bands and five after; 0 8px 32px is thirty-one and twenty-two.
  • It rides on Decoration, not on Box. A drop shadow is drawn around a box and not in it, and its geometry is derived from the corner radii — the sentence Decoration opens with. The alternative was Box’s twenty-eighth component and a wither in every one of the other twenty-seven.
  • --gb-elevation-1/-2/-3, in both themes, as whole box-shadow values. A rule writes box-shadow: var(--gb-elevation-2) and chooses nothing — not the offset, not the blur, and above all not the alpha. That last one is why they exist: black at 16% is a clear soft edge on nord-light’s #eceff4 and very nearly nothing on nord-0, so an application picking the number picks one number and is wrong on one theme, which is exactly what --gb-surface-2 cost three widgets.
  • The alpha is the theme’s and the geometry is not. §1.5’s 0 2px 8px and 0 8px 32px are identical in the two files; only the alpha differs, by roughly two and a half times. ThemeTest asserts both halves, and two goldens — one per theme — are what the difference looks like.
  • transition: box-shadow animates, because a transition naming a property the engine resolves and cannot move is the silent nothing Transitions refuses by policy. Every component interpolates; arriving from none fades the shape in at full size rather than inflating it.
  • The damage rectangle is asymmetric now. 0 8px 32px reaches 24px below a box and 8px above it, so one outset on four sides would repaint bands nothing drew in — and, for a shadow offset further than it is blurred, miss one, which leaves a smear nothing repaints over.
  • What it does not do: knock the border box out of the shadow, which CSS does and which needs a fill rule or a path clip the binding does not export; and put a shadow on any widget, which is a change to every golden containing a card, a menu, a popover or a dialog and belongs in its own. Both are in TODO.md.

margin, and two defects older than it

  • margin resolves and lays out (ADR-0311). The shorthand and its four longhands, over the same Insets that padding and inset use, applied per edge in RenderObject.apply. It is the third of the four properties a widget reached for and did not find — border-bottom, currentColor, margin, max-width — and the one whose binding had been in place the whole time: Yoga has had YGNodeStyleSetMargin since ADR-0029.
  • TODO.md had closed the case on the wrong evidence. tab-new wanted a margin to sit somewhere other than the top of its row, align-self answered that (ADR-0244), and the entry recorded “no live consumer”. But align-self is the cross axis: on the main axis a box that wants to centre itself, or sit at the far end of a row its container is not arranging for it, had no spelling at all. justify-content is the container’s decision about every child at once, and a flex-grow: 1 spacer is a box in the tree that draws nothing.
  • So auto is the half that mattered. Length.AUTO on an edge reaches Yoga’s own YGNodeStyleSetMarginAuto and absorbs the free space on that side — margin: 0 auto centres, margin-left: auto pushes one box to the end of a toolbar with no spacer between.
  • Negative margins are allowed and not clamped, unlike a radius or a border width: those are clamped because a negative one is arithmetic that went wrong, and a negative margin is a technique. No collapsing, because CSS collapses adjacent vertical margins in block layout and never in flex — asserted rather than assumed, since it is the first thing an author who learnt CSS on documents expects to be wrong.
  • A box with no margin costs one comparison, not four foreign calls: a first apply that sees Insets.ZERO is skipped wholesale, which is ADR-0181’s arrangement for limits and worth more here, because Yoga’s default margin is zero and nothing in the catalog wears one.

Two defects turned up on the way, neither about margin and both reachable before it.

  • padding: auto closed the window. Yoga’s setters come in pairs and four of them have no auto half — there is no YGNodeStyleSetPaddingAuto. Yoga binds those without it and refuses an auto by name, which is right for a binding and made padding: auto in a stylesheet an exception thrown in the middle of a layout pass. CssLength reads auto for any length, so it was reachable from ten properties, min-width: auto among them — valid CSS, and what that property computes to on a flex item in a browser. ComputedStyle has a fixed() beside its length() now and those ten go through it; width, height and margin do not, because Yoga binds all three with their auto call.
  • The cascade returned its winners in hash order. Nothing between StyleResolver.resolve and ComputedStyle.apply re-orders, so the order properties come out in is the order they are applied in — and a padding applied after a padding-left overwrites the edge the longhand set. cascade() used a HashMap, so which way round a pair came out was whichever way their names’ buckets fell: padding/padding-left fell the right way and inset/left fell the wrong one, so inset: 8px; left: 20px resolved to 8px on all four edges. It is a LinkedHashMap filled from the already-sorted match list now, with a remove before each put, because LinkedHashMap keeps a re-put key at its first position and the position that matters is the winning declaration’s.
  • It took a new property to find it. margin is the first property added to this engine with four longhands over a value a shorthand also sets, and margin: 8px; margin-left: 20px is the test that failed.

The catalog puts both properties on

  • Five surfaces wear §1.5’s elevation now (ADR-0312): card at level 1, dialog, tour-card and toast at level 2, and affix:affixed > affix-content at level 1. ADR-0310 and ADR-0311 both ended with the same sentence – nothing in the catalog uses it yet – and this is what that sentence was deferring.
  • card.interactive is a hover-elevation at last. §5 has specified one since the section was written and it was a border colour standing in for one. Both properties move together now, and transition: box-shadow interpolates the blur and the offset along with the alpha – which is the difference between a card that rises and a stain darkening under a card that has not moved.
  • affix is §1.7’s line, finally. “detach/attach: opacity on the elevation shadow, fast” has been in the motion table since before there was a shadow to put an opacity on. affix-pinned.png is the whole argument for the widget in one picture: the pinned header casts onto the rows sliding under it.
  • toast at level 2 is the one judgement rather than a quotation. §1.5’s ladder gives level 1 to things raised off the page and level 2 to overlays; a toast is in the window’s own overlay layer with the application’s content directly under it and no scrim between the two, so it has left the page and level 1 over an arbitrary background does not say so.
  • Every edge stays, and not one was a placeholder. A shadow says “nearer” by darkening what is underneath, and a card on another card is on its own colour – where the shadow says almost nothing and the rim says it exactly. The Panels screen has a card inside a card, which is that sentence as a picture.
  • popover, menu and tooltip are still edges, which is exactly §1.5’s level-1 list minus cards. The reason changed without the rule changing: they are drawn in popup windows created at the panel’s own measured size (ADR-0104), and a shadow is drawn outside the box that casts it – so every pixel of one would fall outside the window and be clipped, for a run of fills that draws nothing. It wants a popup sized to the panel plus the shadow’s reach with the extra transparent, which is the same compositor support the rounded corners are waiting on.
  • dialog-actions writes the margin §2 asked for, where it had been padding-top with a comment apologising for the substitution. The picture is identical – the row has no fill and nothing to clip – and the declaration now says what it means.
  • tour-card’s footer lost its Spacer. TourStop built [Skip][Spacer][Back?][Next] and builds [Skip][Back?][Next] with margin-right: auto on Skip: one widget fewer in the tree, and pixel for pixel the same picture – checked by regenerating the tour goldens with the spacer put back. It has to be: the spacer absorbs W − Σwidths − n·g and the auto margin absorbs the same quantity with one gap fewer. The margin is on the trailing edge of the leading button, because the trailing group is one button or two and two auto margins would split the free space and open a hole between them.
  • spacer is not deprecated and the showcase still uses one, in the status bar, on purpose: it is a §1 widget an application writes in markup and a document has no stylesheet to put a margin in. The notice bar on the Overlays screen is the other half of the comparison – same shape, margin-left: auto.
  • Twenty-six goldens moved, every gallery screen among them, because every screen is a wall of cards. They were reviewed rather than accepted blind.
  • RuleBucketTest caught a selector on the way. tour-card > column > row .tour-skip has a rightmost compound naming no type, so the cascade would check it against every element of every kind (ADR-0152). It is button.tour-skip now, folded into the rule that was already there – two rules with one selector being its own small defect.
  • Five comments stopped being wrong, which is most of the value here: card, dialog, dialog-actions, affix and tour-card each described a property that did not exist, and a reader had no way to tell which were still true.

Six entries off an application’s list, and all six closed

docs/gaps.md is what an application on Goldberry files instead of growing its own toolkit, and six entries arrived on it together. All six are closed.

  • The desktop’s light-or-dark setting (ADR-0322, G26). Host.systemTheme() answers Optional<SystemTheme> and Host.onSystemThemeChanged(…) is told when it changes — which on any desktop with a sunset schedule is once a day, while the application is running, and is the half that could not be faked. SDL_GetSystemTheme joined the export list as an optional symbol and SDL_EVENT_SYSTEM_THEME_CHANGED became one event per open window, the way QUIT already becomes one CloseRequested per window — because a Host is per window and that is where an application listens. Both numbers are in the layout probe’s registry, so the C preprocessor checks them: a wrong event number does nothing at all and a wrong ordinal starts the application in the wrong theme, and neither reports an error. The Optional is the design — SDL says UNKNOWN on a desktop with no such setting, and “the desktop says light” and “the desktop does not say” are a theme and a default.
  • text-decoration, from the face’s own metrics (ADR-0321, G27). underline and line-through resolve in the cascade, inherit (CSS propagates them, which reads as inheritance here for text-align‘s reason), and are drawn by Paragraph.paint as a rectangle per line at the position and thickness the font file gives. Four more of BLFontMetrics’ sixteen floats crossed the boundary to do it — no new native symbol and no relink, because bl_font_get_metrics was already filling all sixteen and this side was reading six. The rule is as long as the line, indented with it under text-align, absent from a blank line, and drawn across an ellipsis because the mark is part of the line. A face that carries no post entry gets conventional substitutes rather than nothing, which is the one case where “draw nothing” would have been wrong.
  • And the italic faces (ADR-0323, G27’s other half). BundledFont.UI_ITALIC and UI_STRONG_ITALIC, out of the release the manifest already pins, and font-style: normal | italic in the cascade. Two files rather than one, so the matrix closes: a single italic would leave font-weight: 600; font-style: italic resolving to “the nearest of the three we shipped”, which is how a design system acquires a weight nobody chose. Matching is CSS’s order — family, then style, then weight — so italic code stays upright code, JetBrains Mono having one face. oblique is dropped with a warning rather than read as italic: it asks for a slant, and Inter’s italic is a different drawing, so answering it either way would be a type-design decision taken by a stylesheet. 830 KB, nothing opened until a stylesheet asks, and no pixel different until one does.
  • A picker’s popover inside a popup (ADR-0320, G28). Located now reports in the owner window’s coordinates: a popup sets its router’s origin from its own offset every frame, and both rectangles a widget is handed — the painted one and its clip — move together. Four controls with popovers became correct inside a popup without being touched, and so does the fifth. It is the correction Popup.anchor already made for a submenu, moved a layer down and applied to everyone.
  • A panel that takes no keys (ADR-0319, G29). Popup.keyboard(false) is one flag with three effects, because “does this thing want keys at all” is one question: nothing is focused on opening, a press inside it focuses nothing, and the owner does not forward keys to it. ADR-0104’s forwarding rule is right for a menu and wrong for a bar that floats over a canvas somebody is typing into, where Enter pressed a swatch instead of breaking a line. takesFocus(false) settled the opening and nothing else; this settles the lifetime. Escape stays lightDismiss’s business, because declining keys and refusing to close are different promises.
  • A caret that knows about text-align (ADR-0318, G30). The indent rule moved out of Paragraph.paint’s private half and onto TextAlign.indentOf, and all four of TextGeometry’s questions gained a form that takes the width the text was drawn in and its alignment — caretAt, offsetAt, moveLine and selectionRects, the last of which nobody asked for and which drifts exactly as a caret does. There is now one implementation of “where does this line start”, which is the point: the painter drew each line indented and the caret measured from the paragraph’s origin, so the two parted company by half a line’s slack and the gap grew as the line shortened. Editor carries the alignment, so a canvas editor’s paint, caret, hit test, Up/Down and selection all move together.
  • And §4’s two fields honour both properties now (ADR-0324), which is the caveat ADR-0318 and ADR-0321 both recorded. Value passes style.textFlow(), and each control places its own geometry from the same alignment — by the box in text-input, whose value hugs its text so the paragraph has no slack to indent, and per line in text-area, whose value box has a definite width and whose lines therefore start in different places. The scroll and the indent share one number in the single-line case, which is sound because they can never both be non-zero: an overflowing line has no slack and a fitting one does not scroll. A field can line up on its units column like slider-value has since ADR-0256, and a text-area can be centred; the round trip — draw the caret, press exactly there, get the offset back — is what the tests assert, on the second line as well as the first.
  • A router that does not talk to the dead (ADR-0317, G31). refocus established that the focused element had left the tree and then handed it to focus, which told it so, and State.setState threw — every party correct and the window dead on the next frame. The fix is lost.isMounted() in two places, per element rather than per notification, so an ancestor that survived its child is still told it lost :focus-within. mark had the same guard from the beginning; the notification half agrees with it now.

And the showcase has a card for it. The Forms screen’s fourth Java card is one text-area with every text property §8 has over it — text-align and the type rank as segmented bars, font-weight, font-style, text-decoration‘s two rules and font-family as checkboxes — and a line under the field printing the declarations the choices amount to. It is Java for Choosers’ reason arriving from the other side: a class set computed from seven toggles is a value, and bind= is a read-only channel for text rather than a way to hand a widget its own attributes. Three things it shows that a still picture cannot — the caret staying on the glyphs when the alignment moves, the field being restyled rather than replaced so what a reader typed survives every toggle, and the slant disappearing when the family goes monospace, because matching is family, then style, then weight. Seven assertions and one golden (forms-text-properties.png) cover it.

What it cost in surface: one new package (render.desktop), two new values (TextDecoration, BundledFont.Style), one new ComputedStyle component and one new Typography component, two new Popup/Router flags, four TextGeometry overloads and two more bundled font files. No new native artifact, and exactly one new native symbol — the theme query.

image

ADR-0358. §1’s image is built: a file, a resource, bytes, an Image in hand or an application’s supplier, with srcset variants picked by the window’s scale. It decodes once per source on a virtual thread through a cache bounded at 256 MiB, is its natural size until a stylesheet sizes it, and crops for cover rather than clipping. image.loading and image.error are the skeleton’s fill, and an error shows image-off and the alt text. Alt text is required unless the picture is decorative. SVG is not decoded. The Canvas screen has a card of four.

The catalog’s last four widgets, and the button’s last four options

The written-down surface of docs/core-widgets.md is built. Four widgets and four options were left after chip, and all of them went in on 2026-09-17:

  • steps and wizard (ADR-0344), the other two of §6’s nav package. The list writes index, count and state onto every step on every build, the way the trail writes which crumb is current; error and reachable are the step’s own words, and a press needs both clickable on the list and reachable on the step. The wizard makes one Step per page and hands them to the standalone Steps, builds only the current page, reports Back, Next and Finish, and moves nothing — and when the index changes under it, asks the host to focus the new page on a zero-delay timer, as a dialog does on opening. Its bar is dialog-actions under another name. The showcase’s Navigation screen has both on one index.
  • timeline (ADR-0345), §10’s. An entry is a rail beside a side, the rail stretches so the line runs from marker to marker, and the line after the last marker is drawn only when pending — the unfilled marker that tells a timeline from a list with dots. An alternating timeline gives every entry both sides at half width, which is the flex-basis: 0 the subset does not have. The Collections screen has one. A marker can be a widget — a badge in a marker child (ADR-0356) — and a step’s connector grows by scaleX about its start edge rather than changing colour.
  • link (ADR-0346), §2’s text widget, and one new native symbol with it: SDL_OpenURL, bound optional like the theme call, behind Backend.openUrl and Host.openExternal. The state makes and closes the external-link icon — the one icon the toolkit owns — and Enter activates while Space does not. Three of them are on the Basic screen, one external.
  • button’s outlined, square, circle and float (ADR-0347). Three classes, one line of logic — an icon-only button adds circle unless told square — and Floated, a stateful wrapper that puts the button in the window’s overlay layer and builds nothing in place, forwarding its press to the latest handler so a rebuild does not take it down and put it back.

What each of the four left behind — a badge marker, a scaleX connector, a floating button’s scale-in, and the roles a bridge would give them — is four entries at the top of TODO.md’s catalog section.

Five more gap entries, and three kinds of motion

docs/gaps.md G39 to G43 arrived on 2026-09-17 and are answered. Working notes are in docs/gaps-g39-g43.md.

  • A canvas asks for its next frame (ADR-0348). Canvas.animating(Predicate<CanvasStyle>), asked by the renderer straight after render, through a new Paints.isAnimating(ComputedStyle, Context) whose default is the old question.
  • Faces an application ships (ADR-0349). Application.fonts() returns FontSources, and the window’s book searches them after the bundled faces by the one matching rule both now share (assets.Face). A face that will not open is logged once and drawn in Inter.
  • text-area’s gutter strip (ADR-0350). The field draws a clipped content layer and puts the strip beside it, so the strip reaches the border. The wrap subtracts each padding edge once, where it used to double the left one.
  • A window icon (ADR-0351), and two new native symbols, both optional: SDL_SetWindowIcon and SDL_AddSurfaceAlternateImage. Application.icon() is several sizes, and the backend picks the base SDL scales from. The showcase has a computed icon.
  • G42 was already built as Host.openExternal (ADR-0346).

And three ways for something to move by itself, asked for alongside the gaps:

  • @starting-style (ADR-0352). On an element’s first styled frame, its declared transitions run from the starting style. button.float enters with §3.1’s scale 0.9→1, which ADR-0347 could not build.
  • @keyframes and animation (ADR-0353). CSS’s timing model (delay, iterations, direction, fill, easing per segment) as a second layer of the overlay, beneath transitions and under the same whitelist. Reduced motion drops every keyframe animation. The toolkit’s sheets declare none, and a test keeps §1.7’s rule 4 true of them.
  • A choreography on a canvas (ADR-0354). The showcase’s new Motion screen: a tile floor that settles as a ripple and re-glazes itself, drawn as a function of the frame time, asking for frames only while something moves and woken by a host timer in between. It has a card for each of the other two mechanisms as well.

What that batch left open was closed the same day (ADR-0355): text-input subtracts each padding edge once, a floating button leaves on fast before its overlay is removed, and the Motion floor starts on its first render, so the screen has a golden.

Not started

Client-side decorations. §3, §4, §6, §7 and §10 are complete, mechanism and all; what is left of M3 is one platform question: whether Goldberry carries its own decorations — SdlWindowFlag.BORDERLESS already describes the design — or keeps depending on libdecor and the two packages from two phases that ADR-0083 and ADR-0084 found. Answering it also unblocks the fractional-scaling entry, which was given up “for as long as decorations are unobtainable on the better path”. Everything outstanding is in TODO.md.

M3.5 — the :natives seal

Started. docs/ARCHITECTURE.md §3.1 has always said a raw MemorySegment never leaves :natives, and ExportedSurfaceTest has always enforced it. The second half of that rule — no :natives type in an application-facing signature — was never written down and was broken in two families.

  • The paint family is closed (ADR-0277). paint.Path is an immutable outline over two parallel arrays, with a sealed Path.Segment of six records for reading one back; Stroke, Cap, Join, Dash and a sealed Gradient sit beside it. Frame takes those and nothing else in public: the BlendPath overloads are package-private, and the seam is a single package-private Path.replayInto(BlendPath). Icon holds a Path now rather than a native allocation, SvgPath parses into a Path.Builder — computing SVG’s S and T reflections itself, since a Path has no stateful verb — and BoxPainter.paintOne lost the BlendPath parameter it only carried to pool one. The frame pools it instead, in the one place that sees every drawing call: :core had done that by hand and :widgets had not, so a chart opened four confined arenas per paint. Three stroked icons cost 0.206 ms before and 0.106 ms after; the frame itself is unchanged. Not one golden image moved, which is the evidence that the geometry did not.
  • Dashing is built, and is not a binding (ADR-0278). Blend2D has a dash API, stores what it is given, and never strokes with it — core/pathstroke.cpp is 988 lines with no occurrence of the word. Six symbols were added to the export list and five were taken back out; what shipped is paint.geom.Flattener and paint.geom.Dasher, so a dashed stroke is a solid stroke of a different path and behaves identically on all four targets. The one symbol kept is bl_context_set_stroke_miter_limit, which closes a gap BlendStrokeJoin had admitted to in its own javadoc.
  • The layout family is closed, and sealed (ADR-0279, ADR-0280). A goldberry.layout package holds Length, Insets, Limits, FlexDirection, Justify, Align, Wrap, Position, Overflow and the measure protocol; Box, ComputedStyle and CssLength.parse are written in it; ComputedLayout was deleted rather than mirrored, because render.model.LogicalRect was already the toolkit’s rectangle. The translation is one package-private file beside RenderObject, the only class that ever touches a YogaNode — and YogaTest checks every constant by name from values(), because the compiler guarantees the switch is exhaustive and cannot guarantee an arm names the right counterpart. Around a thousand references moved across 84 files, and no golden image did. :natives now exports its Yoga packages to :core and to nobody else, which was verified by compiling a module that tries to import StyleLength and watching javac refuse it.
  • The shaping family is closed, and sealed (ADR-0282). text.ShapedRun and text.TextDirection replaced the shaper’s own types — a shaped run is six int[] with no foreign memory and nothing to close — and HarfBuzz’s two packages now export to :core and nobody else, checked by compiling a module that tries to name GlyphRun.
  • What was left was one method, and it is closed (ADR-0290, paint.GlyphPen). It was Frame.drawGlyphs(double, double, BlendFont, BlendGlyphBuffer, int), whose only caller is Font.draw. The other leaks were values and a value can be mirrored; this one passes handles, and the difficulty is ownership: glyph rasterization needs a context, a font and a staged buffer, paint owns the first and text.font the other two, and within one module Java has nothing between package-private and public. The answer is to move the native font into paint; it changes where fonts are created, so it gets its own decision rather than being improvised. The enumeration is free: delete transitive from :core’s requires and -Xlint:exports under -Werror names every site — which is why the planned PublicSurfaceTest was never written.
  • And a canvas hears input (ADR-0281), which was docs/gaps.md G3 and the last now on that list. Almost nothing had to be built: Handles, the router, implicit capture on press, the wheel, focus and per-box cursors all existed and were tested — Canvas simply implemented none of them. The one real piece of work is the coordinate space. A canvas painter draws inside the padding (ADR-0193), so the hit-test snapshot now records a content rectangle beside the border box and PointerEvent.content() reports the pointer inside it; Length.resolve is the single implementation both the painter and the snapshot call, so they cannot drift. Making the widget focusable immediately failed SemanticsSweepTest — every focusable widget must say what it is — so a canvas is a Role.FIGURE and its name is the application’s.
  • And a canvas can draw an image (ADR-0283), which was docs/gaps.md G4 and is what G5 and G7 were waiting on. image.Image decodes PNG, JPEG and QOI — the codecs were compiled into libgoldberry from M0 and the export list had simply never named them — and is a value: the decoder’s allocation is copied into a PixelBuffer and destroyed before decode returns, so there is no close(), no lifetime, and the showcase holds one in a static field. Three symbols were added and no more, because the PNG writer is java.base’s Deflater and four chunks rather than seven more bindings — ADR-0278’s reasoning a second time. Frame.drawImage has four overloads, and natural size is one image pixel per device pixel, so the scale sweep over the Canvas golden is what checks it. The one exception to “a rasterizer never allocates our pixels” is the decoder, because the size of a PNG is inside the PNG; it lasts one try block.
  • And a scene can be photographed with no window (ADR-0284), which was docs/gaps.md G5 — a server-rendered preview, an OpenGraph card, an export. offscreen.Offscreen runs the window’s own sequence: three passes, two of them measuring and drawing nothing, with a virtual clock so the same document is the same picture twice. The pieces were all public; the sequence existed only inside Launcher and inside a golden harness that had copied it. The harness is a consumer of it now, so every golden in the repository is a test of the API an application would use — and wiring it up immediately found a bug: the harness never called ElementTree.flush(), so a masonry rearranging itself when told its column widths was applied in a window and never in a golden. Nine gallery images were re-blessed; removing only that one call reproduces all nine of the old ones byte for byte, which is what says it was the cause.
  • And text can be edited outside a control (ADR-0285), which is docs/gaps.md G6 apart from IME preedit. TextEdit and EditHistory were already built and in the wrong module — the rules of text editing lived inside text-input — so they moved to text.edit beside the shaping they are arithmetic over. What was genuinely missing is the two-dimensional half: TextGeometry answers where a caret is on a wrapped paragraph, what Up means when lines differ in length (a column is an x, not a character count), and what shape a selection is across a line break. Editor is the whole editor without a widget — text-input’s key map, its undo coalescing and its clipboard, over a canvas at any transform — and Input gained onFocusChanged, because everything else a canvas draws looks the same focused or not and a caret does not. IME preedit is not in it: SDL_EVENT_TEXT_EDITING is not bound at all, which is M5’s item and G15’s entry.
  • And the clipboard carries more than text (ADR-0286), which is docs/gaps.md G7: has/read/write over a MIME type, with Image.fromClipboard and toClipboard beside the decoder rather than on the SPI — a backend implementing a clipboard should not have to know what a PNG is. A write is an offer: SDL_SetClipboardData keeps two callbacks and asks for the bytes when somebody pastes, so this is the second upcall family after Yoga’s measure and the first where memory outlives the call. Each offer carries its own id, because the cleanup for the previous one arrives while the next is being installed — a single “current offer” field frees the wrong arena every time somebody copies twice. The showcase’s image card pastes a screenshot now.

M4 — GPU

Started, and built in part. docs/gpu-plan.md is the plan and its log: phases 1 to 6 have met their exits on Metal, on this project’s one Mac, and phase 6b (zero-copy on macOS) and phase 7 (hardening) are open. The UI is still rasterized by Blend2D on the CPU (ADR-0002); what M4 changes is how a painted frame reaches the screen, and what else can be on it.

  • SDL_GPU is bound, for :core and :gpu alone (ADR-0475). 56 SDL_GPU functions are on the export list, their structs are checked by the layout probe, and the wrappers in natives.sdl.gpu are exported to those two modules and no others. Shaders are HLSL, compiled offline by DXC and SPIRV-Cross into SPIR-V, DXIL and MSL, and committed (ADR-0476); the composite drives SDL_GPU directly rather than SDL’s GPU renderer (ADR-0477).
  • :gpu has a public API (ADR-0478): devices, textures, buffers, shaders and pipelines as AutoCloseable resources made from records, one frame of scoped passes, staged uploads and readback. It is confined to the device’s thread, misuse is refused in Java rather than in the driver, and no MemorySegment or SdlGpu… type is in an exported signature.
  • A window is composited through a seam :core declares and :gpu provides (ADR-0479). render.composite is exported to :gpu alone, and :gpu’s SdlCompositor provides its Compositor through ServiceLoader: the frame’s damage goes up to a UI texture, which is drawn onto the window’s swapchain. It is the default (ADR-0480): a window presents through the GPU where it can and on the CPU where it cannot — no goldberry-gpu on the module path, no device, a refused claim, a popup, goldberry.gpu=off — and it says which, and why, in the log, in Window.presentation() and in the showcase’s bar (ADR-0492).
  • GPU layers are placed in paint order (ADR-0481). Frame.gpuLayer clears a hole in the CPU frame and records the layer with its scissor. A composited window draws the layer under the UI; headless, Offscreen, a popup and any window the GPU cannot claim read it back into the frame instead. Six z-order cases are goldens that agree both ways.
  • canvas3d is a GPU layer an application renders into (ADR-0482): a Canvas3dRenderer on the window’s device, drawn continuously or at each new revision, and --gb-canvas3d-unavailable with the reason where there is no GPU. The showcase’s GPU screen has two cubes.
  • video-view shows its pictures through a GPU layer when :gpu is present (ADR-0483, ADR-0484). The frame queue holds a picture’s planes, and the shader converts them with the stream’s matrix and range, within one level of CPU present on ten fixture pictures. A minute of 4K60 VP9 on VideoToolbox shows all 3600 pictures, 8- and 10-bit, where CPU present drops 1581 of 3598 (ADR-0485). Without :gpu, or where the layer cannot be placed, the view falls back to the CPU and to converted pictures.
  • Under X11 a window keeps the GPU with a page in it (ADR-0491): a child window stacks above the swapchain there, and the window surface is the X server’s framebuffer, so a window given back from the GPU keeps its id. On macOS and Windows a page still moves its window to the CPU, until someone measures them.

What is not proven. Everything above was built and measured on Metal. On Linux the composited path has run on this project’s machine, under X11 on NVIDIA’s Vulkan driver (ADR-0491, ADR-0492), and nowhere else; Windows (D3D12) waits for a host. The GPU lane has run twice and never reached a test: in Snapshot runs 32 and 33, both Linux legs of linux.yml’s verify job failed at the lane’s own first check, no lavapipe ICD in /usr/share/vulkan/icd.d, and the macOS step, which does not require a device, passed. A device lost mid-render shows black and does not fall back, which is phase 7’s. And on this machine GpuLayerBackendTest segfaults in VULKAN_DestroyDevice when it closes its device, which ends :gpu:gpuTest; ADR-0491 recorded it as found and left it.

M5 — Hardening

Started, with the release half. Text editing depth, docs, and the first release — IME preedit is done (ADR-0289, ADR-0292) — and the three-platform frame evidence M1 was waiting on, which is here rather than in M1 because it is a CI job and because the hardening milestone is where every other “prove it on hardware nobody has run it on” item already lives.

The AccessKit bridge was this milestone’s last toolkit item, and it is on hold (ADR-0440): nothing has asked for it, two of its three platforms are behind the same missing Windows and macOS machines that already block half of TODO.md §12, and the cost is a permanent four-platform obligation in :natives. The semantics tree stays — every interactive node has a role and an accessible name, and a sweep enforces it — so what is missing is the reader, not the data. What is left under M5 is therefore the release half alone, and both halves of that are blocked on an account and a tag rather than on code. The account is no longer one of them: Central’s secrets are set, and snapshots have gone out since run 17 (2026-09-19). What is left is a tag, and the release’s own preconditions below.

The frame evidence — built, run, and asserting no budget

ADR-0342. The three things that job needed are all built, in the order the work fell:

  • A window that can be resized from outside. BackendWindow.resize is the SPI method, a request the window manager answers with a Resized; Sdl3Window hands it to SDL_SetWindowSize, and HeadlessWindow plays the manager the way its popup already did — clamped to the floor, applied when the event is delivered. Window.resize is the public face, and --resize=WxH walks the size a pixel a frame there and back through a ResizeWalk that steps from the window’s own size, between frames: asking from inside the painter changed the size under the frame on every driver where SDL_SetWindowSize is synchronous, and every frame was refused and counted late.
  • A run that says what it cost. FrameRing keeps the run’s totals beside its window, FrameStats.summary() hands them out as a FrameSummary, and the launcher logs one line after the window has closed: frames: 60 frame(s) painted, 0 late; paint mean 22.48 ms, worst 446.07 ms; display 0.0 Hz — the showcase, headless, on this machine, with the JIT warming up. --late-budget=N turns it into a verdict: over N, FrameBudgetException after shutdown and a non-zero exit.
  • A ceiling under it, on three runners. showcase.yml runs each native image for 300 frames with --resize=1580x1100 --late-budget=30, and the summary line goes into the step summary. Overtaken by ADR-0452: no leg ever passed the ceiling, because 30 was reasoned about rather than measured, so the workflow now runs the same walk with no --late-budget on any platform and reports what it cost. What still fails the step is a run that does not log painted 300 frame(s); exiting — a hang, a crash, an image that will not start.

Found on the way: Launcher.run registered its own onResize and onMove after Application.start, into the one slot a window has, so an application’s handler was silently replaced — the showcase’s “resized to” line had never fired. The launcher has its own hooks now, and the application’s slot is its own.

The caveat, written down with the numbers. GitHub’s runners are GPU-less virtual machines, and on Linux the image paints into Xvfb, which reports no refresh rate — the pacer does not pace there and a run can only be late by refusing frames. Measuring there is real evidence about three platforms’ drivers — Cocoa/Metal, D3D, X11 — and far better than one VirtualBox VM, but it is not a claim about hardware. The workflow has not run since the change; it runs on a tag or by hand, and the numbers it produces belong here when it has.

It has run, and these are its first numbers (ADR-0452; the same two lines are in showcase.yml):

LegFramesLatePaint meanWorstDisplay
linux-x643027510.14 ms1799.59 ms0.0 Hz
macos-aarch643002006.42 ms200.26 ms60.0 Hz

display 0.0 Hz is Xvfb reporting no refresh rate, so on Linux “late” counts missed ticks of the pacer’s own timer, and on each leg the worst frame is start-up rather than the walk. Showcase run 24 (2026-09-21) was the first green on all three legs since the walk was added. No frame regression is caught automatically: the step summary is read by a person, and a budget waits on enough green runs to set one per platform from the top of a distribution.

CI — green where it can be reproduced, and saying why where it cannot

Snapshot runs 32 and 33 went red, and the fix has not been through CI (ADR-0495). Every per-OS Java job failed in :natives:gpuTest: the Java jobs build with no library, the GPU test classes skip their setup correctly, and eight of them then called SDL from their teardown regardless. The teardowns return when setup was skipped now, and :natives:gpuTest, :gpu:gpuTest and :media:gpuTest pass locally with no library. Run 31 (2026-09-23) is the last snapshot that went out. The same two runs failed the GPU lane on both Linux legs, for a reason ADR-0495 does not touch — see M4.

Snapshot run 12 went red and is repaired (ADR-0357): WindowResizeTest painted a frame without asking for the library, which fails every Java-only job, and a 500 from github.com failed one asset download that is now retried.

ADR-0338. Every workflow had been red since 2026-08-16. From a fresh clone with no library, five causes turned up and are fixed: two font fixtures that skipped once and then failed ~280 tests, four tests that reached libgoldberry without asking, coverage floors a Java-only build cannot meet (they now run on the linux-x64 verify leg), GoldberryTest still expecting semver after ADR-0333, and a nightly that installed no system packages. The java job and the Linux verify steps pass locally. On a runner, every failed test and the build’s own failure are now check-run annotations, readable without signing in. The four failures no machine here can run were then read off the runners’ logs and fixed blind: two natives tests that turned a file:/D:/... code source into a path the wrong way, a drift guard that split a CRLF checkout on a blank line that was not there, a showcase build that took MinGW’s cc because cl was not on the PATH and so wrote a libgoldberry.dll nothing looked for, and a macOS trace that SDL’s status-bar tray aborted under the headless driver. All four passed at fd36169a, which made the Showcase green on every leg for the first time and left three red jobs in the Snapshot. Their annotations named a real bug — EventLoop fired two overdue timers in creation order when a slow pump handed it both at once — and four more Windows separator assumptions in tests, all fixed with unit tests. At d478ecfe the Snapshot passed on all twelve jobs, Maven Central rehearsal included, and the Showcase on three legs and both uploads: the first green push since 2026-08-16.

The image’s foreign calls are generated, not traced

ADR-0339. The Windows native image, run by hand, died on the html, canvas and Markdown screens with MissingForeignRegistrationError: the trace held what 120 headless frames reached, and a …Calls record binds on first use, so the parser’s holders were never initialised and never recorded. Downcalls.link and the new Upcalls.describe record every descriptor at class initialisation; ForeignSurface initialises every holder package and upcall owner from the module listing; ForeignMetadata writes the foreign section in the agent’s spelling; and :natives:foreignMetadata puts it in the goldberry-natives jar under META-INF/native-image/. Tested: every holder’s handle shape is reported, the owners equal the sources that call upcallStub, and every shape the checked-in trace ever recorded is among the generated ones. Verified by hand on all three platforms (2026-09-17, a manual Showcase run): the native image opens the html, canvas and Markdown screens on Linux, macOS and Windows. On the way there the canvas tab died once more on a resource rather than a call — canvas-sample.qoi, one of five sample images the showcase had never declared (ADR-0160’s rule, applied to the stylesheet and the documents and not to the pictures). Declared now, and DeclaredResourcesTest holds the manual list to every file under the showcase’s resources.

Releasing — snapshots go out, and a release has never run

ADR-0333, ADR-0334, ADR-0335, ADR-0336, ADR-0337; docs/releasing.md is the checklist.

Snapshots have gone to Central since run 17 (2026-09-19), which went out and failed partway, leaving :widgets and :html off that snapshot; check generates the javadoc since, so the same mistake fails on Linux four minutes in (ADR-0405). A release has never run, because there is no tag. goldberry-media is published too, and a release refuses it until FFmpeg is built for windows-x64 and linux-aarch64, which the Media workflow does not do yet; the LGPL’s corresponding-source offer is still to be decided (ADR-0495).

  • Calendar versions. goldberryVersion=2026.1 is the line being worked towards and never carries -SNAPSHOT; the build adds it, and drops it only for -Pgoldberry.release=true with a v2026.1 tag that matches. A mismatched tag fails configuration. CalendarVersion and BuildVersion in build-logic, tested.
  • Maven Central. goldberry.publish on the six shipped libraries — eight since :emoji and :media joined — POMs, sources, javadoc, signing for releases, :core’s test fixtures kept out, and goldberry-natives carrying all four classifier jars when a run hands them over. Rehearsed locally with stand-in libraries into a throwaway repository: every artifact and POM came out, and no POM names a build-time module.
  • A BOM and an umbrella. goldberry-bom pins every artifact; goldberry depends on -common, -natives, -core and -widgets and lists -html and -gpu as <optional>, both generated from PublishedModules so a content module is one line. A consumer build against a local repository resolved the four without goldberry-html, and goldberry-html at the BOM’s version once asked for. The optional list is -html, -emoji, -gpu and -media now, and goldberry-media carries FFmpeg as ffmpeg-<target> classifier jars, the shape of goldberry-natives’ (ADR-0495).
  • One uploader. publish.yml calls the three per-OS workflows and publishes once; snapshot.yml (every push to master) and release.yml (every v* tag, dispatch rehearses) call it. The per-OS workflows lost their push trigger, because four publishers would leave a snapshot’s metadata naming one platform.
  • The showcase is a release artifact (ADR-0340). It was on GitHub Packages twice, as jlink images and native images, on every push; now showcase.yml runs on a v* tag or by hand, builds the native image only, and attaches the three binaries to the tag’s draft GitHub Release. The example’s tests against a built library — the step that found bugs — run on linux.yml’s linux-x64 verify leg on every push.
  • The native showcase. Every leg builds the GraalVM native image, runs it for three frames and uploads it. macOS and Windows trace first. CI pins GraalVM CE 25.3 by GraalVM version (java-version: '25' alone resolved January’s jdk-25.0.2). Run locally on linux-x64 with 25.3.4.1: built in under two minutes to a 49 MiB binary — and then died on its first frame, because the checked-in trace predated the clipboard upcall (SdlClipboard.provide, no registered method). Re-traced, the same build opened its window, painted and exited 0. The refreshed trace adds the clipboard, file dialog and tray upcalls, the :html catalog and the scroll and masonry widgets — eleven reflective types and five downcalls, everything built since 30 August. A clean rebuild on 25.3 also found --no-fallback deprecated with no effect — both of the build’s warnings — and it is gone: 1 min 23 s, peak RSS 2.27 GiB.
  • The licence texts are vendored. All seven of licenses/’s placeholders carry the verbatim upstream file from the pinned revision — the same checkout the superbuild fetched, whose HEAD was checked against libs.versions.toml before copying — and checkLicenses -Pgoldberry.releaseCheck=true passes: eleven components, all vendored.
  • The javadoc is linted (ADR-0343): -Xdoclint:all,-missing on every published module, and clean. The 120 errors were 425 @param lines on …Calls holder classes, moved to their call methods by a script, and twenty links to types another package owns.
  • Not done: Central’s side (namespace, snapshots enabled, token, signing key, secrets); pruning old showcase snapshots, which needs a token with delete:packages. Central’s side is done — the secrets are set, and run 17 was the first snapshot to go out (docs/releasing.md).

Content modules

Three of eleven, and a fourth that is not a module. :media is the third, and the first with native libraries that are not libgoldberry’s (below). The parked web engine is built and is not a module at all: web-view is a widget in :widgets (ADR-0441, ADR-0442). Seven of the eleven have no Gradle subproject, no artifact and no line of code.

Two of eleven. :emoji is the second and is unlike the first: no parser, no native, one font. It left :core when the face was OpenMoji, whose CC BY-SA asked for attribution where the work is seen; :core keeps the slot in §6.1’s font chain and loads the face through an EmojiFont service, and Font.bundled(EMOJI, …) without the artifact fails with a sentence naming it (ADR-0384). The face is Noto Color Emoji now — the COLRv1 build, SIL OFL, 5 MB — drawn by :core from its paint graphs: gradients, transforms, and the composites its waving flags are made of (ADR-0456).

One of eleven, and now all of it. docs/content-widgets.md specifies eleven optional modules — HTML/markdown, PDF, plotting, code, terminal, vector, media, camera, microphone, emoji and the parked web engine — and the plan they sit in is docs/ARCHITECTURE.md §11.1 with ADR-0190 under it. Nine of them have no Gradle subproject, no artifact and no line of code.

:html — both halves are built, and both are usable

docs/gaps.md G8 and G17, closed by ADR-0294, ADR-0295 and ADR-0298.

  • md4c is compiled into libgoldberry and reaches Java through five goldberry_md_* symbols, none of which is md4c’s own. The parser is a SAX interface — five callbacks, thousands of them per note — so the events are encoded into one buffer in C and read once: no upcall stubs, and none of md4c’s seven detail structs in the layout table. That is ADR-0190’s own “the hot path never crosses FFM” applied to a parser, and it is the reason a Document owns no native memory.
  • A departure from ADR-0190, recorded rather than quiet. That record quarantines a content module’s natives into a jar of its own; md4c is one MIT C file whose object code is tens of kilobytes, and a second superbuild with four CI legs would cost more than it isolates. The dependency direction is not relaxed: :natives exports md4c’s wrapper to :html and to nobody else, which ExportedSurfaceTest checks, and neither :core nor :widgets knows Markdown exists.
  • A document is a sealed tree of records, and the HTML writer and the widget renderer are two folds over it — so a preview, an outline, a word count and the bytes a server hands out are all one parse and cannot drift apart.
  • markdown-view is the first widget outside :widgets, which exercises ADR-0131 end to end: the showcase’s markdown.kdl names the node and nothing in that application mentions the module that provides it.
  • A preview follows a property (ADR-0296). markdown-view takes §9’s bind=, so an editor and its preview are two nodes over one value — the gallery’s ninth screen is a split-pane holding a text-area that writes md.source and a view that reads it, and there is nothing in the showcase between them. The sample it opens with is every construct the parser reports, which is also what keeps the golden image honest.
  • Two defects in :widgets fell out of building that screen (ADR-0297), both older than Markdown: a split-pane took its measured length and never asked for the rebuild that would use it, so position was a first-frame guess that only a drag corrected; and a text-area chased its caret from the first layout, so an area opened on a document showed its last line. text-area also gained fill=#true — the editor’s half of §4’s control, sized by its container rather than by its text.
  • Both views are read, not only looked at (ADR-0300). A link is a button.link that hands its destination to the application; an ImageSource the application supplies is what turns a src into a drawn picture, and a src nothing answers for is still its alt text; a Markdown task box reports its ordinal, and Markdown.toggleTask flips that one character of the source so the binding brings the new document back. Every one of them is the toolkit reporting and the application deciding — nothing here opens a browser or reads a file.
  • A document is selectable (ADR-0301). Drag, double-click a word, triple-click a block, Ctrl+A, Ctrl+C, with the separators the document implies. Three facilities that already existed made it cheap: Located says where each word was painted, a painter reading mutable state means a drag repaints rather than rebuilds, and Paragraph.offsetAt puts the caret between the right two glyphs. A word is a word part now rather than a text widget — same count, same box, same cache.
  • What they deliberately still do not do: emphasis is a faux oblique because the system ships two upright faces, a hard break inside a paragraph does nothing, and a line of mixed faces is a row of words rather than one shaped run — so no justification and no hyphenation, which is what an engine would still buy.
  • html-view is built, and litehtml is not what it is built on (ADR-0298). Html.parse is a tokenizer and a tree builder in Java over a sealed model, and the fold into widgets is the Markdown one with an open tag vocabulary: an element contributes html-<tag>, so html.css reads like a browser’s default sheet and <my-callout> is styleable without a line of Java. It cost no new native symbol — the one thing that crosses is md4c’s entity table, which was already there.
  • An anchor is a button.link (ADR-0293), handing its href to the application and nothing else: a Tab stop, a hover, Space and Enter, and no browser opened by the toolkit. That is the one capability G17 promised over G8 that an engine was not needed for, and the showcase’s tenth screen presses one.
  • Two widget trees in one module broke the weaver, and that is fixed. The generated catalog goes in “the longest package prefix every widget shares”, which for markdown.view and html.view is the package :core owns — and two named modules holding one package is a LayerInstantiationException on the module path that no class-path test can see. CatalogWeaver now bounds that choice by the packages the module actually has classes in.
  • What is still the engine’s: a line of mixed faces as one shaped run, and the text selection that follows from it. Nothing on screen waits on it, which is the difference between this line and the one it replaces.
  • A document made the paragraph cache’s size a bug (ADR-0299). One text widget per word asks for ~600 distinct paragraphs a frame against a cache of 256, and least-recently-used eviction then guarantees a zero hit rate rather than a lower one: 287 shapes per frame on a tree that had not changed, and 9.5 ms of style pass. The cache sizes itself to the frame now — 0 shapes, 0.8 ms — and FrameBudgetTest asserts the count rather than a time. The same test had been measuring a screen that no longer existed, which is why nothing caught it.

Two of the eleven are not modules at all in what ships, and both were decided before the document was written: the chart widgets belong to :widgets (ADR-0014), and the emoji font belongs to core’s text stack — which is where core picks up the one licence obligation an application cannot discharge with a notice file. Both are in ARCHITECTURE.md §17.1 as disagreements rather than edits.

What is built that they would stand on, stated so the estimate is honest:

  • A borrowed pixel buffer wrapped as a BLImage costs nothing to hand over (ADR-0031), which is exactly the handover PDFium, ThorVG and libVLC each want.
  • A leaf render object measured by a callback is what an engine-backed html-view, pdf-view and camera-view all are — the same shape text already uses, where Yoga asks and the widget answers. The html-view that shipped is not one: it is a tree of ordinary boxes, which is why it needed nothing from this list.
  • A repaint boundary is a subtree’s own raster (ADR-0071), so a page, a video frame or a camera preview updating off the UI cadence is a layer that re-uploads rather than a tree that rebuilds.
  • Golden-image CI is deterministic on three OSes, so every one of these widgets is testable without hardware — which is why camera and microphone specify synthetic sources rather than acquiring them later.

And the two facts that make the first one cost more than it reads:

  • The export list has no rounded geometry, and it has gradients now. 232 symbols reach Java — five of them added by Markdown, which needed none of this surface at all (ADR-0294) — and the twenty-five bl_context_* among them are the ones the toolkit’s own painter uses — bl_context_save arrived with canvas (ADR-0193) and the three fill-style entries with a chart’s gradient fill (ADR-0207), which is two of the three things this line used to name. A native litehtml container still needs more, so goldberry-html still starts by widening libgoldberry’s paint surface — but it starts from a list that already has the gradients it would have added first, which is what “shared work” was a prediction about.

  • None of the 59 SDL_* symbols is audio or camera. “Zero new natives” is true of the binary and not of the surface — which is no longer a prediction: tray-icon reached that file first and paid eleven symbols for it (ADR-0191), and camera and microphone are the same widening again.

  • One of the eleven turned out not to be a module at all, and is built (ADR-0441). goldberry-web had been parked for two milestones behind an argument that was entirely true and entirely beside the point: libservo is Rust-only against an unstable API, and nobody asked whether a page needed an engine of this project’s. webview/webview is MIT, is one header and brings none — it drives the WebKitGTK, WebView2 or WKWebView the desktop already has — so neither condition that quarantines a content module applies to it.

    It ships as §9’s second widget.shell member, beside tray-icon, because it could not be a widget: webview/webview has no offscreen surface, so a page is always a platform window, and a Wayland session allows neither reparenting a foreign surface nor placing a window where a widget is. A web-view in a layout would have been a box on three platforms and a loose window on the default Linux desktop.

    Overtaken by ADR-0442, the same day, which keeps every fact above and drops the conclusion: that was an argument against a silent fallback, not against embedding. A page is a widget where the window system allows a child window. WebView, in …widgets.core.web, has a box in the layout, and the page’s own window is made a child of the application’s, placed over that box and moved with it — reparented on X11 and XWayland, a subview on macOS, a WS_CHILD window on Windows. On Wayland it opens nothing and paints why. The window form stays beside it in …widgets.shell.web, WebPage and WebViews.open, for an application that wants a page in a window of its own. What an embedded page still cannot do follows from its being a window above the frame: nothing painted covers it, a scroll does not clip it, and no golden image contains it. So a modal parks it off the window’s side (ADR-0444), it waits there until it has loaded (ADR-0445), and on X11 it is never the window manager’s (ADR-0446) and keeps its window on the GPU (ADR-0491).

    Three things it cost that are worth knowing. The native library is separate — libgoldberry-webview, linked into nothing and opened on demand, because GTK and WebKit in libgoldberry’s NEEDED would make them load-time dependencies of every application on Linux; objdump -p libgoldberry.so shows no GTK or WebKit entry, which is the check that whole argument reduces to. The event loop had to learn to stay awake: a page’s engine runs on GLib’s main context, nothing wakes the loop when WebKit has work, and the loop parks for its one-second heartbeat on an idle desktop — so while a page is open, and only then, the wait is capped at 8 ms. And webview/webview 0.12.0 has a bug on its GTK backend: set_size_impl applies the size and then falls off the end into return error_info{WEBVIEW_ERROR_INVALID_ARGUMENT} unconditionally, so every resize succeeds and reports failure. The shim validates the hint itself and translates that one spurious code.

    And the one that cost the most was found by pressing the button. Two GTK majors in one process is a SIGSEGV, not an error: GdkDisplayManager is registered by both gdk-3 and gdk-4 into GObject’s process-global type registry, so the second one back gets 0 and gtk_init_check dereferences NULL. SDL’s Linux tray is libayatana-appindicator, which links GTK 3 — so the showcase was already a GTK 3 process, and its own “Open the Goldberry page” button took the window down. The Linux build therefore links webkit2gtk-4.1, which is WebKitGTK on GTK 3, against webview’s own preference; and the shim checks with RTLD_NOLOAD whether the rival major is already mapped and declines rather than crashing. web-view and tray-icon are a pair on Linux now, which only a separate process would uncouple.

    macOS is built and run: an embedded page there is the engine’s WKWebView added as a subview of SDL’s content view, through a holder window, because webview.h would otherwise replace that content view (ADR-0458). And a key typed into it is the page’s alone: SDL3 hands macOS key events to the application before the focused view, so the backend drops them while a page holds the keyboard and takes the keyboard back on the next press outside it (ADR-0459). Windows is written and unverified, in tray-icon’s sense: webview.h makes its own WS_CHILD window inside SDL’s, and nothing here has compiled or run it. The Linux leg was built and exercised on a Wayland session, through FFM and through a real Host with a real tray up — page opened, titled, sized, navigated, pumped and destroyed.

:media — FFmpeg from Java, and published

docs/goldberry-media.md is the design and docs/media-plan.md the log: all seven of its phases are done on macOS, GPU present among them as M4’s phase 6.

  • FFmpeg’s libraries, driven from Java (ADR-0460), in place of libVLC. avformat, avcodec, avutil, swresample and swscale, with dav1d for software AV1, built by a superbuild of :media’s own as shared libraries; the Engine’s threads, queues, clock and state machine are Java. Royalty-free codecs only, and no FFmpeg network layer: every byte arrives through a Java MediaIO.
  • :media binds its own libraries (ADR-0461), the one module besides :natives that holds a MemorySegment, under :natives’ rules: holders in …media.ffi.calls, a layout probe checked before any struct is read, and no FFmpeg type in an exported signature. They are LGPL-2.1+ and stay replaceable shared objects, under sonames no other FFmpeg has (ADR-0490).
  • The operating system’s decoders are part of it (ADR-0493): VideoToolbox and AudioToolbox, GStreamer, Media Foundation, behind the same Decoder SPI for H.264, HEVC, AAC, AC-3 and E-AC-3, in …media.platform. They were :media-platform until then, optional in name only. macOS and Linux are built and bit-exact against FFmpeg; Windows is written and has not run there (ADR-0489).
  • Four widgets: audio-player, video-view, media-controls and media-player, with the showcase’s Audio and Video screens over them.
  • It is published, optional (ADR-0495), as goldberry-media, with FFmpeg as its ffmpeg-<target> classifier jars. A snapshot carries the targets the Media workflow builds, macos-aarch64 and linux-x64; a release refuses to publish without all four. media.yml is written and has not run on a runner yet.

Module layout

ModuleArtifactContents
:commongoldberry-commonWhat both halves need and neither owns: Logs, which every logger in the toolkit comes from so that SLF4J’s own no-provider warning is quiet before the first one is created (ADR-0023), and Startup, the timeline of what happened before the first pixel (ADR-0028). The lowest module: it requires nothing of Goldberry’s, which is what lets :natives and :core both use it. It exists because they cannot both reach into the other — :core requires :natives, so shared code used to have to live inside the native layer and be exported from it (ADR-0174)
:nativesgoldberry-natives-{platform}-{arch}Hand-written FFM bindings, owning wrappers, and the CMake superbuild that produces libgoldberry — and, where WebKit’s headers were present, libgoldberry-webview beside it (ADR-0441). The second library is linked into nothing and opened on demand, because GTK and WebKit in libgoldberry’s NEEDED would make them load-time dependencies of every application on Linux. It is the one artifact here an installation may legitimately not have
:coregoldberry-coreThe engines and the contracts — the widget/element/render trees, style, layout, text, icons, paint, the backend SPI, and the two backends headless and sdl3 (ADR-0041). No widgets: text, row, column, panel and spacer lived here until they had a catalog to belong to (ADR-0092)
:widgetsgoldberry-widgetsThe widget catalog — controls, containers, menus, charts — plus the showcase screens that serve as the visual regression corpus. One module, a package per control — docs/core-widgets.md’s groups (…widgets.controls and …widgets.overlay, with form/panel/nav/collection as they are built) and one package inside each for every widget and its parts. Half a reversal of ADR-0014, and the second level is what makes ADR-0065’s rule a boundary the compiler enforces rather than a convention: a slider-thumb is now invisible outside …controls.slider, where before “package-private” meant “visible to the whole catalog” (ADR-0091)
:weavernot publishedThe weaver, in two halves. Catalog: collects a module’s @Markup widgets into a WidgetCatalog and declares it — every build runs this, because nothing finds annotated classes at run time (ADR-0131). Models: rewires a @Model’s @Bind fields into bindings and writes its @Action call sites, with the JDK’s class-file API — only a GraalVM native image runs this, since an ordinary jar binds the same annotations reflectively (ADR-0155). Build-time only, like :assets: it runs between compileJava and jar, never reaches a runtime classpath and has no module-info (ADR-0125, ADR-0126)
:htmlgoldberry-htmlThe first optional module, and the first widget outside :widgets: Markdown parsed through md4c into a sealed tree of records, written out as HTML, and rendered as markdown-view — column, row and text under the ordinary cascade rather than an engine (ADR-0294, ADR-0295). An application opts in: it adds the dependency and MarkdownStyles.stylesheet(), and nothing in :core or :widgets depends on it. html-view and its litehtml engine are not here. html-view is now (ADR-0298): a parser in Java, folded into the same widgets. litehtml is still not
:emojigoldberry-emojiOptional. The Noto Color Emoji face and nothing else, 5 MB of paint graphs an application that never draws an emoji should not carry. It reaches :core through an EmojiFont service (ADR-0384, ADR-0456)
:mediagoldberry-media, with ffmpeg-{platform}-{arch} classifiersOptional. Audio and video over FFmpeg driven from Java, the operating system’s decoders behind the same Decoder SPI, and the four media widgets. The one module besides :natives that binds a native library itself, and its libraries are FFmpeg’s own shared objects rather than libgoldberry (ADR-0461, ADR-0493, ADR-0495). It requires static :gpu, and shows video through a GPU layer when :gpu is on the path (ADR-0484)
:gpugoldberry-gpuOptional. canvas3d and the GPU composition path: the public API over SDL_GPU, the Compositor :core declares and this module provides, GPU layers and the video layer :media draws with (ADR-0478, ADR-0479, ADR-0482). With it on the module path a window presents through the GPU by default (ADR-0480)

:assets is not published: it is the build-time tool that fetches the pinned fonts and icon set and compiles Lucide’s 1544 SVGs into a path table, which :core packages (ADR-0033).

:example is not published either: it is the showcase, and it runs on the module path so that what the module graph exposes to an application is exercised rather than assumed (ADR-0023).

:bom and :toolkit are published and hold no code: goldberry-bom pins every artifact’s version, and goldberry is the umbrella an application starts from (ADR-0336).

Every module logs through SLF4J and binds no implementation. An application that adds one gets the toolkit’s diagnostics; one that adds none gets silence, SLF4J’s own no-provider warning included. At TRACE the toolkit reports a start-up timeline, the modules it resolved, and per-frame timings (ADR-0028).

Every module ships a module-info.java. That is not decoration: the module graph is what enforces the rule that raw MemorySegment never escapes :natives, and it is what makes --enable-native-access targetable under JEP 472. See ADR-0007.

It is also what decides where shared code goes. :core requires :natives, so anything both of them need has to sit below both — which is why logging and the start-up timeline are a module rather than a package, and why :core names :common directly instead of taking it through the native layer (ADR-0174):

:common ← :natives ← :core ← :widgets
   ↖________________________/

The call layer

Done. Every C function the toolkit binds is a holder: a small final class holding that function’s address, with its unbound MethodHandle as a private static final FD_<symbol> and a call that takes ordinary Java types. The holders of one library are grouped in a …Calls record, which is what a binding class keeps instead of forty MemorySegment fields (ADR-0173).

// before -- three things that are one thing
private final MemorySegment contextEnd;
this.contextEnd = Downcalls.symbol(lookup, "bl_context_end");
check("bl_context_end", (int) Downcalls.INT__PTR.invokeExact(contextEnd, context));

// after
check("bl_context_end", calls.contextEnd().call(context));
  • The binding classes lost a quarter to a half of their lines — Yoga 658 → 409, Blend2D 821 → 561, SdlVideo 837 → 653, HarfBuzz 393 → 249, Sdl 296 → 198 — and all of it was plumbing. Thirty-six per-shape invocation helpers are gone with it.
  • A record is one subject, not one library. Blend2DCalls was forty-six functions; it is now ImageCalls, ContextCalls, PathCalls, FontCalls and RuntimeCalls, and the 821-line Blend2D binding split the same way into Blend2dImage, Blend2dContext, Blend2dPath, Blend2dFont and Blend2dRuntime — 78 to 248 lines each, one per wrapper. Yoga, HarfBuzz and SDL’s records are split the same way; their binding classes hold several.
  • Every call names its parameters and says what they are. call(a1, a2) is now call(context, rect, argb), with a summary, the C prototype, and a @param for each — 134 functions’ worth, recovered from the call sites that already named them and then written out.
  • A failure names the function it was. Blend2D’s four shared invoke helpers reported "a Blend2D call" for any of the eighteen symbols that went through them, because a shared helper had no way to know which.
  • ADR-0161’s rule is kept more strictly, not relaxed. A holder’s handle is static final and is read inside the method that invokes it; and because there is now one handle per function rather than one per shape, no call site reaches a constant through a parameter at all. That was the compromise the per-shape helpers forced, and it is gone.
  • The holders live in packages that contain nothing else, and those packages are what --initialize-at-build-time names. Measured, because the alternative fails silently: a handle static final on a nested class whose enclosing class is named in the flag runs at 4538 ns/call against 8. The image builds, runs and paints correctly at a fortieth of the speed.
  • Verified end to end. A native image built from the packaged goldberry-natives jar — the shipped native-image.properties, nothing added — calls through a holder at 9.84 ns/call, against 10 ns on the JVM.
  • HolderShapeTest replaces DowncallsTest. The old one could only check that a name matched its layouts, because nothing tied either to a call site. A holder states its signature twice — once in layouts, once in Java types, in one class — so the check is now that the two agree. It walks the compiled classes rather than listing them.

The cost is about 3200 lines of holder code, uniform and uninteresting, and a new symbol now needs a holder class rather than a field and a lookup.

Package layout

Done. :widgets had been split by group and then by control (ADR-0091, ADR-0065); :core and :natives had not, and four packages carried a third of the toolkit — …goldberry.css at 23 types, …goldberry.backend at 21, …natives.yoga at 22, …natives.blend2d at 20. A package that size is a folder, not a boundary.

Every package is now named for the part its contents play (ADR-0172), and docs/ARCHITECTURE.md §2.1 is the map.

ModulePackages beforeAfterLargest package
:core153510
:natives71512
:widgets383911
  • The CSS engine is a compiler, so it reads like one — css.parse, css.select, css.cascade, css.value, with css itself holding the sheet an application loads and the ComputedStyle it produces.
  • Input is split by the part it plays — what arrives (input.event), the vocabulary an accelerator is written in (input.key), the snapshot it is routed against (input.hit), and the interfaces a widget implements to hear any of it (input.handler).
  • A native library is split where the foreign memory stops. The wrappers that hold a handle stay beside the binding class they are the only callers of; the enums, which map a C constant to a Java name and touch nothing, get packages of their own. MeasureCallback, MeasureProbe, SdlWindowHandle, SdlEventBuffer and SdlEventWatch were each moved out and moved back the moment they turned out to traffic in MemorySegment.
  • Eleven members became public, each with a doc comment saying why. The one worth watching is Frame.end(): it used to be unreachable from outside its package and is now merely wrong to call, so the frame enforces its own lifetime instead — ending twice is a no-op, painting afterwards throws.
  • Two splits were tried and reverted. WidgetRenderer reads and writes Element’s package-private style cache, so it is the element tree’s own paint pass rather than a neighbouring role. And the root …goldberry package keeps its ten types because Launcher and GoldberryRuntime make twenty-one calls into Window’s package-private event intake — a toolkit whose Window offers an application a handlePointerMoved has published its event loop by accident.

Two things now hold this in place that are tests rather than prose, and both were checked against a deliberate break:

  • ExportedSurfaceTest (:natives) reads the module’s own descriptor and its own class files and fails if any member reachable from outside mentions a MemorySegment. It discovers its subject rather than listing it, so a package added next month is checked next month. This is docs/ARCHITECTURE.md §3.1 becoming a check instead of a claim.
  • WrittenNamesTest (:weaver) resolves every class name the weavers write into bytecode as text. Splitting bind turned ModelWeaver’s one package prefix into three, and nothing in the compiler would have caught getting that wrong: the weave would succeed and a woven native image would fail to start much later with a NoClassDefFoundError naming a package that no longer exists.

The moves were made by tools/refactor/move_package.py, which is kept in the tree. A package move is four edits, and the fourth is the one nobody does by hand: the file left behind that used a type without an import, because it used to share a package with it.

A month and six modules later, the same rule was applied again. The table above is ADR-0172’s split. An audit of all 215 main packages there were on 2026-09-30 made eleven more moves (ADR-0496) — among them gpu.render into gpu.composite for the compositor, the SDL_GPU enumerations into sdl.gpu.enums, media.picture and media.view.gpu out of :media, paint.stroke, render.clipboard and widgets.data.plot — and recorded the eleven it looked at and did not make. The QR encoder moved from a top-level …goldberry.qr to …goldberry.image.qr, beside the other formats :core owns (ADR-0494), and :media-platform became :media’s …media.platform with its packages unchanged (ADR-0493). Every package now says what it is: a package-info.java with a doc comment in every package that has a class, @NullMarked in every module NullAway checks, and PackageInfoTest in build-logic holding both (ADR-0497).

Native artifacts

Every artifact is built on a native runner (ADR-0012); there is no cross-compilation toolchain. Four runners produce four artifacts, one each, with no cross-targeting anywhere (ADR-0041).

TargetBuilt onOutput
linux-x64ubuntu-24.04 + manylinux_2_28_x86_64libgoldberry.so
linux-aarch64ubuntu-24.04-arm + manylinux_2_28_aarch64libgoldberry.so
windows-x64windows-2022, MSVC -A x64goldberry.dll
macos-aarch64macos-14libgoldberry.dylib

Windows on ARM and macOS on Intel are not built. NativePlatform refuses those two pairs at construction, so the failure names the decision rather than a missing resource.

The manylinux container is not incidental: it pins the glibc floor at 2.28, so the Linux artifacts run on anything from RHEL 8 onward. Building on a stock ubuntu-24.04 would link against glibc 2.39 and refuse to load on RHEL 8/9, Debian 12, or Ubuntu 22.04. A locally built library is therefore not the published artifact — it links against the developer’s own glibc.

Building on native runners produces artifacts, not test coverage, so CI also runs the Java tests against the real library on each platform.

What a build can ask the desktop

A native library is not only a set of functions; on Linux it is also a record of which development headers were installed on the machine that compiled it. SDL compiles its D-Bus, IBus and udev integrations in when it finds the headers and out, silently, when it does not — and the calls that back them then succeed and answer nothing. Host.systemTheme() returning empty on a desktop set to dark is what that looks like from above, and it looked exactly like a desktop with no such setting (docs/gaps.md G32, ADR-0325).

Three things changed, and they are three layers of the same answer.

The headers are declared. LinuxDependencies — the table checkToolchain reads a second before a build starts — lists dbus-1 as NEEDED rather than OPTIONAL, and has a row for ibus-1.0. Each of the three desktop-integration rows names the capabilities a library loses without it:

pkg-configDebian/UbuntuRHEL/FedoraWhat a library loses
dbus-1libdbus-1-devdbus-develSYSTEM_THEME, FILE_DIALOG, SCREENSAVER_INHIBIT
ibus-1.0libibus-1.0-devibus-develINPUT_METHOD (X11 only; Wayland needs none)
libudevlibudev-devsystemd-develDEVICE_HOTPLUG

The superbuild stops. It probes for the same modules SDL probes for, names the package on both package managers when one is missing, and cross-checks its own prediction against the SDL_build_config.h SDL generated — so “the headers are here but SDL compiled it out anyway” is a build failure rather than a library that claims what it cannot do. -Pgoldberry.allowDegradedPlatform=true (CMake: -DGOLDBERRY_REQUIRE_PLATFORM_INTEGRATION=OFF) builds one on purpose.

The library says what it is.

Set<Capability> capabilities = Goldberry.capabilities();

The bits are compiled into libgoldberry, read back through one downcall, and checked against C by the same layout probe every other constant goes through. They describe the library, not the session it runs in: a build that can ask reports SYSTEM_THEME even where the desktop has no such setting, because “could not ask” and “asked and was told nothing” are different facts and only the first one is fixable. The sdl3 backend warns once at start-up when a capability it ships an API for is absent, naming the package to install.

TODO

What is deferred, known-broken, or specified and unbuilt. What is built is in status.md.

These are tracked in the decision log and need answers before the milestones they block can be scheduled honestly. Every entry says what the gap is, why it is one, and — where it is known — what it would take, with the decision record that argued it. An entry leaves the top half when it is answered and moves to Answered rather than being deleted, because each of those records a trap somebody hit and the reasoning that got out of it.

Where the documents disagree with each other — as opposed to with the code — is listed in docs/ARCHITECTURE.md §17.1. docs/design-system.md and docs/core-widgets.md are the authority; the architecture document is a summary of them and records where it knowingly departs. Five of the seven were taken on 2026-09-17 and each went the way the section says it should: the platform primary modifier is a modifier you can name (ADR-0378), a disabled container reaches the cascade (ADR-0379), text style= is built (ADR-0381), the tooltip row’s radius and rank are what ships (ADR-0380), and “one module or two” had been settled in core-widgets.md itself a month before anyone noticed. What is left is where the emoji font lives and who owes its attribution, whether goldberry-charts is an artifact, what “zero new natives” costs (see Content modules), and — recorded in §17.1 and never counted here — dual y-axes, the word checkable doing two jobs, and masonry’s missing row. Pixel-precise wheel deltas left this list and have now left the list below it too: ADR-0115 settled it as a difference rather than an agreement — what §2.4 wanted from “pixel-precise” is scrolling that does not quantize, and a fractional line delivers that without the mechanism the sentence named — while the entry itself sat on under Input, focus and the pointer for two milestones. That section is gone: its other entry was a cost nobody had measured, and measuring it was the answer.

Overlays, popups and windows

  • A popup may not give the platform’s keyboard focus back, and the widget layer has nothing to do with it — the second half of this entry was wrong and has been measured. A popup gets its own tree and its own router, and nothing in the open or close path touches the owner’s: a probe through the real launcher, with a widget logging every focus change, saw a menu open and close over a focused control without that control losing focus once. So there is nothing to remember and nothing to restore at the router level. What is left is the platform’s own window focus — SDL gives a POPUP_MENU window focus on some drivers and not on others — which the headless backend cannot show and which no test here can currently reach. — ADR-0180, ADR-0104
  • A popup’s contents inherit nothing from the widget that opened them, and the answer this entry proposed is wrong for the widget it named. They are the root of a second element tree, so no color, no font-size and no descendant selector reaches in. Right for a menu, whose items are a list rather than part of a button’s subtree. This used to add “a limitation for tooltip, which wants the styling of the thing it describes, and the answer there is to pass the anchor’s resolved style in” — and tooltip pins its typography for a stated reason, §1.4’s caption rank being the one departure in its row that somebody had thought about (ADR-0263). Inheriting the anchor’s font-size would draw a tooltip on a display-ranked heading at 28px. So the subject stands and the example does not: what is left is a popover or a menu whose application wanted a descendant selector to reach in, which nothing has asked for. — ADR-0263, ADR-0103
  • 60 ms is how long a focus-lost is disbelieved for, and it can now be told otherwise. Long enough to cover the focus-lost/focus-gained pair that opening a popup produces, short enough that nobody sees a menu over another application. It is still one default covering every driver and it cannot be derived — the gap between the two events is the compositor’s own scheduling and nothing reports what it will be — so what changed is that -Dgoldberry.popup.settle=250 overrides it without a rebuild, clamped to 1–2000 ms because a delay of zero acts on the first of the pair every driver sends and is the exact bug the delay exists for. A driver that needs the flag will still look like a menu that closes as it opens until somebody sets it. — ADR-0144

The catalog: specified and unbuilt

text-input, and what §4 still owes

  • A field’s scroll offset uses the previous frame’s width. ADR-0116 already decided that is what a viewport does, and it is wrong for one frame after a resize — invisible, because a resize is followed immediately by another frame. Worth writing down because it is the second widget to need the measurement render cannot have, and a third would be an argument for handing the width to render rather than to Measured. — ADR-0167

  • Nothing re-places the caret when the font changes under it. A restyle that changes font-size reshapes the paragraph and the caret follows, because both are computed in the same render. A density change does the same. Neither is broken; what is untested is a font-family fallback swapping mid-edit, which no test can currently provoke. — ADR-0167

  • isModal has one consumer, which is one fewer than a mechanism should have. sheet is the plausible second, and it is not built. A wizard is not: §6 makes its content a focus-scope, and a wizard written inline that trapped the keyboard and the pointer would lock the window around it. It is tested in :core against bare widgets rather than through dialog, so the second one finds a mechanism rather than a dialog-shaped hole. — ADR-0176, ADR-0356

    Read again on 2026-09-30, and it stands. DialogPanel is still the only widget that answers isModal. What is new is a reader: web-view asks Host.isModal and parks its page while a modal is up (ADR-0444), which is a consumer of the answer rather than a second thing that traps. (An “on hold” note for the accessibility bridge sat here by mistake; this entry never waited on it.)

  • There is no third text rank, and one was invented and taken back out. A tour’s step counter wanted something quieter than --gb-text-muted; --gb-text-subtle was added, resolved to nord3, and produced a counter nobody could read on nord1 — §1.2’s contrast floor applies to metadata as much as to prose, and the Nord palette has nothing between muted and the border colour. The size carries the demotion instead. A real third rank would need a colour the palette does not contain. — ADR-0121

  • A scrollbar’s thumb stops being proportional on a very long document. It is floored at 24px, so past about four screens the thumb no longer says how much is visible — only that there is a lot. The trade every scrollbar makes, named here because it is a place the widget knowingly stops telling the truth. — ADR-0117

  • Measured has several consumers whose reason is a sibling’s geometry, which is new: a toast stack banks how tall each toast came out so that it can move the survivors by the height of the hole when one goes (ADR-0178). Every other consumer reads its own box. It obeys the third rule by construction for the same reason the scrollbar does — a reflow is a transform, so the box it moves is laid out where it always was.

  • A virtual list can have its focused row scrolled out of existence. Wheel far from the focus ring and the focused row leaves the window, is unmounted, and the router drops it — which is ADR-0180’s rule doing exactly what it should to an element that has left the tree. Arrow keys are unaffected, because the ring asks the viewport to follow it; only pointer-scrolling away and then pressing one loses the place. Every recycling list has this unless it pins the focused index, and pinning it would keep a row nobody is looking at built for ever. — ADR-0213

  • A table focuses rows and not cells. Right for §10’s grid semantics and wrong for a spreadsheet, which is a different widget rather than an option on this one. Horizontal virtualization is absent for the same reason: it is a different arithmetic, and it is worth it past about fifty columns, which is past where a table is the right thing to be looking at. — ADR-0214

  • A segmented control fills its parent when nothing gives it a width, which is new and is a real loss of convenience: in a toolbar beside other widgets it takes the whole row until an author writes width. It buys the travelling indicator, and there is no third option under flexbox — content-sized cells cannot be travelled between, and a zero basis collapses the bar entirely.

The shell: the tray, and what it cannot say

§9’s tray-icon ships (ADR-0191). What follows is what it does not do, and in three cases what no platform lets it do — recorded here rather than left to be rediscovered by an author whose description had no effect.

  • §9’s “activate event” is not built, and SDL has no callback for it. The sentence asks for icon, tooltip, menu and an activate event; SDL3’s tray API registers a callback per entry and none for the icon itself. So a click on the icon opens the menu and nothing else can be attached to it. Doing this properly means going around SDL to three platform APIs, which is the move ADR-0056 declined when the wheel wanted it; the workable answer is a first row that means “open the window”, which is what most tray applications ship anyway.
  • A checkbox’s tick can end up disagreeing with the application. The shell toggles it before the handler runs and an Item’s command takes no argument, so a handler that declines leaves the platform showing a tick nobody believes in. SDL_SetTrayEntryChecked is deliberately unbound — correcting one row would be the only mutation in an otherwise rebuilt-from-a-description menu — so the way to say no is to close the tray and show it again.
  • The menu cannot change while the icon is up. BackendTray sets the icon and the tooltip and nothing else: its rows are platform objects the shell may have open, and replacing one would mean re-inserting entries underneath a user. A declarative caller closes and reopens, which is correct and is also a flicker in the notification area on some shells.
  • An accelerator on a tray row is dropped, with a warning. A shortcut is bound to a window and a tray has none. The same Item in a menubar still registers one, which makes this the first place in the catalog where one value means two different things depending on who draws it.
  • The deprecation warning on Linux is SDL’s to fix. Loading a tray prints libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib, from the distribution’s library as SDL opens it. SDL’s appindicator_names list holds libayatana-appindicator3.so.1 and libappindicator3.so.1 and not the successor, so the only ways out are a patched SDL or a newer pinned one — neither worth doing for a line on stderr that no user of an application ever sees.
  • Windows and macOS are unverified. The Linux path ran for real — in SdlTrayTest against this machine’s session and in the showcase — and the other two are SDL’s code, compiled and never looked at. A tray is the one widget CI cannot cover: there is no notification area on a runner and no golden image of a GTK popup.
  • A tray callback arriving off the UI thread is logged, not handled. Every platform dispatches it from inside the pump the UI thread is already in, so the warning in SdlTray.invoke is the only evidence there would be if one ever does not. Doing better means posting to the loop, which is a second delivery path for one hypothetical.

canvas, and what a document cannot say

  • Markup cannot name a painter. A canvas node inflates to a styled, sized surface that draws nothing; the drawing is Java. icon solved the same problem with a registry the application owns (ADR-0043) and action with another, so the shape is known — what is not known is whether a painter is a value a registry holds or a method on a model, which is the same question @Action answered for commands and would have to answer again here. Nothing has needed it: every consumer so far is written in Java — the chart widgets, web-view’s placeholder and the showcase’s tile floor.
  • A canvas has no intrinsic size, so one in a row with nothing else to size it is zero wide and silently invisible. A measure function that guessed would be a number the toolkit invented and the application drew into; a diagnostic when a canvas is laid out to nothing would be noise in the collapsed-split-pane case, which is legitimate. Left as a documented sharp edge.
  • A painter is called on every paint of its box, not only when it says something changed. That is what immediate-mode means and it is right for a chart whose data changed; it is wasteful for a canvas whose drawing is static and expensive. The seam for fixing it exists — a canvas that wants caching is a repaint boundary with a Layer (ADR-0071) — and nothing has measured a case that needs it.

Images, and what the primitive is not

  • Image.decode is still synchronous. A large JPEG is tens of milliseconds, and a canvas painter that decodes pays it on the UI thread. The image widget does not: it decodes on a virtual thread through ImageLoader (ADR-0358), and that is the seam a painter should use too. Nothing has measured a painter that needs it.

Editing text

  • No bidi caret. Paragraph.isBidiApproximate already says the shaping does not promise visual order for mixed-direction text, and a caret in it needs a walk the toolkit does not have. Latin, Cyrillic and CJK are exact; Arabic and Hebrew are approximate in the same way the paragraph is.
  • An Editor does not scroll. It draws where it is told and does not know it has been clipped. A canvas with a long document moves its own transform, which it is already doing for everything else on it; a widget wanting this is text-area, which has a viewport.

The clipboard

  • Nothing watches it. There is no “the clipboard changed” notification, so a paste button cannot grey itself out until its menu opens and asks (ADR-0286). X11 and Wayland both deliver ownership changes and Windows has a viewer chain; what is missing is a consumer worth the plumbing.

Content modules

docs/content-widgets.md’s table has thirteen rows, and four of them are artifacts now: :html, whole, with no engine under either half; :emoji; :gpu, built in part; and :media, in progress. goldberry-charts merged into :widgets and goldberry-web became a widget rather than a module. None of the other seven is scheduled while M3 still owes client-side decorations and the rest of §4. The shape they share is ADR-0190 and the summary is docs/ARCHITECTURE.md §11.1. What follows is what each is actually waiting on, which in four cases is the same thing.

  • Both halves of goldberry-html are built, and neither has an engine under it. :html ships Markdown.parse, MarkdownHtml, markdown-view, Html.parse and html-view (ADR-0294, ADR-0295, ADR-0298), and what they do not do is a short list that mostly has one cause — there is no inline layout engine under either, because that is what litehtml would be:

    • Neither can lay out a line of mixed faces as one shaped run, so there is no justification and no hyphenation. This is the item that is litehtml’s, and it is now the whole of what an engine would buy: links, images, task boxes (ADR-0300) and text selection (ADR-0301) all used to be on this list and none of them needed one.

    • Dragging a selection past the edge of a viewport does not scroll on. Both views select, copy and highlight (ADR-0301); what a drag to the bottom of a pane does is stop selecting rather than carry the viewport with it. The same want a text-area has, and neither has it — an auto-scroll is a timer plus a clamp, and the interesting part is deciding what it does on a touchpad’s fractional deltas.

      Closed by ADR-0500: a drag held past the edge carries the viewport on at a speed set by how far past it the pointer is, through EdgeScroll, which text-area shares. There were two faults rather than one — the selection also froze at the edge, because every word is clipped to the viewport and a pointer below it was over none. The touchpad answer is that the speed comes from the pointer’s distance and never from the wheel; a wheel mid-drag scrolls as usual, and the fractional steps are applied unrounded.

    • Emphasis is a faux oblique — transform: skewX(-10deg) — because §6.1 ships two upright faces. A third face is an asset decision rather than a code one, and :assets is where it would be made.

    • A hard break inside a paragraph does nothing. A wrapping row has no widget meaning “start a new line here”, and a spacer with flex-grow — the obvious trick — makes the line before it look justified. Closed by ADR-0426, below.

    • A table’s cells have no rules between them, and a fence does not scroll sideways. Both are the CSS subset: border is uniform, so there is no border-left, and horizontal scroll is not in §10 either. The rules are closed by ADR-0505: a border has four sides now, a row draws the rule above it and a cell the rule before it. Two things the entry could not have said: a border has never taken layout room here — it is drawn over the padding, and every bordered widget counts on that — and the first render’s vertical rules stepped at every row, because each row sized its columns by its own content, so the cells are flex-basis: 0 now. The quotation bar became the border-left it always meant, with no pixel moved. A fence still does not scroll sideways.

    • src="…" is not a thing on either view. Reading a file from markup means deciding what a relative path is relative to and what a missing one does — three answers Icons and the stylesheets each needed a resolver for. An application reads the file and passes the text.

    • <style> and style= are kept in the HTML model and applied by nothing, and neither is a <script> run. The cascade an html-view is under is the application’s stylesheets, which is what makes a page follow the theme; an author’s own colours would fight it (ADR-0298).

    • A keystroke re-parses and rebuilds the whole preview (ADR-0296). Right for a note in a pane, and the widget count is what bites first on anything longer — ADR-0295 put it at roughly one per word. An incremental parse is md4c’s to offer and it does not; a rebuild bounded by what the viewport shows is the list virtualization argument applied to a document, and nothing needs it yet.

    Closed — ADR-0426. A hard break is now its own Words.Piece, and a paragraph with one becomes a column of line rows; with none it builds exactly the single row it built before, which is why no :html golden moved. The CSS split is the decision: .md-prose and .html-prose are declaration-less paragraph hooks now, the row geometry moved to .md-line / .html-line, and the column carries the same 0.25em gap so a typed break and a width break sit at the same leading. A break inside a link deliberately does not split — one button.link is one Tab stop and one hover — so it becomes a space in the label. Three things the entry could not have said: a trailing hard break is unwritable in Markdown (md4c strips the two spaces), two in a row come from a lone backslash and do survive as an empty line, and a table cell provably cannot hold one. gallery-markdown moved, because the showcase’s own sample says “Two spaces at the end of a line / are a hard break” and now demonstrates it.

  • A code editor is goldberry-code, and that module does not exist. The Markdown screen’s editor is a text-area in the code face: a caret, a selection, undo, the clipboard and an input method. It has line numbers now (gutter=#true, ADR-0331) and a seam an application can write shortcuts against (onEdit/edit, ADR-0332), so Ctrl+B, list continuation and Tab-indent are an application’s to write rather than impossible. What is still missing here is highlighting — Tree-sitter is what that waits on, and the fence’s language already reaches the model for it to read (CodeBlock.language()).

  • The export list has no paint surface wide enough for a native document_container. goldberry-html puts litehtml’s C++ container inside its own native library because FFM cannot implement a virtual class, and that container draws through libgoldberry’s exported C symbols. There are twenty bl_context_* entries and they are the ones the toolkit’s own painter needs: no gradient, no rounded geometry, and no bl_context_save — the symbol file says why in its own comment, that there is only ever one clip depth here. content-widgets.md §1.5 promises linear and radial gradients and border-radius, and CSS state nests. So the first commit of an engine-backed goldberry-html is a widening of the toolkit’s own native surface, reviewable on its own, and it is shared work: goldberry-vector and goldberry-terminal want the same surface. Nothing on screen is waiting on this any more (ADR-0298): html-view renders through the widget tree, and what an engine would add is the inline layout and the text selection above. When it lands it is a second renderer over the same model rather than a replacement for one. Statically linking a second Blend2D into the module is the way out that does not work — two runtimes in one process, and a BLContext handed across them is undefined behaviour. — ADR-0190, ADR-0007

    Narrower than it reads — found by the 2026-09-30 sweep. Two of the three gaps were closed for the toolkit’s own reasons and the entry was not told: gradients are exported (bl_gradient_* and bl_context_set_fill_style, ADR-0207), and so are bl_context_save and restore, whose comment in the symbol file now says a second clip depth is needed (ADR-0193). There are 25 bl_context_* entries rather than twenty. Rounded geometry is cubic paths plus bl_path_elliptic_arc_to, with no round-rectangle primitive. Whether what is exported now is enough for document_container has not been checked against its virtuals, and that check is the first commit of an engine-backed module. Text selection is not something an engine would add any more: ADR-0301 built it.

  • No SDL audio or camera symbol is exported, so goldberry-camera, goldberry-mic and the core Sound API that content-widgets.md §8 hands to SDL audio for UI effect sounds all begin at the same file. This is no longer a guess about what that costs: tray-icon began there too and paid eleven symbols, two binding classes and five probe constants for it (ADR-0191). The modules’ “zero new natives” claim is true of the binary and not of the surface.

    Half of it moved, for :media — corrected 2026-09-30. Audio output is exported now: nine SDL_* audio-stream symbols, which :media’s AudioSink writes through (ADR-0462), with SDL’s ALSA and PulseAudio drivers required on Linux (ADR-0488). Of the 149 SDL_* entries on the list, 56 are SDL_GPU and 9 are audio. Still missing: every camera and recording symbol, and the core Sound API, so goldberry-camera and goldberry-mic begin at the same file as before.

  • The backend SPI has no PTY. goldberry-terminal needs Optional<Pty> openPty(cmd, env, size) — forkpty/openpty on Linux and macOS, ConPTY on Windows — which is the same optional-capability shape as gpuSurface() and is the real platform work in that module. libvterm itself is a state machine and a cell grid, which is the part the text stack is already good at.

  • goldberry-pdf is the only module that vendors a prebuilt. PDFium’s own build wants gn/depot_tools, so :natives-pdf consumes pinned, checksum-verified community binaries — which is a different supply-chain posture from every other native in the toolkit, where the superbuild compiles from a pinned commit (ADR-0030). Worth an ADR of its own before the first jar.

  • goldberry-code has a consumer before it has a widget, and the consumer now exists. md4c’s code fences want a highlighter; markdown-view renders a fence as plain monospace lines with the language shown above them, which is the “renders fences plain” branch — and the language is already in the model (CodeBlock.language()), so the seam is a real one rather than a plan. So goldberry-html either depends on goldberry-code optionally or keeps rendering them plain. The optional-dependency mechanic — a module that improves when another is on the module path — exists now, as JPMS services: :core uses an EmojiFont that :emoji provides (ADR-0384) and a Compositor that :gpu provides (ADR-0479), and video-view draws through a GPU layer when :gpu is present (ADR-0484). So goldberry-html → goldberry-code and goldberry-vector → image/svg+xml are two more services of a known shape, and what is missing is the module.

  • goldberry-plot’s colormaps are data with a provenance. viridis-class tables are public domain, which is a claim the licence tooling has never had to check for something that is neither a font nor a library. ./gradlew checkLicenses knows about artifacts.

Style, colour and motion

  • Nothing in the catalog wears an elevation yet. Five surfaces do (ADR-0312): card at §1.5’s level 1 and lifting to level 2 on card.interactive:hover, dialog, tour-card and toast at level 2, and affix:affixed at level 1 with the transition: box-shadow §1.7’s motion table has asked for since before there was a shadow. The edges all stay, for ADR-0166’s reason: a shadow cast onto another card falls on that card’s own colour and says almost nothing, where the rim says it exactly.

    popover, menu and tooltip are the ones still without, and no longer because the subset lacks the property. They are drawn in popup windows created at the panel’s own measured size (ADR-0104), so a shadow — which is drawn outside the box that casts it — would fall entirely outside the window and be clipped: a run of fills that draws nothing. What it needs is a popup sized to the panel plus the shadow’s reach with the extra transparent, which wants the same transparent-popup compositor support the rounded corners are waiting on. --gb-elevation-3 is unused and stays so: it is the level for a thing the pointer is dragging, and nothing here is dragged. — ADR-0312, ADR-0166

  • A popup’s transparent corners need a compositor, and are unverified on Windows and macOS. Without one the flag is ignored and the corners are whatever the platform leaves there. The fallback that always works — filling the frame with the panel’s own colour, for square corners — is kept in reserve. — ADR-0111

Rendering and performance

  • Opening a long note still shapes all of it, on the frame that opens it. A keystroke into a 500 kB text-area costs what a keystroke into a 2 kB one costs now, because the text is shaped one hard line at a time and only the rows on screen are drawn. The first frame is not: 499 079 characters, 78 to 95 ms across runs on this machine, and again whenever the cascade resolves a different face. It is irreducible in the shape the control has — how far the content scrolls is a fact about every line, and nothing knows a line’s height without shaping it — so closing it means shaping the lines below the fold off the frame and filling in the scroll range as they arrive, which is a different control and a different promise about what the scrollbar means. Nobody has reported it: the downstream editor’s complaint was the keystroke, and TextAreaFrameBenchmark is where the number would have to come from first (ADR-0045). — ADR-0388

  • The scale-invariance thresholds are calibrated on one CPU. The worst honest disagreement measured over the corpus is 0.332% of pixels against a 1.2% limit, and Blend2D JITs its antialiasing for the CPU it finds (ADR-0030) — so the margin on AVX-512, on Apple Silicon and under MSVC is answered by the next CI run rather than by argument, exactly as the goldens’ own tolerance is. -Dgoldberry.golden.scales.report=true prints what every check measured, which is how a runner pressing against the limit would say so in numbers. — ADR-0162

    Partly answered by CI, as it said it would be. The golden suites run on windows-x64 under MSVC and on macos-aarch64’s NEON path (windows.yml, macos.yml), and both are green. What is still unmeasured is AVX-512, and the thresholds themselves were still set on one machine.

  • The rounded corners and the transforms have not been rasterized on AVX-512. Blend2D JITs its pipelines per CPU. The goldens now run on Apple Silicon’s NEON path and under MSVC in CI, and they pass there; the four cubics and the eleventh golden’s rotations and skews on AVX-512 are still answered by a future run rather than by argument — which is what the golden images’ per-channel and area tolerance is for. The transform half also rests on BLMatrix2D being six consecutive doubles in the order matrix(a, b, c, d, e, f) writes them, which the layout probe now checks against the compiled library on every target because the operand crosses as void* and a reordered union would produce a skewed frame and BL_SUCCESS. — ADR-0064, ADR-0068, ADR-0050

  • The units between the two text libraries are a convention, not a checked fact. HarfBuzz reports positions in whatever scale its font was set to; Blend2D multiplies them by size / units-per-em. Both are right, and applying a size on both sides applies it twice — 128× for Inter at 16 points — which draws text off the edge of the window and returns BL_SUCCESS. The layout table cannot catch this: it is an agreement between two libraries, not a fact about either. What holds it is Font owning both objects and never scaling the shaper, plus a test that compares the inked span against the measured width. Anything that builds a ShapedFont and a BlendFont by hand can still get it wrong. — ADR-0034

  • A line boundary keeps a kern it should drop. Each line is a slice of the whole paragraph’s single shaping, so the kern between the last character of one line and the first of the next is included where a per-line shaping would drop it. A fraction of a pixel at the end of a line, in exchange for wrapping that costs no shaping at all. Re-shaping only the final lines, and only for painting, is the fix if it ever shows. — ADR-0036

  • Element.update invalidates a subtree only when the cascade could see the change. ADR-0149 narrowed the state path and ADR-0315 narrowed this one: a rebuilt widget throws away what is below it when matchesDiffer says its identity to the cascade moved — its type, its classes or its id — and invalidates its own style alone when it did not. What is left is the case where the identity did move, which still costs the subtree and which nothing has measured as a problem. — ADR-0149, ADR-0315

  • A HUD costs about three shaped paragraphs a frame, and reports the cost as its own. Its readings are strings that change every frame, so no cache keyed on the string can hold them. The caption says so rather than hiding it, and the ways out are all worse than the disclosure: refreshing the text at 10 Hz would need per-frame state a widget cannot have, and excluding the overlay subtree from the timings would report a frame the window did not paint. — ADR-0152

  • libgoldberry-webview is opened at start-up, and WebKitGTK with it. The backend asks it whether Capability.WEB_VIEW holds, and opening it maps WebKitGTK and GTK 3: 26 ms of a 520 ms native start, before any page is asked for. docs/content-widgets.md §11 calls the library “opened on demand”; the capability question is the demand. Answering it without the library — a build fact, like ADR-0422’s — or on first use would take it off every start. — ADR-0506, ADR-0441

Platform, compositor and CI

  • The 60 fps claim is measured on one machine, and closing M1 is a CI job. §16’s M1 asks for a styled wrapped paragraph resized at 60 fps on Linux, macOS and Windows. The budget half is met with 3.9× of headroom (ADR-0047); the breadth half is one VirtualBox VM. Scheduled at M5 — see status.md for the shape of it. The three things that were missing are all built (ADR-0342): Window.resize and --resize=WxH walk a window’s size from outside, FrameSummary prints what a run cost at exit, and showcase.yml paints 300 frames while resizing on each runner. It asserts no budget, on any platform (ADR-0452): Xvfb reports no refresh rate, so “late” on a runner counts missed ticks of a software timer. The first run was made by hand and all three legs report what they cost — linux-x64 302 frames and 75 late, macos-aarch64 300 and 200 — which are the first numbers any leg has produced, and two samples locate a ceiling no better than none. What is missing now is a budget somebody measured, on a display somebody chose, and there is still no tag. The caveat travels with the numbers: GitHub’s runners are GPU-less VMs, so what this can prove is that three platforms’ drivers hold the budget, not that hardware does. — ADR-0045, ADR-0147

  • A like-for-like Wayland frame measurement is still owed. ADR-0037’s numbers — paint 5.10 ms, present 1.92 ms, 7.86 ms median — were taken on X11, after the Wayland run crashed the compositor, and they are compared against ADR-0031’s Wayland ones. Nothing in that work made present faster; the driver changed. The two rows should not be read against each other until the same frame has been measured twice on the same session type. — ADR-0037, ADR-0031

  • The Wayland preference is evidence from one compositor. SDL chooses X11 on a Wayland session unless the compositor advertises wp_fifo_manager_v1, which GNOME’s Mutter does not; Goldberry asks for wayland,x11 instead, because XWayland resizes visibly worse. Confirmed on GNOME only — KDE, Sway and the rest are untried, and the driver is logged at start-up so a report can say which one it got. — ADR-0027

    The preference itself has since been reversed (ADR-0086): on a Wayland session Goldberry asks for x11,wayland, unconditionally, because under XWayland the window manager decorates the window. So the one-compositor evidence is now evidence for a path taken only with -Dgoldberry.backend.videoDriver=wayland or where there is no XWayland.

  • The macOS window opens, and the CI leg still would not have caught it. gradlew run failed with “No available video device”, which points at the superbuild and was not the superbuild: macOS drives AppKit from the process’s first thread and the java launcher does not put main there. The showcase passes -XstartOnFirstThread on macOS and Sdl3Backend appends the explanation after SDL says no — as a diagnosis rather than a precondition, since a JVM embedded on the real main thread would lack the launcher’s environment variable and would work anyway. The hole that hid it is half closed: macos.yml still links the library and runs the tests without ever opening a window, but showcase.yml runs the packaged image on macos-14 and asserts it painted three frames — so a repeat of this failure would now turn a tick red. What that leg cannot catch is anything about gradlew run, which is the path this bug was found on and the one an application author uses. — ADR-0039

  • The compositor still dies, and shutting down cleanly did not stop it — the core dump says whose bug it is. The entry below concluded that exiting with a live Wayland surface was the trigger and that Goldberry.shutdown() was the fix. The showcase has called shutdown() ever since, and GNOME Shell crashed twice more on 2026-08-17. /var/crash had the core, and it names the frame: text wl_event_loop_dispatch libwayland-server → wl_client_destroy libwayland-server → <destroy listener> libmutter-14 → g_signal_handler_disconnect libgobject → g_type_check_instance ← SIGSEGV Mutter, tearing down a departing client, disconnects a signal handler on a GObject that g_type_check_instance rejects — an instance already finalized. That is unambiguously a compositor bug: wl_client_destroy runs whenever any client goes away, for any reason, and surviving it is the one thing a compositor cannot be excused from. Our own process exits 0 with no JVM crash log, having destroyed its window and called SDL_Quit first. The nearest exported symbol below the faulting frame is meta_xwayland_signal, 2.2 KB back, so the crashing function is a static one in Mutter’s Xwayland area — suggestive, not conclusive, and not enough to file upstream on its own. What is left for this repository is not a fix but a defence: nothing should be able to open a real surface by accident. See the entry below on the two unreliable ways to ask for a headless run. Reproducing this deliberately costs the developer their session, so it is not something to iterate on casually. gnome-shell 46.0-0ubuntu6~24.04.14, Ubuntu 24.04, under VirtualBox/vmwgfx.

  • The toolkit never shut SDL down, and a compositor died of it. Sdl3Backend.close() destroys every window and calls SDL_Quit; nothing called it. Goldberry.run() returning does not shut the runtime down — its contract says so — and Goldberry.stop() ends the loop with the window still open, so the showcase exited with a live Wayland surface and let the socket close. GNOME 46’s Mutter then crashed unwinding the connection, in wl_client_destroy → its destroy listener → g_signal_handler_disconnect, on a GObject already freed. That is a compositor bug — every killed process disconnects abruptly and a compositor has to survive it — but disconnecting properly is right regardless, and the showcase now calls Goldberry.shutdown(). Open: whether run() should shut down on return, which would change a documented contract. Seen once, on GNOME 46.0 under VirtualBox/vmwgfx, after SDL3 moved from release-3.2.0 to release-3.4.14 in the same session. — ADR-0022

    Narrowed since: Goldberry.launch(), the documented front door (ADR-0093), owns the runtime and calls Goldberry.shutdown() itself, so the showcase no longer has to. The open question is now only about run(), whose contract still says shutdown() is rarely needed, and so only about applications that assemble the loop by hand.

  • No CI leg exercises Wayland. showcase.yml runs under xvfb-run, which is X11, where the window manager decorates the window and libdecor is never reached — which is why two consecutive decoration bugs shipped without a single red tick. A Wayland leg needs a headless compositor in CI (weston --backend=headless or sway --headless), which is a job nobody has written yet. — ADR-0084

  • Native decorations on Wayland need a launcher that embeds the VM. The GTK plugin is the only thing that draws decorations matching the desktop, and its one requirement is getpid() == gettid(). The stock java launcher runs main on a thread it creates and so fails it; a launcher whose own main calls JNI_CreateJavaVM and then the Java main runs Java on the primordial thread, and the plugin loads there — demonstrated with a throwaway C launcher against the real showcase. jpackage does not help; it goes through the same ContinueInNewThread. Shipping one is a distribution change (a native binary per platform, VM argument handling, and a story for ./gradlew run and java -jar), so it is recorded as the answer and not yet taken. Two things bound how much to invest in it: upstream is building an out-of-process GTK plugin (libdecor MR 176) that dissolves the thread restriction entirely when it ships, and the ecosystem’s own answer on GNOME/Wayland is that every non-GTK toolkit — Qt, Firefox, Chromium — draws its own decorations in-process, which is the SdlWindowFlag.BORDERLESS design Goldberry has reserved but not built. — ADR-0084

  • A window on GNOME/Wayland needs two packages from two different phases. libdecor-0-dev at build time, or SDL compiles no libdecor support at all (ADR-0083), and libdecor-0-plugin-1-cairo at run time, because the GTK plugin that libdecor pulls in by default refuses to start off the process’s initial thread and a JVM is never on it (ADR-0084). Installing either alone leaves the window bare. Whether Goldberry should carry its own decorations instead — SdlWindowFlag.BORDERLESS already describes the design — is the standing question behind both records. Since ADR-0086 this bites only where Wayland is forced or there is no XWayland.

  • CI is green, and the fixes that made it so were written blind. Nine causes on Windows and macOS were diagnosed from runner logs and fixed on a Linux machine; all passed at fd36169a and d478ecfe. What that leaves: no machine here can run a Windows or macOS test before a push, so a platform-specific regression is caught by the Snapshot rather than locally. The annotations make that cheap to read, not free. — ADR-0338

    And it has happened since, twice. Windows went red and was fixed blind again (ADR-0450, ADR-0454). Then Snapshot runs 32 and 33 were red on every OS in :natives:gpuTest, whose teardowns called SDL after a missing library had skipped their setup. That fix (ADR-0495) passes here and has not been through a CI run: master has not been pushed since.

  • Two workflows are written and have not passed. media.yml builds FFmpeg and runs :media:check with FFmpeg and the platform decoders required on macos-aarch64 and linux-x64, with GStreamer’s plugins on the Linux runner (docs/media-plan.md, phases 1 and 5). The GPU lane in linux.yml runs lavapipe under the offscreen driver on both Linux targets with a device required, and asks the macOS runners without requiring them (docs/gpu-plan.md, 2026-09-24). Which runners can host a GPU device at all is still the open question in gpu-plan.md’s measurements table. Both are answered by a push. — ADR-0495, ADR-0480

    The GPU lane had run, twice, and is repaired — ADR-0503. “Never run” was this list’s mistake: Snapshot runs 32 and 33 both stopped at the lane’s own ICD check, because Mesa names the file lvp_icd.json now. Run here on lavapipe, the lane then found a real bug — a test destroying a GPU device after SDL_Quit, the VULKAN_DestroyDevice segfault that had been put down to this machine’s NVIDIA driver — and a golden blessed on Metal that no other driver draws to the pixel. All three are fixed, and the lane passes here as CI runs it. media.yml has still never run.

  • Media on Windows and on linux-aarch64 is written and untested. The superbuild and the loader cover all four targets, and linux-x64 and macos-aarch64 are the only two that have run. D3D11VA is on by default on Windows and has no runner to prove it; VAAPI is off by default on Linux, on purpose, because it makes libavutil link libva. — ADR-0486, docs/media-plan.md

The native build and its bindings

  • The layout registry’s constant half is where the value is. When this was written it was seven struct layouts and 61 constant rows, 48 of them Yoga enumerators; it is 66 struct layouts now, 27 of them SDL_GPU, with the constants generated from the binding enums. The struct half has a known limit — YGSize is identical on all six targets, so its row proves nothing the round trip in ADR-0017 does not — but the constant half is where the value is: YGAlignCenter is 2 and YGJustifyCenter is 1, and a Java constant that drifts from either produces a layout that is wrong on every platform at once and never an error. — ADR-0010, ADR-0029
  • VideoPlaybackTest has two races, and one of them fails alone. statistics (“expected 4 shown, got 5”) was believed to fail only when :media:test and :media:testWithoutGpu ran side by side; on 2026-10-01 it failed once in three runs on its own. playsToTheEnd failed in a full check with [BUFFERING, PLAYING, ENDED] where it expects OPENING first — a status listener attached after the first transition. Both are the test’s clock and the video thread meeting in an order the assertion does not allow, not a player defect anybody has seen; and both make a red check mean less than it should. — ADR-0463

Build, artifacts and release

  • A build with no network cannot produce a usable goldberry-core. The bundled fonts and icons are fetched from upstream releases and cached, so this bites once per checkout rather than once per build — but a jar assembled without the asset step contains a toolkit that cannot render text. The build already needed network for the native superbuild, so no new constraint; it is written down because the failure is far from its cause. — ADR-0033

  • A release has never run against Central. The publishing chain has never run against Central. Central’s side is done — the namespace, snapshots enabled for it, the token, the key and the secrets — and snapshots have gone out since run 17 on 2026-09-19. What has never run is release.yml → publish.yml → a Central Portal deployment, which waits on the first tag. docs/releasing.md is the list. — ADR-0334

  • The macOS and Windows native showcases are built from unreviewed traces. The checked-in reachability metadata was traced on linux-x64 and is reviewed as source; CI traces the other two headlessly before building, for 120 frames, and uses what it saw. A screen that run never reaches can lack a registration and fail when opened. Diffing the first CI traces against the checked-in file says whether per-platform traces are needed at all; if they are, they belong in the repository beside the Linux one. The macos-14 runner’s 3 cores are the likeliest place for the build to be slow (2.27 GiB peak and 1 min 23 s on 8 Linux threads). — ADR-0337

  • A stale Linux trace is found by a native build, not before it. The foreign calls no longer depend on the trace at all — every holder and every upcall owner is registered from the bindings, and ForeignSurfaceTest holds the owner list to the sources that call upcallStub (ADR-0339). What the trace still carries is reflection and resources, and a screen the run never opened can still lack a reflective registration; with the showcase built only on a tag or by hand (ADR-0340), that is found later than it was, on the release build. — ADR-0339

  • An application still adds its platform’s natives jar by hand. The goldberry umbrella cannot pick goldberry-natives:<v>:linux-x64 for the consumer’s platform — a POM has no way to — so the BOM lines up its version and the classifier is the application’s. A Gradle plugin, or module-metadata variants keyed on OS and architecture, would close it. — ADR-0336

    Narrowed — ADR-0438. Half the proposed fix does not work, and it was measured rather than argued. Module-metadata variants keyed on OS and architecture cannot close this: a variant is chosen by matching the consumer’s attributes, and a plain JVM consumer declares no operating system — so it resolves the unattributed jar silently, exactly as today but with more machinery behind it. A consumer that does declare one then fails with an ambiguity, because a variant that is missing an attribute is compatible with every value of it, so the ordinary runtimeElements ties with the platform-specific one. The three ways out of that tie are all the consumer’s: attributing the shared bindings jar (which is not platform-specific), deleting the unattributed variant (which breaks every consumer that works today), or a disambiguation rule — and those are registered on the consumer’s schema, where a producer cannot put one. So a Gradle plugin is the whole of the answer, which is what JavaFX, LWJGL and sqlite-jdbc each ship. It is not built: it is a new published artifact with its own release surface, on a release path that has never run. :media has the same problem since ADR-0495: an application picks goldberry-media’s ffmpeg-<target> classifier by hand too. What did change is the documented snippet — all four classifiers, because NativeLibrary picks at run time and the one-platform form is the one that fails quietly for somebody building on macOS for Linux.

  • The release job has never uploaded to a GitHub Release. ADR-0340 attaches the three native images to the tag’s draft release with gh release; the first v* tag is its first run, and a manual dispatch exercises everything but that step. — ADR-0340

  • A release refuses to publish goldberry-media with two of its four FFmpeg builds missing. media.yml builds macos-aarch64 and linux-x64, so a snapshot carries those two ffmpeg-<target> classifiers, and the release path requires windows-x64 and linux-aarch64 as well. Both are written in the superbuild and neither has been built. — ADR-0495

  • A native image resolves a host name before main. About 50 ms of its 70 ms before the toolkit’s first line is a lookup through libnss_mdns4_minimal (/etc/nsswitch.conf, /etc/hosts, then the wait). The JVM does no such thing — its one nsswitch.conf read is for the user’s name — and logback resolves HOSTNAME lazily and this configuration never asks, so it is not logback’s ContextBase. The image is stripped, so the stack did not say whose it is; a build with symbols, or an InetAddressResolverProvider that prints its caller (which found nothing on the JVM), would. — ADR-0506

  • The native image cannot install the GLib log handler. It logs “no handle to bind a GLib callback to”, and GLib’s messages go to stderr there — what ADR-0443 routes into the logger everywhere else. An upcall the image’s foreign-call registrations do not cover, which ADR-0339’s rule says cannot happen: every upcall owner is registered from the bindings. — ADR-0506, ADR-0339

On hold: the accessibility bridge

On hold — ADR-0440. Every entry in this section waits on the AccessKit bridge, which is not being built and which no milestone owns. Each keeps its prose and its reasoning, which is still the right reasoning; where one says “M5”, read “no milestone”. What has changed is that the thing at the end of them is not coming on a schedule. The way back is a consumer asking, not a date.

  • A toast is not announced, and the only thing still missing is the bridge. The widget half is finished: a toast answers [Live#POLITE] and [Role#STATUS] and names itself with its own text, which is the claim §7’s “live region” is and the claim a role and a name cannot make. It matters here and nowhere else in the catalog because every other widget is announced when something happens to it — the focus lands, the pointer arrives — and a toast has no such event: nobody focuses it, nobody has to click it, and it is gone in five seconds. So a reader that speaks only what is reached would still have said nothing about it on the day the bridge landed. What is left is M5’s AccessKit bridge and no decision from the catalog. — ADR-0225, ADR-0177
  • Role has no link and no list. link answers BUTTON, and steps, timeline and breadcrumbs answer GROUP over ROWs, each with the reason written on it: a role nothing consumes is a value written for a bridge that does not exist. The AccessKit bridge is where the words arrive. — ADR-0346
  • Four widgets announce what they are and cannot say what they hold. Each has a specification sentence with two halves and only the first is built. code-input is “a single textbox with the whole code as its value” — Role.TEXT_FIELD, one Tab stop, boxes with no role at all. calendar is “grid with each cell’s full date as its name” — Role.GRID, cells that are parts. date-picker is “combobox owning a grid, with the formatted date as its value text” — Role.COMBO_BOX. color-picker is “combobox with the hex as its value text”, and it gets half of that one: its closed swatch is a Role.BUTTON whose accessible name is the hex, which is as close as a name can come to a value. The second half of all four needs the same thing and there is nowhere to put it: Semantics is a role, a name and a liveness, with no value channel and no per-cell channel for any widget. So this is the AccessKit bridge’s entry rather than any of theirs, and the four are named because they are the controls whose specifications spent a sentence on what they would say. M5. — ADR-0276, ADR-0274, ADR-0273
  • A trail is not a landmark, and a crumb is not a link. §6 gives breadcrumbs “navigation landmark containing links, current page marked”, and Role has neither a landmark nor LINK: the row answers Role.GROUP — “a boundary with content in it and no better word” — and the crumbs answer Role.BUTTON, which is true of what pressing one does and silent about what it is. The third of the three, “current page marked”, is built, through :checked and the accessible name. Filed rather than guessed at, for the reason the two entries below are: a role nothing can consume is a constant written for a bridge that does not exist, and adding LINK and a landmark now would make this gap look closed. M5. — ADR-0306
  • A slider with two axes has no role, here or in ARIA. color-picker’s plane answers Role.SLIDER, which is true as far as it goes — a control whose value you move continuously — and says nothing about the second axis. GROUP is “a boundary with content in it” and a plane has none; GRID promises cells addressed by row and column, which is the one thing a continuous plane is not. Filed rather than guessed at: the answer is probably a role and a second value channel, and the shape of that depends on the AccessKit bridge nothing has built. M5. — ADR-0276

Answered

Kept rather than deleted: each is a trap somebody hit, and the reasoning that got out of it is usually worth more than the fact that it is fixed.

  • The LGPL corresponding-source offer for FFmpeg is not decided. The licence texts, NOTICE and ffmpeg-NOTICE.txt with the tag and configure line ship with the natives jar, and relinking is -Dgoldberry.media.libdir. What LGPL-2.1 §6 also asks of a binary distributor — the source itself, or a written offer of it — is not settled, and it gates FFmpeg’s first appearance on Central in a release. — ADR-0495, ADR-0490

    Closed — ADR-0508. The binaries are FFmpeg itself in object form, so the clause is LGPL-2.1 §4 and not §6, as this entry had it: the complete corresponding source goes with them, or is offered from the same place. goldberry-media publishes it as its ffmpeg-sources classifier, one jar per version, beside every ffmpeg-<target> — snapshots included, because a snapshot on Central is a distribution too. It holds FFmpeg n8.1.3 and dav1d 1.5.4 from git archive, checked against commits now pinned beside the tags (the superbuild checks its clones against the same ones), the superbuild as the recipe, every target’s notice, the licence texts, and a README on rebuilding offline and relinking; 24 MB, byte-identical from a fresh clone. goldberry.publish refuses any ffmpeg-<target> without it before a single module uploads.

  • “Starts in milliseconds” is still unproven. The timeline exists and the first numbers are in ADR-0028 — SDL_Init(VIDEO) is ~99ms and dominates, while mapping libgoldberry is under 2ms — but they were measured under gradle run, which adds a launcher and its own JVM. The headline claim needs the example launched directly. — ADR-0028

    Closed — ADR-0506. Launched directly, timed from exec by an outside clock: the native image opens its window in about 120 ms and presents its first frame in about 520 ms (365 ms with the GPU off); the JVM takes about 2 s, 1.3 s with a JDK 25 AOT cache. The entry’s premise was the timeline, and the timeline was wrong: its zero was ProcessHandle’s start instant, which on Linux is built from a boot time in whole seconds and was 218 ms late on this boot, so ADR-0028’s 533.8 ms “runtime starting” was never what it said. ProcessAge reads the kernel’s clock at both ends now and agrees with the outside clock to 10 ms. SDL’s video subsystem, the 99 ms this entry named, is 14 ms.

  • No primary selection. X11’s middle-click buffer has its own SDL calls (SDL_GetPrimarySelectionText) and is unbound: it is one platform’s idea, and the widgets that would fill it — a text field on X11 — would have to know they are on X11.

    Closed — ADR-0504. The three SDL calls are bound (ABI 17) and offered as an optional PrimarySelection on Backend and Host: the sdl3 backend offers one only when SDL’s driver is x11 or wayland, and the headless backend an in-memory one a test can turn off. text-input, text-area, Editor and the content views publish a finished selection — a pointer selection on release, a keyboard one when the key lands, and not the select-all a Tab arrives with — and a middle click in a field moves the caret there and pastes, as one undoable edit. A password never publishes. The premise did not hold: no widget knows it is on X11, because a field only asks its host whether a primary selection exists. The trap was the one the entry did not name — SDL answers these calls on every driver, from a private in-process buffer off X11 and Wayland, so the decision is the driver’s name in the backend and not whether the calls work.

  • Styled.restyle is an escape hatch with nine overrides now, and the honest risk is what goes into it. What a widget writes there is unthemeable and unoverridable — right for a number nobody else can compute, wrong for anything else. It has one rule (“only what a stylesheet could not have written”), and the count it was written for has outgrown the sentence that watched it: ColorSwatch, SegmentedDivider, SegmentedIndicator, TabIndicator, Tab, ScrollContent, ScrollThumb, ScrollViewport and AffixContent (counted 2026-09-30). The signal this entry set — a caller that is not a count — has not been read against those nine.

    Closed — ADR-0499. Read against the rule, seven of the nine write a number no stylesheet could have: from a count, the application’s data, a measurement, input, or a sum §8 has no calc() for. Two wrote something a stylesheet could have. SegmentedDivider’s opacity: 0 is the beside-selection class and a rule in controls.css now, and ScrollContent’s flex-shrink: 0 was a pin against the stylesheet and is set in render. No picture changed. RestyleSweepTest holds every override to a list with its reason, so a tenth fails until somebody writes down why a stylesheet could not have written it.

  • Nothing paints a tray icon for you, and nothing swaps it on a theme switch. The mechanism is there — TrayIcon.icon(pixels) and BackendTray.icon — and §9’s “theme-aware light/dark variants” is an application’s two PixelBuffers and a Window.onSystemThemeChanged handler it has to write, which rebuilds the tray with the other one. The toolkit ships no default mark of its own, so a tray with no icon is whatever the desktop draws for an application that supplied none.

    Closed — ADR-0501. TrayIcon.icons(forLightShell, forDarkShell) carries §9’s two variants, each named for the panel it sits on rather than for its ink, and Trays.show swaps the icon in place through BackendTray.icon on every theme change, leaving the menu alone; forLightShell where the desktop says nothing, as CSS reads no preference. Reality differed in two places. The swap could not be built without a leak: Host.onSystemThemeChanged returned nothing, and a tray is closed and shown again whenever its menu changes, so it returns a Subscription now, which the tray’s handle closes. And the setting SDL reports is the desktop’s application theme, not the panel’s shade — GNOME’s top bar is dark either way — so a pair follows the best signal there is rather than the truth. The other half stands on purpose: no default mark ships, because a tray icon names the application and Goldberry’s on every one that forgot would misname them all.

  • customPropertiesFor still walks to the root, re-running the whole cascade at every ancestor, so it is O(depth × rules) where it could be O(rules). The style cache amortises it almost to nothing — each level is cached against its parent map’s identity (ADR-0152), so an ancestor’s cascade reruns only on a miss — but a first frame and every invalidated subtree still pay it. Worth doing when a deep tree makes a first frame visible. — ADR-0070

    Closed — ADR-0502, and the entry had the cause wrong. Measured with DeepTreeStyleBenchmark at depths 51, 101 and 201 against the catalog’s sheets: the walk never re-ran an ancestor’s cascade, because ADR-0152’s cache and the renderer’s top-down order make every ancestor a hit, and it cost 0.5–1.5% of a first frame. What cost was the line after the cache check — each node copying the root’s ~180 inherited custom properties into a fresh map and comparing it back, to find that it declared none: 48–63% of a first frame’s style resolution. A node now copies only when one of its own --* winners differs from what it inherits. First-frame resolution is 1337 → 505 µs at depth 51 and 7796 → 4098 µs at 201, with identical results, which CustomPropertiesCacheTest checks against an uncached walk. The term that still grows with depth is descendant-combinator matching, recorded in the ADR and not scheduled.

  • A tour cannot find the viewport its target is in — read against the code, and it stands. §5 asks it to scroll a target into view, and Stop takes a ScrollController the application supplies. Discovering it means walking from an element to its nearest scrolling ancestor. BuildContext.findAncestorState looks like the answer and is not: it walks up from the element being built, and what a tour needs is a walk up from the target it names — a different question, and one the tree offers no way to ask. ADR-0120 avoided the same wall by turning the question around; here there is nothing to turn around, because the tour is not the thing being revealed. — ADR-0268, ADR-0121

    Closed — ADR-0439. Both premises are true and the conclusion is false, which is why re-reading it twice did not catch it. Element implements BuildContext, so findAncestorState walks up from whatever element it is called on rather than from the one being built; and Host.anchor(id) already returns a region whose owner() is that element — the tour was calling it on every build for the rectangle and discarding the owner. ScrollScope.enclosing(target) is the walk. ADR-0120 had written down that findAncestorState “stays, because it is how an application-level scrollIntoView from inside a scroll view reaches the viewport”, which is this call, kept for it, three hundred decisions earlier. Stop.within(controller) survives for the application that means an outer viewport, since the walk finds the innermost.

  • margin is not in §8’s subset, which tab-new found after border-bottom and currentColor. It is now (ADR-0311), and this entry closed the case on the wrong evidence. It was right that tab-new stopped wanting one — what that widget reached for was a way to sit somewhere other than the top of its row, which align-self answers (ADR-0244) — and wrong to conclude from it that the property had no consumer, because align-self is the cross axis. On the main axis a box that wants to centre itself, or to sit at the far end of a row its container is not arranging for it, had no spelling at all: justify-content is the container’s decision about every child at once, and a flex-grow: 1 spacer is a box in the tree that draws nothing. margin: 0 auto and margin-left: auto are what those are, and Yoga’s binding has had the auto call since ADR-0029.

    The entry’s other half stands and is worth keeping: three properties a widget reached for and did not find, all silently ignored, and the subset is right to be small. and nothing warns when a declaration is dropped. Something does now, for the toolkit’s own sheets: border-bottom was written a fourth time, in table-head, and drew nothing (ADR-0215). SupportedPropertyTest resolves every rule the catalog and the showcase ship through the real cascade and fails on anything reported as unsupported — so a dead declaration is one failure with the property in it rather than a debug line among thousands. And on anything reported as a bad value, since ADR-0216: border-radius: 7px 7px 0 0 and background: none were two more rules doing nothing, with the property spelled right and the value refused. An application’s stylesheet is still on its own, deliberately: naming backdrop-filter before it exists must not stop a window opening. (That sentence said box-shadow until ADR-0310 built it; backdrop-filter and letter-spacing are what is left of §8’s unimplemented list.) — ADR-0216, ADR-0215, ADR-0109

  • The catalog’s specified surface roughly tripled, and most of it is built now. docs/core-widgets.md gained twenty-one widgets and four options in one pass — link, affix, segmented, date-picker, time-picker, color-picker, code-input, autocomplete on both text-input and select, tree-select, collapse, carousel, statistic, skeleton, breadcrumbs, steps, wizard, message, tour, tree, calendar, timeline, and button’s outlined / square / circle / float options — each with a design-system.md §3 metrics row and, where it moves, a §3.1 row. §5 requires a spec and a metrics row and gallery coverage before code, in that order: they had passed two gates of three, and the third is what “built” means.

    This entry said “none of it is built”, then “one of them is built now”, then “four widgets and four options are left” — and now none are. The last four went in on 2026-09-17: link (ADR-0346), steps and wizard (ADR-0344), timeline (ADR-0345), and button’s outlined / square / circle / float (ADR-0347). What each left behind is its own entry under The catalog below.

    Everything else on it went in: segmented first, then affix, the three pickers, code-input, autocomplete on both controls, tree-select, collapse, carousel, statistic, skeleton, message, tour, tree, calendar, and breadcrumbs last.

    The way segmented went is still the argument for writing them down first, read from the other end: two of its five specified metrics and both of its specified transitions turned out to be undrawable in §8’s subset, and that was found by implementing it rather than by writing it. The point of writing them down first is that the arguments are cheap then and expensive later — message against toast, segmented against radio-group, code-input against a styled text-input are all decisions that would otherwise have been made by whoever happened to need one, and none of them was.

  • tree moved from deferred to specified, and table has since followed it, which changes what M5 owes. ARCHITECTURE §17 defers “tables/trees”; table still is, because it waits on virtualization, but tree reuses list’s model and item-factory and does not — and select tree=#true needs it, so the two arrived together.

    Answered — both are built. table stopped waiting on virtualization when virtualization arrived: it is a list with columns (ADR-0214), and docs/ARCHITECTURE.md §17 lists both as built. Nothing is left of M5’s debt here.

  • A toggle’s thumb does not follow the pointer during the drag — and the design system says it should not. Left open as a defect after ADR-0075 and closed by reading rather than by building: §1.7’s first principle names the controls that track 1:1 — “drags (slider, knob, fader, splitter, scroll) track the pointer 1:1” — and toggle is not among them, while §3.1’s toggle row asks for the opposite, “thumb translate base”. A switch here is a control with two positions that animates between them, and tracking the finger would be a third behaviour neither document asks for. It would also cost the mechanism the entry named: transient per-element state for a value that is neither the model’s nor the stylesheet’s, which nothing else in the catalog wants. Reopened only if the design system changes its mind, in writing. — ADR-0075, docs/design-system.md §1.7, §3.1

  • The toggle does not shrink with a compact density, and that is answered rather than open (docs/widgets-finishing.md, ADR-0356): §3’s row carries no compact value for toggle where the rows that shrink carry one, so the pill staying 36×20 inside a 28-tall row is the specification rather than a gap. Kept here because the screenshots are what would say whether §1.3 meant it. Read off §3 rather than decided: the rows with a compact value carry it in parentheses and the toggle row does not, so the pill stays 36×20 while the row around it takes --gb-toggle-height. Whether a 28-tall row holding a 20-tall pill is what §1.3 intends is a question for whoever writes the compact screenshots. — ADR-0075, ADR-0074

  • No file lists. text/uri-list is bytes like anything else and works today, but nothing turns those bytes into paths. Drag-and-drop is a different platform mechanism and is built now: Window.onFileDrop delivers one FileDrop per gesture, with the paths and the point they landed on (ADR-0330). What is still unbound there is SDL_EVENT_DROP_TEXT — the same shape, and nothing has asked for it.

    SDL_EVENT_DROP_TEXT closed — ADR-0408. “The same shape” turns out to be literal rather than loose: SDL tokenises dropped text on \r\n and raises one event per line, then one shared DROP_COMPLETE for both kinds — so TextDrop carries a list of lines, and a test exists specifically to stop the shared completion turning a file drop into a text drop. What had blocked it was diagnosed here and is worth keeping: the blocker is a missing constant, not a missing symbol, so adding the enum value fails the layout probe rather than the link, and the bill is a shim row and an ABI bump on four platforms. ADR-0422 was bumping the ABI anyway, so the bill was already paid.

    The other half closed too — ADR-0406. “Nothing turns those bytes into paths” stopped being true on 2026-09-19: UriList reads text/uri-list into names, and the entry was never told. Found by the 2026-09-30 sweep.

  • goldberry-media breaks the one-library assumption. LGPL relinkability means libVLC stays a separate shared object with its plugin tree beside it, and every packaging rule in :natives — one static library, hidden visibility, one export list — assumes the opposite. It also needs a codec/patent note written before it gets code, which content-widgets.md §8 says and this list repeats because it is a gate rather than a caveat.

    Answered — libVLC is gone, and both halves went another way. :media drives FFmpeg from Java (ADR-0460) and binds its own libraries outside :natives (ADR-0461), so the one-library rule is untouched and there is no plugin tree. FFmpeg ships as shared objects under sonames of its own (ADR-0490) in ffmpeg-<target> classifier jars, and -Dgoldberry.media.libdir is the relinking path (ADR-0495). The codec note was written before the code: royalty-free codecs only, with the Decoder SPI for the patented ones (docs/goldberry-media.md). What is still open about shipping it is under Build, artifacts and release.

  • Text selection in html-view is deferred, and it is the same character-quad work as text-editing depth (ARCHITECTURE.md §17) and as pdf-view’s selection. Three widgets waiting on one mechanism is an argument for building it once, in core, rather than in whichever module lands first.

    Answered — ADR-0301. Both views select, copy and highlight, through geometry the frame already had rather than character quads. The goldberry-html entry above has said so since 2026-09-13; this one was never struck. pdf-view is still unbuilt.

  • goldberry-web is parked, not deferred. Built, and not as a module (ADR-0441). Every word of the entry was true about Servo and none of it was about the question: libservo is Rust-only against a deliberately unstable API, so the module would indeed own a cdylib shim and its breakage — and nobody asked whether a page needed an engine of this project’s at all. webview/webview is MIT, is one header, and brings no engine: it drives the WebKitGTK, WebView2 or WKWebView the desktop already has, so neither condition that quarantines a content module applies. This is the fifth entry in this run of work that was wrong about itself, and it is the most expensive kind for the second time: “parked” reads like an answer and stopped anybody re-reading it for two milestones.

    What it is not is a widget. webview/webview cannot render offscreen, so a page is always a platform window; and a Wayland session allows neither reparenting a foreign surface nor placing a window where a widget is, so a web-view in a layout would be a box on X11, Windows and macOS and a loose window on the default Linux desktop. It ships as §9’s second widget.shell member instead — a value and the call that opens it, tray-icon’s shape. CEF-OSR stays the documented escape hatch, and is still the only engine that would have made a box possible.

    That paragraph was overtaken the next day (ADR-0442). web-view is a widget wherever the window system allows a child window: on X11 the page is reparented into the toolkit’s window and is never the window manager’s (ADR-0446), and the window stays on the GPU (ADR-0491). On macOS it is a subview (ADR-0458). On Windows SetParent is written and unverified. On Wayland it opens nothing and paints why, rather than a loose window. WebViews.open stays as the separate-window form.

  • Nothing in the catalog uses a margin yet. It does (ADR-0312): dialog-actions writes the top margin §2 asked for instead of the padding-top that stood in for it, and tour-card’s footer lost the Spacer that pushed Skip away from Back and Next — margin-right: auto, pixel for pixel the same picture. The showcase’s notice bar likewise. spacer is not deprecated: it is a §1 widget an application writes in markup, and a document has no stylesheet of its own to put a margin in, so the showcase’s status bar keeps one on purpose with the notice bar beside it as the comparison.

  • A dropped declaration is reported once, and align-items: start is why. The Panels screen filled the console while it scrolled: start is CSS’s alias for flex-start and Yoga has only the second, so the declaration was dropped — correctly — and reported per element per style resolution, which on a moving screen is sixty times a second. The typo is fixed and the report is now deduplicated by property and value, because a stylesheet is static and a value that is not one cannot become one on the next frame. What changed since: start and end are taken now, because they are not aliases but CSS — Box Alignment Level 3 defines them and Yoga has only the flex- pair, so the toolkit had been dropping a declaration the specification allows (ADR-0247). left and right are still refused, and for a reason rather than an omission: they are not the same as start/end under RTL.

  • A segment’s focus ring lands exactly on the bar’s edge. §2.2’s ring is 2px at a 2px offset and the bar’s inset is 2, so the two coincide — legible in segmented-focus.png, and an accident of two numbers derived separately rather than a thing anyone chose. If either moves, look at the image.

    Answered — ADR-0217. One of them moved: the bar’s inset is 1px now, and the ring sits off its edge. Answered on 2026-08-30 and found still open by the 2026-09-30 sweep.

  • A generated registry can fail at class-init time now, and only for private members. A VarHandle lookup that cannot find its field throws ExceptionInInitializerError where a direct field reference would have thrown NoSuchFieldError at link time — the same class of failure with a different exception, and both are impossible within one compilation, which is how a registry and its model are always built. Recorded because it is the one thing ADR-0098 moved later rather than earlier.

    Answered — ADR-0125. There is no generated registry any more: the weaver rewrites the model’s bytecode, and weaver/src/main holds no VarHandle. ADR-0125 superseded both ADR-0096 and ADR-0098, and this entry outlived them.

  • How damage is computed, and the bug a resize found in it. Each render object remembers where it was, and a node that changed damages the union of where it was and where it is — both, because damaging only the new position leaves the old drawing on screen. It reads the node’s own changed flag rather than its subtree’s, or a parent whose child moved would report the whole window. A resize broke it in the field: a remembered rectangle belongs to the previous frame, so the union fits neither when a window is dragged a pixel narrower, and the backend refused the frame mid-drag. Damage is now clamped on the way out rather than only where each rectangle is computed — and the regression test resizes by one pixel, because that is what a drag produces and a test that jumped by fifty would have passed against a fix that only handled large changes. Every damage test had used a single frame size, which is the natural thing to write and the one case that cannot fail. — ADR-0071, ADR-0072

  • Both ways to run the showcase headlessly were broken, and one of them cost a desktop session. goldberry.backend.videoDriver existed and was not in :example’s forwarded-property list, so -Dgoldberry.backend.videoDriver=dummy reached the Gradle daemon and stopped there — the exact failure the comment beside that list already described for goldberry.log.level. The obvious fallback, SDL_VIDEODRIVER=dummy in the environment, does not work either: a JavaExec fork inherits the daemon’s environment rather than the one gradlew was invoked with, so it applies or does not depending on how the daemon happened to be started — which reads as flaky rather than as broken. A run intended to be headless therefore opened a real Wayland surface and took GNOME Shell down with it. The property is now forwarded, and ./gradlew run -Pgoldberry.backend.videoDriver=dummy is the checked way to drive the showcase without a compositor.

  • What does the release container actually compile into its Wayland driver? Two dependencies decide it and linux.yml installs neither. egl is one of the five specs in SDL’s single CheckWayland pkg_check_modules — lose any one and the entire Wayland driver is dropped silently, and the container has no mesa-libEGL-devel. libdecor-0 decides whether a Wayland window that does get built has a titlebar and a resize edge. The manylinux leg runs CMake directly with no JDK, so checkToolchain never gets to ask either question, and the drift guard deliberately held that workflow only to the packages SDL refuses to configure without. Answering it means reading SDL_VIDEO_DRIVER_WAYLAND and HAVE_LIBDECOR_H out of a container build’s SDL_build_config.h — not another look at the table. — ADR-0082, ADR-0083

    Half of this moved while G32 was being closed (ADR-0325), and what is left is now a packaging problem rather than an unknown. Measured in quay.io/pypa/manylinux_2_28_x86_64: dbus-devel, systemd-devel, ibus-devel and mesa-libEGL-devel all install and all provide their .pc files; libdecor-devel and xkeyboard-config are in no repository the container has, so those two cannot be fixed by adding a line to the workflow. The superbuild does now read SDL’s generated SDL_build_config.h and cross-checks it against its own probe — for HAVE_DBUS_DBUS_H, HAVE_IBUS_IBUS_H and HAVE_LIBUDEV_H, which is the same machinery this question asks for pointed at three other defines — and the drift guard now also holds linux.yml to every package a capability depends on. Extending both to SDL_VIDEO_DRIVER_WAYLAND and HAVE_LIBDECOR_H is the remaining work, and the honest form of it is probably a Capability.WINDOW_DECORATIONS, since the answer for the container may be “it cannot” rather than “install this”.

    Closed — ADR-0422. It guessed the honest form right: Capability.WINDOW_DECORATIONS and Capability.WAYLAND, warned about rather than required, because libdecor-devel is unavailable in the release container and a REQUIRED probe there would produce no library at all. What it did not anticipate is that extending both was the wrong move. The existing pattern is a pkg-config prediction confirmed against SDL’s generated header, and SDL decides the whole Wayland driver with one check over five specs plus a scanner binary — so a prediction narrower than that reports “present” where SDL reports “absent”, and the cross-check then fails a build that was fine. These two are therefore read out of SDL_build_config.h and never predicted, which is strictly better where it is available and is why a header SDL did not generate now reports both bits absent rather than carrying on. WINDOW_DECORATIONS is also deliberately narrower than it sounds: it is libdecor at build time and says nothing about ADR-0084’s plugin, because a bit that was set on the exact machine where the bug is would be the worst possible value.

  • The export machinery has now caught the same class of bug three times. --exclude-libs,ALL forced static-archive symbols local, so SDL_Init linked in without being exported; removing the flag fixed it, because a version script cannot promote a symbol already marked hidden. Blend2D then hit the identical wall from the other side: a static build defines BL_STATIC, which makes BL_API expand to nothing, so the superbuild’s global hidden visibility applied to every Blend2D function. All 13 linked in and arrived local — nm -D showed none of them while nm showed them all as t. HarfBuzz then did it a third time and more bluntly: HB_EXTERN is defined as bare extern, with no visibility attribute at all, so all 24 of its symbols went local too. Fixed by giving both targets default visibility; the version script’s local: * still gates the output. The fix is a loop rather than two blocks, because the next static upstream will probably need it as well. The equivalent question on the MSVC .def and Mach-O -exported_symbols_list branches is still answered by the next CI run rather than by argument — and the Mach-O branch has the same dependency on visibility that this fix addresses. — ADR-0018, ADR-0031

    Answered by CI, as it said it would be. The Mach-O branch linked and passed on macos-14 (ADR-0338), and Windows is green on both generators, with the force-link list moved into a file like every other platform’s (ADR-0454).

  • Only a popover follows a scrolling anchor; a menu and a select hold the rectangle they opened against. Following is a property of having been opened by id, which is Popover’s documented shape and the one the entry that asked for this named. Menus and SelectState resolve the anchor to a rectangle themselves, because they want a minimum width and a Fit as well and no Host.popup overload takes an id and those two. It is one overload’s worth of work and nothing has asked for it: a dropdown is dismissed by a press elsewhere, and the wheel over an open one scrolls its own list. — ADR-0270, ADR-0145

    Closed — ADR-0432. The overload exists — Host.popup(content, anchorId, placement, minimumWidth, fit) — and Menus opens by name through it. The entry is wrong about SelectState: a select has no id to be anchored by, SelectField is Located and takes its rectangle from the frame, and ADR-0119 explicitly rejected generating one because two unnamed selects in a window would then depend on that generation being unique. So only Menus was a customer and select still does not follow. The entry’s own “nothing has asked for it” stands as the value of this record on its own; what it is really for is being the prerequisite of the entry below.

  • A popup whose anchor scrolls out of sight follows it out of sight. Now that a popover travels with its anchor, an anchor scrolled past the top of its viewport takes the popup with it, and the placement clamps it to the work area rather than dismissing it — so a menu can end up pointing at a widget that is no longer drawn. The region carries the clip that would answer “is it still visible”, so the mechanism is there; what is missing is a decision about what should happen — close it, hide it, or pin it to the viewport’s edge — and nothing has asked for one yet. — ADR-0270, ADR-0114

    Closed — ADR-0433. The decision is close, and the other two were rejected for reasons worth keeping. Pin is the only one that makes the toolkit lie: a menu parked at the viewport’s edge points at whatever row scrolled up to meet it and the user cannot tell. Hide leaves a popup holding the keyboard, so Down moves a selection nobody sees and Enter runs a command nobody chose. Close costs the in-progress interaction and nothing else, which is the one failure a user can see and undo. The threshold is intersection rather than containment, and closing takes the popups opened after it, since a submenu anchors to a rectangle inside its parent. The region’s clip answers only half the question, which the entry assumed was the whole of it: a row scrolled fully away is still reported — ADR-0114’s empty-clip stop is about a subtree’s own clip — and a box with no clipping ancestor sits under an infinite clip, so the predicate is the clip and the window’s rectangle. One shipped behaviour changed: ADR-0270’s followsAScrollingAnchor asserted a menu travelling with an anchor that had left the window entirely, which is the picture this record calls wrong.

  • A masonry’s column count is a number and not a breakpoint, and what stops it is the spec gate rather than the mechanism. Two columns at 1200px are two columns at 720px — half as wide and twice as tall — because the count is a constructor argument and no selector can count columns. This used to say that “as many columns as fit at a minimum width” is “a layout pass that reads its own width, which is the loop ADR-0196 built the last-frame read to avoid”, and that reads the record backwards: ADR-0196 is the last-frame read, masonry already banks every card’s height through Measured, and reading its own width is the same door one step over. Measured’s third rule holds for it too, with one caveat worth stating — a column count changes the masonry’s height and not its width, so the number is stable under the thing it causes for a masonry whose width comes from its parent, which is every one in the showcase and not every one imaginable. What actually blocks it is that masonry has no row in docs/core-widgets.md at all — it is named once, as what the showcase’s screens are made of — so §5’s spec-then-metrics-then- gallery gate has nothing to have passed. — ADR-0222, ADR-0196

    Closed — ADR-0436. min-column-width is built, exclusive with columns, defaulting to 320, with the wall’s own width read through Measured from MasonryBox — and the resolved gap read with it, because n columns need n minimums and n−1 gaps and counting without them over-counts at every boundary. It settles in 3 passes worst case, which matters exactly: Offscreen measures twice and paints the third, so a responsive wall is photographed settled with nothing to spare. The spec row written for this task was wrong about shrink-to-fit and has been corrected: masonry-column is flex-basis: 0, so a wall with no definite width measures zero whatever its count is and the count is independent of itself by construction — degenerate and stable, and identical with a fixed columns, so it is a pre-existing defect rather than anything this option introduces. The showcase adopts it on the Basic screen only, at 560 rather than 320, because at 320 a 1168-point wall becomes three columns a third narrower than its cards were built for.

  • The gallery goldens cannot see typography at all, and the 150% half is now waiting on a decision rather than on a mechanism. GalleryGoldenTest builds its renderer with the single-font constructor — which ignores font-family, font-size and font-weight by design, so that a golden image is not a test of whichever Inter is on the machine — so every screenshot draws prose, headings and button labels at one size. A screen with no typographic hierarchy looks exactly like a screen with one, which is how a screen title and the paragraph under it stayed the same 13px with nothing catching it. ShowcaseTypographyTest asserts sizes through the cascade instead. The clipping half read as a gap in the tests and was a gap in the toolkit: nothing enforced §1.4’s 150% because nothing implemented it, so there was nothing for an image to be of. renderer.textScale exists now (ADR-0267) — it scales the text and deliberately not the boxes, which is the condition §1.4 asks components to survive. What is left is what to assert: since text-overflow: ellipsis shipped, some cutting is correct, so “no text is clipped” is no longer the sentence, and a golden of eleven screens at 150% would pin every one of those decisions at once in a picture before anybody had taken them. — ADR-0267, ADR-0118

    Closed — ADR-0435. The assertion is a rule, not a picture, and it is differential between 100% and 150%: no line is cut without something asking for the cut, and no box overruns its container. Three candidates were rejected, one of them by measurement — “every ellipsis at 150% was reachable at 100%” is backwards and false on the corpus, since HTML gains one correct marked cut and Markdown two. The entry’s premise is also wrong in a way that would have made the picture worthless: renderer.textScale exists but does not reach the gallery, because WidgetRenderer’s one-font constructor discards the style the scale is applied to — a 150% golden taken the way the gallery’s are taken would have photographed the 100% tree and passed for ever. The audit opens a book instead, making it the first check here to lay the gallery out with real font-family, font-size and font-weight. OverflowWatch answers half: its noise is a fact, its silence is not, because the walk is gated on the root node’s hadOverflow. The gallery does not survive 150% today — the overruns are carried as a named ratchet, and the five the narrow Basic screen had are already gone, removed by masonry’s responsive columns rather than by anything aimed at them.

  • Measured is a door every widget can now open and almost none should. A widget that sizes itself from last frame’s measurement lags its own content, and one that does so in a way that changes the measurement never settles. Nothing enforces the rule that keeps it safe — read geometry to interpret an input or to draw something that cannot affect layout, never to decide a size — and the scroll view obeys it by construction rather than by check. — ADR-0117

    Closed — ADR-0420. Settled, a harness that drives the real frame loop to a fixed point and fails on oscillation, now holds six consumers to the rule. A runtime check was considered and refused, because it cannot tell an oscillation from a resize drag. There are eleven consumers now, not one, and the interesting number is how many actually feed back: masonry, scroll and split-pane settle in 2 passes; table, text-area and text-input in 1, meaning they show no layout feedback at all — so the harness also asserts that a consumer was reached, which is what caught toast placing no box in a windowless harness and passing vacuously. toast, tour, image and IconSheet stay uncovered, each with its reason written down.

  • A row’s focus name still collides between two unnamed lists. host.focus takes a name global to the window, and list scopes its rows by the list’s own id — which settles it wherever an application named one, and leaves the case of two lists, both unnamed, holding an item with the same identity. tree has the unscoped version of the same thing. What would close it properly is a focus name that is relative to a subtree, which the router has no notion of. — ADR-0212

    Closed — ADR-0437. PointerRouter.focusById resolves inside the enclosing focus-scope chain before falling back to the window. The entry says the router “has no notion” of a subtree and it does — enclosingScope, used for traversal and never for resolving a name — so the fix is about eight lines and no published API moved. focus-scope is the right naming boundary for a reason that is not a coincidence: the elements a composite manufactures names for are exactly the ones its arrow keys rove over, because a row gets a name so that End can reach it. The entry also understates tree, which is not merely “the unscoped version” — a named tree collided too, where a named list was already settled by its id prefix. One residue is written down rather than fixed: a virtualized unnamed list’s not-yet-built row is not in its own scope to be found, so the cross-frame retry can still reach another list’s row for one frame.

  • SelectList is in the wrong package. It now has two callers, which is what moved Option into a package of its own; it stayed put because the CSS type it carries is select-list, so moving it renames a type in every stylesheet and every golden rather than editing one file. Autocomplete itself reaches markup through suggestions= and options= (ADR-0367). — ADR-0182

    Closed — ADR-0417. The reason this entry sat is false. cssType() returns the string literal "select-list"; nothing derives a CSS type from a class’s package or simple name, so the move renamed nothing in any stylesheet, golden or test — every select-list in controls.css, the select-list-dark golden name and SelectTest’s assertion are byte-for-byte unchanged. Eight Java files moved and that was all. The other half of ADR-0182’s note was true and was the real defect: the class documented itself as “not constructible” while sitting public in an exported package, so the new package is not exported.

  • A slider maps the pointer over the track’s full width, so at the extremes the thumb’s centre is up to 8px from the finger. Mapping over the travel needs the thumb’s width, which is the stylesheet’s and not the widget’s. The mapping is monotonic and reaches both ends exactly. The door this entry named is open and a different one is shut: “a widget being told a resolved metric” is Paints.Context.length and has been since ADR-0251 — but it is a render-time read, and the pointer arrives at onPointer where there is no context to ask. scroll solved exactly that by banking the number into its State; a Slider is a record with nowhere to bank one, so closing this means making slider stateful. That is still a bigger change than 8px, and it is now a different sentence. The tick marks do not have this problem: their inset is half a thumb, written in the stylesheet beside the thumb’s own width, so a mark and the thumb agree exactly while the finger is the thing that is up to 8px out. — ADR-0080, ADR-0079

    Closed — ADR-0430. slider is stateful on scroll‘s arrangement — Slider (record) builds SliderControl (the CSS type), and SliderState banks --gb-slider-thumb-size read at render for onPointer to use. The mapping is over the travel. The entry was right about everything including the tick marks, which SliderGeometryTest had been asserting all along and which pass untouched. One cost it could not have known: there is no calc(), so the thumb’s border-radius and slider-ticks’ inset stay hand-maintained halves of the new token, held together by a test rather than by arithmetic.

  • An indeterminate bar turns where it should run off the edge, and that is now a choice rather than a limit. progress’s indeterminate sweep travels there-and-back within its track because the off-the-edges drawing — the more common one — needs the bar clipped at the track’s edges. This entry said nothing clipped; overflow: hidden has shipped since ADR-0114, so the drawing is available. What is left is a design decision about a shipped animation rather than a missing mechanism — and it is the only thing left on ADR-0235’s list, now that the label half has been built (ADR-0255). — ADR-0235

    Closed — ADR-0418. The sweep crosses and leaves — −100% to 333% of the bar’s own width — with overflow: hidden written in controls.css rather than forced in Java, so the clip stays the stylesheet’s. Two goldens moved and were re-blessed; progress-determinate, progress-light, progress-reduced and both spinners did not, which is the evidence the clip costs the other drawings nothing. One new cost, named rather than hidden: Clip is a rectangle and not CSS’s rounded clip, so the track’s 2px cap squares off momentarily — about 0.86 px² per corner.

  • No Image.scaled(...). Scaling happens at the blit, which is where the destination size is known. A resampled copy — for a thumbnail written to disk — is a different operation and would need a filter argument that bl_image_scale has and nothing has asked for.

    Closed — ADR-0428. bl_image_scale is bound as BlendScaledImage, mirroring BlendDecodedImage. The filter argument the entry names is real and mandatory in C, and the answer is an enum with a default: no single filter is right both for shrinking a photograph and for doubling a 16×16 icon, and the wrong choice is silent in both directions, so Image.scaled(w, h) is Lanczos and Image.scaled(w, h, Resampling) is the other four. BL_IMAGE_SCALE_FILTER_NONE is deliberately unbound — it is the absence of a filter rather than one of them.

  • The frame sequence exists twice. Launcher.paint and Offscreen run the same steps in the same order, and only one of them is the hot path with damage, frame statistics, the HUD and the models’ refresh woven through it (ADR-0284). Extracting the common core is the right refactor and was not taken during a feature: what holds them together meanwhile is that every golden image goes through Offscreen, so a divergence moves a picture.

    Closed — ADR-0423. FrameSequence in a new non-exported frame package holds the element tree, the render tree and the router, and both callers use it — every golden unmoved, which is the safety net this entry itself named. The entry was wrong that it is one sequence. There are three orders, not two: a window lays out, paints, then captures; a measuring pass lays out and captures without painting; the drawing pass lays out and paints without capturing. A single “run a frame” method would have needed two booleans about windows. So five of the six steps are shared and draw is deliberately left out — it differs by design (ADR-0072) and has no ordering constraint to protect.

  • No animation strip. One call, one picture. A caller wanting frame 3 of a transition wants to drive the clock between paints, which is an object with a lifetime rather than a builder that renders once.

    Closed — ADR-0424. Offscreen.strip(Widget) returns a Filmstrip: a closeable object that mounts the tree once and answers advance(millis) and frame() repeatedly. The hard part was what stays alive between frames, which the record states rather than leaves to be discovered — every frame gets its own buffer, and the pictures survive closing the strip.

  • No reuse and no cache. Each render builds a fresh element tree and unmounts it, so rendering the same document twice does the work twice. A font book can be handed in and kept; nothing else can.

    Closed — ADR-0425. Studio keeps a renderer over a font book and hands out wired Offscreen builders. The entry named the wrong thing as the cost. “A font book can be handed in and kept” is true, and the book was not the expensive part: the cascade index and the shaping cache were, and both live on the renderer, which had no way in. A result cache is still refused, and for a reason worth keeping — a Widget has an equals, which is exactly what makes it tempting and wrong, because a card closing over a mutable model is equal to a stale one.

  • Nothing renders off the UI thread, and nothing says it must not. A render touches no window and no backend, so a server thread is probably fine — “probably” is why it is written here rather than in the javadoc. What would have to be checked first is the shaping cache and Blend2D’s own worker pool.

    Closed — ADR-0425. Folded into the entry above, because they are one decision: the reusable unit is the renderer over a font book, and that is precisely the object that must not be shared across threads. Both suspects the entry named were clean. The shaping cache is per-renderer and already fail-fast; Blend2D’s pool is process-wide but already degrades to synchronous with identical pixels. The real hazard was Fonts, which had documented confinement since ADR-0044 and enforced nothing — and the unsafe arrangement was the one Offscreen’s own javadoc recommended, “hand over one Fonts and keep it”. That advice is gone and the assert is there; rendersConcurrently drives eight threads to a pixel-identical result. So the javadoc says it rather than saying “probably”.

  • No word-wrap-aware PageUp/PageDown. The page is ten lines, hard-coded, because an editor drawn on a canvas has no viewport to measure. A caller that knows its own height moves the caret itself.

    Closed — ADR-0410. Editor.viewportHeight(double) makes a page max(1, floor(height / lineHeight)), counted in visual lines, with ten kept as the fallback and defended against the four alternatives. A page is the screenful rather than the screenful-less-one, because the overlap belongs to a scroll and this editor’s caller owns the scroll. The entry’s “a caller that knows its own height moves the caret itself” turns out to be more expensive than it sounds — it means re-implementing desiredX column-keeping and intercepting the key before onKey, which is the argument for the setter.

  • The headless clipboard is eager. It keeps the bytes rather than serialising on demand, so nothing in a test exercises the laziness the platform imposes; the upcall path is covered in :natives against the real SDL instead.

    Closed — ADR-0407. It holds a supplier per type now, so a test can assert that nothing was serialised until something asked. Every read calls the supplier again rather than caching, which is the platform’s actual contract: a double that produced bytes at write time rewards an application for serialising per copy instead of per paste, and the real clipboard then silently forgives it.

  • A refusal is not modelled anywhere. Every write returns a boolean and the in-memory clipboard always returns true, so the branch an application writes for “the compositor declined” is only ever taken on a real desktop.

    Closed — ADR-0407. refuseWrites(boolean) makes the false branch reachable from a test, with the default behaviour unchanged — a test seam rather than a new policy. Worth noting that ClipboardDataTest’s own javadoc asserted this gap (“what it cannot model is the platform’s laziness or a refusal”) and was wrong from the commit that closed it; ADR-0286’s identical sentence is left as written, because it was true when it was written.

  • A text-input holding a long value shows its end, not its beginning. TextEdit.of puts the caret at the end and the field keeps the caret in view from its first layout, which is what text-area did until ADR-0297. The fix is the same flag and the same argument; it is not done here because a field is not a document and changing two controls on one screen’s evidence is how a fix becomes a regression somewhere nobody looked.

    Closed — ADR-0412. An untouched field shows the head; the caret stays at the end; a press, key, edit, composition or focus makes it chase again. No markup attribute — the default is the decision, and ADR-0326’s own argument applies, that a call site which must say where the caret goes can forget to. The entry said “the same flag and the same argument” and only the flag was the same: ADR-0326 had already fixed the read-only half and left a test asserting the editable tail on purpose, so this had to argue against a written sentence rather than against silence. It also turned up a real bug the entry could not have predicted — the flag was set after apply()’s equality check, so End on an untouched field did nothing at all.

  • An icon larger than its slot overflows it. An Icon is a path built at a size and cannot be rescaled at paint time (ADR-0043), so a 20px glyph in a menu’s 16px leading column is 20px — centred now rather than parked in the corner, which is the difference between “large” and “misaligned”, but still larger than the column. An application that wants them to fit builds them at 16, and nothing says so at the door. — ADR-0143

    Closed — ADR-0419. Icons.SLOT and Icons.bind(String) name the size at the door, and ItemLead reports an overhang at debug, deduplicated by name, size and column — ADR-0394’s rule, since a warning firing on every legitimately larger icon says nothing. The entry was misleadingly general: item-lead is the only slot in the catalog an icon can overflow, because every other widget uses Box.icon, which sizes the box to the glyph. It also corrected a comment that had the override backwards — .style(style) is applied last, so the CSS width wins, not Box.icon.

  • An outer shadow is painted under the box, not cut out of it. CSS knocks the border box out of a box-shadow so a translucent background does not have its own shadow showing through from underneath. The toolkit paints the whole shape and relies on the box being drawn on top — and cannot do better today, because cutting the hole needs a path clip or a fill rule and the Blend2D binding exports neither. The obvious trick is worse than the problem: a reversed sub-path under the default non-zero winding fills the parts of itself the outer shape does not cover, so the inner half of a blur would paint a dark ring where it was supposed to erase one. It is invisible under an opaque background, which is every shadowed surface the design system has, and shows under a box mid-opacity transition, which fades its shadow by the same factor and so darkens itself slightly. What it would take: BLContextSetFillRule or a path-clip call on the export list, and then one reversed sub-path per band. ShadowPaintTest.throughATranslucentBox pins the current behaviour, so the day that lands there is a test that says the deviation is gone. — ADR-0310

    Closed — ADR-0427. Each band is now filled together with the border box under BL_FILL_RULE_EVEN_ODD, so a point inside both is crossed twice and left empty — one extra sub-path per band, no extra fill. The entry offered a choice that does not exist: Blend2D clips to a rectangle and nothing else, and both bl_context_clip_to_rect_i/_d were already exported, so there is no path clip to prefer and the fill rule was the only option rather than the cheaper one. Its reversed-sub-path warning was right and is why the rule matters. “Invisible under an opaque background” turned out to be true of interior pixels only: twelve goldens moved, every one of them on the anti-aliased arc of a rounded corner where the box covers a fraction of a pixel and the shadow beneath that fraction is now cut away — the same seam a browser has. The fix also retired machinery the entry did not mention: ADR-0310’s occluded band flag has one answer once the hole is cut, so culling moved to ShadowGeometry.coveredAt. ShadowPaintTest.throughATranslucentBox asserted the deviation and now asserts its absence.

  • One non-text pair is below §1.2’s 3:1, and no colour can lift it. This entry said sixteen, and filed them as one thing waiting for one decision. Measured against the arithmetic rather than against the sentence they were three, and fifteen are fixed (ADR-0258). The twelve control boundaries were a gap in the palette nobody had put anything in: Nord stops between --nord3 and --nord4, which measure 1.17:1 and 6.39:1 against --gb-surface-2, so a palette edge is either invisible or a white ring around a dark control — --gb-checkbox-border is the midpoint, at 3.17:1 and 3.22:1. The three marks were --gb-accent on --gb-border, one pair wearing three names, missing by 0.02; the light accent slid to #5c7ea8 and every other pair it appears in moved the same way, so there was nothing to trade against. What is left is the light theme’s slider thumb, and it is not a ramp question: the track sits between a white thumb and a dark accent fill, and clearing 3:1 against both needs its relative luminance at once ≤ 0.300 and ≥ 0.688. No solid colour is both. What has to change is what a light-theme thumb is — a border round it, or a fill that is not white — which is a sentence docs/design-system.md §3 does not contain and is the one genuine decision in the original sixteen. Twenty-two goldens moved, which is why this had waited. — ADR-0258, ADR-0240, ADR-0239, ADR-0088

    Closed — ADR-0429. The entry called this “the one genuine decision” and framed it as a choice between a border and a fill that is not white. It is not a choice: the groove needs a thumb at luminance ≤ 0.209 to clear 3:1, and the light accent fill sits at 0.200, so every fill dark enough to be seen against the bare groove vanishes into the half of the track that is filled. Clearing both at once needs ≤ 0.083 — #525252 or darker — which is the “hole punched through the control” the theme file already rejects twice for the switch. So the fill answers the accent and a 1px --gb-slider-thumb-border answers the groove, and the dark theme sets it transparent because nord6 on nord3 is already 6.40:1. The measurement found a worse pair than the one it went looking for: the light theme’s pressed thumb was var(--nord4), the groove’s own colour, at 1.00:1 for as long as the control has existed — the sweep had only ever looked at resting fills. It sweeps all three thumb states now, one border token covers all three, and MARKS_BELOW_FLOOR is empty. “Twenty-two goldens moved” was the cost of sliding a ramp; an edge touches only the thumb, and two moved.

  • button.ghost has no contrast ratio, and is therefore not checked. Its fill is transparent and its hover is a #ffffff14 wash, so what a user reads depends on the surface underneath — there is no single pair to measure. It is left out of ContrastTest rather than measured against black, which is what ignoring alpha would silently do and would score it as passing. The same is true of --gb-selection. A backdrop-aware check would need the painted frame rather than the cascade, which is a different kind of test. — ADR-0087

    Closed — ADR-0431. BackdropContrastTest renders real trees with the real rasterizer and reads the pixels back — five surfaces × four probes × two themes, forty pairs. It deliberately does not reimplement src-over: a hand-rolled compositor is a second opinion and the first one is what ships. Nothing failed, worst at 4.79:1 (button.ghost:active on the dark theme’s --gb-surface-2), so the entry’s four exclusions were correct to make and correct to leave — what changed is that an unmeasured pass is now a measured one. Worth recording that the check’s own first draft reported three failures that were its fault, sampling three pixels into an unpadded row and reading ink over fill; the flat-region guard that caught it is why the rest is believable.

  • rem is the configured root size, not the root element’s. What ADR-0242 left: em resolves against the element’s own computed size now, and rem still reads CssLength.Context.rootFontSize(). CSS says the root element’s computed font-size, so the two agree unless a root declares one — and recovering that inside ComputedStyle.of is not possible, because a node is handed its parent’s style and not the root’s. It needs a third thing threaded down, or a field on the renderer that is only correct after the root has resolved. Nothing in the catalog styles a root’s font-size, so this is exact today. — ADR-0242

    Closed — ADR-0416. Both halves, split: the root’s own rem falls out of the two-pass structure ADR-0242 already built for em, and descendants get it through WidgetRenderer. The entry called the renderer-field shape “correct only after the root has resolved” as a drawback, and it is the specification — CSS says rem on the root’s own font-size refers to the initial value. And the claim that this was “not possible inside ComputedStyle.of” was half wrong: that half was already in reach.

  • A bare text with no ancestor setting color renders black, which is ADR-0066’s deliberate INITIAL and a trap all the same: the showcase’s new gain label was unreadable on the dark theme. A control gets away with saying nothing because controls.css sets color on checkbox, radio, toggle and slider themselves; a primitive does not. The showcase now sets color: var(--gb-text) on its root, which is what an application should do — but nothing warns one that has not. — ADR-0066

    Closed — ADR-0415. StyleLint.uncolouredRoot(root) answers it as a lint asked for rather than a warning logged, which is ADR-0257’s shape and avoids ADR-0394’s trap of firing sixty times a second on a correct application. The entry implies the check can read the sheets, and it cannot: the showcase — the one application that does this right — writes #root rather than :root, so a synthetic :root probe would have reported the reference application as the defect. It takes the root element instead.

  • StyleElement documents three nullable members inside a @NullMarked package and annotates none of them. type(), id() and parent() each say “or null” in their own javadoc and each is declared as a plain String or StyleElement, in a css package that is marked — so NullAway reads all three as non-null. Nothing had noticed because every implementation lived in an unmarked package; StyleLint’s probe is the first written in a marked one, and it cannot say what the interface says. The lint’s package is unmarked as a result, which is the wrong end to fix it from. Closing it properly means annotating the interface, which moves every implementation and every caller — and would probably find real nullness bugs on the way, which is the argument for doing it rather than against. — ADR-0257

    Closed — ADR-0413. All three are @Nullable now, Selector.Compound with them, and css.lint is marked rather than unmarked — which was the entry’s point, that the lint’s package was the wrong end to fix it from. The entry predicted “real nullness bugs on the way” and there were none. Six NullAway diagnostics, every one already handled at the call site, several with comments explaining why. The finding is better than the prediction: a @NullMarked package had been enforcing a contract nobody believed for long enough that the first code physically unable to lie about it was made to leave the room instead.

  • A widget’s CSS classes share a namespace with the design system’s. A hud reading named display picked up §1.4’s .display type rank and rendered at 28px. Renamed, and nothing prevents the next one: there is no prefix convention, no check, and the two sets of names are written in different files by different people. — ADR-0153

    Closed — ADR-0414. The convention is not a prefix, which is what the entry guessed: both namespaces are published vocabulary, and a widget’s class is already namespaced by its type, so the seven §1.4 rank names are reserved instead and ClassNamespaceTest holds the line. There were two more collisions already in the tree, which is the entry’s “nothing prevents the next one” having already happened twice. TreeRow minted heading, so every branch label in every tree drew at 15px/600 in a fixed-height row — three goldens moved when it was fixed. Skeleton.Shape minted title, harmless in pixels today and waiting for the first em.

  • Editor still shapes its whole text. The canvas editing seam from ADR-0285 holds one Paragraph over everything it is given, which is what text-area did until ADR-0388. TextDocument is exported and is the obvious second caller. Nothing has measured an Editor over a document, so nothing has earned the change. — ADR-0388, ADR-0285

    Closed — ADR-0411. Editor holds a TextDocument now, and the numbers the entry said nobody had taken are taken: a keystroke into 500 kB goes 111.5 ms → 1.4 ms, and the characters shaped go 500 097 → 132 against 129 for a 2 kB document. Opening is still proportional (130 ms for 500 kB) and the record says so. Only the shaping half of ADR-0388 ports: its “drawn a screenful at a time” half does not, because the rows in view are the caller’s scroll and transform rather than the editor’s. gallery-canvas is byte-identical, which is the evidence the change is pixel-neutral.

  • What is still asserted only at 1x, now that the goldens are not. Every image in the golden corpus — about 245 of them now — is drawn again at 2x and 1.5x and checked for being the same picture, and ClipTest, TransformPaintTest and IconPaintTest do the same without a golden behind them — so the whole widget catalog, text included, is now covered against the logical-against-physical family ADR-0157 found. Four classes of direct pixel assertion are not, and two of them are deliberate: BoxPainterTest and TextPaintTest each already carry their own scale cases and would gain little; DamageTest is excluded on purpose, because a damage rectangle is in physical pixels by design and legitimately differs between scales, so an invariance check there would assert something false; and ThreadedPaintTest is about two worker counts agreeing, which is orthogonal. What no test at any scale covers is a fractional scale other than 1.5 — 1.25 and 1.75 are ordinary Windows settings and neither is exercised. — ADR-0162, ADR-0157

    Closed — ADR-0434. 1.25 is in every check’s sweep and 1.75 goes nowhere, which is the opposite of what the entry asked for and is arithmetic rather than thrift: a multiplier exercises Yoga’s rounding, so what matters is the offsets k·m mod 1 visits — 2 gives {0}, 1.5 gives {0, ½}, 1.25 gives {0, ¼, ½, ¾}, and 1.75 gives the same four quarters, re-asking a question 1.25 has answered while its magnitude is bracketed by 1.5 and 2. The cost is about 4 s of check per multiplier repo-wide. Two things the entry could not know: gallery-canvas misses at 1.25 (1.229% against a 1.200% budget) because a hard-edged QR module grid resampled at 5/4 comes back inverted rather than blurred, so that one image is excluded from the sweep by name rather than the budget being loosened on 245 images that meet it with tenfold room; and on that same screen a natural-size image tile differs wholesale between scales where the stretched and cropped tiles beside it differ only at their outlines, which has the shape of ADR-0157’s bug and is now recorded but unswept.

  • Present costs 6.6 ms with no compositor to wait for. The question ADR-0045 opened while closing another. ADR-0031 measured present at ~10 ms and concluded “most of it is waiting on the compositor rather than copying”. Under SDL’s dummy video driver — no compositor, no display, no surface to hand anyone — present still measures 6.6 ms, essentially the same as under Wayland. Whatever that time is, the explanation on record is wrong, and present is the largest single term in a frame. — ADR-0031, ADR-0045

    Closed — ADR-0409. The entry was right that the explanation on record was wrong and wrong about everything else, its own number included. Measured over 300 frames under the driver it names, present’s median is 0.127 ms and its p95 0.319 ms — off by a factor of about fifty — and it is 0.8% of a frame rather than the largest term in one. The largest term is end, at 10.2 ms: the join that waits for Blend2D’s workers, with draw’s 5.9 ms of queueing in front of it, so a frame under dummy is rasterization and almost nothing else. Two plausible misattributions were ruled out on the way and both were already right — the Blend2D join is timed before the present boundary, and the frame pacer’s sleep caps the event wait rather than the painted frame. What survives is the qualifier: under dummy the frame is rasterized straight into SDL’s surface and present copies nothing, so 0.127 ms is the floor rather than the universal figure. ADR-0031’s Wayland number is left standing because it cannot be re-measured here — opening a real surface on this machine takes GNOME Shell down — and the like-for-like Wayland measurement below stays open.

  • Nothing bumps goldberryVersion after a release. Forgetting leaves master publishing 2026.1-SNAPSHOT after 2026.1 is out, which Maven orders below the release. A step in release.yml that opens the bump as a pull request would close it. — ADR-0333

    Closed — ADR-0421. That is exactly what landed, and the entry named the right shape: a bump job that needs: publish, checks out the default branch and opens bump/<next>. Two things it did not say. The arithmetic is the part with a decision in it, and it is a tested value in build-logic rather than a sed — VersionBump moves a patch line to its next patch, because a release/2026.1 branch bumped to 2026.2 would claim a feature release from a maintenance branch, and a sed that incremented the last number would get that right and get 2026.3 in January wrong. And the guard needed no rule of its own: comparing what the default branch declares against the tag rules out both a patch tag and a re-run whose bump already merged. CalendarVersion.nextRelease had been written by ADR-0333 and called by nothing until now.

  • PNG is the only format written. WebP is written too, 2026-09-17, and losslessly by default: VP8 is worst at the flat colour and hard edges a user interface is made of, so encodeWebp() is the lossless path and encodeWebp(quality) is for a photograph. JPEG is still not written and is what is left of this entry: Blend2D ships a JPEG decoder and no encoder, so it means a third codec library or a written one — neither worth it while a lossless WebP is a third of a PNG. — ADR-0385

  • Reduced motion is obeyed but not detected. Detected, 2026-09-17, and obeyed by a renderer the launcher builds: the settings portal on Linux, SystemParametersInfoW on Windows and NSWorkspace on macOS, each a read-only FFM query against a library the process already has, with no native build behind any of them. “The desktop does not say” is still an answer and still is not an instruction. Asked once per process; a change mid-session waits for a restart. — ADR-0383

  • Nothing detects the density the user wants. Answered: there is nothing to detect, 2026-09-17. Reduced motion was detectable because three desktops expose it; density is not, because none of them has such a setting — §1.3’s compact mode is an application’s own decision about its screens, which is what this entry suspected and what asking the three platforms confirmed. — ADR-0383

  • Layout verification has not yet passed in CI. It has, since the first all-green snapshot. The verify legs on all three runners ran the layout probe against the downloaded artifact at d478ecfe, which is the run book/src/status.md records as twelve green jobs. — ADR-0016

  • AsmJit’s W^X handling on Apple Silicon is now reachable. Reached, and green. The showcase painted frames on macos-14 on all three legs and the macOS goldens ran, which is the JIT it was a question about doing its work. What is still untried is AVX-512, which is a different machine and is on the scale-invariance entry rather than this one. — ADR-0338

  • text has no style="body" attribute. It has, 2026-09-17, and it is the same class the stylesheet already had a rule for: style= names a closed vocabulary and is checked, class= is the open one and is not. — ADR-0381

  • One frame only. Every frame of a GIF, 2026-09-17, composited under the file’s own disposal rules, with its delays and its loop count; image.anim.Animation says which one is showing and holds no clock. An animated WebP followed a few hours later, once webpdemux was linked — libwebp composites its own canvases, so the disposal model is upstream’s there. — ADR-0382, ADR-0385

  • A tabs indicator still cannot travel, though segmented’s does. It travels, 2026-09-17, by being displaced onto the header it is leaving and then let go of — a difference between two painted rectangles rather than a position, so a strip painted with no router behind it draws exactly what it drew before. — ADR-0377

  • Two key maps. One, 2026-09-17, and there were three by the time it was read again. text.edit.keys is the table; each editor keeps its own text and answers a sealed EditCommand, so a key added tomorrow fails to compile in the three places that have to answer it. Converging the editors is still the rewrite it always was. — ADR-0376

  • Nothing has a minimum size, so overflow is silent. It says so once, 2026-09-17. The layout pass asks the root whether its line overran — one foreign call on a frame where everything fits — and names what is off the edge when it did. A scroll viewport and an absolute child are not overruns. — ADR-0375

  • align-content is still absent. It is resolved, 2026-09-17, defaulting to stretch, which is what every box in the catalog already did. It also gives SPACE_BETWEEN and its two neighbours a property that means them: they were constants the enum advertised and no declaration could reach. — ADR-0374

  • flex-basis is one of two layout properties §8 names and nothing resolves. It resolves, 2026-09-17, and masonry is the consumer that wanted it: flex-basis: 0 with flex-grow: 1 is 1/n of a row after its gaps, where the inline width: 100/n % it replaces was 1/n before them. The collapse that took it out in the first place is still real and is now the author’s to avoid. — ADR-0373

  • statistic’s sparkline waits on canvas. Built on 2026-08-23, and the entry outlived it: canvas is in the catalog and the sparkline is the last child of the column, exactly as this said it would be.

  • No image cache for Image.decode itself. Answered rather than built, 2026-09-17. ImageLoader.shared() is public, bounded and off-thread, and a canvas painter that decodes by hand can use it — which is what the entry itself named as the seam. A second cache inside Image.decode would be one the caller cannot see, cannot bound and cannot clear. — ADR-0358

  • Nothing reorders tabs. A strip with onReorder does, 2026-09-17. The dragged tab follows the pointer 1:1, which needs no interpolation, and the drop asks the application for the new index; the others still jump into place, which is ADR-0097’s geometry and nothing asked for more. — ADR-0372

  • An affix pins on one axis. On one per axis, 2026-09-17: edge="top left". There was no rule to write about which wins, because each axis is its own subtraction. — ADR-0371

  • A reveal moves both axes at once. By default, still, 2026-09-17, and a caller that means one axis says so with reveal(self, clip, axes). — ADR-0370

  • The circular drag is not built, and §3 offers it. It is, opt-in, 2026-09-17, with no accumulated angle: a jump across the gap is recognised from the knob’s current value, and held at the nearer end. — ADR-0369

  • A select tree=#true has no typeahead. It has the tree’s own, 2026-09-17. The design question was answered by ADR-0209 (visible rows only). The defect was elsewhere: a tree moves its typeahead with host.focus(id), and a popup’s host asked only the window. Focus by name now tries the open popups, topmost first. — ADR-0368

  • A list is Java, like canvas and like autocomplete. A document places one, 2026-09-17, and table and tree the same way: bind= names the widget the model built, since its factory is code. Autocomplete names the bound list its answer lands in, with suggestions= or options=. — ADR-0367

  • A tab’s content is rebuilt when it is selected again. Only by default, 2026-09-17. keep-alive keeps every shown tab mounted and hidden while another is selected, through a new Styled.isHidden(): kept, not rendered, not focusable. §5’s lazy default is unchanged. — ADR-0366

  • A tab strip scrolls, and has no chevrons at either end. It has them, 2026-09-17, while it overflows: a ScrollController can now say where its viewport is and when that changes, which was the missing question. — ADR-0365

  • The “always show scroll bars” gutter is not built, and nothing switches it. Both, 2026-09-17, in density’s shape rather than a settings mechanism: Scrollbars.ALWAYS is a token stylesheet an application passes to Controls.stylesheets, and a viewport that finds a gutter pads its content by it and stops fading its bars. — ADR-0364

  • A revealed row lands rather than glides. It glides, 2026-09-17, over the overlay duration. The offset goes to the target at once and the viewport draws the way there on the frame clock, so direct input never waits; a reveal asked mid-glide measures where its row will be. — ADR-0363

  • A text-area has no visible scrollbar. It has scroll’s, 2026-09-17: neither a scroll around the text nor a second bar. ScrollBar is three numbers and two callbacks, and a text area knows all three. — ADR-0362

  • A table has no column resizing. It has, 2026-09-17, by asking: a resizable column’s header carries a grip, and a drag asks the application for a width in pixels anchored at the width the header last came out as. Not a split-pane between the headers, which divides one box between two panes. — ADR-0361

  • A pinned affix is not pushed out by the next one. It is, 2026-09-17, without knowing its sibling: an affix stays inside the box it is in, which is CSS’s rule for sticky, so a section’s header leaves with its section. The same change gave table a sticky header, which this entry was blocking. — ADR-0360

  • A select’s field is as wide as its current value. As wide as its widest option, 2026-09-17. The value cell’s preferred width is the widest of the option labels and the placeholder, shaped in render; a stylesheet’s width still wins. — ADR-0359

  • There is no img widget. There is, 2026-09-17: image, with the object-fit modes, a natural size that takes part in layout, loading and error states and a decode that does not run on the UI thread, which is the list this entry said a catalogue entry would need. SVG is still not decoded. — ADR-0358

  • A step’s connector fills by colour, not by scaleX. It grows, 2026-09-17. The reason given was that §8’s subset has no transform-origin, and it has had one since ADR-0068. The sentence was copied from an older note and never checked against TransformTest. The connector is now a track with a fill scaled about its start edge. — ADR-0356

  • A badge cannot be a timeline’s marker. It can, 2026-09-17, from a marker child that entry lifts onto the axis. The slot is named rather than inferred, so a badge written as content stays content. The rail keeps its width and a wide marker overhangs it. — ADR-0356

  • A floating button does not scale in. It does, 2026-09-17, from an @starting-style: the entering state the overlay layer had no way to give it, and CSS’s own answer to “what does an element transition from on its first frame”. And it scales out: a FloatSlot bound to a switch puts leaving on the button, the stylesheet plays the exit on fast, and a host timer removes the overlay after it. A general closing phase for overlays is still not built, and nothing else has asked for one. — ADR-0352, ADR-0355

  • A field’s room is width - 2 × left padding. Each edge comes off once, 2026-09-17, in text-input as in text-area. — ADR-0355

  • §8’s subset has no @keyframes and is not going to grow one. It has one, 2026-09-17. ADR-0081’s argument was about loops that must stay in phase, and those stay clock functions. What it did not cover is authored motion (sequences, staggers, fills) that otherwise needs a widget of its own. §1.7’s rule 4 still binds the toolkit’s sheets, and ToolkitLoopsTest checks it. — ADR-0353

  • The published javadoc is built with doclint off. On, less missing, and clean across every published module. The 120 errors were one idiom — @param lines on a …Calls holder class rather than on its call method, 425 of them, moved by a script — and twenty [Foo] links to a type in another package or module, three of which named things that no longer existed. The missing group stays off because this codebase documents in prose. — ADR-0343

  • No licence text is vendored yet. All seven are, 2026-09-17. The superbuild’s cached checkouts under natives/.deps/<target>/<name>-src are the pinned revisions — their HEADs were compared against libs.versions.toml before copying — so the verbatim files came from there rather than from a download that might have been a different tag. checkLicenses -Pgoldberry.releaseCheck=true passes with eleven components. What stays true: a bumped pin means re-copying that one file, because the copyright lines are the upstream’s and not the licence’s. — ADR-0015

  • IME preedit is not drawn, and committed text already works. IME preedit is missing entirely. Both were closed by docs/gaps.md G15 and G16 and the entries had not been struck. SDL_EVENT_TEXT_EDITING and SDL_SetTextInputArea are bound, text.edit.Editor draws the composition where it will land, and text-input and text-area take one inline; a password deliberately does not, because a candidate window is an unmasked window. — ADR-0289, ADR-0292

  • The native image’s foreign registrations are generated but the fix is untested on an image. Tested by hand on all three platforms, 2026-09-17. A manual Showcase run built the image on Linux, macOS and Windows, and the html, canvas and Markdown screens open on each. What the hand test found on the way was not a foreign call but a resource: the canvas screen’s five sample images had never been declared, which ADR-0160’s rule already covered and DeclaredResourcesTest now enforces. What is still open from this entry: CI runs the image for three frames and opens no screen, so a --screen=<name> launcher argument would let it do what the hand test did. — ADR-0339

  • A Validator is over a String, and date-picker will want otherwise. The second seam turned out to be a composition, and field needed no change at all. Validator.parsing(parse, message, rule) is a rule over the parsed value expressed as a rule over the text it was parsed from, so Field still holds a Validator<String>, FieldState still reads its control’s binding as text, and neither of them knows a date was involved — which is the evidence that this was never a type parameter. It also keeps this entry’s own premise intact rather than contradicting it: what the user typed is text until something parses it, and a validator is exactly the thing that decides whether it can be. parse may throw or answer null and both mean the same thing, because java.time throws where a hand-written parser returns null and a seam that took only one would make the other an application writing a try/catch to satisfy a method. An empty value passes without the parser running, for matching’s stated reason. — ADR-0274, ADR-0169

  • An absolutely positioned child is placed against the border box, and the clip is the padding box. Fixed where ADR-0265 said it was, and the golden tail was somewhere else. ContainingBlock shifts an absolute child’s inset by its containing block’s padding on the way to the Yoga node, per edge and only on the edges the box named — Yoga’s no-inset path already lands on the padding and is already right. On the style rather than on the computed rectangle, because with left and right both given Yoga derives the child’s width from them: a correction applied after the layout pass could have moved the child and could not have resized it. Percentages are declined on both sides of the sum and say so, since a percentage inset resolves against a size that does not exist yet. text-input’s and text-area’s compensations came out in the same commit, as ADR-0265 insisted they must. What the record could not predict is where the images moved: not segmented, tour or scroll, whose parents genuinely have no padding, but tabs — an underline pinned across a header with padding: 0 12px came out 24 points short of its own label — and toast, where an overlay pinned to a corner started counting from the application’s content box instead of from the window. Both mean the border box and now say so, through acrossBorderBox, in terms of the padding their own style resolved rather than a number repeated in a stylesheet. — ADR-0272, ADR-0265, ADR-0167

  • A window that moves does not re-clamp its popups, and a scrolling anchor does not drag one. Both halves are built, and the second one was a question nobody was asking rather than a report nobody was making. The move half is what the entry described: BackendEvent.Moved, an SDL translation of WINDOW_MOVED deduplicated per position, and a fabricated-event test under the dummy driver. It re-places immediately rather than after the next paint, which is the opposite of the resize case and for the stated reason — a move produces no new capture and invalidates none, so the current one is the right one, and no repaint follows a move at all because nothing inside the window changed. The scroll half was proposed as Located on the anchor, and the anchor turns out to have nothing to report: anchor(id) answers from a hit-test capture taken every frame, so the position already had a fresh answer and what was missing was the question. replacePopups now runs at the end of any frame with a popup anchored by id, which is also the guard — a popup opened against a rectangle a caller computed has nothing to re-resolve. Underneath both was a third thing, and it had been wrong since before anything scrolled: a popup anchored to Region.bounds(), the layout rectangle, which for a button inside a scroll is hundreds of pixels from where the button is drawn. It reads painted() now, in all three places, and the two rectangles are identical for every box nothing transformed — which is why it took a scrolling anchor to show it. — ADR-0270, ADR-0231, ADR-0123

  • Nothing reports a dropped frame. late is a reading now, and the pacer is what can count it. Both halves the entry named are in one number: the frames the loop never reached, which leave no record in a ring that only holds frames that were painted, and the frames the platform refused after they were painted, which were in the mean as though somebody had seen them. The arithmetic is max(pendingSince, lastFrame + interval) — and pendingSince is the whole of why this is honest, because the naive version (the gap since the last frame, over the interval) reports a window nobody touched for a minute as three and a half thousand dropped frames. It drew every frame it was asked for. §1.7’s idle loop is the common case, so a count of missed refreshes that does not know when the request arrived is a count of how long the user was away. A window rather than a total, like every other number on FrameStats: a total only ever goes up, and somebody watching a HUD while they work would never see it come back to zero. — ADR-0271, ADR-0146, ADR-0101

  • “Pixel-precise wheel deltas” is not reachable through SDL. A difference rather than an agreement, and the entry that outlived its own resolution: §7.1 asked for pixel-precise deltas with a line-based fallback, SDL reports only detents as floats, and the two platforms with a pixel axis underneath do not surface it. What §2.4 actually wanted from “pixel-precise” is scrolling that does not quantize, and a fractional line delivers that without the mechanism the sentence named — so the honest change was to the document rather than to the backend. This had already been settled and said so in this file’s own introduction while the entry sat on in the list below it. — ADR-0115, ADR-0056

  • Every pointer event now costs an SDL_GetModState. Measured: 8.71 ns a call, so 34.8 µs per second of dragging. Polled per event rather than carried on it, because SDL’s mouse events have no mod field where its keyboard events do. The entry said “not measured — named here so it can be if a profile ever points at it”, which is what ModifierPollBenchmark (./gradlew :natives:benchmark) is: at a generous 4000 events a second that is 0.0035% of one core, spread across a whole second of frames whose budget is 16.7 ms each. Nothing to do, and nothing to carry on the event either — ADR-0089’s reason for polling stands, since latching the mask from the last key event leaves it stuck down when a window loses focus mid-chord. The number is the answer: the next person to wonder should find the measurement rather than repeat the worry. — ADR-0089

  • A guard at the top of onPointer is a guard on every pointer kind, and nothing warns. It warns now, once per kind per node type. The entry’s own last sentence was the design: a default that is “right for dragX’s NaN and quietly wrong for a null button”, because the two are not the same kind of default. NaN is arithmetic — the meaninglessness propagates and every comparison against it is false in both directions, so a caller cannot act on it by accident. A null button is a reference: it is unequal to everything, so button() != PRIMARY is true for a move and the guard fires backwards, keeping the press it was written for and dropping every drag. It stays null rather than throwing — an input handler that threw would turn a lost drag into a window that falls over — and Button.NONE was the other shape and fixes nothing, since the guard still fires backwards against a value that now looks deliberate. All nine button() reads in the toolkit are already inside a kind check, so it fires on the mistake and on nothing else. — ADR-0266, ADR-0168

  • A widget can reach its window, and the in-window overlay layer is still the application’s. Toasts.of(context) is the door, and it is a map. The entry had already shrunk once — BuildContext.host() answered the popup half — and what was left was a toast raised from a handler deep in the tree, with every layer above it carrying a callback. findAncestorState cannot do it and it is worth knowing why: a toast stack is mounted in the overlay layer, which is a sibling of the application’s root under window-root and not an ancestor of anything inside it, so walking up reaches window-root and stops. host() gives the window and Toasts.at is the one place that knows which stack is on it, so the mechanism is a weak map from the first to the second — kept in :widgets, because :core learning what a toast is would undo the reason Toasts, Menus and Dialogs are three classes there rather than three methods on the window. A general host.service(Class) is the shape to reach for if Menus or Dialogs ever want the same thing, which is ADR-0140’s own rule about one consumer not being enough. — ADR-0264, ADR-0140, ADR-0100

  • The enter/exit lifecycle is a tab’s own, not the toolkit’s. A tour has no arrival or exit. The promotion happened two records ago, and the tour uses it now. The first entry asked for TabPhase to be promoted “when the second consumer arrives”; it is widgets.core.Phase, moved there by ADR-0166 — whose own javadoc says “there was never anything tab-shaped in it” — with the closing → removed half extracted into Departure by ADR-0234. Six families use one or both, including toast and dialog, which is to say both of the consumers the entry named as wanting it. So the second entry’s “that is TabPhase again” was pointing at a wall that had been a door for milestones, and what was missing was a tour walking through it. It has an arrival now (opacity and a 4px rise, §3.1’s popover row bar the scale, which popover itself also lacks and for the same transform-origin reason) and a travelling cut-out (one rectangle interpolated, so the ring, the hole and the card cannot disagree mid-flight). AnimationSweepTest caught two real gaps within a minute — a Phase on TourVeil with no isAnimating, and a package with no test naming it — neither of which an image would have shown. The goldens did not move: TourGoldenTest has a virtual clock now, so the settled tour is the picture it always was. — ADR-0269, ADR-0166, ADR-0109

  • A tour card’s height is estimated, not measured. It is measured, and the mechanism was already in the file. The entry said measuring “needs the measure-then-place machinery ADR-0104 built, which works on windows rather than on boxes” — and TourStop already banks the window’s own rectangle from the frame before, through Located. The card is one node further in and Measured is the same door. ESTIMATED_HEIGHT survives with a narrower meaning: what the first frame decides with, before anything has been laid out and had a height to report. Measured’s third rule holds by construction — the card’s width is fixed and its content is the stop’s own text, so the height does not depend on whether it was placed above or below — which is masonry’s argument rather than the scrollbar’s, and is asserted rather than claimed. — ADR-0268, ADR-0121

  • A tooltip’s 500ms delay is a constant, and the token that would replace it cannot be read. Both blockers expired, and one was never true. The first — “nothing above the cascade can read a resolved custom property” — is BuildContext.token (ADR-0254), and the launcher holds an Element, which is a BuildContext. The second asked whether the design system should carry a duration that is not motion, and design-system.md §3’s tooltip row had already answered: “delay 500ms show / 100ms move-between”. So the question was settled before it was asked — and the code had built the first number as a constant and the second not at all, so a user reading along a toolbar was served the full sentence of hover intent at every button. That is a specified behaviour that was never built, hiding inside an entry about tokens. BuildContext.duration is token’s sibling with the cascade’s own ms/s parser where its length parser is — made public rather than written twice, because two readers for one syntax disagree the day either grows a unit. — ADR-0262, ADR-0105

  • A focus ring is only ever pictured on the dark theme, apart from one. There is a rule now, and it is a test. The entry asked for “a rule about which states are worth a second theme rather than one more image”, and the rule is narrow on purpose: §2.2’s ring is the one mark in the system with no second means of being seen — a hover has a wash, a checked control has a fill, a disabled one has its opacity, and each of those is drawn in colours some other golden already covers. A ring is only a ring, and --gb-focus differs per theme. FocusGoldenPairTest reads the resource directory rather than a list, so a focus golden added next month is checked next month; it also asserts that the sweep found something, because a discovering test’s own failure mode is passing by seeing nothing. Three images came with it — menu-focus-light, menubar-focus-light, tabs-focus-light — and doubling the whole corpus was the alternative and is not a rule so much as the absence of one. — ADR-0261, ADR-0240

  • An icon-only segment has no accessible name, and neither does an icon-only button. name= is on Attributes now, so every widget has one. The entry separated two things that had drifted together: “§13’s semantics are M5’s” is true of the AccessKit bridge, and was never true of Semantics.role() and accessibleName(), which have shipped for milestones with a sweep enforcing them. What was missing was somewhere to put a name a widget cannot work out — an icon-only control’s label is the empty string by construction, so Button.accessibleName() answered "": a control a reader cannot announce, passing a sweep that only checked for null. It sits beside tooltip and context-menu for their reason, which the entry had already written (“a gap the whole catalog shares”), and the label wins where there is one. — ADR-0260

  • --gb-list-row-height has no consumer. It has two, and has had since list shipped. ListState reads the token through BuildContext.token (ADR-0254) and list-row writes height: var(--gb-list-row-height), so the number the density decides is the number the rows are and the number the spacers are spaced by — which is now checked, since a disagreement between the last two is reported (ADR-0257). The entry’s closing line, “list is M3”, is the other half that expired: list is built. — ADR-0257, ADR-0254, ADR-0074

  • A static @Action is still unsupported, and nothing refuses one explicitly. Both paths refuse one by name, and both refusals are tested. The entry was right that it cannot work — the woven path would generate target::method for a method with no target, and the reflective one writes findVirtual — and wrong that nothing says so. ModelWeaver throws a WeaveException and RuntimeBinding an IllegalStateException, both reading “is static; an action changes a model, and a static one has no model to change”, and ModelWeaverTest.staticAction and RuntimeBindingTest.staticAction are the two tests. Nothing was built to close this; it had been closed and the entry was not updated, which is the argument for reading an entry against the code before believing it. — ADR-0098

  • Nothing warns when a declaration is dropped for being unsupported, in an application’s stylesheet. An application’s stylesheet can still be all classes, and nothing says so. Both are asked for now, which is what both entries said the answer had to be. StyleLint is SupportedPropertyTest’s machinery with the test taken off it: every rule through the real cascade, every declaration to the real ComputedStyle, and Finding values back with the line and column the parser saw. Neither entry wanted a louder log and ADR-0216 is why — a dropped value already warned at WARN and group-box-title drew square corners for months anyway. The engine side is four lines: with returns this in exactly two places and both are failures, so identity is the answer and it cannot drift from the behaviour because it is the behaviour. What it deliberately does not do is tell the two failures apart, which would mean the engine reporting rather than being asked — thirty edited switch arms in the frame loop for a difference the author reads off §8’s list either way. An unresolvable var() is not a finding either: it is already the resolver’s report, once, which is ADR-0243’s shape. The test that used to do this lost a logback appender, two sentence-matching filters and its own two guard tests, and gained two sweeps it could not afford — the light theme and the compact density, either of which can resolve a var() the other does not. — ADR-0257, ADR-0249, ADR-0216, ADR-0215

  • flex-grow means nothing inside a scroll, and nothing says so. It says so now, once per axis. The entry expected this to be hard — “a diagnostic would have to know that a grow resolved against an unbounded main axis, which Yoga knows and does not report” — and it is a field comparison: ScrollContent.render is handed its children as boxes, with flex-grow already resolved, and the content box’s main axis is the scrolling axis by construction. Nothing had to be asked of Yoga. It also catches a widget that set the growth itself, which no rule in any stylesheet would have shown. ADR-0251’s warnIfNestedOnTheSameAxis is the shape, down to the static set that keeps it a message rather than a stream. — ADR-0257, ADR-0251, ADR-0116

  • A row height that disagrees with the stylesheet is a silent layout error. It is reported now, and by a simpler mechanism than the entry predicted. It asked for “a Measured assertion on the first built row”; what it got is the cascade, because list-row declares height: var(--gb-list-row-height) and that number is resolved before the row is laid out — so the check is exact, free and a frame earlier than a measurement. Two things it turned up. The mismatch has to be seen twice, because the first build of a tree has no cascade (ADR-0254): a list reading the token answers the default on that build and the stylesheet’s value on the next, so under a compact density there is one frame of a real disagreement that settles by itself — and reported naively, the form that cannot be wrong would have been the noisiest one. And that forced the second: the check runs on one row of the window, since twenty rows resolving the same height would report twenty times a frame and “seen twice” could not then tell a frame from a sibling. The entry’s last sentence was already stale — reading --gb-list-row-height is ADR-0254’s door and it is open. — ADR-0257, ADR-0254, ADR-0213

  • A slider’s value label is left-aligned in its box, because §8’s subset has no text-align. It is text-align: end now, and nothing had to be added to Box. The entry’s reason was quoting §8’s own note — “Box cannot express them” — and that note was right about backdrop-filter and letter-spacing, wrong about this one, and has since been overtaken on box-shadow too (ADR-0310: a Decoration component and a stack of rounded rectangles, no Box field required). Paragraph.paint is already handed the box’s width, because it has to be or the text could not wrap to it, and every TextLine has already measured itself: the two numbers an alignment needs were in the same method the whole time, and what was missing was a keyword saying what to do with them. slider-value is width: 40px by declaration (ADR-0080), which is exactly the condition under which an alignment means anything — and the four goldens that moved are all the same readout, slider-value.png plus the showcase’s Basic screen in its three variants, with 9%, 50% and 100% finally lining up on their trailing edge. left and right are refused, for ADR-0247’s reason: they are not the same as start/end under RTL. justify is refused for a different one — it is a respacing rather than a placement, and a paragraph shaped once has nowhere to put the extra advance. — ADR-0256, ADR-0255, ADR-0080

  • A menu row overflows rather than ellipsising, and the missing property is white-space: nowrap. A segment’s label overflows its cell when it is longer than 1/n of the bar. Both are cut now, and so are option and select-value. The entry was right about the property and right about why three attempts at clipping had failed: a box with text is a measured leaf, so narrowing it re-measures the paragraph and wraps it, and there is then nothing overflowing to clip. §8’s subset has white-space: normal|nowrap and text-overflow: clip|ellipsis now, and white-space is the whole mechanism — under nowrap the measure function ignores the width Yoga offers and reports the width the text wants, so a box may be laid out narrower than its own content, which is the state the clip and the ellipsis were always waiting for. Three things worth keeping. text-overflow is read only at paint time: an ellipsised line is drawn short and measured long, because a paragraph whose measurement shrank from being truncated would let the ellipsis decide the width that caused it. The cascade carries the two properties apart and hands out one value, because CSS inherits white-space and does not inherit text-overflow and a bundle cannot be half-inherited — so whiteSpace had to join inheritsSameAs as well as inheritingFrom, which is ADR-0248’s standing warning. And ADR-0148’s flex-shrink: 0 came off the label rather than being reverted: it stopped the wrap by stopping the shrink, and nowrap stops the wrap without it. The accelerator keeps its own, because half of Ctrl+Shift+K is not a shortcut. What ADR-0235 left that is not closed is progress’s indeterminate sweep, which is a design decision about a shipped animation. — ADR-0255, ADR-0235, ADR-0148

  • --gb-list-row-height has no consumer, and no widget can read a resolved custom property at build time. BuildContext.token is the other door, and it was three lines. Element already implemented both BuildContext and StyleElement, and ElementTree has held a StyleResolver since ADR-0149 — what was missing was the method. WidgetRenderer.prepare is the new part and it is about ordering: render hands the tree its resolver on the way in, which is a frame too late for a reader in build. The stakes were higher than a repeated number: density-compact.css sets --gb-list-row-height: 26px, so a list written virtualized(32) virtualizes on the wrong pitch the moment an application switches density. Two things measuring turned up. The first build of a tree has no cascade — a Stateful widget builds inside the ElementTree constructor, before any renderer exists — so a token there answers its default and the second build is the first that can see the stylesheet; a virtualized list settles by construction, and that is now written down rather than assumed. And the token must be declared at or above the list, because ListView is a composition node whose state builds the list element — which is where it ships, on :root. — ADR-0254, ADR-0251, ADR-0213

  • --gb-caret-width is not a token and the caret is one logical pixel. It is a token now, and it was an accessibility gap rather than a styling question. The entry’s diagnosis was right — a caret { width: 3px } is overwritten rather than honoured, because the caret’s box is set after the cascade — and the token was not shipped only because nothing could read one, which ADR-0251 changed. A thicker caret is a low-vision aid, which is why §13 lists that kind of switch. Two things the entry did not mention. text-input and text-area each had their own CARET_WIDTH = 1, the second’s comment saying it was the first’s — one constant in widgets.form.Carets now, with a test that says they agree. And there is a third consumer: TextInputState.laidOut reserves “the caret’s own width of room” so a field does not scroll short of showing it, hard-coded to 1 — a three-pixel caret against a one-pixel reserve is a caret clipped at the end of the text, which is the failure that would have looked like a text-rendering bug. — ADR-0253, ADR-0251, ADR-0167

  • A window’s maximized state is write-once and cannot be read back. All three are built, and the question the entry left open has an answer that follows from what maximizing is. Window.maximize(), restore() and isMaximized() ship, and SDL_EVENT_WINDOW_MAXIMIZED/RESTORED arrive as one BackendEvent.MaximizedChanged — FocusChanged’s shape, because SDL sends two and every consumer wants the boolean. isMaximized() answers what the platform last reported, not what was last asked. ADR-0221 had already established that maximized is a state rather than a size, and every platform routes the ask through a window manager that may refuse it — so a flag set on the way out would be a lie the moment one did. The cost is stated rather than hidden and is asserted by a test: between the request and the event, isMaximized() is still false, because that is a window which has been asked and has not yet agreed. It is also what makes the interesting half work — an application can learn that the user maximized it, which no amount of tracking one’s own calls can produce. — ADR-0252, ADR-0221

  • A widget cannot read a resolved custom property, so scroll’s line height is a constant. It can, and the entry was half stale when it was written. Paints.Context.color has read one since ADR-0195 — that is how a chart gets --gb-chart-1…8 — so what was missing was the same door for a number, and length is it. The interesting half is that reading it is not enough: the wheel arrives where there is no context to ask, so ScrollViewport reads the token in render and banks it into ScrollState through the shape onMeasured already had. That makes it a frame late, which is ADR-0117’s bargain unchanged — a paint always precedes an input. What is left is list, and it needs a different door; it is above. — ADR-0251, ADR-0195, ADR-0116

  • Nested same-axis scrollers are banned in the canon and nothing enforces it. A nested pair says so now, once. BuildContext.findAncestorState is the whole implementation — it exists for scrollIntoView and answers this question with nothing added, which is why it is asked in ScrollState.build rather than by teaching the renderer about scroll views. It stays a diagnostic and not a refusal: chaining already makes the arrangement work, and turning a canon rule into a crash is worse than the rule going unheard — the author’s problem was that nobody told them. Deduplicated by axis for ADR-0243’s reason, because build runs per element per invalidation and a document that nests in four places has one mistake. — ADR-0251, ADR-0243, ADR-0116

  • stack is still owed. It is built, and it positions nothing. The entry’s own last sentence had become “what stack still wants is stack” once ADR-0244 took its last blocker. It is nine lines: the first child stays in flow so the box has a size — a stack whose children are all out of flow is a box of nothing, and this is what makes wrapping an existing widget in one a change that cannot move it — and every child after it is position: absolute so an overlay cannot resize what it sits on. §1’s “positioned by alignment or absolute insets” needed no code at all: both already worked, and this is the case ComputedStyle.INITIAL’s inset comment has been describing since before anything could reach it — “the difference only shows on an absolute node, where zero would stretch it and undefined leaves it where the alignment put it”. Z-order is document order, which is the painter’s existing rule for siblings. — ADR-0250, ADR-0244, ADR-0100

  • A style that really changes still re-resolves its whole subtree, and only the inherited properties can matter. It compares the inherited half now, and the notion the entry wanted already existed. ComputedStyle does have a list of what inherits — inheritingFrom is two lines, color and typography, and its comment even enumerates what is deliberately not there. What was missing was reading it twice. The difficulty was not the comparison: stableStyle’s return did two unrelated jobs, the children’s cache key and what the node paints, so loosening it would have handed back an older instance with last frame’s transform and then painted with it — a scrolling viewport frozen at its first offset while every child cached happily. Two variables, because there are two jobs. The test for that is the one that matters and it was written against the mistake: folding the roles back together fails it. — ADR-0248, ADR-0142

  • The rule buckets are only as good as the stylesheet, and nothing enforces that the toolkit’s own stay type-first. They are enforced, and measuring found the assumption was already half wrong. 16 of 340 rules named no type, and they were two families rather than a scattering. Seven were tour’s parts — built from plain Text and Button widgets carrying a class, so every one was matching a known type and simply not saying so; text.tour-title matches exactly what .tour-title matched and lands in a bucket. Seven rules, one word each, and no golden moved, which is the evidence that it changed what the cascade looks at rather than what it finds. The other eight cannot be qualified and should not be: the typography scale is ranks an application puts on whatever it likes, and :root is the theme’s token layer. RuleBucketTest holds those eight as an exact set — a threshold is a number somebody raises. — ADR-0249, ADR-0152

  • --gb-surface-2 has been mistaken for an elevation three times, and it is unresolved whether it should keep existing. It stays, and the trap is an asserted fact now. The look the entry asked for found five readers and not one of them wants a direction: a default badge’s fill, a scrollbar on hover, a group-box-title band, a skeleton-bar and a collapsed split-divider. Every one wants a plate merely distinct from what is under it, which is what the token promises — the three that were wrong wanted “raised” or “sunken” and have their own tokens now. Renaming it would not have stopped a single one of them. What does is ThemeTest: --gb-surface-raised is never darker than --gb-surface and --gb-surface-sunken never lighter, on both themes — and --gb-surface-2 takes opposite directions in the two files, a step up on dark and down on light, which is exactly why each consumer looked right to whoever wrote it and wrong to everybody on the other theme. The theme files carry the same sentence at the definition. — ADR-0245, ADR-0168

  • A select’s typeahead works closed and not open. It works open, and the condition the entry set for adding a capture phase was met. The entry named the fix — Handles had an onKeyCapture and no onTextCapture — and refused to add it on spec, “because a capture phase is a routing rule and inventing one for a single consumer is how a router grows two”. There is a consumer now. textInput captures root-first then bubbles, which is dispatchKey’s shape exactly and removes an asymmetry nobody had written down: one event kind had a phase the other did not. SelectList reads the letters on the way down and calls the same typeahead the closed control calls, so n, n, n cycles the same options in the same order either way. It consumes what it acted on, leaves blank text alone — a space means “pick this one” everywhere else — and a tree gets none, which is above. — ADR-0246, ADR-0141

  • Whether to accept CSS’s alignment aliases is open. They are taken, because they are not aliases. align-items: start is CSS — Box Alignment Level 3 — and Yoga has only flex-start, so the toolkit was dropping a declaration the specification allows and telling the author they had made a typo. It filled the Panels screen’s console for long enough to need deduplicating before anybody asked whether the declaration was actually wrong. Two entries in one map, applied in keyword after the enum’s own lookup so a constant named START could never be shadowed by it. left and right stay refused: they are justify-content only and are not start/end under RTL, so §2.4’s bidi support means the toolkit cannot promise they stay equivalent. Two tests that encoded the old decision were rewritten, both in the group that exists because of this typo. — ADR-0247, ADR-0216

  • align-self is not in §8’s subset. It is built, and the entry was wrong twice in the toolkit’s favour. §8 had listed align-items/self/content all along and named only flex-basis as unimplemented — so the document claimed this worked, and what was missing was the implementation rather than the sanction. And Align.AUTO was already waiting for it: the enum’s own comment says “AUTO only means anything for align-self”, a value that existed for a property that did not. The price the entry quoted was real — 47 positional argument lists across two records, with alignItems and alignSelf the same type, so a swap between them compiles and runs — and it was already insured. RecordWitherTest has existed since ADR-0181 for exactly this: it asks every wither to set its component to what it already holds and requires the record back unchanged, which no transposition survives. The clean sites were scripted and the four carrying inline commas edited by hand. stack is one blocker lighter; what it still wants is stack itself. — ADR-0244, ADR-0181, ADR-0111

  • Nothing warns that a var() resolved to nothing — it logs, per node, per frame. It says it once, and the field is the resolver’s rather than a static. The entry named the fix — “once per property per stylesheet would make it a diagnostic” — and ComputedStyle had already met the same problem one stage later and answered it (ADR-0216): a stylesheet is static, so a declaration that cannot resolve cannot resolve next frame either, and “that is not a louder warning, it is a quieter log”. The difference worth having is where the set lives. ComputedStyle’s is static and needs a public forgetReportedDrops() for tests; a StyleResolver is built per stylesheet set and lives as long as its renderer, so once per resolver is once per stylesheet — a theme swap builds a new one and legitimately reports what the new theme is missing, and a test is isolated by constructing its own rather than by remembering a static hook. Keyed by property and element type, which is a refinement of the entry: the same token failing on button and on text is two facts, and which types it reaches is the blast radius somebody debugging it wants. A cycle is keyed by the property alone, because that is a fact about the property. The drop is unchanged and still happens every time; only the report is once. — ADR-0243, ADR-0216, ADR-0121

  • em and rem do not resolve against the node’s own font-size. em does now, in two passes, and the fix needed no plumbing at all. CssLength.Context was always the right shape; what was missing is that nothing built one per element — WidgetRenderer holds one for the whole tree and handed the same instance to every node, so em was one constant at every depth. The two passes are CSS’s own rule rather than a refinement: on font-size an em is the parent’s size, because the value being computed cannot be its own input, and on everything else it is the element’s own. Both were already in hand — parent.typography().size() is passed for inheritance anyway. Measuring it turned up a number the entry did not mention: Context.DEFAULT is 16 and Typography.INITIAL is 13, so 1em was not the parent’s size, not the element’s own, and not any size the toolkit renders text at. Transform was the same bug in a second place and said so in a comment; it takes a Context now. What is left is rem, and it is above. — ADR-0242, ADR-0066

  • Nothing validates an application’s own theme. ThemeAudit does, and the pairs are found by convention rather than listed. The arithmetic was nine private lines in ContrastTest; it is css.contrast.Contrast now, in :core and exported, because a theme is core and an application should not need the widget catalog to learn its colours are unreadable. The part the entry did not anticipate is what makes it worth having: a hard-coded list of the toolkit’s own pairs would check a custom theme’s overrides and miss everything it added, so the rule is every --gb-<name>-bg with a matching --gb-<name>-text — which the design system already follows, and which audits --gb-mycard-bg for free. Two details decide whether it works on a real theme: values are substituted, so --gb-badge-warning-bg: var(--gb-warning) is measured rather than skipped as “not a colour”; and a translucent pair is skipped rather than scored, because what it composites over decides the answer — --gb-hud-bg is #1c212ae6 and is the shipped example. ContrastTest now calls the same code, so the number CI asserts and the number an application audits against cannot drift. It does not cover the non-text floor: which token is a mark is not something a naming convention can tell, and those sixteen are above. — ADR-0241, ADR-0239, ADR-0087

  • §2.2’s focus ring is below §1.2’s floor on every surface of the light theme. It follows the accent now, which is what the dark theme always did. Opened by ADR-0239 the moment the non-text floor was first measured, and the cause was a ramp left behind rather than a colour anyone chose: both themes set the ring to their accent, except that the light theme’s accent had moved down the Frost ramp to --nord10 for contrast and the ring kept the pale --nord8. One token, 1.64:1 → 3.31:1 at worst, and a palette value rather than an invented one. What it exposed is the more useful half and is above: changing a shipped colour moved no golden, because every focus golden in the catalog was NORD_DARK. — ADR-0240, ADR-0239

  • Non-text contrast is not checked at all. It is measured now, and the question the entry could not answer had a simpler answer than it looked. What counts as the background of a mark drawn onto its own box is its own box: a mark is coloured by the color of the element it is drawn in and that element supplies its own background, and for every mark in the catalog the same rule sets both — a checked tick is --gb-checkbox-mark-checked on --gb-checkbox-bg-checked, both from check-indicator:checked. So the pair is one ComputedStyle’s two properties and nothing needs the painted frame. Three sweeps: a mark against its box, a ring against the surface behind it, and a control against that surface by the better of its fill and its edge — a maximum rather than two measurements, because §1.2 asks that some means identifies a component, and measuring separately reported --gb-border failing everywhere when a decorative divider is supposed to be subtle. What the sweeps find is above and is not this entry’s any more. — ADR-0239, ADR-0088

  • A wheel over a disabled control is swallowed outright, and the scroll view above it never gets a turn. It chains now, and the answer was “per event kind” — for one kind. The entry stated the question correctly and the resolution is the narrow half of it: dispatch still refuses a press, a release and a click aimed into a disabled subtree, for ADR-0059’s unchanged reason — that argument is about the thing being aimed at, and a click on a disabled button must not become a click on the row holding it. A wheel is not aimed at a control; it is aimed at whatever scrolls, which is what every platform does with one. So for a wheel the chain is built and its disabled prefix dropped rather than the whole dispatch abandoned: the dead subtree still handles nothing, and what changes is only who gets a turn afterwards. isInput is untouched — taking WHEEL out of “the user doing something” would have made the disabled knob start turning. What this also records is why nothing caught it: DisabledPropagationTest’s tree had nothing above the disabled container, so “refused” and “swallowed” logged identically. — ADR-0238, ADR-0236, ADR-0059

  • Nothing recomputes the cursor when the tree changes under a still pointer. It does now, and the entry named half of it. The cursor half is exactly as written — a fourth position field, remembered from every entry point that carries one rather than from pointerMoved alone, and cursorAt re-run from updateRegions after each paint. NaN is the whole of “we do not know”, and it means it twice: before the pointer has arrived and after it has left, which is another window’s pointer and not a place to ask about. The capture freeze is reached through rather than around, so a repaint during a drag does not thaw the shape. What measuring it turned up is that :hover and :active had the same staleness, and that mark’s own comment denied it — “a control that was hovered before it became disabled does not keep the state” was describing an intention as an achievement, because clearing is not suppressed but nothing called it. Fixing only the cursor would have shipped a control drawing its hover wash while its cursor said not-allowed, so both halves went together; §2.1 left no decision to defer. — ADR-0237, ADR-0057, ADR-0059

  • A knob inside a scroll view is still untested, and Kind.WHEEL had exactly one consumer for a long time. Both cases are tested, and the one the entry predicted would fail did. These were two entries saying the same thing from either end, and they close together. The wheel route had been covered since ADR-0061 — a fabricated SDL_MouseWheelEvent through the real translate and the real sink — but until scroll shipped there was nothing above a knob for an unconsumed wheel to reach, so the half of the contract that is about not consuming had never been run. Knob.wheel consumed unconditionally; it now consumes what it moved, which is ScrollViewport’s existing rule applied to a second widget rather than a new one invented for it. KnobChainingTest is the first test in the catalog to drive a wheel through a real bubble between two widgets, and the arrangement is what took the work: the list has to be scrolled off its top first, or the viewport’s own edge rule refuses the wheel and the test passes before the fix. What the exercise turned up is one entry it did not close, above: a disabled control swallows a wheel for a reason that is the router’s rather than the knob’s. — ADR-0236, ADR-0089, ADR-0116

  • The overlay enter/exit lifecycle is a specification without a subject, and the imperative AnimationController has now lost all three of its own. The survey is done, and the answer is two objects rather than one controller. The arrival needs nothing shared — Phase is already the whole of it, and six widgets use it without wanting more. The departure was the same code twice: dialog and message each held two flags, a timer and six lines, and independently got the same four rules right — idempotence, two flags that mean different things, stop-drawing-before-telling, and gone-at-once under reduced motion. That is a mechanism waiting to be named, and it is Departure. It is still not an AnimationController: it drives no value, interpolates nothing and owns no clock. What the survey also settles is that toast and tab must not be converted — a toast’s departure ends when its stack’s queue says so and a tab’s ends inside render — which is ADR-0092’s rule about generalising from examples that already agree. — ADR-0234, ADR-0178, ADR-0081

  • Overlays do not animate in or out. They do, and this was fixed by the widgets rather than by the layer — which is what the entry itself predicted. §1.7’s overlay curve wanted “a toast to arrive rather than appear, which is a transition on the widget and not on the layer”: a toast slides 16px from its edge and reflows when a sibling goes ([ADR-0177], [ADR-0178]), a dialog scales from 0.96 and fades ([ADR-0176]), a banner rises 2px ([ADR-0175]). The stack half of this entry stays open above, because it is a layout widget and this was never about one. — ADR-0100

  • Two popups do not know about each other. They still do not, and they do not have to: what was wrong is that Escape and a press outside were the same code. A press that lands somewhere else is the user pointing at something other than the menu, and the whole chain goes; Escape is the user stepping back out of what they opened, one menu at a time. The launcher ran dismissPopups() for both, so opening File → Recent and pressing Escape took the parent with the submenu. Finding which handler was at fault was most of the work — a Popup watches its own window and closes only itself, which is correct and never runs, because since ADR-0189 no popup holds the platform keyboard and the key reaches the owner. One more thing the entry did not say: the innermost popup is not always the one that goes, because a tooltip refuses light dismissal — so the walk looks past it rather than stopping. — ADR-0233, ADR-0103

  • A tooltip is plain text, has no maximum width of its own and does not follow the pointer. All three are what §7 specifies for v1, so they are boundaries and not gaps — and an entry that records a boundary belongs here rather than on a list of what is missing. All three are what “rich content” would change, and none of them has a caller asking. The delay is a different matter and stays open above: it is a number this toolkit chose rather than one §7 gave, and the token that would let an application change it cannot be read. — ADR-0105

  • Placement still clamps, and now two callers have stopped asking it to. That is the design, stated, and there is nothing here to build. A popup taller than the work area is clamped to the near edge; menu and select cap their own content first, from a measurement rather than a guess ([ADR-0179]). Any other caller that opens an oversized popup and offers no Host.Fit gets the clamp — which is right for a facility that cannot know what its content means: a tooltip that scrolled would be a tooltip that should have been a dialog. The entry read as a gap because it opens with “still”, and what follows it is a division of responsibility rather than a shortfall. — ADR-0179, ADR-0104, ADR-0118

  • Nothing hit-tests an overlay by rule. Both halves are rules now, and the second was two mechanisms pretending to be one. The topmost painted region taking the pointer was already true — the capture is in paint order and is scanned backwards — and is written on elementAt with a test that fails if it stops being. The modal half was worse than unwritten: Handles.isModal said in as many words that “the pointer is not this flag’s business”, because a dialog is unreachable by mouse through its scrim. That is modality by geometry, and a widget that declared itself modal without a scrim trapped the keyboard and let every click through — the two halves of “modal” disagreeing in an accessibility feature. It is one flag now: the pointer reaches the modal’s subtree and its ancestors, and nothing else. The ancestors are the point rather than a loophole, because a scrim is the panel’s parent and a click on it is what closes the dialog. — ADR-0232

  • PointerRouter has one listener slot, not a list. It is a list, and the decision the entry was waiting for is that there is nothing to decide. What is delivered is a notification and not an event: nothing is passed, nothing can be consumed, and each listener reads hovered() or focused() from the router for itself — so no listener can change what another sees and order is not a policy. An event — one carrying a target, or consumable — would be the shape worth refusing, and is the one ADR-0105’s objection was aimed at. The slot also had a bug the entry had not noticed: a setter named onPointingChanged reads like a registration and behaved like an assignment, so a second caller silently dropped the first and a tooltip simply stopped appearing. It hands back a Subscription now, which the slot could not express at all. — ADR-0230

  • --gb-*-line has one consumer, and the widgets that should be next have not been looked at. The survey is done, and it found a rank missing rather than a rank unused. Six rules drew ink in a bare semantic hue and only one of them was a line — the field:invalid edge the entry named. The other four were words, and §1.2’s floor for words is 4.5:1 where -line is derived against 3:1, so pointing them at -line would have moved them from clearly wrong to quietly wrong: --gb-danger-line is 3.53:1 on the dark theme’s surface. So a hue has a fourth rank, --gb-<hue>-text, and the HUD has two tokens of its own because its plate is the same in both themes and a theme-varying red on a near-black plate is an absence rather than a warning. The badge half of the entry was a false memory: a badge is a filled chip with its own pair and has no border. Eight golden images changed, every one of which had been recording a colour below §1.2’s floor. And the survey is a lint now — noBareHueDrawsInk reads the stylesheet, which is the one question a contrast measurement cannot answer. — ADR-0229, ADR-0175

  • collapse and carousel never stop asking for frames. They ask their Phase now, and it was the same three lines the entry predicted. The question the frame loop asks is are you still moving, and both were answering were you built in a state where you could move — a carousel’s was never even conditional, so every window with one on it repainted at the refresh rate for ever. A phase settles itself on the frame that finishes it; a DoubleUnaryOperator closing over one cannot say whether it has. Two things the entry did not predict: a section shut half way through an arrival keeps a phase nothing will ever settle, so CollapseSection guards on open; and CarouselTest’s own animating() case asserted the bug, because it had been written against the implementation rather than against §1.7. — ADR-0228

  • Every clock-driven arrival costs one wasted frame. It does not, and “harmless and worth writing down” was one line short. The entry had the diagnosis exactly right — the renderer asked whether a node was animating before it drew it — and drew the wrong conclusion from it: a phase learns it has finished by being read, and the only place a widget is handed the frame clock is render. Asked afterwards, the answer is current. One line moved, and it is worth one frame of every animation in the toolkit. TabMotionTest documented the waste in a comment and now asserts its absence. — ADR-0228

  • A message takes no bind=, so a banner whose text comes from a model has to be described away rather than emptied. It takes one, and the thing that was missing was a name rather than a mechanism. The entry had already named the fix — “a way for a widget to describe nothing, which the element tree has no word for” — and what looking at the renderer showed is that the tree could always do it: a node that is neither Styled nor Paints and has no children contributes no box, which is how every composition node already works. So Widget.nothing() is a singleton leaf and no new branch anywhere. A bound banner whose value is blank is not there, and comes back when the value does — the same element, the same subscription, the same arrival, which is what makes this a value change rather than a node being rebuilt. The dismissed case converged on it and stopped leaving a gap. What is not converted is field-message: doing so changes the spacing of every form, which five golden images say is a design decision about §4’s “message slot” rather than a bug fix. — ADR-0227, ADR-0175

  • A closing overlay used not to animate at all, and every golden passed. The lesson has somewhere to live now, and it is a test rather than a paragraph. The entry was right twice — the corpus cannot catch this by construction, and an assertion on isAnimating is the only thing that can — and stopped one step short: the second half is a specification for a test, and left as prose it is read by people who already know. AnimationSweepTest is that test, in two rules: a widget holding a Phase declares isAnimating (structural, scoped to things that actually paint, or it names six false positives and gets deleted), and every declaration of isAnimating has a test beside it that names the method (which catches the animations a Phase does not describe — a tab’s number, a scrollbar’s idle clock). It found a real gap on its first run: ScrollViewport and ScrollFade had no such assertion anywhere. — ADR-0226, ADR-0176

  • A right-click does not select what it is over. It does, and the premise was half right. The toolkit still has no notion of what “select” means for an arbitrary widget — but the widget under the pointer does, and what was missing was never a concept of selection: it was a moment at which a row could be told the gesture had happened to it. The launcher’s existing walk supplies one, because it already goes from what was clicked up to whatever named the menu; it now remembers the deepest widget on that walk that can answer and asks it, once, immediately before opening. The rule the entry did not state is the one that makes it worth having: a row already in the selection leaves it alone, so right-clicking one of five chosen files opens a menu about the five. — ADR-0224

  • A bare Alt tap does not activate the menu bar, and F10 does. It does, and the entry had already written the design. “Key-release tracking with a nothing-happened-in-between rule, at the window level” is exactly what shipped — the part it did not predict is where the keycode has to be read. Key names no modifier on purpose, so Alt reaches the router as Key.UNKNOWN and is indistinguishable there from every letter that arrives as text; the platform keycode is the only place the distinction survives, and Window is the last component that holds one. What is bound is not a Shortcut at all but a gesture, with its own tiny vocabulary and an exhaustive list of what spoils it — another key, a repeat, a second modifier, a press, a wheel, a focus change, and deliberately not pointer motion. F10 stays beside it as the binding that survives a compositor which eats Alt, and both toggle now. — ADR-0223

  • menubar is not built, and it wants a menu that outlives one opening. Both halves ship, and the model that outlives an opening turned out to be the one the author already wrote. What is built and discarded per opening is the popup; a Menu is a value, so a menubar holding one holds it for as long as the bar is mounted. Accelerators walks that description and binds every command with a key on it, with no menu on screen and none needed. A bar’s children are items and a nested item is a heading, so no markup was added. — ADR-0163, ADR-0106

  • Left and Right do not move between menus while one is showing. They do, and fixing either did fix both. The missing item-to-popup callback is MenuSignals, and a bar hands its root menu a Menus.Siblings saying what the two arrows that leave it mean — wrapping at the ends and skipping a separator or a disabled heading. A submenu gets none, which is what keeps Left in one going back a level rather than leaping along the bar. — ADR-0219, ADR-0163

  • An accelerator is unbound by key, so a menubar going away can take somebody else’s binding with it. The map remembers owners now, and menubar is the only thing that uses it — which is what the entry predicted. A binding is (action, owner) compared by identity; removeShortcut(key) still removes whatever is there, and removeShortcut(key, owner) is a no-op when somebody else has taken the key since. The bind side is unchanged: two commands on one key is still last-writer-wins, and what changed is that the loser cannot unbind the winner. A displaced binding is still not restored — that needs a stack per key, and nothing has asked for one. — ADR-0220, ADR-0163

  • The keyboard menu key does not open a context menu. It does, and so does Shift+F10. The entry named both pieces correctly: Key.MENU is SDL’s SDLK_APPLICATION, and the element-wise anchor turned out to already exist as anchorOf, which the tooltip path had been using since ADR-0111. Shift+F10 is bound beside it because a Mac keyboard has no menu key; bare F10 is deliberately left to the menubar (ADR-0163). The walk up to the widget that named the menu is now one method both halves call, because “a right-click on a label is a right-click on the button” and “the menu key on a focused button is that button’s menu” are the same rule. — ADR-0208, ADR-0108

  • A keyboard Right into a submenu waits 150ms. It opens in the same frame. Item can tell a hover from a keypress now: hovered() is what the pointer did and open() is what a key did, and the delay is for the pointer — it stops a submenu dropping out of one travelling past three rows, and a keypress has travelled past nothing. — ADR-0219, ADR-0112

  • Left does not close a submenu. It does, and it is the arrow that opened it, undone. In a submenu it closes back to the menu above; at the root of a bar’s menu it moves along the bar; at the root of a context menu it does nothing, deliberately — a menu that vanished on an arrow key would be a menu nobody could navigate, and Escape is the key that means “put this away”. — ADR-0219, ADR-0112

  • Nothing marks the row whose submenu is showing. item.open does. The row keeps :hover’s wash for as long as its branch is on screen, which is what the pointer moving into the submenu made visible: the row it came from went plain while its submenu was still showing. A class rather than a pseudo-class, because “the branch that is showing” is a menu’s own bookkeeping and not a state the element tree tracks — the shape menu-title.open already used. — ADR-0219, ADR-0113

  • A message cannot go away with a fade. It can, by reversing the order: the × fades the banner while it is still described and tells the application when the fade is over, so nothing has to outlive the description.

  • The sibling reflow is still not built, and toast is now the thing that could build it. It is built, and which toasts move turned out to be a fact about the overlay layer rather than about the widget. A toaster is pinned to a corner and controls.css puts the newest toast at that end, so the column is anchored by its newest member: a hole in the middle leaves everything between it and the corner alone, and the older half travels in to close it. The ordinary case therefore moves nothing — a stack that shares a timeout loses its oldest first, and the oldest has nothing older to move. Neither number was Host.anchor(id) in the end: the height comes from Measured, banked every frame because the toast is gone by the time it is wanted, and the gap comes from toaster { gap } through the channel ADR-0177 opened for the frame clock. A column of messagees still cannot have it, for ADR-0175’s unchanged reason: a banner has no owner to hold the list. — ADR-0178

  • A toast cannot be dismissed by clicking it The plate is the affordance now. What §7’s omission of a × meant is that a toast does not need a second affordance competing with its action for a 360×40 plate — not that a persistent one should be undismissable. The click was already being swallowed, because the plate is hit-testable and a click on it reached nothing and did nothing. The trade-off, stated: a click aimed at the action button that misses it dismisses without acting; the button is told first, so a hit is never lost. — ADR-0182

  • A tooltip is plain text and has no maximum width of its own It has one now: 320, and the number is a judgement rather than a specification. §2’s metrics row gives a tooltip a padding, a radius and two delays and no width, so this is Toaster.DEFAULT_MAXIMUM’s kind of decision — without it a sentence of help text is a ribbon across the window that is harder to read than no tooltip. The other three “consumers” of max-width turned out not to be: toast keeps its width on the design argument its own note already made — the same 360 on every toast is what makes a stack read as a stack, and a maximum would give the ragged pile back; popover’s minimumWidth is a runtime measurement (field.size().width()) that no declaration can express (ADR-0145); and text-area’s max rows is built and is a row count rather than a length. — ADR-0181

  • A menu caps itself by estimate, not by measurement. It measures now, and so does select. The popup facility takes a Host.Fit — a callback handed what the content measured and the room it has, between the measure and the place — so the guess and the second copy of --gb-menu-item-height are both gone. A twenty-row menu measures 667px where the estimate said 696, which is 29px of menu needlessly wrapped on a short display and nothing at all on a tall one. Returning the content unchanged costs nothing; returning something else costs a second element tree, which is the right way round because nearly every popup fits. — ADR-0179, ADR-0118

  • A field refuses right-to-left text outright. It takes it, and draws it mirrored. The interim this entry asked for is chosen: a paragraph shapes bidi text with the direction forced to LTR, so the glyphs are right, their order is not, and every width, caret and hit test agrees with what is on screen. It says so — Paragraph.isBidiApproximate() and one warning per distinct string — because the alternative to a crash should not be a silence. What is still ahead is the real thing: java.text.Bidi run splitting, which is several runs per line, visual reordering within a line, and a caret that knows which run it is in and which side of it. That last part is why the half-measure of handling uniformly right-to-left paragraphs was not taken — it changes what widthBetween means to every caller, which is the same change full bidi needs. M5, with the IME preedit it sits beside. — ADR-0218, ADR-0167

  • Markup cannot hand a controller to a widget. It can, through a fourth registry — and the first attempt was refused by the codebase itself. This entry guessed the answer was “a registry beside actions and bindings”, and it was; what it did not guess was that the binding registry would settle the question. A @Bind field holding a controller is refused with “a value that cannot change is not something to subscribe to”, which is exactly what a controller is. Named is the registry for objects that are neither methods, resources, nor values that change. scroll still has the gap — a ScrollController could be named the same way and nothing has done it. — ADR-0170

  • Nothing can ask for focus, so field has no click-to-focus. A container can hand focus down now. Handles.delegatesFocus() turns the router’s walk round: a press that finds no focusable ancestor takes the first focusable descendant of a container that claims one. It has one consumer, which is one fewer than a mechanism should have — group-box and card are candidates and neither has asked. — ADR-0170

  • Host.focus still does not exist. It does, and dialog is what needed it: host.focus(id, fromKeyboard), by id for Host.anchor’s reason — a widget has no element and never will. The rule that makes it useful was not the obvious one: a node that cannot take focus resolves to the first focusable thing inside it, so “focus this dialog” and “focus this form” mean what a caller intends. It is refused for anything outside an open modal. A form jumping to its first error is now two lines an application writes, and nothing in the toolkit writes them. — ADR-0176, ADR-0170

  • Nothing restores focus when a modal closes. It does, and the entry understated the problem. “The keyboard lands nowhere in particular” was the visible half; Element.unmount tells the element tree and nothing else, so the router went on holding the element that had left it — an unmounted node receiving key events and keeping its dead subtree reachable. So the fix is two rules: the router never holds an element that is not in the tree (right for a switched tab and a shortened list as much as for a dialog), and if there is somewhere to put the keyboard back, it goes there. The remembered element is indeed the first state the trap has held, kept to one slot, written at exactly one moment, and allowed to go stale on purpose. — ADR-0180

  • min-width and max-width are not in the CSS subset All four are, and dialog has the two numbers §2 asks it for. One value rather than four components — the four are only meaningful together, and they are the same question asked four ways, so a caller that handled three would have a bug nobody would find. The trick for “80% of the window” was the scrim: a percentage resolves against the containing block, so the scrim’s padding across had to go or the maximum would have been 80% of the window less 48px — measured at 330 in a window where §2 permits 339. The four consumers this entry named are all resolved, and only two of them by being built. tooltip has a max-width of 320 — a judgement rather than a specified number, because §2’s metrics row gives it no width at all, and 320 so a label cannot reach a dialog’s minimum. text-area’s max rows shipped with the widget (ADR-0171). toast stays a width: giving every toast the same width is what makes a stack of three read as a stack, and a maximum would size each one to its own string, which is the ragged pile. And popover’s minimumWidth is not a min-width consumer at all — it is “at least as wide as the control this dropped from” (ADR-0145), a runtime measurement of a different node, which no stylesheet can state. — ADR-0181

  • A field’s error summary is a list and not a widget. message is built and Message.summary(errors) is the summary — one danger banner with a line per failure, and empty when nothing is wrong, because a summary of no errors is not an empty banner. It is a factory rather than a child form adds, for two reasons that were not obvious until the widget existed: a form does not know where its summary belongs (above the fields is the convention, below is what a long form wants, a dialog’s header is what a dialog wants), and a form that drew one would have to rebuild whenever any field’s message changed — which is a notification from Validated to FormAccess that nothing else needs. What is still open is a form summary=#true that does exactly that, and it is waiting on that notification rather than on the banner. — ADR-0175, ADR-0169

  • A golden of a text-area is a golden of its first frame. Both halves are fixed now. The first was the gallery rendering twice and asserting on the second, 200ms in, which §7’s message forced. The second — feeding the hit-test regions back between those frames — stayed open because nothing needed it badly enough, and masonry did: a layout that reads last frame cannot be photographed at all without it (ADR-0196). Measured is delivered by the router, from the rectangles a laid-out frame produced, so a harness that only rendered gave every self-measuring widget a first-frame answer for ever. The harness runs render → lay out → hand the router the regions, twice, which is what a window does — and the Forms image is now the one the running application shows, with its text-area wrapped at the width it actually has. — ADR-0175, ADR-0171

  • tree is built in a first cut, and §3 asks for more. All five of the leftovers are built, and one of them was never actually blocked. The keyboard three — Home/End, *, type-to-select — needed rows the focused one cannot see, which is why they waited and why each is a callback the tree hands down (ADR-0209). The other two are checkable= and the selection models (ADR-0210). Multi-selection was recorded here as blocked on list and that reading was too strict: tree defined the node model itself for the same reason, and wrote down that list will have to agree — the selection models are the shape every desktop list has, which makes it a small promise to make on list’s behalf. list is built now and the promise was kept: Selection moved to it and tree imports it, and nothing about the shape changed on the way (ADR-0212). The checkbox needed a different question answered first, and it was in the design document rather than in the code: §3 spends the word checkable twice, on which rows are an answer and on whether rows carry a box. Both ship, under two names, and the disagreement is now in ARCHITECTURE.md §17.1. §2’s chevron rotate is two marks instead, because §8’s subset has no transform on a mark — the wall select’s chevron hit — so a closed row draws > and an open one v, and the cost is the animation. — ADR-0210, ADR-0209, ADR-0184

  • list renders every row, and table is still waiting on the recycler neither has. Both are built, and the promise held. The item-factory did survive contact: virtualized(rowHeight) calls the same function with the same items and nothing about the API moved (ADR-0213). table turned out not to be waiting for the recycler at all but for list — a table’s rows are a list’s rows with more than one thing in them, so it composes one (ADR-0214). What is still out is rows of varying height, which the arithmetic rules out rather than merely lacks: index × height is only a position if every row is that height, and the usual way round it — an estimate corrected as rows are measured — makes the scrollbar drift under the reader’s thumb.

  • A select’s list is clamped rather than scrolled when it is taller than the screen. It scrolls. The popup facility reports what it measured, so neither caller has to guess, and both give the same answer from the same helper — Fitted, which wraps content taller than the room in a viewport of the room’s height and leaves everything else alone. — ADR-0179, ADR-0141

  • select multiple= … is not built It is. The selection is a set, change is a toggle in that mode — the set is the application’s, so asking for a value it already holds can only mean taking it out — and the list stays open while values are picked, which needed a popup whose content can change while it is open (Popup.content, new). §4’s free-text autocomplete is built too: TextInput.suggesting(options) offers a SelectList under the field, the rows commit on Enter rather than following the focus, and the field’s text is never rewritten without the user choosing. select autocomplete=#true is built too (ADR-0183): the closed control holds a real text-input, so the editing model, the undo history, the clipboard and the caret stay where their rules already are; the field stops being a Tab stop and delegates focus, so a combobox is one stop; Esc restores and a free-typed value is refused unless free, both off one nullable string of offered text that TextInputState.follow already knew how to honour. tree=#true is built too (ADR-0184), and so is a first cut of tree itself, which list had to agree with since §3 says the two share an item-factory — and does, now that list is built and the selection models have moved to it (ADR-0212). — ADR-0182, ADR-0141

  • A select opened from the keyboard does not give focus back to the field. The field never loses it. Measured rather than reasoned about: the owner window’s router is not touched by a popup opening or closing, so the field keeps both its focus and its ring for as long as the list is up. What remains is the platform-level question above, which is not a control’s problem and not this control’s in particular. — ADR-0180, ADR-0104

  • A gradient fill needs a symbol the export list does not have. It has six now, and the entry was right about which commit came first. The widening is the whole of the interesting part: bl_gradient_init_as, _destroy and _add_stop_rgba32 build one, bl_context_set_fill_style and its _rgba32 companion put it on the context and take it off, and bl_context_fill_path_d — the plain fill, with no _rgba32 suffix — is the only styleless drawing call on the list and the only way a ramp reaches a path. The OKLCH in the original wording turned out to be vacuous: a fade between two alphas of one hue is the same curve in every perceptual space, and what makes it correct is premultiplied interpolation plus repeating the colour at the far stop, because 0x00000000 is transparent black and a green fading to it goes through grey. goldberry-html and goldberry-vector both start one commit further along. — ADR-0207

  • split-pane is not built. carousel is not built. Both ship, and §5 is complete. The divider turned out to want knob’s gesture anchor rather than slider’s position — the pointer is somewhere inside a six-point bar, and reading its position would snap the divider under the finger on every press — and the carousel’s rotation is one one-shot timer rescheduled after each slide, so that a pause is a timer not scheduled rather than one suspended. What did not ship is one of the carousel’s three brakes; see the entry below. — ADR-0165

  • A carousel does not pause when focus lands inside a slide. The third brake ships, and it cost one line because something else needed the same thing. This entry guessed the price wrong in an instructive direction: it said closing the gap meant “:focus-within in the selector engine, the matcher and the router’s focus bookkeeping”. None of that was needed. A carousel does not want to style itself on focus-within, it wants to be told — and so does a field, which validates when the keyboard leaves it. So what shipped is Handles.onFocusWithin, a notification rather than a selector, reporting only the moves that cross a subtree’s boundary. The selector-engine version is still unbuilt and now has no consumer asking for it. — ADR-0169, ADR-0165

  • collapse’s accordion= is not built. It ships, as a widget rather than as a flag on column. The flag belongs on the container — “one open at a time” is a rule about siblings — but honouring it needs state, and statefulness is a property of the type: putting it on column would give every column in every document a State it never uses. column accordion=#true inflates to an Accordion that reports column as its own CSS type, so the document writes what §5 says and an ordinary column pays nothing. — ADR-0166

  • Per-corner radii do not exist, and segmented is the second control that wanted one. They exist, segmented uses them, and the fourth asking is what built them. button.square asked first, segmented second — both went round the outside, the bar keeping the radius and the segment inset. group-box-title could not: its top corners meet a rounded frame and its bottom ones meet the body, and no arrangement of nodes fakes that. It had been writing border-radius: 7px 7px 0 0 since it shipped, and the engine had been dropping the declaration with a warning nobody read. Corners is four numbers over CSS’s 1-4 shorthand, the uniform case emits the drawing it always did, and elliptical corners are still refused. SegmentedTest’s pinned numbers did what they were pinned for: the bar is drawn joined again, with §3’s hairline between its cells, and the design system’s row is amended back. button.square’s joined buttons and tabs are the two callers of Corners.inRow that have not arrived yet. — ADR-0217, ADR-0216, ADR-0097

  • WaylandDecorationsTest asserted /proc exists. It asserts the platform’s own half of the contract now. One test read the real /proc/thread-self and asserted Optional.of(false) unconditionally — true on Linux and false everywhere else, in a suite all three OS legs run (macos.yml and windows.yml both run :core:test unfiltered). The fix is not a skip: where /proc can answer it is still the live check that the parsing works against a real symlink, and where it cannot the assertion is that the answer is empty — which is onInitialThread’s documented contract, “a machine that cannot say must produce silence rather than a guess”, and the branch macOS and Windows actually take. Gating with @EnabledOnOs(LINUX) would have left two of the three platforms asserting nothing about the call that runs on them. — ADR-0084

  • A popup hangs on screen when the application loses focus to another window No popup of any kind holds the platform keyboard now, so anyWindowFocused means what it says: the application is focused exactly when one of its own real windows is. A popup never relied on focus anyway — the owner has forwarded keys to whatever popup is open since ADR-0104, precisely because SDL focuses POPUP_MENU windows on some drivers and not others. — ADR-0189

  • (was) — still open, and one candidate is eliminated. anyWindowFocused() counts popup windows, so a popup holding platform focus keeps the whole check true. A MENU-kind popup is focusable and is the likely culprit; the suggestion panels are TOOLTIP-kind and NOT_FOCUSABLE since ADR-0186, so they can no longer be it. The next step is a real window and a log of FocusChanged per window id, which the headless backend cannot produce. (earlier) The window hides and the popup stays where it was. The mechanism ADR-0144 describes is wired — the launcher watches FocusChanged and calls dismissPopups after a settle delay if no window of the application is focused — so this is a fault inside it rather than a missing feature. Two candidates and no evidence yet: a popup window still reporting focused, so anyWindowFocused never goes false; or the platform not sending FocusChanged at all when the owner is hidden rather than deactivated. Diagnosing it needs a real window and a real compositor. — ADR-0185

  • flex-wrap is not in §8’s subset. It is, and it took the shape this entry predicted — one component on Box, one on ComputedStyle, one line in the render tree, and 48 positional reconstructions. What it did not predict is the half that mattered: putting the property on the field wraps the chevron onto a second line under the chips, so the chips needed a box of their own. A golden image is what said so; nothing in the CSS looked wrong. — ADR-0192

  • Nothing drives §3’s select family through the real loop. SelectLoopTest does, and found a seventh defect on its first run: a click opened the list and closed it again in the same gesture, because the press focused the editor (which opens it) and the click then toggled from a stale open flag. One signal opens an editable control now, and the signal is focus. What the harness still cannot reach is the platform’s window flags — reverting NOT_FOCUSABLE fails nothing, because the headless backend has none. — ADR-0188

  • (was) Nothing drives §3’s select family through the real loop. All three defects ADR-0185 fixed were found by running the application and were green in CI, because every test drives the widget by hand and each fault lives in the seam between the widget and a running window — the application’s rebuild, the platform’s focus, the pointer. MenusTest does drive the real launcher against the headless backend and is the shape that would have caught them. Closing this is worth more than the three bugs were. — ADR-0185

  • The HUD’s budgets assume a 60 Hz display. They are shares of the display’s own frame now. SDL_GetCurrentDisplayMode’s refresh rate was already bound for the frame pacer; it reaches a hud through FrameStats.displayHertz(), and a platform that will not say falls back to 60 with the reading showing dashes rather than a number it does not have. — ADR-0153

  • A click costs one node’s style. It cost the whole tree’s, and the HUD is what found it. Hover and active apply to the ancestor chain, and every node in that chain invalidated its entire subtree in case a descendant combinator read the state — 74 of 78 elements per click on the showcase. This was never on this list because nothing could see it until the frame had a breakdown. — ADR-0149

  • The style cache makes a settled frame free. It did not, and had not since scroll shipped. ADR-0070 measured style resolution as the largest term in a frame and cached it; the cache was keyed on the parent’s style by identity, and the style a parent hands down is not the one it caches — restyle runs afterwards and allocates. Every node under a scroll, a tab or a segmented re-resolved on every frame, which in the showcase is every node on the screen: 10 069 µs to render 77 unchanged elements. This was never on this list, because nothing had measured it. — ADR-0142

  • Nothing tells the toolkit its window lost focus. FocusChanged does. A menu left open while the user switched applications stayed on screen over the one they switched to, because a popup is always-on-top by kind and light dismissal only ever saw a press inside the owner window. — ADR-0144

  • option lives in …controls.segmented and select will want it. It lives in …controls.option, and select wants exactly what segmented wanted. The guess this entry refused to make — “a model, possibly a tree node, a popup to render in” — turned out to be wrong in every part: a row in a dropdown is the same record as a cell in a bar, and the whole difference between them is a stylesheet’s ancestor selector and one flag saying whether the keyboard chooses or merely moves. ADR-0092’s rule paid for itself twice over — it stopped a generalisation that would have been made from the wrong example. — ADR-0141

  • A press that dismisses a popup also activates what it lands on. It does not, and this was never written down as a gap because nothing had hit it. With a list open, the press on the field that dismisses it was also read as “open it”, so a select toggled twice and stayed open. The launcher already took the press for the secondary button (ADR-0108); it now takes any press that actually closed something, which is what the click that puts a menu away does everywhere. — ADR-0140

  • A popup does not size itself to its content. It does, in two passes. RenderTree.measure lays a tree out with no surface; the second pass exists because Yoga lays a root out at exactly the available size when that size is definite — there is no parent for it to be “at most” of — so measuring against the window returns the window. Nothing definite first, then the width pinned only if the natural width overflows. — ADR-0104

  • Placement is not policy. Placement is, and it is arithmetic. Preferred side, flip only when the preferred side does not fit and the opposite one does, then shift along the cross axis; clamped to the near edge when it fits nowhere. Computed against the display’s work area — SDL_GetDisplayUsableBounds, reached through BackendWindow.workArea() and translated by position() — which is the rectangle that excludes the taskbar a menu would otherwise open under. — ADR-0104

  • Focus does not travel into a popup. The keyboard belongs to the open popup. Its router focuses the first item after the first frame, and keys the owner window receives are forwarded to the topmost popup before the owner’s own router sees them. Forwarded rather than delegated to platform focus, because whether a popup gets the keyboard is per-driver and a tooltip must never have it. What is still owed is the return: §7’s “restores focus on close” is the widgets’ to keep, and nothing yet remembers what had focus before a menu opened. — ADR-0104

  • Sdl3Backend.translate’s MOUSE_WHEEL branch has never run. Answered: it runs, through the real SDL, on every CI run. A test cannot turn a wheel — but SDL_PushEvent can, which is what the call is for. A fabricated SDL_MouseWheelEvent, written at the offsets the layout probe has already checked against the compiled C, goes onto SDL’s own queue, comes back out of the ordinary pump and takes the shipping route: the real translate, the real window lookup, the real sink. The tests assert the sign is inverted exactly once (SDL’s y is positive away from the user, the SPI’s is positive down the document), that “natural scrolling” is undone before that rather than after, that a touchpad’s fractions survive, and that the position comes from the wheel arm’s own fields — reading it through the motion arm’s accessor returns 3.0 where the answer is 120.0, because the vertical delta lands at exactly that offset. Under SDL’s dummy video driver, so it needs no display and runs on all three platforms. The cursor half was already answered: the showcase sets Cursor.CROSSHAIR at start-up, so SDL_CreateSystemCursor and SDL_SetCursor really run. — ADR-0061, ADR-0056, ADR-0057

  • Group opacity is a multiply, not a layer. Answered: it is a layer. A node with opacity < 1 and children is composited through an offscreen raster drawn at full strength and faded once, which is what CSS specifies. group-opacity.png is two overlapping squares under a parent at 50%, and the test asserts the overlapping pixel equals the non-overlapping one — true for a layer, false for a multiply. A translucent leaf keeps the cheap path deliberately: its own shapes can overlap each other, but by a fraction of a level on an antialiased edge, and an allocation and a blit per faded label is a poor trade. Three goldens with a :disabled control at 45% moved, and the diff is confined to that control — the correction, reviewed rather than accepted. — ADR-0071, ADR-0064

  • body-strong is not drawn, and no control uses a weight. Answered: a weight is a face. Inter-SemiBold.ttf is extracted beside the variable file, font-weight resolves to one of two shipped faces in the cascade, and a button’s label is Inter 600 at 13/18. Instancing the wght axis would have been the smaller download and needed symbols in both HarfBuzz and Blend2D — three export branches, answered only by a CI run across four targets — while §1.4 ships exactly two weights. The axis stays a real optimisation for the day an intermediate weight is specified. — ADR-0066

  • There is no italic face, and an application has asked for one. Built, as two files rather than one. docs/gaps.md G27 wanted italic beside underline and strikethrough; the other two came with ADR-0321 and the faces with ADR-0323. It was ADR-0066’s question one step on — an italic is a face, because Inter’s italic is drawn rather than slanted, and shearing the upright glyphs is a type-design decision rather than a workaround. Two faces, so the matrix closes: one would have left semibold italic resolving to the nearest of three, which is how a design system acquires a weight nobody chose. Matching is CSS’s order (family, style, weight), so italic code stays upright code; oblique is dropped with a warning. The variable-axis answer ADR-0066 deferred stays deferred, and stays the right change the day an intermediate weight is specified — which is still nothing. — ADR-0323

  • Seven shipped button colour pairs are below §1.2’s 4.5:1 floor. Fixed, and the worst of them was a rule applied where it does not hold. §1.2 had always said “every text/surface pair meets WCAG 4.5:1 […] validated in CI against both themes”; nothing validated anything until badge forced the question, and the first run of ContrastTest found --gb-button-danger-text on --gb-button-danger-bg at 3.55:1 — --nord6 on --nord11, unchanged since the first control shipped. Two things in the numbers were the shape of the fix rather than its size. Every ramp’s darkest step already passed (button.danger:active is 5.11:1 on light), so nothing needed a new colour system — the ramps needed sliding, and the value that was :active is roughly where rest belongs. And the worst pair was a hover state that was worse than the rest state one step from it: button.danger:hover at 2.95:1 on dark, below the 3.55 it moved from. The dark theme lightens on hover, correctly, for a surface moving one step toward the light — and a danger button is not a surface, it is a saturated fill carrying --nord6, so lightening moved it toward its own text. Stated as a rule it already described three of the four filled variants: a fill that carries text moves away from it. So button.danger on dark now darkens on hover, against that theme’s usual direction and alone in the toolkit in doing so. --gb-danger-fill and --gb-accent-fill replace the aliases to --nord11 and --nord10, and the danger ramp is now identical on both themes, because the hue is and the text on it is. The one piece of collateral was worth catching: --gb-checkbox-bg-checked-hover and its radio and toggle counterparts aliased the button’s ramp, on the argument that a checked control and a primary button share the accent — true until a button’s fill started being chosen for its label. A checked glyph carries a mark, which §1.2 asks 3:1 of, so --gb-accent-bg-hover/-active are split out holding the values the button’s ramp used to, and every checkbox, radio, toggle, slider, progress and spinner golden is byte-identical — two button images are the only ones that moved, which is what says the split landed where it was aimed. KNOWN_FAILURES is now empty and stays, asserted equal to the measured failures and asserted empty by name: nothing is exempt, and re-exempting a pair fails a test that says what happened — ADR-0088, ADR-0087, ADR-0082

  • Nothing animates. Answered for the properties that can. The frame clock, the curves, the overlay, the whitelist, OKLCH interpolation and reduced motion all ship, and the frame loop goes idle the frame after a transition ends. What is left of §1.7 is listed below rather than here. — ADR-0067

  • transform is in §1.7’s whitelist and is not implemented. Answered, and the trap it named is what the change is about. transform and transform-origin parse, cascade, apply down the box subtree the way opacity does, animate through the overlay, and — the part worth the separate record — route input through the inverse of the matrix the painter used, computed once while painting rather than re-derived on the input path. A transform the painter applies and hit testing ignores produces no error and no wrong pixel: the control is drawn where the stylesheet asked and simply does not respond where it looks like it should. No new native symbol crosses the boundary: bl_context_apply_transform_op was already exported for the display scale, and BL_TRANSFORM_OP_ASSIGN replaces the context’s matrix rather than composing onto it — so the stack is accumulated in Java, which is also what makes it invertible. Blend2D’s save/restore are not exported and turned out not to be needed. A computed transform is the function list, not a matrix, because translate(50%) and the 50% 50% origin default are proportions of a box that has no size until Yoga has run — and because halfway between rotate(0) and rotate(180deg), interpolated entry by entry, is a collapsed box rather than a right angle. — ADR-0068

  • The check mark still does not scale. Answered, and transform was never what was missing. §1.7 and §3.1 specify the checkbox tick and the radio dot as “scale 0.6→1 + opacity”; the opacity half shipped with ADR-0067 and the scale did not arrive with transform. The reason is that a Box.Mark is drawn onto the box carrying it, so scaling the indicator scaled the 16px glyph with it — the ring grew with the tick. The mark is now a cascade node of its own (check-mark, radio-dot), which makes them the third and fourth parts and the first justified by something other than “two surfaces need two backgrounds”: two things must move independently, and the unit of independent movement is a node. The mark is built in every state and hidden with opacity, because a node that appears with the value has no previous style to move from and would snap. radio-group-scaling.png is the frame at 80 ms of 160, one dot growing in and the one it replaced shrinking out, and what it asserts is that all three rings are the same 16px circle — which is precisely what the naive fix gets wrong. §3.1 now has no unimplemented row for any shipped control. — ADR-0073, ADR-0068, ADR-0065

  • :active was set on one element, so no control had a pressed state. Fixed. :hover walked the ancestor chain from the beginning; :active was set on the single deepest element the press landed on — so pressing a checkbox’s 16px glyph lit up check-indicator, pressing its label lit up text, and checkbox itself matched only in the sliver of padding between them. checkbox:active had been in controls.css since the control shipped and was very nearly a dead rule. §2.1 requires every control to render a pressed state, and one that depends on which of its own parts you hit does not have one. Found by trying to write the radio’s pressed appearance, not by a test — and the test that now covers it asserts the ancestor, which is the half the original test never looked at. — ADR-0073

  • An unnamed key crashed the window. Fixed. keyPressed built a Shortcut from every key that reached it, to use as a map key. Shortcut refuses to hold Key.UNKNOWN — an accelerator on it could never fire — so the IllegalArgumentException went up the UI thread with nothing above it. Not an edge case: Key names the keys a shortcut might use, so every letter, digit and punctuation mark that arrives as text is UNKNOWN, and the crash was one keystroke away at all times. The accelerator tests never saw it because they only ever pressed keys that had names. — ADR-0073

  • A checkbox was invisible on the surface it normally sits on. Fixed, and the reason CI missed it is the interesting half. --gb-checkbox-bg was nord1, which is --gb-surface; the light theme’s was #ffffff, which is its --gb-surface. The token’s own comment gives the mistake away — “one step up from the window” was measured against --gb-bg, and almost nothing sits directly on the window. Both glyphs now take the button’s ramp on each theme rather than one of their own, which is the scale §2.1’s “one surface step” is already defined by. Every golden image in this repository paints on --gb-bg, so a control that disappears on --gb-surface was invisible to the entire suite; controls-on-surface-{dark,light}.png add the missing axis rather than one more scene. — ADR-0073, ADR-0050

  • --gb-density is not implemented. Answered, and deliberately at four controls rather than at thirteen. §1.3’s regular | compact ships: every control sizes itself from --gb-control-height, and density-compact.css is a three-token :root block in the theme layer — the same slot as nord-light, because that layer is defined by what it holds rather than by what it is called, and a fifth cascade layer would differ from the fourth in its name and nothing else. The layer is also what makes the override work: both blocks are :root, so specificity ties and layer is the only term left to separate them, which is why the test asserts the layer rather than the resolved height. Density.REGULAR ships no stylesheet at all — regular is not something an application applies, it is what the toolkit already is, and a density-regular.css restating 32 would be one number in two files, which is the arrangement that produced both the §10.1 typography table and the checkbox’s private surface ramp. Padding, gap and radius stay literal, asserted so: §1.3’s density row names heights and list rows, and tokenising the rest “for symmetry” invents a scale the design system does not have. --gb-density itself is a marker rather than the mechanism, because a keyword cannot select a number in §8’s subset. Every existing golden is byte-identical, which is the check that the token swap was a refactor; two new ones are the same scene at both densities. The showcase switches on Ctrl+D and not one widget in that file mentions a height, which is the whole of what “token-conformant apps adapt with zero code” claims. Named rather than implied: compact is below §1.3’s own 32×32 hit-target floor, deliberately — the floor is the regular default rather than an invariant, the trade is what a density preference is, and it is bounded by the glyph staying 16px so compact costs margin around the target rather than a smaller target. — ADR-0074, docs/design-system.md §1.3

  • A slider’s groove was invisible on a surface. Fixed, and it is the fourth instance of one defect. --gb-slider-track-bg was nord1 on the dark theme, which is --gb-surface — so the unfilled groove vanished on any panel, which is where the showcase’s options live. A slider hides this better than anything before it: the fill and the thumb still show, so the control looks like a control and merely appears to have no track. It is --gb-border now, because a 4px groove is an edge. What is different this time is that controls-on-surface-{dark,light} already existed — ADR-0073 added it for exactly this — and had not been extended to the new control, so the axis was covered and the control was not. everySurfacelessControlIsCovered now asserts every entry in Controls.controlTypes() is in that scene, with button exempt and saying why, and the scene is one helper the golden and the guard share. Verified by deleting the slider from the scene and watching it fail by name. — ADR-0079, ADR-0073

  • A slider has no tick marks and no value label. Both ship, and the label needed exactly the mechanism this entry predicted. A widget can name the part its pointer position is measured against — Handles.localPart(), a CSS type resolved by the router — because a label at the end of the row takes its width off the track and a value mapped along the control is short by that width at every position, drawn correctly and reported nowhere. The marks hang out of a zero-height row, moved clear of the thumb by a transform so that adding a scale does not move the groove. — ADR-0080, ADR-0079

  • fader’s dB scale is not implemented. It ships, as a value rather than a function. Scale is a sealed interface with two inverse methods and two records — the obvious DoubleUnaryOperator spelling is the wrong one, because §11’s parity invariant compares two control records for equality and two lambdas doing the same arithmetic never are. knob’s taper is what it was built general for. What it does not have is a second curve: §3 names dB and nothing else, and inventing a log or an exp for symmetry would be inventing a scale the design system does not have (Principle 3). — ADR-0080

  • Arrow-key group navigation inside composites does not exist. Answered, as a mechanism rather than as a radio group. Handles.focusScope() makes a subtree one Tab stop with the arrows roving inside it, and tabs, menu, select’s popup list and a toolbar all get it by returning true from one method. The router owns both halves, by the argument already written on Tab — traversal is a property of the tree and not of any node in it — and the test is written against bare widgets in :core rather than against radio, because the next three users will look nothing like a radio. — ADR-0073

  • A focus scope has no axis. Answered. Handles.focusScope() returns a FocusScope — NONE, HORIZONTAL, VERTICAL or BOTH — and radio-group is the one composite in the catalog that legitimately answers BOTH, because its direction is its stylesheet’s and .inline flips it. The axis is the widget’s even though traversal stays the router’s: the router cannot know what a widget means by the other pair, and the widget cannot see its own siblings. It only matters on the path where the widget declines the key, which is why a boolean survived four controls — arrows reach the focused chain first, so a menu bar that handles Down itself works either way. The failure it prevents is a menu item with no submenu declining Right and a BOTH scope quietly sliding focus to the next item: the user asked to open something and the selection moved instead, with no error anywhere. Home and End belong to no axis and reach the ends of any scope, because they name a position in the set rather than a direction on screen. Four widgets unblocked by an enum. — ADR-0078, ADR-0073

  • A disabled group fades correctly only by an explicit undo. Answered, and the undo is deleted rather than generalised. A rule whose only job was to undo its own mechanism was the mechanism saying it was the wrong one. — ADR-0077

  • Layer promotion does not exist, so every animating frame repaints the window. Answered. A promoted subtree is rasterized at full strength and untransformed, so its alpha and matrix apply to the blit — and a group that is only fading or moving now keeps its raster, which is the case §1.7 wanted promotion for and which ADR-0071 shipped without. One flag had been answering three questions: does the screen differ (damage), does an ancestor’s raster differ (yes, it bakes in this node’s finished blit), does this raster differ (no, alpha and matrix are the composite’s). A descendant’s opacity is baked in, which is why it could not be fixed by dropping opacity from one comparison. Measured on the showcase’s tree at 45%: a frame of the fade is 199 µs against 554 µs, 2.8×. RenderTree.layersRepainted() is public because a cached raster and a fresh one produce the same image, so no pixel assertion can tell them apart — which is exactly how the bug survived a test file written about layer caching. — ADR-0072, ADR-0071

  • Damage tracking says what to upload, not what to paint. It paints what changed now. bl_context_clip_to_rect_d and bl_context_restore_clipping are the third and fourth new exports, and RenderTree.paint(frame, damage) clips to the damage — 367 µs to 117 µs on a frame where one small box changed. Read that carefully: the damaged area was 0.23% of the window and the saving is 3.1×, not 400×, because the clip saves rasterization while the tree walk still visits every box for Blend2D to clip away. Skipping the traversal too is a further change and is not made. Correctness rests on a promise the SPI now makes: BackendWindow.retainsFrameContents(), false by default so a backend that says nothing gets a full repaint. Window checks three things that fail independently — the promise, the buffer’s identity (a backend may retain and still rotate between two), and the size — plus a fourth case where the backend lends nothing and the buffer is Window’s own, which retains by construction. A clipped repaint is asserted pixel-identical to a full one across a whole frame, because otherwise damage is a rendering bug with a performance excuse. — ADR-0072

  • A disabled container does not disable its descendants. Answered, and the sentence turned out to have two halves that pull apart. docs/core-widgets.md says “disables its descendants for input and semantics” — and deliberately not for paint, which is where the double-fade came from. Input propagates: no press, click, wheel, focus or key reaches a descendant of a disabled container. Paint does not: :disabled stays on the node that declared it, because the container’s own 45% already fades everything under it (opacity multiplies down a subtree) and a descendant that also matched would land at 20%. It costs nothing in expressiveness, since §2.1 requires disabled to be opacity and never a colour remap. The effective value is derived by walking up the ancestors, not stored — ADR-0073’s lesson applied again: a second copy of a fact the tree already holds disagrees the first time something changes without telling the thing that cached it. The router is the choke point, one guard in dispatch plus isFocusable, so a control written without its own disabled check is still unavailable — and the keyboard needed no guard at all, because focus is the only route a key has, so one line about focus covers onKey, onKeyCapture and onText together. The cut is input versus observation: enter, exit, motion, hit testing and the cursor all still work, which is what keeps ADR-0059’s two cases — a click that must not fall through, and a tooltip explaining why something is unavailable. form, group-box and a dialog in its closing phase all get this for free. — ADR-0077, ADR-0059

  • The state and rebuild API. Answered. The stateful-widget lifecycle, rebuild scheduling and dirty-marking are settled: state lives on the element, setState mutates immediately and defers the rebuild, and the tree flushes dirty elements once per frame. — ADR-0052, ADR-0004

  • KDL 2.0 Java parser. Answered, by writing one. No third-party parser was adopted: the tokenizer and parser are hand-written for the §9 subset, with the §9 example document as a test. — ADR-0051, ADR-0005

  • YGSize struct-by-value upcall returns. Answered, and now driven by Yoga itself. A Java upcall returning YGSize by value is called from C and arrives intact; the return segment is allocated once per callback rather than per call, and an exception thrown by a measure function is held and rethrown in Java instead of taking the process with it. The node API is bound, so the callback is invoked by real layout passes with the constraints the flexbox algorithm arrived at — not by a C probe written for the purpose. Proven on linux-x64; the checks run on every target in CI, so the other five are answered by the next run rather than by argument. — ADR-0017, ADR-0029

  • Windows has never been built. Answered. All four targets link, and all three export branches are now exercised rather than argued about: the ELF version script on both Linux targets, the Mach-O -u,_symbol / -exported_symbols_list pair on macos-aarch64, and the MSVC /INCLUDE: and .def branch on windows-x64. The Windows leg builds goldberry.dll, runs :natives:test against it with goldberry.native.required=true so a skipped test cannot pass for a passing one, and matches the golden images — which is also what answers Win64’s 4-byte long, the one thing no other target could catch. What Windows has not done is open a window: the leg links the library and runs the Java tests, exactly the hole ADR-0039 describes for macOS. The showcase image workflow is what would close it. — ADR-0012, ADR-0041

  • Live resize stalls on Windows and macOS. Taken, and half proven. Both platforms run a modal loop during a resize gesture, so SDL does not return from event pumping until the drag ends and frames stopped with it. Goldberry now installs an SDL_AddEventWatch callback and draws from inside it: SDL keeps pumping events within the platform’s loop, and a watch is called from inside that pump, so it is the one place a frame can be produced while the platform holds the thread. Four guards decide whether it does anything — the UI thread, an active sink, re-entrancy, and the event type — and each is there because a watch is called in circumstances a pump never is; the resize the queue then delivers a second time is coalesced away rather than laid out twice. What CI proves is the whole mechanism except the platform: a test pushes an event from inside an event handler, which is the same state a modal loop creates, and asserts that the resize and a frame come out of the watch re-entrantly. What is left is that Windows’ and macOS’ loops really do pump during a drag — SDL’s own documented behaviour, and a human with a mouse is what would confirm it. — ADR-0060, ADR-0024

  • Blend2D and AsmJit have no release tags. Answered. Neither upstream has ever cut one, so both are pinned by commit SHA instead — Blend2D at 6dbc2ce and AsmJit at 0bd5787, the pair that has actually built, linked and passed the tests. All six upstreams now resolve to exactly one commit, so the build is reproducible. What remains before publishing is the licence texts. — ADR-0030

  • Shaping itself is unverified: there is no font to shape with. Answered. Inter, JetBrains Mono and OpenMoji are fetched at build time, pinned by version and SHA-256, and packaged into goldberry-core (ADR-0033). Shaping now runs against real outlines: real glyph ids rather than .notdef, a proportional face measurably different from a monospace one, and emoji resolving through OpenMoji. Right-to-left glyph reordering is still unchecked — it needs a script the bundled faces cover. — ADR-0032

  • Nothing draws a glyph or an icon yet. Both do. bl_font_* and bl_context_fill_glyph_run_d_rgba32 were bound first; the path API followed — seventeen symbols, one per SVG command, plus the three stroke options an icon needs because Lucide is drawn in strokes rather than fills. SvgPath reads the table’s path data with SVG’s own number grammar, and every one of the 1544 icons is asserted to parse and produce geometry. What is still open is that an icon is not a Box: the showcase draws them over its sidebar rather than laying them out in it, because nothing decides an icon’s intrinsic size until the widget model does. — ADR-0043, ADR-0004

  • A Font costs two copies of the font file, and there is one per size. Two copies per face now, not per size. FontFace holds HarfBuzz’s whole font — which is size-independent because Goldberry never scales the shaper — and Blend2D’s data and face; Font.on(face, size) adds only the object the size lives on. A second size measures at 4.4 µs against 681, and four sizes of Inter cost three megabytes rather than twelve. Faces are owned explicitly rather than cached globally, because these objects are thread-confined and a per-thread cache of native memory has no hook that would ever free it. What remains is the two copies themselves: each library owns its own memory, and neither takes a borrowed buffer for font data. — ADR-0044

  • Nothing measures text for layout yet. It does. A Paragraph shapes once and wraps with arithmetic, and its measure function reports a height to Yoga through the YGSize upcall. What is still ahead is bidi run splitting — right-to-left text is shaped in logical order and therefore drawn mirrored, because HarfBuzz returns those glyphs in visual order and prefix sums taken in logical order would otherwise measure the wrong ones (it was refused outright until ADR-0218, which cost a window every time somebody pasted Arabic into a field). Font fallback between the UI and emoji slots — the thing that makes a paragraph several runs rather than one — is built: the itemizer splits emoji out by UTS #51’s sequence rules and a paragraph takes one measurement over up to two shapings, with the emoji face’s advances rescaled into the base font’s design units. — ADR-0218, ADR-0393, ADR-0036

  • The paragraph cache is a one-entry memo. Both caches exist, and the numbers say why. ParagraphCache holds shaped paragraphs keyed by (font, text); the width memo stays inside each Paragraph. Shaping is 56 µs and a cache hit is 0.05 µs, while a memoised wrap is already 0.02 µs — so shaping is the only part worth a cache, and caching layouts would save nothing. The cache has no consumer yet, because nothing rebuilds a widget tree; it exists because the measurement says it will be needed the moment something does. §6’s third key component, the width bucket, is the per-paragraph memo, and the “resolved text style” is a Font until the CSS engine has something better. — ADR-0037

  • A fresh upcall stub per text box per frame is the largest cost of text in a layout pass. Answered: the render tree is retained. RenderObject owns a YGNode that survives the frame and keeps its measure callback for as long as the paragraph behind it is the same instance. Measured on a showcase-shaped tree with seven measured leaves at 960×640: layout and walk fall from 190 µs to 7.2 µs, and a whole frame from 354 µs to 148 µs. The 7.2 µs row is the one that had to be won — it hands over a fresh box tree every frame, as a real application produces, and it matches the do-nothing case because every Yoga setter is guarded by a comparison against the box already applied. Yoga dirties a node when a style is set, not when it changes, so an unguarded retained tree would cost exactly what a thrown-away one costs plus the memory management. Retention also introduced this repository’s first keep-state bug, caught by its own equivalence test: Yoga does not dirty a node when its measure function is replaced, so a paragraph swapped for longer text reported the height cached for the old one — six lines of prose laid out as one, with no error anywhere. — ADR-0069, ADR-0037, ADR-0004

  • The cascade is now the largest term in a frame. Answered: it resolves invalidated nodes, which is what §5 always said it did. A node’s resolved style is cached on its element and checked by identity against two things — the resolver, so a theme swap or a hot reload invalidates everything at once with no event to remember to fire; and the inherited style, so a parent that re-resolved hands its children a different instance and they re-resolve without being told. Invalidation is a subtree, because a descendant combinator means a node’s own match depends on an ancestor’s state: checkbox:hover check-indicator restyles the indicator while the checkbox’s own style need not change at all, and that rule is in controls.css today. One hook — setPseudoClass — covers :hover, :active, :focus, :disabled, :checked and :indeterminate, and fires only on an actual change, which matters because the renderer mirrors three of them onto every styled element every frame. The CPU a frame spends before rasterizing falls from 148 µs to 3.5 µs — 354 µs to 3.5 µs taken with the retained render tree, a factor of a hundred. — ADR-0070, ADR-0052

  • The isolated paint benchmark and the in-app paint number disagree by 10×. Answered: it is present. A frame that follows a present costs about four times what the same frame costs painted back-to-back — 2.19 ms against 0.57 ms, measured by skipping present and changing nothing else — and the benchmark never presents. It was not the borrowed compositor buffer, which was the standing hypothesis: painting into a heap buffer measured 2.28 ms against the surface’s 2.22 ms. Nor the icons (+0.01 ms), the display server (Wayland 2.22, X11 2.07), the compositor (SDL’s dummy driver 2.00), or the environment at all — the benchmark’s own loop, run inside the live application between two real frames, came out at 0.49 ms while those frames cost 2.06 and 2.25. The mechanism is cache and TLB pollution; a synthetic 96 MB eviction between iterations reproduces 1.6× of the 3.8×. — ADR-0045

  • Every frame damages the whole window. Answered for the upload. Something now knows which parts changed: the retained render tree remembers each node’s rectangle and reports the union of old and new for whatever moved. What is still true is that the painting is full-frame — see the damage entry above for why that needs an SPI change rather than more code here. — ADR-0071, ADR-0004

  • CMake arguments live in five places. The refs do not any more. CMakeLists.txt reads gradle/libs.versions.toml itself, so a ref bump is one edit and there is no default to drift from; a floating ref is refused at configure time. The manylinux container never needed a JDK to read the catalog, only something that can parse a text file. checkPinnedRefs is inverted — it asserts no copy has come back, across every workflow rather than three, which is what would have caught example.yml pinning Blend2D to a floating master. The rest of the argument list — build type, install prefix, target id — is still kept in step by hand. — ADR-0035

  • Nothing warns at run time that a window came up undecorated. Answered: WaylandDecorations warns, once, with the command that fixes it. Not by asking SDL, which cannot answer — libdecor_new succeeds even when every plugin failed, so SDL marks the surface WAYLAND_SHELL_SURFACE_TYPE_LIBDECOR and exposes nothing to say the frame is empty. It is inferred from which plugin files are installed, which works because the GTK plugin is guaranteed to fail in a JVM. The verdict is three-valued and stays silent when it cannot locate a plugin directory: a warning that is sometimes wrong is worse than none. — ADR-0084

  • A Goldberry window on GNOME/Wayland has no titlebar out of the box. Answered: X11 is the Linux default now. On a Wayland session the backend asks SDL for x11,wayland, unconditionally — under XWayland the window manager decorates the window itself, which is the only configuration today that produces a titlebar matching the desktop. Wayland stays behind X11 rather than being dropped, so a session without XWayland still gets a window, and the ADR-0084 warning still fires there. -Dgoldberry.backend.videoDriver=wayland asks for Wayland anyway. The cost is ADR-0027’s resize quality and fractional scaling, given up for as long as decorations are unobtainable on the better axis. — ADR-0086

  • A window on GNOME/Wayland had no titlebar and could not be resized. Answered: SDL was built without libdecor. Wayland has no decoration protocol of its own, GNOME’s compositor declines to draw them server-side, and every use of the client-side path in SDL sits behind #ifdef HAVE_LIBDECOR_H. Without libdecor-0-dev SDL builds a complete Wayland driver that opens an undecorated toplevel — and since a Wayland resize is client-initiated from the decoration’s own edge, the same missing header removes resizing too. The Java side was never involved: WindowSpec.of asks for decorated and resizable and Sdl3Backend.createWindow passes exactly that. It only became visible when ADR-0082 added egl and the Wayland driver started being built at all. — ADR-0083

  • checkToolchain passed and the build died two minutes later. Answered: the table it checked had drifted from what SDL demands. It probed pkg-config --exists xss, a module no distribution ships — SDL’s own spec is xscrnsaver — so the row returned “absent” whether the package was installed or not, and it was marked optional besides, while SDL’s CheckX11 treats XScrnSaver as a FATAL_ERROR. XTest, the next hard stop in line, was not in the table at all. Both CI workflows already knew all of this, in comments, written by whoever hit it there twice. The table is now LinuxDependencies in build-logic with a three-valued Necessity, and LinuxDependenciesTest asserts it against the packages the workflows install — the invariant that broke. — ADR-0082

Building an application

How a Goldberry application is put together: what the classes are, what each one is allowed to know, and where a thing goes when you are not sure.

The rule underneath all of it is one sentence: data flows down, events flow up (ADR-0063). Everything below is that sentence turned into files.

The four kinds of class

Values ────────► Views ────────► Actions ────────► Values
   (read)          (report)         (assign)         (notify)
What it isWhat it may know
Valuesa @Model class of plain fieldsnothing. No widget, no window, no toolkit type beyond @Bind
Actionsan @Actions record nested in the valuesthe values. Not widgets, not the window
ViewsWidget records — or a .kdl documentthe values it reads and the actions it calls
Applicationone implements Application, and no annotationall three, plus the Host. The only class that knows a window exists

Nothing points backwards. Values do not know actions exist; actions do not know views exist; a view cannot write a value, because what it is handed is an Observable with no set on it.

Values

A class of fields. Not a record — a record’s components are final and a bound field has to be assignable.

@Model
public final class Settings {

    @Bind("app.gain")                               private Number gain = 40;
    @Bind(value = "app.theme", restyle = true)      private String theme = "dark";
    @Bind(value = "app.bytesRead", repaint = false) private long bytesRead;

    // Projections are fine: a question about the fields with one answer.
    public Theme theme() {
        return "light".equals(theme) ? Theme.NORD_LIGHT : Theme.NORD_DARK;
    }
}

Each field declares what changing it costs:

  • the binding — always. Anything bound to app.gain is told.
  • a frame — by default; repaint = false for a value nothing on screen shows (ADR-0135).
  • a restyle — restyle = true when a rule depends on it, not a widget. A theme and a density, and almost nothing else (ADR-0133).

Assignment is what is observed, so hold a List and replace it rather than editing one in place. The build refuses to bind an array for exactly this reason.

Actions

A record wrapping the values, nested inside them. One method per thing a control can ask for.

@Model
public final class Settings {

    @Bind("app.gain") private Number gain = 40;

    @Actions
    public record Commands(Settings values) {

        @Action("app.louder") public void louder() { values.gain = values.gain.doubleValue() + 1; }
        @Action("app.pick")   public void pick(String name) { values.theme = name; }
    }
}

@Actions, not @Model. A class of methods holds no values and publishes no paths; calling it a model said otherwise (ADR-0139). A class carrying both markers, or an @Actions class with a @Bind field, is a build failure saying which one it should be.

Name it for its domain — Commands, Editing, Playback — and not Actions. A nested type called Actions shadows the annotation, so @Actions would resolve to your own record and you would have to write the annotation out in full. The showcase does exactly that and pays for it, on purpose, so there is one worked example of the wart.

values.gain = … notifies, even though the assignment is in a different class: the build rewrites a write to a @Bind field wherever it appears (ADR-0134).

A record, because it holds one thing and holds it immutably: no state of its own, equals that means what it says, a constructor nobody writes. Wanting a mutable field here is the signal that the thing is state — put it with the values, where the rest of the state is.

Nested, because a nestmate reaches a private field. That is the whole reason, and it is worth the one file: a sibling top-level class works too, but forces every value open to the package (ADR-0137). Nesting is scoping, not coupling — the values class holds no reference to Actions and compiles with it deleted.

Three shapes, in order of preference:

ShapeFieldsWhen
values with a nested @Actions recordprivatethe default
one class, values and methods togetherprivatea model with three fields
values and a sibling @Actions classpackage-privateyou want two files and will pay for them

An @Action method takes no argument, or one the toolkit can parse from a string (String, double, int, boolean). A button reports that something happened; a slider reports what it should become.

Views

Two forms, and they resolve names identically.

Markup, for structure:

column class="settings" {
  slider bind="app.gain" min=0 max=100 change="app.set-gain"
  button "Louder" press="app.louder"
}

Java, for anything a document should not carry — a loop, a conditional, a widget built from a list:

public record Panel(Settings settings, Settings.Commands actions) implements Widget.Stateless {

    @Override public Widget build(BuildContext context) {
        return new Column(
                new Slider(0, 100, Models.observable(settings, "app.gain"), actions::setGain),
                new Button("Louder", actions::louder));
    }
}

bind="app.gain" and Models.observable(settings, "app.gain") are the same lookup against the same registry. There is one name for a value, not two (ADR-0129).

What a view may not do

Write. A widget is handed the Observable half of a value and there is no set to call — so a control built from markup cannot reach the model even by accident, and “who changed this?” always has an answer.

Widget state versus application state

Two different things, and putting one where the other goes is the commonest mistake.

Lives inDies when
a scroll offset, a caret, which tab is open, a hoverState on the widgetthe widget is unmounted
the gain, the theme, the document being editedthe values classthe application exits

Ask: would a second screen showing this need the same answer? If yes, it is application state.

The application

One class. The only one that knows a window exists.

public final class Hello implements Application {

    private final Settings settings = new Settings();
    private final Settings.Commands actions = new Settings.Commands(settings);
    private Icons icons;

    /// Everything markup may name, and everything the window follows.
    @Override public List<Object> models() {
        return List.of(settings, actions);
    }

    @Override public void start(Host host) {
        // Anything the window owns for its whole life is made here and released
        // in stop(): a widget is a value that gets rebuilt and thrown away, so
        // nothing that costs money to build belongs in a build method. An icon
        // is parsed from the bundled set and scaled to one size, which is work
        // to do once rather than per frame.
        icons = Icons.strict().bind("plus", Icon.bundled("plus", 16));
        host.shortcut("Ctrl+T", actions::toggleTheme);
    }

    @Override public Widget root() {
        return new Panel(settings, actions);
    }

    @Override public List<Stylesheet> stylesheets() {
        return Controls.stylesheets(settings.theme());
    }

    @Override public void stop() {
        icons.close();
    }

    public static void main(String[] args) {
        Goldberry.run(new Hello());
    }
}

models() is the whole of the wiring. From that one list the toolkit gets:

  • what a document’s bind= and press= resolve against;
  • when to repaint — any value that asks;
  • when to restyle — any value declared restyle = true.

There is no repaint() call anywhere in an application, and there should not be one (ADR-0128).

More than one model

The list is a list because a window has actions of its own — “open the menu”, “toggle the HUD” — that need a Host and therefore have no business on a view model. Put them on the Application itself, annotate it @Model, and add this:

/// The window's own actions. Two Runnables, so this knows what they are called
/// and nothing about who performs them.
@Actions
public record WindowActions(Runnable openMenu) {
    @Action("app.open-menu") public void open() { openMenu.run(); }
}
public final class Hello implements Application {

    private final WindowActions window = new WindowActions(this::openMenu);

    @Override public List<Object> models() { return List.of(settings, actions, window); }

    private void openMenu() { host.popup(…); }
}

The Application itself is not a @Model. It owns the window, the lifecycle and the native resources; making it also a thing markup resolves names against puts two unrelated roles on one class (ADR-0138).

Two models may not claim one name; the build says which two.

Inflating a document

var inflater = Widgets.inflater(icons, models().toArray());
var window   = inflater.inflate(KdlParser.resource(Hello.class, "window.kdl").getFirst());

From models(), not from a list written out again. Two lists that must agree are two lists that will not: the showcase shipped for one commit with actions missing from the inflater and present in models(), so every test passed and the window threw no action named "app.toggle-theme" is bound on the first frame.

icons is the one registry that cannot be derived: an Icon is parsed from the icon set and built scaled to one size, so icon="plus" in a document reloaded on every keystroke would re-parse and re-scale one per reload. Markup may name an icon and must never build one. (It used to be a stronger rule — an icon held a BlendPath, a native allocation, and had to be closed exactly once. Since ADR-0277 an Icon is an immutable value whose close() does nothing, so the cost is work rather than a leak.)

Widget names need no registration at all. Every module on the path that ships widgets announces itself, so Widgets.inflater already knows button and column and anything a third widget module brought with it (ADR-0131).

Shipping a widget

If you are writing widgets rather than using them:

@Markup("gauge")
public record Gauge(double value, Observable<?> source, Attributes attributes)
        implements Widget.Leaf, Styled, Paints {

    public static Widget inflate(KdlNode node, List<Widget> children, Wiring wiring) {
        return new Gauge(node.numberProperty("value", 0), wiring.bound(node),
                Attributes.of(node));
    }
}

That is the whole registration. The build collects every @Markup class in the module into a catalog and declares it as a service; an application that never names your module gets your widget.

Every built-in must be constructible three ways — Java, KDL, and styleable by CSS — and a test enforces it. Hold your own widgets to the same rule; it is what makes a document portable between an application and a preview tool.

Where does it go?

I have…It goes…
a value a widget showsa @Bind field on the values class
a value nothing shows, but something watches@Bind(…, repaint = false)
a value a stylesheet depends on@Bind(…, restyle = true)
something a button doesan @Action on the actions class
something only Java callsa plain method on the actions class
a derived answer about the valuesa method on the values class
a scroll offset, a caretState on the widget
an icon, a font, a native handleopened in start, closed in stop
a menu, a popup, an acceleratorthe Application, which has the Host
a new node name for markup@Markup on the widget
an action that needs the Hosta small @Actions record of Runnables, built by the application

The package layout that follows

com.example.app
├── Hello.java              Application: the Host, the lifecycle. No annotation.
├── Settings.java           @Model values, with a nested @Actions record Commands
├── WindowActions.java      @Actions record: the actions that need the Host
├── window.kdl              structure
├── app.css                 appearance
└── ui/
    ├── Panel.java          Widget records
    └── Gauge.java          @Markup, if you ship widgets

Views in their own package, because they should be replaceable without touching the model. Everything else is flat: there is not enough of it to file.

Starting fast

What a user waits for is the process, not the toolkit, so it depends on how the application is launched. For the showcase, from exec to its first frame on a Linux desktop (ADR-0506):

LaunchFirst frame
GraalVM native imageabout 520 ms, the window open at about 120 ms
JVM with a JDK 25 AOT cacheabout 1.3 s
JVM, coldabout 2 s

A native image is the fastest and the one a release of the showcase ships. On the JVM, train an AOT cache (JEP 483, JEP 514, JEP 515): run the application once with -XX:AOTCacheOutput=app.aot through the screens a user opens first, then launch it with -XX:AOTCache=app.aot. Classes then arrive loaded and linked and the hot methods already profiled. The cache belongs to your application — it is specific to the JDK build and the module path, and it is trained on your screens — which is why the toolkit documents it rather than shipping one.

-Dgoldberry.log.level=TRACE prints the start-up timeline after the first frame: each phase from process start, so you can see which part is yours.

What the build does to all this

Your model is plain Java; the build makes assignments to it observable, using the JDK’s class-file API on the compiled class. That is one Gradle plugin or one Maven <execution>, and it is the subject of its own page — including what happens when you forget it, which is a loud error naming the missing step rather than a control that renders perfectly and never moves.

Model weaving

A Goldberry model is plain Java. You write fields; the toolkit makes assignments to them observable.

You do not have to run the weaver. A plain jar binds a model reflectively and needs no build step at all; weaving is what a GraalVM native image is built from (ADR-0155). The short version is below; this page starts with what the weaver does, because that is the form everything else is described against.

@Model
public final class Settings {
    @Bind("app.gain")                          private int gain = 40;
    @Bind(value = "app.theme", restyle = true) private String theme = "dark";

    @Action("app.louder") private void louder() { gain++; }
    @Action("app.pick")   private void pick(String name) { theme = name; }
}
slider bind="app.gain" min=0 max=100 change="app.pick"
button "Louder" press="app.louder"
public final class Hello implements Application {

    private final Settings settings = new Settings();

    @Override public List<Object> models() { return List.of(settings); }

    @Override public Widget root() {
        return Widgets.inflater(icons, settings).inflate(document);
    }
}

gain++ moves the slider and asks for a frame. Changing theme restyles first. There is no Property, no set/get, no listener registration, and no repaint() anywhere in the application.

The five annotations

OnMeans
@Modela classit holds values: its @Bind fields are rewired
@Actionsa classit holds only methods, acting on somebody else’s values
@Bind("a.b")a fieldmarkup names this value; restyle = true means a rule depends on it
@Action("a.b")a methodmarkup names this handler; no argument, or one the toolkit can parse from a string
@Markup("button")a widget classthis is the node name a document writes for it

@Markup is the widget-author’s half: the build collects every annotated class in a module into a WidgetCatalog, declares it in the module descriptor, and Widgets.inflater(...) finds every catalog on the path. A module that ships widgets is found by an application that never names it (ADR-0131).

What the weaver actually does

It rewrites the compiled class, between compileJava and anything that reads its output, using the JDK 25 class-file API (JEP 484). For the class above, Settings.class comes out with:

  1. implements BoundModel, and a lazily created FieldListeners;
  2. a synthesised goldberry$set$gain(int) — compare, store, notify;
  3. every putfield gain rewritten into a call to it — in that class and in any other class in the same build that assigns to it, which is what lets an @Actions class beside the model change its values (ADR-0134);
  4. bindings() and actions(), built from the annotations, the second as one invokedynamic per action bootstrapped by LambdaMetafactory.

For a module with @Markup widgets it also writes a GoldberryCatalog, patches provides WidgetCatalog with … into module-info.class, and drops a META-INF/services entry — both, because a jar has to work on the module path and on the class path, and the module system ignores META-INF/services for a named module.

Step 3 is a one-for-one instruction swap: putfield pops objectref, value, and so does an instance call taking one argument.

Why it has to be a build step to see the write

A field write cannot be intercepted any other way. getfield and putfield are not virtual, so no subclass and no proxy can see one — the class that declares the field is the only place the write can be observed. Doing that to the compiled class in the build is the one option that needs no -javaagent, no opens, and nothing generated at runtime, which is also what lets the result go into a GraalVM native image (ADR-0127).

You probably do not need to run any of this

Model weaving is for a native image. An ordinary jar binds the same annotations at run time and needs no build step at all (ADR-0155):

WovenBound at run time
Whoa GraalVM native imageeverything else — gradle run, mvn exec:java, an IDE, java -jar
Build stepthe weaver, over the compiled classesnone
A change notifiesinside the assignment that made itat the next sweep
Needsnothingthe model’s package open to the toolkit, in a named module
Models.isWoventruefalse

Everything else is identical. The same Models.bindings, the same Models.actions, the same paths, the same values, the same refusals — and RuntimeAgreesWithWovenTest drives one model class both ways through the same actions to keep it that way.

The sweep, and the one line it sometimes costs

Reading is exact either way: an Observable over a woven field and one over a VarHandle both see the field itself. What the reflective form cannot do is see the write, so it compares each field against what it last held and notifies what moved. That sweep runs

  • after every action a document dispatches — across every model, because an @Actions record writes to the model beside it;
  • at the top of every frame, over the models Application.models() named;
  • wherever you call Models.refresh(model).

Which leaves one case: a field written from neither an action nor anything that leads to a frame.

job.onFinished(text -> {
    model.status = text;
    Models.refresh(model);   // a no-op, returning false, once this is woven
});

A model in a named module opens its package

The reflective form needs private access, and JPMS is what grants it:

opens com.example.app to io.github.digitalsmile.goldberry.core;

A classpath application needs nothing — the unnamed module is open. The refusal names the package and that exact line if you forget. The woven form needs neither, which is one more reason an image is the cheaper artifact.

Turning weaving on

./gradlew build -Pgoldberry.nativeImage=true    # the whole build, woven
./gradlew :example:weaveModels                  # one module, to look at

The catalog half of the weaver is not optional and is not affected by any of this — see below.

What it never touches

Classes with neither marker are not rewritten — not even re-serialised, unless they assign to some model’s @Bind field. Reads are left alone: getfield is already the fastest thing that could happen.

And within a class it rewrites, it rebuilds only the methods it has to. A method is rebuilt if and only if it contains a write the weaver replaces; everything else is copied out of the original class file byte for byte, stack-map frames included. That is not tidiness. Rebuilding a method means regenerating its frames, and a frame where two of the author’s types meet — `Base x = b ? new A()
new B()` — is only computable by resolving both and asking what they have in common. A weaver that rebuilt every method would have to resolve every class the module was compiled against, to weave a method with nothing in it for the weaver.

A rewritten method keeps what javac wrote beside its code. Signature, both annotation attributes, MethodParameters and Exceptions survive — on the model and on any class that writes to one. This is what lets the reflective binder (ADR-0155) keep working on a class that happens to have been woven, and it is asserted against the woven bytes by MethodAttributesTest rather than against whatever is on the test classpath.

Weaving is idempotent in the sense that matters: a pass over a tree that changed nothing rewrites no model and writes no class file. It is not a no-op — the catalog half rewrites its GoldberryCatalog and its service entry every run, because that half is a generator rather than a rewriter and comparing its output to decide would cost more than writing it.

And it is safe on a half-recompiled tree, which is what an incremental build hands it. A woven class is still recognised as a model on a later pass; a write the weaver already turned into a setter call still counts as a write; and a model gains package-private setters when a writer outside its nest appears. Each of those three was a real defect: without the first, a recompiled sibling’s writes were left unrewritten and the build was green with dead bindings; without the second, recompiling only the model re-wove it with private setters its sibling could no longer reach, for an IllegalAccessError at the first click.

The two halves

The weaver does two unrelated jobs to the same tree, and a build asks for them separately:

FlagDoesNeeded by
--modelsrewires @Bind fields, writes the @Action call sitesa native image only
--catalogwrites the module’s WidgetCatalog from its @Markup widgets, patches provides into module-info.class, writes META-INF/servicesevery build

Neither flag means both, which is what every pre-ADR-0155 integration already wrote.

The catalog half has no runtime equivalent and never will: finding annotated classes while the program runs means scanning the path, which is the thing a provides exists to avoid (ADR-0131). So a module that ships widgets runs the weaver whatever it is building; a module that only keeps a model runs it only for an image.

Adding it to a project

The weaver is one jar with no dependencies beyond the JDK, and a main that takes directories of compiled classes. Every integration below is a way of calling that.

Gradle

Apply the plugin to any module that ships @Markup widgets, or that you want to be able to weave for an image:

plugins {
    id 'goldberry.weave'
}

It registers four JavaExecs — weaveCatalog and weaveModels, each for the main and test source sets. The catalog pair hangs off classes and testClasses so jar, run and every Test task reach through it; the model pair joins them only under -Pgoldberry.nativeImage=true.

In a build that consumes Goldberry from a repository rather than from this source tree, the equivalent is:

configurations { goldberryWeaver }
dependencies { goldberryWeaver "io.github.digitalsmile:goldberry-weaver:$goldberryVersion" }

def weave = tasks.register('weaveModels', JavaExec) {
    dependsOn tasks.compileJava
    def classes = tasks.compileJava.flatMap { it.destinationDirectory }
    // The weaver FIRST, then the classes it is weaving and everything they were
    // compiled against: regenerating a stack-map frame means resolving the
    // author's own types. The weaver's own jar alone is not enough.
    classpath = files(configurations.goldberryWeaver, classes, sourceSets.main.compileClasspath)
    mainClass = 'io.github.digitalsmile.goldberry.weaver.WeaverMain'
    argumentProviders.add({ [classes.get().asFile.absolutePath] } as CommandLineArgumentProvider)
    inputs.dir(classes)
    // A stamp, NOT `outputs.dir(classes)`. Declaring javac's own directory as
    // this task's output tells Gradle that two tasks write to one place, and
    // Gradle answers an overlapping output by throwing away the compiler's
    // incremental state -- every build then fully recompiles the module and
    // everything downstream of it (ADR-0398).
    outputs.file(layout.buildDirectory.file('tmp/weaveModels/stamp'))
    outputs.upToDateWhen { false }      // in place, and cheap on a settled tree
    doLast { layout.buildDirectory.file('tmp/weaveModels/stamp').get().asFile.text = 'woven' }
}
tasks.named('classes') { dependsOn weave }

Maven

There is no first-class Maven plugin. exec-maven-plugin runs the weaver as it stands, bound to process-classes, which is the phase that exists for exactly this. Add <argument>--catalog</argument> (or --models) before the directory to run one half; with neither it runs both:

<plugin>
  <groupId>org.codehaus.mojo</groupId>
  <artifactId>exec-maven-plugin</artifactId>
  <version>3.5.0</version>
  <executions>
    <execution>
      <id>weave-models</id>
      <phase>process-classes</phase>
      <goals><goal>java</goal></goals>
      <configuration>
        <mainClass>io.github.digitalsmile.goldberry.weaver.WeaverMain</mainClass>
        <arguments>
          <argument>${project.build.outputDirectory}</argument>
        </arguments>
        <classpathScope>compile</classpathScope>
      </configuration>
    </execution>
    <execution>
      <id>weave-test-models</id>
      <phase>process-test-classes</phase>
      <goals><goal>java</goal></goals>
      <configuration>
        <mainClass>io.github.digitalsmile.goldberry.weaver.WeaverMain</mainClass>
        <arguments>
          <argument>${project.build.testOutputDirectory}</argument>
        </arguments>
      </configuration>
    </execution>
  </executions>
  <dependencies>
    <dependency>
      <groupId>io.github.digitalsmile</groupId>
      <artifactId>goldberry-weaver</artifactId>
      <version>${goldberry.version}</version>
    </dependency>
  </dependencies>
</plugin>

A real Mojo would be nicer — one <plugin> block, incremental, no <mainClass> to get wrong — and is a small amount of work whose only awkward part is that this repository builds with Gradle and would have to write META-INF/maven/plugin.xml itself. It is not built, and this is honest about that rather than implying otherwise. Nothing above is a workaround for a missing feature: process-classes is where class post-processing belongs, and the weaver is a program that post-processes classes.

Any other build, or none

java -cp goldberry-weaver.jar:target/classes:<compile classpath> \
     io.github.digitalsmile.goldberry.weaver.WeaverMain target/classes

The classpath is not optional, which the shorter java -jar line this page used to show quietly implied it was. The weaver regenerates stack-map frames for the methods it rewrites, and a frame where two of your types meet at a control-flow join is only computable by loading both — so the weaver has to be able to see the classes it is weaving and everything they were compiled against. Without them, such a method fails with Could not resolve class, and there is nothing the author of that method can do about it.

It prints one line per class it wove, exits 0, and exits 1 with a message naming the member when it refuses a model.

If you forget

Nothing. A model that is annotated and not woven is bound at run time, which is the ordinary case — that is ADR-0155. All five annotations are RUNTIME-retained so that the reflective binder can read them.

The one thing you can forget is the opens line, in a named module, and the refusal quotes it back at you.

What it refuses, and why

Each of these is a failure naming the member — at build time when the weaver runs, and on the first Models call when it did not — because a binding that fails silently is a control that renders perfectly and never moves. Both forms refuse the same list, which is the point: a model that builds as an image builds as a jar.

RefusedBecause
static @Bind fieldA binding belongs to an instance; a static one is shared by every window in the process
final @Bind field (unless a Property)A value that cannot change is not something to subscribe to
an arrayOnly the assignment is observed, so values[0] = x would notify nobody. Hold a List and assign a new one
a path that is not a.b.cThe grammar Bindings enforces at runtime, checked first (ADR-0062)
two members claiming one nameTwo features quietly sharing one name presents as a value changing by itself
an @Action taking two argumentsA control reports either that something happened or what it should become, never both
an @Action parameter that is not String, double, int, boolean or a boxA valued action crosses as the string the document wrote down
a static @ActionAn action changes a model, and a static one has no model to change
an abstract or empty @ModelNothing to weave into, or nothing to publish
@Actions with a @Bind fieldA class that holds values is a @Model
@Actions with no @Action methodIt publishes nothing
both @Model and @Actions on one classA class holds values or it does not
a @Model extending a @ModelEach would get its own listener store and the subclass’s would shadow the superclass’s, so inherited fields would notify nobody
@Bind(restyle = true) on a PropertyNo writes to it are rewired, so there is nowhere to put the call
@Markup without public static Widget inflate(KdlNode, List<Widget>, Wiring)Nothing for the node name to build. Java cannot say this in an annotation, so the build says it
two classes claiming one @Markup nameA document writing it would get whichever the build saw last

Known limits

Notification is deferred when nothing wove the class. The sweep points above cover every path a document takes; a write outside all of them waits for Models.refresh. Woven, there is no deferral at all.

The order a registry lists its names in differs between the two forms. The weaver publishes in class-file order; reflection cannot recover that — getDeclaredFields and getDeclaredMethods promise no order — so the reflective form sorts by member name. It shows up only in the Bound: ... list a strict registry prints when it refuses a name.

Reading through a binding boxes a primitive. Models.observable(model, "app.gain").get() on an int field allocates, where the old Property<Integer> handed back a box it already held. Writes got faster and reads got slower; the numbers are in ADR-0125.

Native image

A Goldberry application can be built as a GraalVM native image: no class loading, no reflection on the binding path, and a start-up measured against the process rather than against a JVM. That is what ADR-0127 designed the binding schema for.

Built and run in CI on linux-x64, macOS and Windows (ADR-0337) — wired, not yet run there. One 41 MiB file with nothing beside it, starting in well under a second and painting at about 1 ms a frame headless — faster than the JVM build over a short run, because there is nothing to warm up (ADR-0161). The FFM downcalls, the six upcalls, the fonts, the icons, the stylesheets, the KDL, the WidgetCatalog service and libgoldberry itself all travel inside it.

It logs, too, which took one hand-written metadata entry — see below.

Before anything else: the C toolchain

native-image links with the system gcc, so it needs a C toolchain and the development package for zlib — not merely the runtime one, which is what a desktop already has:

sudo apt install build-essential zlib1g-dev     # Debian / Ubuntu
sudo dnf install gcc glibc-devel zlib-devel libstdc++-static

Without it the build runs to completion, spends a minute on analysis, and fails at the last step with cannot find -lz. zlib1g alone is not enough: the linker resolves -lz through the libz.so symlink that zlib1g-dev installs.

The two commands

./gradlew :example:nativeImageMetadata -Pgraalvm.home=/path/to/graalvm
./gradlew :example:nativeImage         -Pgraalvm.home=/path/to/graalvm

GRAALVM_HOME works instead of the property. Either way it must be a GraalVM and not a stock JDK — native-image and the tracing agent ship only with the former, and the task says so if you point it at the wrong thing.

The result is one file: example/build/native/goldberry-showcase-<target>. No launcher, no lib/ directory, nothing to set. libgoldberry is carried inside the binary as the same classifier-jar resource a released application would use, and unpacked to a temporary file on first use — a shared object has to be a real file to be dlopened, so it cannot be mapped straight out of the image (ADR-0159).

That makes a writable temp directory a requirement, and -Dgoldberry.native.library is still the way out of one that is read-only or noexec.

Why there are two commands

A closed world has to know every foreign function the program will call before it runs, and Goldberry’s are not knowable from the source: a binding class takes a SymbolLookup obtained at run time and builds its handles from it, which is what lets an application choose which libgoldberry it loads. libgoldberry exports 246 symbols — SDL3 75, Yoga 69, Blend2D 56, HarfBuzz 25, libwebp 11, and 10 of the shim’s own, md4c reaching Java through those rather than through exports of its own — plus six upcalls, from five owners: SdlEventWatch, SdlFileDialogs, SdlTray, Yoga’s MeasureCallback and SdlClipboard. ExportListTest holds the list to the bindings in both directions — an exported symbol nothing binds is dead weight in every artifact, and a bound symbol nothing exports is a link error at the first call — and ForeignSurfaceTest.upcalls holds the six.

So the first command runs the showcase under GraalVM’s tracing agent and records what it saw — the foreign descriptors, the resources, the reflection Logback does — into

example/src/main/resources/META-INF/native-image/io.github.digitalsmile/goldberry-example/

That path is under src, not build: the metadata is source. It is reviewed in a diff, it changes when the application does, and being inside the jar is what lets a downstream image build find it without being told (ADR-0156).

The trace is only as good as the run, and that is why resources are not traced. :core, :widgets and :example each ship a META-INF/native-image/…/reachability-metadata.json declaring their own files by glob, because that set is finite and a directory listing cannot be one screen short. The first image built here proved the point by omitting nord-light.css, density-compact.css, JetBrainsMono.ttf and OpenMoji-black.ttf — every one the far side of a toggle the run never flipped (ADR-0160).

Because those declarations travel in the jars, an application building its own image gets the toolkit’s resources without knowing it needs them.

What is still traced — the reflection, the services, the upcall stubs — really does depend on what the code did, and the warning applies to it unchanged: a screen the run never reaches contributes nothing. Re-run the metadata task after adding one, and read the diff.

The FFM descriptors are no longer traced at all (ADR-0339). This page used to say they were never run-dependent, because a holder links its handle in its class initializer; it forgot that a …Calls record binds when its wrapper is first used, so a run that opened no Markdown initialised no MarkdownCalls and the agent recorded none of its five functions — which is how the Windows image died on the Markdown screen with MissingForeignRegistrationError. Now :natives:foreignMetadata initialises every holder and every upcall owner and writes the foreign section itself, into the goldberry-natives jar under META-INF/native-image/, where native-image reads it for any application.

The image is woven, the jar is not

nativeImage depends on weaveModels, and the build orders it before jar. That is not a detail: an image built from unwoven classes would bind its models by reflection, which is the one thing an image must not do (ADR-0155). Everything on the weaving page about -Pgoldberry.nativeImage=true applies to building the modules by hand; nativeImage arranges it for you.

What the flags are for

FlagWhy
--module-path / --moduleThe showcase runs modular, as it does everywhere else (ADR-0007)
--enable-native-access=…nativesJEP 472, naming the one module that touches native code
--no-fallbackRemoved. A fallback image was a JVM in a trench coat; GraalVM 25.3 no longer builds them, and the flag only warned that it had no effect (ADR-0337)
-H:+ReportExceptionStackTracesNames the class that could not be reached, rather than a stack in the builder

Nothing about class initialization is passed here. :natives ships its own META-INF/native-image/io.github.digitalsmile/goldberry-natives/native-image.properties naming the two classes that have an opinion, and they are opposite opinions:

ClassWhenWhy
NativeLibraryrun timeIt dlopens in its initializer, which must not happen in the builder
Downcallsbuild timeIt holds the shared Linker, and every holder’s initializer calls Downcalls.link
the …calls packagesbuild timeA downcall handle is only a call if it is a compile-time constant, and only a build-time initializer makes it one (ADR-0161)

They are packages and they have to be. A holder is a nested class, and naming its enclosing class does not reach it — measured at 4538 ns/call against 8, and silently, because the image builds and runs. Naming a hundred and thirty-four nested classes in a flag is not a list anyone can maintain, and naming the binding packages instead would build-time initialize Sdl and Blend2D, whose holder idiom dlopens the library in the builder. So the holders live in packages that contain nothing else (ADR-0173).

All of them travel in the jar, so an application building its own image gets them without knowing they exist — the same argument ADR-0160 makes for resources.

The one flag the frame rate depends on

GraalVM’s FFM downcalls are not optimized — oracle/graal#8113 lists it as open work, and it costs a factor of 450 on the call itself. Goldberry takes the workaround: a holder’s FD_<symbol> handle is unbound (it takes the address to call as an argument), so it can be linked while the image is being built, which is what turns it into a constant the compiler can lower into a direct call.

Sixty frames of the showcase, headless, on this machine (--resize=WxH walks the window a pixel a frame while it runs, and --late-budget=N fails the run past N missed refreshes — ADR-0342):

./example/build/native/goldberry-showcase-linux-x64     -Dgoldberry.backend.videoDriver=dummy --frames=60
60 framesper frame
without the --initialize-at-build-time lines2.55 s42.5 ms
with it0.061 s1.0 ms

The same rule applies one level down, and it is the trap to know before editing a binding: a downcall handle has to be read by the method that calls it. Passing one into a helper as an argument costs 810 ns a call in an image against 8.9 ns when the helper names the constant itself — the JVM inlines and folds it, and native-image does not. That is why a holder’s call names its own FD_<symbol> field rather than taking a handle, and why the handle is static final on the holder rather than a component of it: an instance field is a value read from an object, not a constant read from a class, and measures 4540 ns/call (ADR-0173).

And the list of packages is its own hazard. native-image.properties is the one file in :natives that nothing compiles, runs or reads — it is a hand-typed package list consumed by a tool that is not part of this build — so a package added to the module and not to the file is invisible in every way but the measurement above. Three of the nine were missing when the 2026-09-18 review counted them. NativeImagePropertiesTest reads the shipped resource against ForeignSurface.holderClassNames() and requires the two to agree, naming the one deliberate exception: desktop.calls stays out, because PortalSettings binds a dozen libdbus functions in a static initialiser and build-time initialising it either fails the image with a MemorySegment in the image heap or bakes the build machine’s D-Bus into it.

Nothing fails when it is missing. The image builds, runs, paints correctly and is forty times slower, which is why the number is written down here. (It is silent only because the generated metadata registers the descriptors anyway; a descriptor registered nowhere raises MissingForeignRegistrationError and names itself.) To check it, move the properties file aside and rebuild passing the run-time half by hand:

./gradlew :example:nativeImage -Pgraalvm.home=… \
    -Pgraalvm.args="--initialize-at-run-time=io.github.digitalsmile.goldberry.natives.NativeLibrary"

Two metadata directories: traced, and written

The agent’s output goes to META-INF/native-image/io.github.digitalsmile/goldberry-example, and nothing hand-written goes in there — the next trace overwrites it. Anything a human has to add lives in the sibling …/goldberry-example-manual. native-image reads every META-INF/native-image/** it finds, so the two are merged for the tool and kept apart for the diff.

There is exactly one entry in it so far, and it is instructive:

{ "module": "io.github.digitalsmile.goldberry.example", "glob": "logback.xml" }

Logback asks a ClassLoader for logback.xml, so the agent records it as a classpath resource. The image runs on the module path, where that file is at the root of a named module and has to be registered against that module or it is not there at all. The symptom is the worst kind: with no configuration found, logback ends with no appenders and prints nothing — not even its own status — so an image that is working perfectly looks like an image that is doing nothing.

The general shape of that trap is worth remembering: the agent records how a lookup was made, not where the file will be. A resource fetched through a ClassLoader by a library that knows nothing of modules is recorded without one.

Why the library is carried rather than linked

Statically linking the archives into the image is the obvious answer and it does not work. The linking part is fine — -Wl,-u,<symbol> pulls the code in, given -lstdc++ and -lm which native-image does not pass. What fails is that Goldberry resolves every native function by name at run time, so the symbols have to be in the executable’s dynamic symbol table, and native-image links with its own --version-script that makes everything it does not list local. --export-dynamic-symbol does not beat it, and a second version script is refused outright — “anonymous version tag cannot be combined with other version tags”.

ADR-0159 records the experiments. Revisit if native-image grows a way to extend its export list.

What is not built

No CI job. Built (ADR-0337): every showcase.yml leg installs GraalVM Community, builds the image, runs it for three frames and uploads it; on a v* tag the three go on the tag’s GitHub Release (ADR-0340), and the workflow runs only on a tag or by hand. Linux builds from the checked-in trace; macOS and Windows trace first, and those traces are not reviewed. Neither task is wired into build, still, because a local build has no GraalVM to count on.

No image of the toolkit on its own. :core and :widgets are libraries; an image is a property of an application, and :example is the application here.

About these records

An architecture decision record captures one significant choice: the forces that pushed on it, what was decided, and what the decision costs. The point is that the reasoning survives the people who did the reasoning.

Conventions

  • One decision per file, named NNNN-kebab-case-title.md, numbered in the order they were recorded rather than the order they were made.
  • Records are immutable once accepted. A decision that turns out to be wrong is not edited — a new record supersedes it, and the old one gains a Superseded by ADR-NNNN line so the history stays readable.
  • Every record states its consequences, including the bad ones. A record with no costs listed has not been thought through.
  • Start from the template.

Status values

StatusMeaning
ProposedWritten down, not yet agreed. Open question.
AcceptedAgreed and in force.
SupersededReplaced; see the record that replaced it.

Recorded retroactively

ADR-0002 through ADR-0005 document decisions that were already made in docs/ARCHITECTURE.md before this log existed. They are written up here because they are the load-bearing ones: every later choice leans on them, and a reader who does not know why CPU rasterization or SDL3-only was chosen will keep proposing to undo them.

ADR-0001: Record architecture decisions

  • Status: Accepted
  • Date: 2026-08-15

Context

docs/ARCHITECTURE.md describes the system as intended, and describes it well — but a design document has a structural weakness: it says what the design is, in the present tense, with the reasoning compressed out. Six months on, nobody can tell which lines are considered choices with rejected alternatives behind them and which are placeholders that survived because nobody revisited them.

That distinction matters most for the decisions that are expensive to reverse: CPU rasterization, SDL3 as the only desktop backend, the three-tree widget model. Each of those will look questionable to someone at some point, and without the reasoning written down they will either be re-litigated from scratch or undone by someone who did not know what they were load-bearing for.

Decision

Keep an architecture decision log in book/, one immutable record per decision, following the conventions in About these records. The design document stays the description of the system; the book becomes the description of the reasoning. New features and changes get a record before or alongside the code.

Alternatives considered

  • Reasoning inline in docs/ARCHITECTURE.md. Rejected: the document is already long, and interleaving rationale with specification makes both harder to read. It also has no way to express “this was decided, then reversed.”
  • Commit messages and PR descriptions. Rejected: they are keyed to changes, not to decisions, and a decision made across five commits is unrecoverable.
  • A wiki. Rejected: it drifts from the code because it is not reviewed with it.

Consequences

  • Decisions become reviewable in the same pull request as the code that implements them.
  • There is now a per-change cost: a feature that changes the architecture needs a record, and writing an honest consequences section takes real thought.
  • The log will contain records that are wrong. That is the intended behaviour — they get superseded, not deleted, and the wrong turn stays visible.

ADR-0002: CPU rasterization with Blend2D

  • Status: Accepted (recorded retroactively)
  • Superseded in part by ADR-0480: a window’s painted frame is presented through the GPU by default, so an application with goldberry-gpu on its module path loads a driver at its first frame. Rasterization is still Blend2D’s, on the CPU.
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §1, §3, §5

Context

A desktop UI toolkit has to choose what draws its pixels, and the choice sets the floor on startup time, memory, and how many ways the toolkit can fail on a user’s machine.

GPU rasterization is the default assumption in modern toolkits, and for good reason on animation-heavy or content-heavy UIs. But it buys that throughput with a context: driver initialization on startup, a device-lost path that has to be handled correctly, a shader pipeline to compile and cache, and a long tail of machines where the driver is the reason the app does not start. For the UI Goldberry targets — application chrome, forms, panels, text — the GPU is mostly idle between frames, and the work it would accelerate is not the bottleneck.

Goldberry also targets GraalVM native-image as a first-class output and claims millisecond startup. A GPU context is squarely at odds with that number.

Decision

Rasterize the UI on the CPU with Blend2D, and open no GPU context for plain UI. Blend2D’s JIT-compiled pipelines and banded multithreading make CPU rasterization fast enough that the GPU is not needed for this workload; its built-in PNG/JPEG/QOI codecs remove an image-decoding dependency; and it renders glyph runs directly from font outlines, which removes FreeType.

The GPU is not excluded — it is scoped. BackendWindow.gpuSurface() is in the backend SPI from day 1 (§12), and a window containing a canvas3d switches to a composition mode where the CPU-rasterized UI is uploaded as a texture and composited with GPU content. Apps that want 3D get it; apps that do not, never touch a driver.

Alternatives considered

  • Skia. The obvious comparison and a better rasterizer in raw capability. Rejected on footprint and build cost: it is an enormous dependency with a bespoke build system, and vendoring it into a single-shared-library distribution (ADR-0008) fights the toolkit’s whole packaging story. Its strengths are mostly on the GPU path Goldberry is choosing not to take.
  • Cairo. Rejected: slower, no JIT pipelines, and its threading story is poor.
  • A hand-written rasterizer. Rejected: correct antialiased path filling with competitive performance is years of work, and it is not the project’s point.
  • GPU-first (Vulkan/WebGPU) for everything. Rejected: contradicts the startup and footprint goals, and turns every driver bug into a Goldberry bug.

Consequences

  • Startup is fast and failure modes are few: no driver, no context loss, no shader cache. The headless backend is nearly free, which is what makes deterministic golden-image tests possible on all three OSes (§14).
  • Blend2D has no filter effects. Blur and frost must be implemented in Java — a three-pass separable box blur over downscaled layer copies, using the Vector API — and drop shadows have to be nine-slice cached (§5). This is real work that a GPU pipeline would have given us for free.
  • Very large animating surfaces, and heavy compositing, will be slower than a GPU toolkit. Layers and damage tracking (§5) are the mitigation, and they are mandatory rather than optional as a result.
  • Blend2D’s JIT means executable-memory allocation at runtime. On Apple Silicon this needs W^X handling, and on hardened or JIT-restricted environments it may need a fallback. This must be verified in M0, not assumed.

ADR-0003: SDL3 as the only desktop backend

  • Status: Accepted (recorded retroactively)
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §3, §4

Context

Windowing is where cross-platform toolkits go to die. Win32, Cocoa, X11, and Wayland each have their own model for window lifecycle, input, DPI, clipboard, cursors, popups, and tray icons — and the differences are not superficial. A toolkit that writes its own backend for each is signing up for three or four permanent, specialist maintenance burdens, and the cost is not front-loaded: it arrives as a decade of platform-specific bug reports.

Goldberry is one project with a large surface area above the windowing layer. Spending its effort there would starve the layers that are actually its point.

Decision

Use SDL3 (≥ 3.2) as the desktop windowing backend on Linux, Windows, and macOS. It provides windowing, input, per-monitor DPI, clipboard, cursors, popup windows, tray icons, and SDL_GPU — the full set the backend SPI needs — and it is a permanent dependency, not a bootstrapping shortcut to be replaced later.

The Backend SPI (§4) exists, but not as an invitation to grow desktop backends. It has exactly two implementations, and that is the complete list:

  • sdl3 — every desktop OS
  • headless — renders to a BLImage for golden-image tests

No hand-written Win32, Cocoa, or Wayland backend. No AWT bridge, ever.

Amended by ADR-0041. This ADR originally listed a third implementation, an OS-compositor backend. It was cut with the platform scope; the list above is the current one.

Alternatives considered

  • GLFW. Rejected: no clipboard-image, tray, or popup-window support, and its scope is deliberately narrower than what a full toolkit needs.
  • Hand-written per-platform backends. Rejected on maintenance cost, as above. This is the decision that most obviously trades control for sustainability, and the trade is made deliberately.
  • Wrapping platform widgets (SWT-style). Rejected: it makes styling, rendering, and testing hostage to the platform, which is incompatible with a CSS-styled, golden-image-tested toolkit (ADR-0005, §14).
  • AWT/Swing as the windowing layer. Rejected: it drags in the entire desktop module, defeats native-image startup goals, and its DPI model is a liability.

Consequences

  • One windowing implementation to maintain and reason about, and per-monitor fractional DPI works because SDL already solved it.
  • The SPI’s shape is bounded by what SDL3 can express. Where SDL lags a platform feature, Goldberry lags it too, and the honest fix is upstream.
  • SDL3 is a hard runtime dependency of every desktop app built on Goldberry. It is statically linked into libgoldberry (ADR-0008), so this is a build-time fact rather than a deployment one, but it is not optional.
  • headless keeps the SPI honest — an abstraction with one implementation rots, and a second one that must pass the same tests is what stops it.
  • The software present path is a clean fit: SDL_GetWindowSurface plus SDL_UpdateWindowSurfaceRects maps directly onto present(PixelBuffer, List<DamageRect>) with no renderer and no GPU context, which is exactly what ADR-0002 requires.

ADR-0004: Three-tree retained declarative model

  • Status: Accepted (recorded retroactively)
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §5, §11

Context

The programming model is the part of a UI toolkit that users touch every day, and it is the hardest thing to change later — it determines what every widget, every piece of app code, and every test looks like.

Two families were realistic. Immediate mode rebuilds and redraws the whole UI each frame: trivially simple state handling, but it re-does layout and paint work constantly, and it fits poorly with accessibility trees, retained input focus, and CSS-style cascading — all of which assume a persistent node identity. Retained mode keeps a node tree: efficient and accessible, but classic retained OO toolkits (Swing, SWT) push mutable state into the widget objects themselves, which is the source of most of their bugs.

Flutter’s answer is to split the difference, and it has been proven at scale.

Decision

Adopt the three-tree model:

  1. Widgets — immutable Java records with a pure build(). Cheap to construct, cheap to throw away, diffed by type and key.
  2. Elements — the mutable instantiation of a widget. Holds state, owns lifecycle, and is what persists across rebuilds.
  3. Render objects — one per visual node. Owns a YGNode, a ComputedStyle, and the paint logic.

App authors write in the declarative widget layer and mostly never see the other two. The element tree is what gives node identity to focus, semantics, and animation; the render tree is what layout and paint operate on.

Alternatives considered

  • Immediate mode (Dear ImGui-style). Rejected: incompatible with a real accessibility tree (§13), with retained focus (§7.2), and with CSS cascade and transitions (§8). It also burns CPU continuously, which contradicts ADR-0002’s premise that CPU rasterization is affordable because frames are rare.
  • Classic retained OO (Swing/JavaFX-style mutable widgets). Rejected: mutable widget graphs make state and invalidation the app author’s problem, and the resulting bug class is well documented across thirty years of toolkits.
  • Two trees (widget → render object, no element layer). Rejected: without a persistent middle layer there is nowhere to hang state and lifecycle across rebuilds, and node identity has to be reconstructed by matching, which is exactly the fragile part.

Consequences

  • Rebuilds are cheap, so app code can rebuild freely; the diff decides what actually changes. Layers and damage tracking (§5) build naturally on top of render-object identity.
  • Three trees is genuinely more machinery than two, and the widget/element/render distinction is the main thing new contributors have to learn.
  • Java records give immutable widgets with no boilerplate, which is what makes this model pleasant in Java rather than merely possible.
  • Open, and the largest gap in the current design: Closed by ADR-0052: the state and rebuild API. docs/ARCHITECTURE.md says elements hold state and mentions a Property<T> in §9, but the stateful-widget lifecycle, the rebuild scheduling, and how a state change marks the tree dirty are not specified. This is the API every user touches and it needs its own record before M2.

ADR-0005: CSS subset and KDL as the contracts

  • Status: Accepted (recorded retroactively)
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §8, §9, §10

Context

A toolkit needs a way to express styling and a way to express structure. Both are usually invented in-house, and both in-house inventions have the same failure mode: they are almost, but not quite, a thing the user already knows. Every proprietary styling DSL ends up reimplementing a worse cascade; every bespoke markup format ends up needing comments, escaping, and a schema.

There is also a second-order requirement. Hot reload is a goal (§8), and a design system with themes (§10) needs variables and a cascade with defined precedence. Both are far easier against a format with real semantics than against a builder API.

Decision

Styling: a genuine CSS subset, parsed by a pure-Java css-syntax-compatible tokenizer — no native code. Selectors are limited to type, .class, #id, descendant, child, and six pseudo-classes, with standard specificity. Cascade layers are fixed at four: toolkit base → theme → application → inline. Custom properties and var() are the theming mechanism, which is what makes Nord light and dark a stylesheet swap rather than a code path (§10).

The property split is a design invariant, not an implementation detail: layout properties compile directly to Yoga, paint properties resolve into an immutable ComputedStyle. A property that cannot be assigned to one side or the other does not belong in the subset.

Markup: KDL 2.0, and the markup schema — not the Java API — is the stable contract. The Java builder API is generated to stay in lockstep with it.

The parity invariant, enforced by test: every built-in widget is constructible from Java, constructible from KDL, and styleable via CSS. A widget that is not is a build failure.

Alternatives considered

  • A proprietary styling DSL. Rejected: it would have to grow a cascade, variables, and specificity anyway, and would arrive at a worse CSS that nobody already knows.
  • Full CSS. Rejected: unbounded scope. Grid, floats, and the full selector grammar are years of work for features a desktop toolkit does not need. The subset is drawn where flexbox ends.
  • XML or FXML for markup. Rejected: verbose, and FXML’s reflective handler binding is exactly the magic §9 avoids — wiring is explicit (Kdl.inflate(doc).bind(controller)).
  • JSON or YAML. Rejected: JSON has no comments; YAML’s ambiguity is a known source of configuration bugs. KDL is designed for hand-authored documents.
  • Java builders only. Rejected: no hot reload, and no format for tooling to target.

Consequences

  • Users bring existing CSS knowledge, and theming is a stylesheet swap. Stylesheets are runtime-loadable, so hot reload preserves application state.
  • Users also bring existing CSS expectations, and will hit the subset’s edges. The boundary must be documented precisely, or every missing selector reads as a bug. calc() is deferred and will be missed.
  • Invalidation is coarse in v1 — a pseudo-class or class change recomputes the subtree. This is a known performance cliff on large trees, accepted for v1.
  • KDL 2.0 needs a Java parser. Open: whether a suitable one exists at the required maturity or whether Goldberry writes its own. This must be settled before M2 can be scheduled honestly.
  • The parity invariant costs something on every new widget — three surfaces to implement instead of one — and that is the point: it is what stops the KDL and CSS paths from quietly becoming second-class.

ADR-0006: FFM bindings via jextract, generated in CI

  • Status: Superseded by ADR-0010
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §3.1, §3.2

Superseded on 2026-08-15. Bindings are hand-written; jextract is not used. The arena discipline, the wrapper rule, and the shared upcall stub described below all still hold — ADR-0010 changes only how the binding code is produced, and replaces the cross-platform layout diff with a stronger check against the compiled library.

Context

Goldberry binds five native libraries and has no JNI. The Foreign Function & Memory API is final in Java 22 and Java 25 is the project’s floor, so FFM is the mechanism. What remains is how the bindings are produced and where they live.

Hand-writing MethodHandle and MemoryLayout declarations for Blend2D, Yoga, HarfBuzz, SDL3, and libxkbcommon is thousands of lines of mechanical, silently wrong-able code. jextract generates it from the headers. But jextract is not part of the JDK — it is a separate download tracking specific JDK releases — and its output is platform-specific, because struct layouts genuinely differ across platforms.

Decision

Generate FFM bindings with jextract, per platform, in CI. Verify with an automated check that struct layouts agree across platforms where they are expected to, which is what guards against the classic traps — long being 32-bit on Win64, differing struct padding, differing enum widths.

Around the generated code, three rules from §3.1 hold:

  • Wrappers, not raw segments. Every native object gets a thin Java wrapper with explicit ownership. Raw MemorySegment never leaves the natives module (see ADR-0007, which makes this enforceable rather than aspirational).
  • Arena discipline. One shared Arena per window for long-lived objects (fonts, Yoga config, xkb keymap); a confined per-frame arena for scratch (HarfBuzz buffers, glyph arrays, damage lists) that dies at frame end. Wrappers expose close(); a Cleaner is the safety net, never the mechanism.
  • One shared upcall stub. Yoga measure callbacks dispatch through a single stub keyed on the node’s context pointer rather than allocating a stub per node.

Alternatives considered

  • Hand-written FFM bindings. Rejected: volume and error rate. The errors are the bad kind — a wrong offset is memory corruption, not an exception.
  • JNI. Rejected: a C compiler in the loop for every binding change, no native-image story worth having, and worse performance than FFM downcalls.
  • Panama’s jextract at build time on every developer machine. Rejected as the default: it makes jextract a hard prerequisite for anyone building the project, including people who only touch Java code.

Consequences

  • The binding layer is mechanical and regenerable; upgrading a native dependency is a regeneration plus a layout diff, not an audit.
  • jextract becomes a CI prerequisite, and its version is coupled to the JDK version. A JDK upgrade may require a jextract upgrade.
  • The measure-callback upcall returns YGSize, a struct of two floats, by value. Struct-by-value returns from upcalls are the fiddliest corner of FFM, and the ABI classification differs across SysV, Win64, and AAPCS. This should be the first native thing proven in M0 — if it does not work cleanly, the layout-engine boundary changes shape, and that is much cheaper to learn now than after the layout code is written.
  • GraalVM native-image and FFM need reachability metadata and --enable-native-access; the config ships in the jars under META-INF/native-image (§15).
  • Open: whether generated bindings are committed to version control or generated during the build. Committing them gives IDE navigation, reviewable diffs on native upgrades, and makes the cross-platform layout check a plain git diff; the cost is a large volume of generated code in the repository and a regeneration step that can be forgotten. Recommendation is to commit, but this is not yet decided.

ADR-0007: JPMS modules enforce the native boundary

  • Status: Accepted
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §3.1, §15

Context

docs/ARCHITECTURE.md §3.1 states that raw MemorySegment never escapes the natives module. That is the single most important invariant in the native layer: once a segment leaks into application code, ownership becomes ambiguous, the arena discipline stops being enforceable, and use-after-free becomes a supported feature.

Stated as a rule in a document, it is a convention. Conventions of this kind hold until the first deadline.

There is a second, unrelated pressure pointing the same way. JEP 472 restricts the JNI and FFM APIs: Java 25 emits a warning when restricted native access happens from the unnamed module, and a later release turns that warning into an error. --enable-native-access takes a module name. A classpath project has no module name to give it.

Decision

Every Goldberry module ships a module-info.java from the first commit, and the build sets modularity.inferModulePath = true. The module graph is:

natives   (exports nothing yet — wrapper packages only, once M0 lands)
core      → exports io.github.digitalsmile.goldberry
charts    → requires transitive core
gpu       → requires transitive core
gallery   → requires core, charts, gpu

io.github.digitalsmile.goldberry.natives is the module named in --enable-native-access. The jextract-generated binding packages stay unexported permanently; only the hand-written wrapper packages are exported.

Alternatives considered

  • Classpath now, modules later. Rejected. Retrofitting a module graph onto an established codebase means discovering every accidental cross-package dependency at once, and split packages are found late and fixed expensively. The cost of doing it on day 1, when there are five empty modules, is approximately zero.
  • Modules only for :natives. Rejected: a module cannot depend on the classpath cleanly, so this poisons the boundary it is meant to protect.
  • Enforce the boundary with an ArchUnit-style test. Rejected as the primary mechanism — it catches violations after they are written rather than making them unrepresentable. Reasonable as a supplement later.

Consequences

  • The §3.1 invariant is enforced by javac. Code outside :natives cannot name a generated binding type, whatever its author intended.
  • --enable-native-access=io.github.digitalsmile.goldberry.natives is expressible today, so the JEP 472 deprecation is a non-event.
  • Consumers of Goldberry get a clean module graph, which matters for anyone building with jlink or native-image.
  • JPMS imposes real constraints: no split packages, and reflective access needs explicit opens. Test source sets need --patch-module handling, which Gradle does automatically but which shows up in stack traces when it goes wrong.
  • Adding a module now costs a module-info.java per module — trivial while the modules are empty, which is precisely why this is being decided now.

ADR-0008: Build the CMake superbuild before the vertical slice

  • Status: Accepted
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §3.2, §16

Context

M0 has two separable halves. One is the native build: a CMake superbuild that statically links Blend2D, Yoga, HarfBuzz, and SDL3 (plus libxkbcommon on Linux) into a single shared library, libgoldberry, on three operating systems and two architectures. The other is the Java shape: jextract bindings, the wrapper and arena discipline, and a window that presents a CPU buffer.

They can be done in either order. Developing against distribution packages via pkg-config on Linux first would reach a window on screen sooner and prove the FFM design earlier; the superbuild would then follow as a packaging task. The counter-argument is that “works against my distro’s SDL3” and “works against our statically linked single-artifact build on macOS” are different claims, and building the Java layer against the first can bake in assumptions that the second invalidates — library initialization order, symbol visibility, how the library is located and loaded, and which headers jextract is actually pointed at.

Decision

Build the CMake superbuild first, as docs/ARCHITECTURE.md §16 specifies. The Java layer is developed against libgoldberry — the real artifact, on all target platforms — from the beginning, not against distribution packages.

The Gradle→CMake seam exists now in natives/build.gradle.kts as the cmakeConfigure and cmakeBuild tasks, deliberately not wired into build until the superbuild links.

Alternatives considered

  • System libraries via pkg-config, Linux-first. This was the recommended option and was not taken. It reaches a window sooner and retires design risk before build risk, but it proves the design against an artifact that is not the one shipped, and the differences are exactly where native packaging goes wrong.
  • Both in parallel. Rejected: it is the same work as the superbuild plus a throwaway build path, on a project with one contributor.

Consequences

  • What M0 proves is what ships. There is no second integration step where the Java layer meets the real artifact for the first time.
  • The distribution story — LWJGL-style classifier jars, one artifact per platform/arch — is exercised from the start rather than designed at the end.
  • The first visible pixel is further away. The superbuild is the highest-risk, lowest-feedback part of the project, and it is now the first thing built. If it takes longer than expected, there is no partial result to show for it.
  • Every contributor needs a working C/C++ toolchain, CMake, and Ninja before they can build the native module, even for Java-only work. The natives tasks are kept out of the default build graph partly to soften this.
  • Blend2D’s AsmJit needs W^X handling on Apple Silicon, and libxkbcommon builds with meson upstream rather than CMake. Both are superbuild problems and both are now on the critical path.
  • The pure-Java layers — the three trees, the CSS engine, the KDL inflater, the semantics tree — have no native dependency and can proceed in parallel with full unit-test coverage. That is the hedge against this decision’s main risk.

ADR-0009: Publish under io.github.digitalsmile

  • Status: Accepted
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §3.1, §15

Context

docs/ARCHITECTURE.md originally specified the Maven group io.github.digitalsmle and the base package io.github.digitalsmle.goldberry, in four places. The project owner’s GitHub account, and the package in the repository’s only Java file, are digitalsmile — with the i.

This is worth a record rather than a silent fix, because of what it would have cost. Maven Central group ids are permanent: io.github.<user> coordinates are verified against GitHub account ownership, so io.github.digitalsmle would not have been claimable at all. Had it survived to a release, the base package would be embedded in every module name, every --enable-native-access flag, every import in every downstream application, and every published artifact — and Maven Central does not delete published versions.

Decision

The Maven group is io.github.digitalsmile and the base package is io.github.digitalsmile.goldberry. JPMS module names follow: io.github.digitalsmile.goldberry.{core,natives,charts,gpu,gallery}. Published artifact names are goldberry-core, goldberry-charts, goldberry-gpu, and goldberry-natives-{platform}-{arch}.

The design document has been corrected in all four places.

Consequences

  • Coordinates match the GitHub account that Maven Central will verify against.
  • Fixed before the first commit, so there is nothing to migrate.
  • The general lesson is worth keeping: identifiers that end up in published coordinates deserve a deliberate check, because they are among the few things in a codebase that genuinely cannot be renamed later.

ADR-0010: Hand-written FFM bindings

  • Status: Accepted
  • Date: 2026-08-15
  • Supersedes: ADR-0006
  • Relates to: docs/ARCHITECTURE.md §3.1

Context

ADR-0006 chose jextract on the reasoning that hand-writing MethodHandle and MemoryLayout declarations is thousands of lines of mechanical, silently wrong-able code. That reasoning was about volume, and it assumed the volume was fixed. It is not.

jextract binds a header, not an API surface. Pointed at SDL3 it emits bindings for every public symbol in SDL — thousands of functions and hundreds of structs — when Goldberry calls perhaps eighty of them. Repeat across Blend2D, HarfBuzz, Yoga, and libxkbcommon and the generated surface dwarfs the toolkit. That surface is not free: it is code to compile, review on every upgrade, keep out of the module’s exports, and make reachable for native-image.

Three further costs pushed the same way:

  • jextract is a separate toolchain coupled to JDK releases. It is not part of the JDK, and a JDK upgrade can require a jextract upgrade before the project builds at all.
  • Generated bindings are not the bindings we want. §3.1 requires every native object to be wrapped with explicit ownership and arena discipline. Generated code knows nothing about that, so it gets wrapped anyway — meaning the generated layer is an intermediate that exists only to be hidden.
  • The generated code is the wrong shape for the upcall design. The single shared measure-callback stub keyed on a context pointer is a hand-written optimisation regardless.

Decision

Hand-write the FFM bindings. Bind only what Goldberry actually calls, in the shape the wrapper layer wants, inside the natives module.

Because this gives up jextract’s layout correctness guarantee, replace it with a stronger one. The compiled library reports its own layout, and a test asserts the Java side agrees. libgoldberry exports a probe table — for every struct Goldberry binds, the sizeof, the alignment, and the offsetof of every bound field, as the C compiler computed them for that exact target. A test walks the Java MemoryLayout declarations against that table and fails on any disagreement.

This is a better check than the one it replaces. ADR-0006’s cross-platform layout diff compared jextract’s output across platforms; this compares the Java declaration against the actual compiled library on the actual target, which is the thing that has to be right.

Alternatives considered

  • jextract, with the generated surface trimmed by header filtering. Rejected: the --include-* flags make the binding set a build-configuration problem, and a symbol that falls out of the filter fails at build time in a way that reads as a jextract bug rather than a missing include.
  • jextract for structs, hand-written for functions. Rejected: it keeps the toolchain dependency for a fraction of the benefit.
  • Hand-written with no verification. Rejected outright. A wrong offset is memory corruption, not an exception — silent, target-specific, and it surfaces as a crash somewhere unrelated. Without the probe table this decision would be irresponsible.

Consequences

  • The binding layer is small, readable, and shaped for the wrapper and arena design rather than adapted to it. Reviewing it is possible.
  • No jextract in CI, and no JDK-to-jextract version coupling. This also settles ADR-0006’s open question about committing generated bindings: there is no generated code, so the question disappears.
  • Every bound struct must be registered in the C probe table, and a field bound in Java but absent from the probe is a test failure. This is a real ongoing cost and it is deliberately not optional — it is the entire safety argument for this decision.
  • Upgrading a native dependency becomes a manual review of the changed headers rather than a regeneration plus diff. The probe test catches layout changes, but a semantic change to a function’s contract is caught only by reading.
  • Binding a new function is now a small, deliberate act. That is a feature: it keeps the native surface honest and visible, and §3.1’s rule that raw MemorySegment never escapes the module is easier to hold when a human writes every crossing.
  • The probe table only validates structs Goldberry binds and remembers to register. A struct bound in Java and forgotten in C is unverified. Mitigation: the registry is the single place Java layouts are declared, and the test fails on any registered layout missing from the probe.

ADR-0011: Zig as the cross-compilation toolchain

  • Status: Superseded by ADR-0012
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §3.2; ADR-0008

Superseded the same day. The macOS caveat below turned out to be decisive rather than incidental: once macOS needs a native runner regardless, Zig’s remaining job is Linux and Windows, and the case for it does not survive that reduction. See ADR-0012.

One thing here does survive: the glibc floor. It is a real problem that native runners do not solve on their own, and ADR-0012 solves it a different way.

Context

docs/ARCHITECTURE.md §3.2 scoped cross-compilation narrowly: native GitHub Actions runners for Linux, Windows, and macOS, with only linux-aarch64 cross-built using Zig. That works, but it makes the CI matrix the only place a complete set of artifacts exists. A developer on Linux cannot find out that a change broke the Windows build without pushing, and the feedback loop for the highest-risk part of the project — the native superbuild, which ADR-0008 placed on the critical path — runs through CI.

The goal is that one Linux host produces every artifact in the §15 matrix: {linux, windows, macos} × {x64, aarch64}.

Decision

Use zig cc / zig c++ as the cross-compiler for every target, driven from CMake toolchain files, with Gradle generating the per-target compiler wrappers. Zig bundles what makes this possible: glibc headers for many versions, musl, mingw-w64 for Windows, and libSystem stubs for macOS — one ~50 MB download instead of a per-target sysroot zoo.

Six targets:

TargetZig tripleOutput
linux-x64x86_64-linux-gnu.2.28libgoldberry.so
linux-aarch64aarch64-linux-gnu.2.28libgoldberry.so
windows-x64x86_64-windows-gnugoldberry.dll
windows-aarch64aarch64-windows-gnugoldberry.dll
macos-x64x86_64-macos.11libgoldberry.dylib
macos-aarch64aarch64-macos.11libgoldberry.dylib

The glibc version is pinned into the triple deliberately. Building against glibc 2.28 makes the Linux artifacts run on anything from RHEL 8 onward, regardless of how new the build host is — a portability guarantee that is awkward to get any other way and that native runners do not give you for free.

Alternatives considered

  • Native runners per OS (the §3.2 plan). Not rejected — see Consequences, it remains necessary for testing. Rejected only as the way artifacts are built, because it puts the slowest feedback loop on the riskiest work.
  • A conventional cross-toolchain set: mingw-w64 for Windows, osxcross for macOS, a crosstool-NG sysroot for aarch64. Rejected: three separate toolchains to install, version, and keep consistent, where Zig is one.
  • Clang with per-target sysroots assembled by hand. Rejected: this is approximately what Zig already is, minus the packaging and the bundled libc.
  • Docker images per target. Rejected as the primary mechanism: it moves the problem into image maintenance and is markedly slower to iterate against. It remains a reasonable way to reproduce the toolchain in CI.

Consequences

  • One zig install cross-builds all six artifacts from Linux. A developer can find out that a change broke the Windows link without pushing.
  • Linux artifacts get a pinned, portable glibc floor.
  • Cross-compilation produces artifacts, not test coverage. Nothing built here can be run on Linux except the Linux targets. Golden-image tests (§14), the DPI behaviour, and the backend integration still have to execute on real Windows and macOS, so the CI matrix in §3.2 does not go away — its job changes from building to testing.
  • macOS needs an SDK that Zig does not and cannot ship. Zig’s bundled libSystem stubs cover libc, but SDL3’s macOS backend needs the Cocoa, Metal, CoreGraphics, and CoreText frameworks, whose headers come only from Xcode. Apple’s licence restricts use of that SDK to Apple-branded hardware, so this is a licensing question and not only a technical one. The toolchain therefore treats macOS as a target that activates when goldberry.macosSdk points at an SDK, and fails configuration with an explicit message otherwise. Building the macOS artifacts on a macOS runner remains the unambiguous option.
  • windows-aarch64 via Zig’s mingw-w64 is the least-travelled path of the six and should be treated as experimental until it has linked and run once.
  • Blend2D’s AsmJit under mingw-w64, and HarfBuzz’s build under Zig, are both unverified. They are the two most likely places the first cross-build breaks.
  • The project now depends on Zig’s bundled headers being correct, and on Zig’s own release cadence. Zig is pre-1.0 and its CLI has changed across minor versions; the version is pinned in gradle/libs.versions.toml for that reason.

ADR-0012: Native CI runners, with a pinned glibc on Linux

  • Status: Accepted
  • Date: 2026-08-15
  • Supersedes: ADR-0011
  • Relates to: docs/ARCHITECTURE.md §3.2, §15

Context

ADR-0011 chose zig cc so that one Linux host could produce every artifact in the §15 matrix. Its own consequences section recorded a caveat on macOS; that caveat turned out to be decisive.

Zig bundles libSystem stubs, which cover libc — but SDL3’s macOS backend needs the Cocoa, Metal, CoreGraphics, and CoreText frameworks, and those headers ship only in Apple’s SDK, whose licence restricts use to Apple-branded hardware. That is not a gap Zig can close. macOS needs a native runner regardless of what the other targets do.

Once macOS is native, the question becomes whether Zig is worth keeping for the other four artifacts, and the answer changes:

  • Windows. Zig links through mingw-w64. Blend2D’s AsmJit under mingw was already flagged in ADR-0011 as one of the two most likely first failures, and MSVC is the toolchain Blend2D and SDL3 are primarily tested against. Choosing mingw buys nothing and costs a known risk.
  • Linux. Zig’s advantage here was never really cross-compilation — it was the glibc floor. This part of ADR-0011 was correct and still is: building natively on ubuntu-24.04 links against glibc 2.39, so the artifact refuses to load on RHEL 8 (2.28), RHEL 9 (2.34), Debian 12 (2.36), or Ubuntu 22.04 (2.35). For a library published to Maven Central that is a distribution defect, and native runners do not fix it by themselves.

So the real decision is not “cross-compile or not”. It is “how is the glibc floor pinned”, and Zig was only one answer to it.

Decision

Build every artifact on a native runner, and pin the Linux glibc floor with a container rather than with a cross-compiler. Four runners produce four artifacts:

RunnerContainerProduces
ubuntu-24.04manylinux_2_28_x86_64linux-x64
ubuntu-24.04-armmanylinux_2_28_aarch64linux-aarch64
windows-2022—windows-x64
macos-14—macos-aarch64

One runner per artifact, and no cross-targeting: every leg builds for the machine it is running on.

Amended by ADR-0041. This ADR originally had the same four runners produce six artifacts, with two of them cross-targeting inside their own native toolchain — MSVC at -A ARM64 and Xcode at CMAKE_OSX_ARCHITECTURES=x86_64. Those two rows were cut. The mechanism is unchanged and is what either row would come back through; what changed is the matrix, not the decision.

The manylinux_2_28 images are the mechanism for the glibc floor: glibc 2.28 headers — the RHEL 8 baseline — with a modern GCC on top. This is the same approach the Python wheel ecosystem has used for a decade.

Zig is removed entirely.

Alternatives considered

  • Zig everywhere (ADR-0011). Rejected: cannot do macOS at all, and on Windows it trades a well-tested toolchain for a risk that was already identified.
  • Native runners with no container, building on ubuntu-22.04. Rejected: a glibc 2.35 floor still excludes RHEL 9 at 2.34, so it does not actually solve the problem — it just makes it less visible.
  • Zig for Linux only, native for Windows and macOS — which is what docs/ARCHITECTURE.md §3.2 originally specified. A reasonable option, and the runner-up. Rejected because a container achieves the same glibc floor with the toolchain the upstream projects are actually tested against, and keeping Zig for one platform means maintaining a second compiler for one row of the matrix.
  • Symbol-versioning tricks to fake an older glibc. Rejected: fragile, and it fails in ways that surface at load time on a user’s machine.

Consequences

  • Every dependency builds with the toolchain its maintainers test against. Both risks ADR-0011 named — AsmJit under mingw-w64, HarfBuzz under Zig — disappear.
  • Linux artifacts run on anything from RHEL 8 onward, and this is enforced by the build environment rather than by discipline.
  • Should Windows on ARM come back, MSVC cross-targeting ARM64 from the same x64 runner is well-travelled, unlike Zig’s mingw path for the same target.
  • Local cross-building is gone. A developer on Linux builds linux-x64 and must push to find out about the rest. This is the cost ADR-0011 was trying to avoid, and it is accepted deliberately: the alternative paid for that feedback with toolchain risk on the riskiest part of the project, and linux-x64 is the target one actually iterates against.
  • A locally built library is not the published artifact. Local builds link against the host’s glibc, so a .so built on a developer machine has a higher floor than the released one. Only the container build is shippable, and that has to be understood rather than discovered.
  • CI becomes the only place a complete artifact set exists.
  • ARM runners are free for public repositories. This decision assumes the repository stays public; going private makes linux-aarch64 and the macOS legs billable, at which point the Zig-for-Linux runner-up becomes attractive again.
  • The Linux legs are the one place CMake is not driven by Gradle: putting a JDK inside the manylinux image would buy nothing. CI invokes CMake directly and Gradle only packages the results, so the CMake arguments now exist in two places and must be kept in step.

ADR-0013: Groovy DSL for the build

  • Status: Accepted
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §15

Context

docs/ARCHITECTURE.md §15 specified Gradle with the Kotlin DSL, and the build was originally written that way. The Kotlin DSL’s advantages are real but they scale with build size: compile-time checking of build scripts, IDE completion and navigation into Gradle’s API, and generated type-safe accessors — all of which matter most on a build large or intricate enough for a mistyped property to be hard to find.

Goldberry’s build is neither. Four modules, one convention plugin, one version catalog, and a CMake invocation. Against that, the Kotlin DSL charges a real price: slower first configuration while scripts compile, and Kotlin’s stricter typing turning ordinary Gradle idioms into ceremony.

Groovy also remains the dialect the majority of Gradle documentation, Stack Overflow answers, and existing build files are written in.

Decision

Write the build in the Groovy DSL. settings.gradle, build.gradle, the four module scripts, and the convention plugin under build-logic/ — which becomes a precompiled Groovy script plugin via the groovy-gradle-plugin plugin rather than kotlin-dsl.

build-logic itself is kept. It exists so that the toolchain, lint, JPMS, and JUnit configuration is written once rather than four times, and it is the only approach that stays compatible with Gradle’s project-isolation direction — a root-level subprojects { } block is precisely what project isolation breaks.

Alternatives considered

  • Keep the Kotlin DSL (what §15 specified). Rejected as above: its benefits are proportional to build complexity, and this build has little.
  • Groovy, but drop build-logic for subprojects { } in the root build. Rejected: it is simpler today and a migration tomorrow, and it forfeits project isolation. Reasonable to revisit if the convention plugin never grows.
  • Groovy, with apply from: 'gradle/java-conventions.gradle' script plugins. Rejected: simpler than build-logic but not configuration-cache friendly, and it has no plugin identity, so plugins { id '...' } is unavailable.

Consequences

  • Build scripts are shorter and read like most Gradle in the wild.
  • Build errors move from configuration time to execution time. A misspelled property is a runtime failure rather than a compile error, and the IDE cannot complete or navigate Gradle’s API. This is the whole of what is being given up, and on a build this size it is a fair trade.
  • The version catalog is clumsier inside the convention plugin. Precompiled Groovy script plugins get no generated libs accessors, so the catalog is read through extensions.getByType(VersionCatalogsExtension).named('libs') and entries are looked up by string. A renamed catalog entry now fails at configuration time instead of at compile time. Module build scripts are unaffected — they still get the generated libs accessor.
  • docs/ARCHITECTURE.md §15 has been corrected; it was the specification that said Kotlin DSL.
  • Reversible at low cost while the build is this small. That will stop being true.

ADR-0014: One widgets module, charts included

  • Status: Accepted
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §11, §14, §15

Context

The original module layout had five modules: :natives, :core, :charts, :gpu, and :gallery. Two of those were thin for different reasons.

:charts was to hold five widgets — sparkline, line-chart, bar-chart, area-chart, donut-chart — all built on the canvas primitive and the theme palette, and §11 describes the set as “deliberately small — not a plotting library”. A separate published artifact, module descriptor, and version for five canvas-based widgets with no dependencies of their own is more packaging than the content justifies.

:gallery was a showcase application that, per §14, doubles as the visual regression corpus. But its content is one screen per widget — which means it tracks the widget catalog exactly, and keeping the catalog and the screens that exercise it in separate modules guarantees they drift.

Decision

Merge :charts and :gallery into a single :widgets module, published as goldberry-widgets. It holds the widget catalog above the core primitives — controls, containers, menus, and the chart widgets — together with the showcase screens that serve as the golden-image corpus.

:widgets depends on :core only. The gallery’s former dependency on :gpu is deliberately dropped: a published widget library must not drag SDL_GPU and its driver surface into every consumer. The canvas3d showcase belongs in :gpu.

The module layout is now four: :natives, :core, :widgets, :gpu.

Alternatives considered

  • Keep :charts separate (the §15 layout). Rejected: consumers who do not want charts save a few canvas-based classes with no transitive dependencies, which is not worth an artifact in the publishing matrix.
  • Merge :charts into :core. Rejected: :core is the primitives, style, layout, text, paint, and backend SPI. Charts are ordinary widgets built on canvas like any other, and putting them in :core would blur what :core is.
  • Keep :gallery as a separate non-published module. Rejected for the drift reason above, and because a showcase in its own module tends to lag the catalog rather than gate it.

Consequences

  • Consumers of goldberry-widgets get the chart widgets whether they use them or not. The cost is small and bounded — they are canvas-based, pull in no native code beyond what :core already requires, and are a deliberately limited set.
  • The visual regression corpus now lives beside the widgets it covers. §14’s intent — a widget with no screen has no pixel coverage — becomes enforceable in one module rather than across two, which is the main reason for the merge.
  • One fewer artifact to publish, version, and document.
  • The showcase ships inside a published library rather than as a separate application. If that becomes unwanted — because the screens grow large, or because shipping demo code to consumers grates — the seam to split it back out is a source set, not a rearchitecture.
  • docs/ARCHITECTURE.md §11 and §15 are corrected: goldberry-charts becomes goldberry-widgets, and :gallery is gone from the module list.

ADR-0015: Licensing and third-party disclosure

  • Status: Accepted
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §3, §6.1, §6.2, §6.3, §15

Context

docs/ARCHITECTURE.md §15 stated the licensing intent in one line: the toolkit is Apache-2.0, and the bundled assets are Inter (OFL), JetBrains Mono (OFL), Lucide (ISC), and an OpenMoji derivative (CC BY-SA). That is correct as far as it goes, but it is missing the larger half of the obligation.

The native libraries are statically linked. §3.2 links Blend2D, AsmJit, SDL3, Yoga, HarfBuzz, and libxkbcommon into a single shared library. Their compiled code is therefore present in every binary artifact Goldberry publishes — this is redistribution in object form, and the MIT-licensed components among them require their copyright and permission notices to travel with it. §15 did not mention them at all.

The OpenMoji derivative is the only share-alike obligation in the project. §6.2 ships the COLRv0 colour variant with its CPAL palette re-themed toward Nord. That is a derivative work of a CC BY-SA 4.0 asset, and §15’s “published with attribution per license” understates what follows.

There is also a practical hazard specific to how this repository is being built: licence texts are easy to approximate and dangerous to get wrong. A licence file containing a plausible-but-inexact text is worse than one that is honestly absent.

Decision

Four artefacts, and one rule about their contents.

  • LICENSE — the Apache License 2.0, verbatim.
  • NOTICE — the Apache-2.0 §4(d) notice file: what Goldberry bundles, under which licences, and the OpenMoji modification disclosure.
  • THIRD-PARTY-NOTICES.md — the full disclosure, split by how a component is bundled, because that is what determines the obligation: statically linked into libgoldberry, embedded in the jars, or build-time only.
  • licenses/<component>.txt — one file per component.

The rule: licence texts are vendored verbatim from the revision actually used, never from a licence template and never from memory, because the copyright lines are part of the licence. Until a component is vendored its file carries a NOT-VENDORED marker and a reference text clearly labelled as informational. Standard short licences (Zlib, MIT, ISC) carry a reference body; HarfBuzz’s non-standard “Old MIT”, the OFL, and CC BY-SA 4.0 carry none, because approximating those would misstate them.

./gradlew checkLicenses enforces the disclosure both ways: every file in licenses/ must be referenced by THIRD-PARTY-NOTICES.md and vice versa. It warns about NOT-VENDORED markers by default and fails on them under -Pgoldberry.releaseCheck=true, so an unvendored licence cannot reach a release.

Every jar carries META-INF/LICENSE, META-INF/NOTICE, META-INF/THIRD-PARTY-NOTICES.md, and META-INF/licenses/.

Alternatives considered

  • A single flat THIRD-PARTY file with all texts inlined. Rejected: it cannot be checked mechanically, and a per-component file is what an audit or an SBOM tool actually wants.
  • A licence-scanning Gradle plugin. Rejected for now: those tools report declared Maven metadata, and Goldberry’s obligations come almost entirely from statically linked C libraries and embedded font assets, which such plugins do not see. Worth revisiting once there are real Maven dependencies to scan.
  • Reproducing every licence text now, from memory. Rejected outright. This is the one place in the repository where a confident-looking approximation causes legal rather than technical harm.
  • Dropping OpenMoji to avoid share-alike entirely. Not chosen, but recorded as the escape hatch: shipping only the unmodified monochrome variant, or swapping the emoji font, removes the obligation. §6.1 makes the emoji slot one of exactly two font slots, so it is replaceable by design.

Consequences

  • The obligations are enumerated and machine-checked rather than remembered, and adding a dependency without a licence entry fails the build.
  • Every licence file is currently a placeholder, because nothing is bundled yet — no native library has been built and no font vendored. The framework is deliberately in place first so that vendoring an asset is an act with a licence entry attached, rather than something reconstructed at release time.
  • Vendoring ten licence files is real work that must happen before the first publish, and -Pgoldberry.releaseCheck=true is what makes forgetting it impossible rather than merely unlikely.
  • The share-alike obligation on the re-themed OpenMoji font is permanent for as long as Goldberry ships it. It reaches the font only — CC BY-SA has no linking or combination clause of the kind copyleft software licences use, so the Java and native code stay Apache-2.0 — but the font itself can never be relicensed, and any About dialog must carry the attribution and the statement that changes were made.
  • Jars grow by a few kilobytes. Consumers get the disclosure without needing the repository, which is the point.
  • Source-file licence headers are not added. Apache-2.0 recommends them but does not require them, and the LICENSE plus NOTICE files satisfy §4. Worth reconsidering before the first release, as a single mechanical pass.

ADR-0016: Verify the downloaded artifact, and never skip the check in CI

  • Status: Accepted
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §15, ADR-0010, ADR-0012

Context

CI ran for the first time on 2026-08-15. The native jobs succeeded on both Linux targets — libgoldberry.so built inside the manylinux container on x64 and aarch64 — and both Verify layouts jobs failed, after 14 and 34 seconds. Nothing was verified.

The verify jobs exist because building an artifact is not the same as testing it (ADR-0012). They download the library the native job produced and run :natives:test against it, which is where the hand-written struct layouts are checked against the compiled library — the check ADR-0010 rests on.

Two mistakes met in the middle.

The first: :natives:test depended on cmakeBuild. That wiring exists so a local ./gradlew build verifies against a real library instead of skipping, and it is right for a developer machine. On a verify runner it is exactly wrong. Those runners have no C/C++ toolchain and are not meant to — the whole point of the split is that the library is built once, on a runner chosen for its glibc, and tested elsewhere. So checkToolchain failed before a single test ran. That is the 14 seconds.

The second was worse, and would have outlived the first. The test task set goldberry.native.library unconditionally to the host’s install directory. A system property set on the task wins over one passed to the Gradle JVM with -D, so the downloaded artifact was never going to be read: the tests would have looked for a library at a path that does not exist on that runner, found nothing, and — because LayoutVerificationTest skips when no library is loadable — reported three skipped tests and a green job.

A red job that verified nothing is a nuisance. A green job that verified nothing is a lie, and it is the one that survives, because nobody investigates a passing build. Fixing only the toolchain failure would have converted the first into the second.

Decision

Handing Gradle a library to verify is also what tells it not to build one: when goldberry.native.library is set, :natives:test no longer depends on cmakeBuild, and the supplied path is what the tests load. One flag carries both halves of the intent, so the two cannot drift apart.

And goldberry.native.required=true makes a missing library a failure rather than a skip. CI passes it on every verify job. A test that needs native code still skips on a contributor’s machine — a Java-only change should not require a C++ toolchain and twenty minutes of superbuild — but in the one place whose only purpose is to run that check, not running it is a failure.

Both properties are accepted as either -D or -P.

Alternatives considered

Pass -Pgoldberry.skipNative=true to the verify jobs. It would have fixed the toolchain failure with a flag that already existed. It says the wrong thing — the job is the most native thing in the pipeline — and it leaves the property override in place, so the job would have gone green having verified nothing. It treats the symptom that shouts and leaves the one that whispers.

Install a C/C++ toolchain on the verify runners. Then the job would build its own library and test that. It would pass, and it would be meaningless: the artifact under test would be one built outside the manylinux container, against the wrong glibc, by a different compiler — not the binary that ships. This inverts the reason the jobs are split at all.

Drop the skip and always fail when the library is absent. Simpler: one behaviour everywhere, no flag. It also means a contributor fixing a typo in NativePlatform cannot run ./gradlew build without CMake, Ninja, Meson and a dozen X11 development headers. The skip is a real convenience for a real person; what it lacked was a way to say “not here”.

Assert on the test report in a CI step — fail the job if the skip count is non-zero. This works, and it puts the rule in YAML, three workflow files away from the test it governs, where a new verify job would forget it. The rule belongs next to the assumption it overrides.

Consequences

The verify jobs test the binary that ships, and cannot pass without loading it.

:natives:test -Dgoldberry.native.library=<path> is now the supported way to check a library built anywhere else — a downloaded CI artifact, a colleague’s build, a hand-configured CMake tree — and it no longer starts a twenty-minute superbuild to do so.

The cost is a third build property, and a new way to be wrong: passing goldberry.native.library and expecting a native build. The build logs which library it is verifying when it declines to build one.

The stronger guarantee is still missing. required=true proves the tests ran against a library; it does not prove it was the downloaded one. If the download step silently produced nothing, isAvailable() is false and the job fails — the outcome we want, by luck rather than by design. Binding the artifact’s identity to the check waits for something to bind it to, most likely the build fingerprint goldberry_abi_version already implies.

ADR-0017: Prove the struct-by-value upcall from C, and make it cheap

  • Status: Accepted
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §3.1, §5, ADR-0010

Context

Yoga measures a leaf node by calling back into the host:

typedef YGSize (*YGMeasureFunc)(
    YGNodeConstRef node,
    float width, YGMeasureMode widthMode,
    float height, YGMeasureMode heightMode);

YGSize is two floats, and it comes back by value. That is the fiddliest thing Goldberry asks of the Foreign Function & Memory API, and it sits on the layout hot path: once per measured node, per layout pass, per frame, for every piece of text on screen.

It is also the one crossing the layout table cannot check. ADR-0010 accepts hand-written bindings because libgoldberry reports its own sizeof, offsetof and _Alignof, and a test asserts the Java declarations agree. That argument covers memory. It says nothing about registers. YGSize is eight bytes with no padding on every target we ship, so its row in the layout table is identical everywhere — and yet the return convention differs on each: packed into XMM0 on SysV x86-64, a homogeneous float aggregate in s0/s1 on AArch64, folded into RAX on Win64. A FunctionDescriptor that is wrong about this produces an upcall stub the JVM builds without complaint and C reads as garbage. Yoga would take that garbage as a measurement and lay out around it.

Nothing on the Java side can catch that. Asserting what the callback returns proves only that Java can read back what Java just wrote.

Two smaller forces, both consequences of this being a hot path and a callback:

An upcall’s return value has to live somewhere. The obvious implementation allocates a segment per call — putting an allocation in the inner loop of layout to hold a value the linker copies out microseconds later.

And an exception thrown inside an upcall has nowhere to go. There is no Java frame beneath it, only Yoga’s C++. The JVM’s answer is to terminate the process. A measure function calls into text shaping, which loads fonts, which can fail.

Decision

The check comes from C. libgoldberry exports goldberry_probe_measure, which takes a YGMeasureFunc, calls it, and reports what arrived through out-parameters. It is compiled by the target’s own C compiler against Yoga’s own header, so what it receives is what Yoga would receive. MeasureUpcallTest calls it through an upcall stub with two distinct, exactly representable values and asserts both survive. That test runs on every target in CI, which is the only place the question is actually answered.

Out-parameters rather than a returned YGSize, deliberately: returning one would put a struct-by-value downcall return in the same test, and a failure could then be either mechanism.

MeasureCallback allocates its return segment once, in the arena it owns, and hands the same segment back on every call. The callback is synchronous — the linker has copied the result before Yoga can call again — so one segment per callback is enough, and the hot path allocates nothing.

And a measure function that throws does not reach C. MeasureCallback catches everything, reports zero to Yoga, holds the first exception, and rethrows it from throwIfFailed() once control is back in Java. One node is laid out wrongly; the alternative is losing the process.

Alternatives considered

Prove it by binding Yoga’s node API instead. YGNodeSetMeasureFunc plus a real YGNodeCalculateLayout would exercise the callback the way production will. It is the stronger end-to-end test and it should exist — but it answers this question no better, because the ABI is the risk and both callers use the same one, and it needs six more exported symbols and an opaque-handle design that is not written yet. Proving the mechanism first is what M0 asked for; the node binding follows.

Trust the layout table. It is already the safety argument for everything else, and extending it here would cost nothing. It would also be worthless: YGSize has the same size, alignment and offsets on all six targets, so the row passes whether or not the return convention is right. A check that cannot fail is worse than no check, because it reads like coverage.

Allocate the returned segment per call, from a confined arena closed immediately. Simple, obviously correct, and it puts an allocation and an arena close on the path that runs once per text node per frame. Rejected on cost, not on correctness — but the reuse it replaces is only safe while the callback is synchronous, which is now a documented assumption rather than an obvious truth.

Let exceptions propagate. Honest, in that a broken measure function is a serious bug and a hard crash is unambiguous. It is also unrecoverable and untestable: MeasureUpcallTest could not assert on a failing callback at all, because the JVM running the assertion would be gone.

jextract. It generates upcall stubs and would have got the descriptor right without anyone reasoning about XMM0. ADR-0006 chose it and ADR-0010 replaced it; this is the class of bug that decision took on, and the answer is not to re-litigate it but to make the check specific enough to catch this.

Consequences

The YGSize return is proven on real hardware for every target, and the proof runs on every push rather than being asserted once and assumed after.

goldberry_probe_measure is a test-only symbol in a shipped library. It is four lines and it is on the export list, which is the honest place for it — the alternative is a second artifact built with different flags, which is a worse thing to have to trust.

Measure functions may not assume the process dies when they fail. Every native call that can invoke a callback must be followed by throwIfFailed(), and forgetting it makes a failure look like a measurement of zero. This is the one sharp edge the design adds, and it is currently a documentation obligation rather than something the compiler enforces.

MeasureCallback is confined to its creating thread and must be closed, and closing it while a Yoga node still holds the pointer leaves that node calling freed memory. Node lifetime and callback lifetime are now coupled, and nothing yet enforces the coupling — the Yoga node binding will have to.

The reuse of the return segment is safe only while the callback is synchronous and non-reentrant. Yoga’s measure functions are leaf calls, so this holds today. If a future engine measures in parallel, the segment becomes per-thread or the design goes back to allocating.

ADR-0018: SDL’s conventions stop at the boundary

  • Status: Accepted
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §4, ADR-0003, ADR-0010

Context

SDL3 is the desktop backend (ADR-0003), and this is the first slice of it: the lifecycle, error and version calls, chosen because they prove the symbols are reachable and the calling conventions are right without needing a display.

SDL is a C library with C habits, and two of them travel badly into Java.

Errors are a false return plus a message from SDL_GetError(). The message is per-thread and is overwritten by the next failing call, so it has to be read immediately or not at all. Passing that convention through means every call site in Goldberry checks a boolean and remembers to fetch the message before doing anything else, and the failure mode for forgetting is not a crash — it is code that carries on with a window that was never created.

Subsystems are a bit mask. SDL_INIT_VIDEO | SDL_INIT_EVENTS is fine in C and is an invitation to typos in Java, where the alternative is a type the compiler understands.

There is a third thing, less obviously a convention: SDL_WasInit reports what SDL actually initialized, which is a superset of what was asked for — video implies events, gamepad implies joystick — and a future SDL may report a subsystem this code has never heard of.

Decision

Neither convention survives the boundary.

A failing SDL call raises SdlException, carrying the C function name and whatever SDL_GetError() said, captured at the point of failure while it is still the right message. Callers cannot forget to check, because there is nothing to check.

SDL_InitFlags becomes a Set<SdlSubsystem> in both directions. The bit values are declared explicitly from SDL_init.h and asserted against those literals in a test, so a mistyped bit fails the build rather than silently never initializing a subsystem.

And decoding a flag mask ignores bits it does not recognise. This is the opposite of MeasureMode.of(), which rejects an unknown YGMeasureMode, and the asymmetry is deliberate: an unknown measure mode means the callback signature is wrong, which is a bug in Goldberry, while an unknown init flag means SDL grew a subsystem, which is a Tuesday. One should fail loudly; the other should not turn a dependency bump into a crash.

Alternatives considered

Return boolean and let callers check. Faithful to SDL, and it makes the binding layer a pure translation with no policy in it — which is a real virtue when the C library is the specification. Rejected because the error message is the perishable part: by the time a caller decides it wants to know why, another call on the same thread may have replaced it. Faithfulness that loses information is not faithfulness.

A checked exception. The failures here are genuinely recoverable — no video device, no audio — and a checked exception would force the decision to be made. It would also put throws SdlException on every method of the backend SPI and every implementation of it, for a condition that is fatal to a UI toolkit at startup and impossible everywhere else. Rejected on ergonomics, and recorded here because it is the kind of thing that looks like an oversight later.

EnumSet in the signature rather than Set. Marginally faster and more precise about what is meant. Rejected because it forces callers to construct one; Set.of(...) is what people write, and the mask is built by iteration either way.

Wait for the backend SPI and bind SDL against it. The SPI is the thing SDL sits behind, so binding without it risks binding the wrong surface. But the lifecycle calls are the part of SDL that no SPI shape can change — something has to initialize the library and report which one is linked — and binding them first answered a question about the build that the SPI could not have: whether an upstream symbol can be exported at all.

Consequences

SDL failures are impossible to ignore and arrive with their message intact. Subsystems are a type. The version of SDL linked into libgoldberry is reportable, so a mismatch between the pinned ref and what was actually built is visible rather than assumed.

The cost is that the binding layer is no longer a pure translation of SDL. There is policy in it now — which failures raise, which unknown values are tolerated — and every future binding has to decide the same questions rather than inherit an answer. The two rules above are the precedent: perishable information is captured at the boundary, and unknown values are rejected when they mean we are wrong and tolerated when they mean the world moved on.

Binding these nine symbols also found a real defect in the export machinery, which had been invisible because every symbol exported until now came from goldberry_shim.c — an object file, not an archive member. --exclude-libs,ALL forces symbols from static archives to be local, and a version script cannot promote a symbol the linker has already been told to hide. SDL_Init was linked in, occupied four megabytes, and was not exported. The local: * clause in the version script was always sufficient on its own; the extra flag was belt-and-braces that cut the belt. It is gone, and the first upstream symbol on the export list is what found it — which is an argument for binding something real early rather than deferring until the design is settled.

ADR-0019: The backend SPI’s first cut

  • Status: Accepted
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §4, §14, ADR-0003, ADR-0007

Context

§4 sketches the whole SPI: windows, popups, tray icons, a clipboard, a GPU surface, cursors, event pumping. It is a good sketch, and most of it cannot be written yet — not because the work is large, but because there is nothing to check it against. An interface designed against no caller is designed twice, and the second time is after something depends on the first.

What M0 needs is narrower: a window, a way to hand it pixels, a scale factor that is right at 125% and 150%, and enough events to resize and close.

Three things about the surrounding design push hard on the shape.

HiDPI is not a feature to add later. §4 requires fractional scales to be day-1 correct. The bug this produces is famous and quiet: layout works in one unit, rasterization in another, and mixing them looks perfect on the developer’s 100% display and is wrong by half on a user’s.

macOS decides the threading model for everyone. AppKit requires window and event calls on the process’s first thread, so Goldberry.launch(app) takes over the calling thread rather than spawning one. A rule that only bites on one platform is a rule discovered at release time.

§3.1 forbids a raw MemorySegment escaping :natives. But the CPU presentation path is precisely a native buffer travelling from Blend2D to a backend, and a copy per frame is not acceptable.

Decision

Only what M0 needs. Backend, BackendWindow, WindowSpec, the geometry types, a sealed BackendEvent with five cases, and BackendException. Popups, tray, clipboard, cursors and GpuSurface are deferred until something needs them. Pointer and keyboard events are deferred too, and for a stronger reason: they need the §7 dispatch model — capture/target/bubble, pointer capture, the KeyEvent/TextEvent split that keeps IME preedit possible — and a blank window cannot tell us whether we got it right.

Logical and physical pixels are different types. LogicalSize is floats and is what layout, styling and application code use. PhysicalSize is ints and is what gets rasterized. DisplayScale is the only bridge, and it owns the one rounding rule — round half away from zero, applied once, at the boundary. Nothing else may reimplement it. The compiler now catches what a code review would not.

UI-thread confinement is enforced, not documented. The backend captures its creating thread and throws from every method called on another. wakeup() is the single exception and exists so other threads have exactly one legal way in.

headless ships in :core and depends on nothing native. It keeps presented frames instead of drawing them, and everything above the SPI is testable with no libgoldberry at all. It also enforces every rule the interfaces state — damage bounds, frame-size agreement, frame coalescing, thread confinement — so those rules have tests before the first real backend exists.

Pixels cross as a ByteBuffer. MemorySegment.asByteBuffer() produces one without copying, so Blend2D’s own memory reaches the backend directly while :core never sees a segment. §3.1 is satisfied by the module graph, not by a convention about who calls what.

Alternatives considered

Build the whole §4 sketch now. It is written down, so it feels like a transcription rather than a design. It is not: Optional<BackendPopup> implies a decision about what a popup is, Clipboard implies a decision about formats and ownership, and GpuSurface implies knowing how composition is driven. Writing them against no caller produces interfaces that are hard to change precisely when the first caller shows they are wrong.

One Size type with floats everywhere. Simpler, and it is what most toolkits do. It also makes the HiDPI bug expressible: present() takes a buffer, layout produces a size, and nothing stops the second being handed to the first. The two types cost a conversion call at each boundary, which is the point — the conversion is where the rounding rule lives, and it is now impossible to skip.

Document the threading rule instead of enforcing it. Cheaper, and a check on every call is not free. Rejected because the failure it prevents is a crash inside AppKit with a stack that names none of Goldberry’s code, on a platform that not every contributor has. The check pays for itself the first time it fires.

headless in its own module. Cleaner dependency-wise, and it keeps a test backend out of the published goldberry-core jar. Rejected for now because §15 fixes the module list at four, and because a test-only artifact that users cannot depend on is a worse default than a small one they can — golden-image testing is something applications should be able to do too.

PixelBuffer as an interface. More flexible: a backend could accept a Blend2D image directly and skip the descriptor. It also means every backend handles every implementation, and the validation that currently happens once in a record constructor happens nowhere or everywhere.

Consequences

Everything above the SPI can be built and tested with no window, no display and no native library, on any machine. The rules the SPI states have tests before any real backend exists, so sdl3 inherits a conformance suite rather than a document.

Mixing logical and physical pixels is a compile error. The cost is that scale.toPhysical(...) appears at every boundary, and code that genuinely does not care about the distinction has to pick one anyway.

The deferred surface is real debt, and it is not free to add later: Backend will grow methods, and every implementation gains them at once. That is the trade accepted here — three implementations are planned and the list is closed (ADR-0003), so the cost of adding a method is bounded and known, while the cost of an interface designed too early is not.

BackendEvent being sealed means adding pointer events will stop every exhaustive switch from compiling until it says what it does with them. That is the intended behaviour and it will be briefly annoying.

The headless backend’s pumpEvents parks for its timeout rather than blocking on a real queue, so it can report a wakeup that carries no event — which is exactly what a real backend does, and the reason the SPI says callers must not treat the frame loop as event-driven only.

ADR-0020: One UI thread, virtual threads behind it

  • Status: Accepted
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §4, §5, ADR-0019

Context

The UI thread is fixed by a platform: AppKit requires window and event calls on the process’s first thread, so Goldberry takes over the calling thread rather than spawning one, and the SPI enforces single-thread confinement everywhere (ADR-0019).

That settles where UI work runs and leaves the harder question. A toolkit with one UI thread is a toolkit one slow call away from a frozen window. Reading a file, loading a font, waiting on a service — each is milliseconds most of the time and seconds occasionally, and the occasional case is the one users remember. So work has to leave the UI thread, and its results have to come back to it, because only it may touch a window.

Every toolkit solves this and most solve it the same way: a invokeLater / runOnUiThread / Platform.runLater primitive, and a rule that callers must remember. The rule is the problem. It is invisible at the call site, the failure is a race rather than an exception, and it reproduces on someone else’s machine.

Decision

EventLoop.supplyAsync runs work on a virtual thread and completes its future on the UI thread. Every thenAccept, thenApply and whenComplete downstream therefore already runs where it is allowed to touch a window, without anyone writing a hand-off:

loop.supplyAsync(() -> loadTheThing())
    .thenAccept(thing -> window.setTitle(thing.name()));   // on the UI thread

UiExecutor is the primitive underneath, and it is an Executor, so anything that takes one can target the UI thread. It queues from any thread and wakes the loop — enqueue first, then wake, because the other order races: the loop can wake, find nothing, and park again before the task lands.

The loop drains queued work before and after each pump. Before, so work posted while it was parked reaches the frame this pump produces rather than the next one. After, so work posted by an event handler does not wait for the next platform event — which, on an idle desktop, may be never.

A drain runs one generation of tasks, not until the queue empties. A task that posts another runs on the next drain, so a self-scheduling animation cannot starve events.

Virtual threads rather than a pool because the work this exists for is mostly waiting. A pool sized for CPU throughput is the wrong shape for a hundred blocked reads, and getting the size right means guessing at a workload the toolkit does not know. A blocked virtual thread costs a continuation.

Alternatives considered

A plain invokeLater and nothing else. Every toolkit has one and it is genuinely sufficient. It also makes the correct pattern the verbose one: the caller writes the background dispatch, the hand-off back, and the error path, and gets one of the three wrong eventually. supplyAsync is that pattern with the ceremony removed; ui().execute is still there for the cases it does not fit.

Complete futures on the background thread and let callers hop. This is what CompletableFuture.supplyAsync does by default, and it is why so much UI code has an invokeLater inside a thenAccept. It also means the default is wrong — a callback that touches a window works in testing and corrupts state under load. Defaults should be safe and explicit escapes should be available, not the reverse.

A fixed thread pool. Predictable, bounded, and familiar. It also needs a size, and every size is wrong for some application: too small and a few blocked reads stall the rest, too large and a CPU-bound task swamps the machine. Virtual threads make the question not need an answer.

Render on a second thread. The largest source of UI-thread time is rasterization, and moving it is the obvious win. It is also not ours to move: Blend2D already rasterizes on its own worker threads (§5), and the UI thread hands over a finished buffer. Adding another layer of threading above that would be inventing a problem.

Bound the task queue. Unbounded queues are a memory leak waiting to happen. Bounding it means either blocking the producer — reintroducing the stall this exists to prevent, on a thread that has no idea it is doing UI work — or dropping UI updates, which is worse than either. A producer that outruns the UI thread is a bug in the producer, and unbounded makes it show up as memory growth rather than as a mysterious freeze.

Consequences

The safe thing is the short thing to write, and the unsafe thing raises an exception naming the thread it was called from rather than corrupting state quietly.

close() does not wait for background work. An application that cannot exit because a download is still running is a worse failure than a task that never delivers its result, and the result has nowhere to go once the UI is gone.

The queue is unbounded, so a runaway producer grows the heap. That is a deliberate trade and it is the kind of bug a heap dump answers in a minute.

Frame requests are still synthesized rather than vsync-aligned. requestFrame() sets a flag and the next pump turns it into a FrameDue, so a requested frame arrives promptly but not in step with the display. Real vsync alignment needs a renderer to align to, and it will change this file rather than the SPI.

An event-loop test that never terminates hangs the build rather than failing it — JUnit’s @Timeout defaults to the same thread and cannot interrupt an infinite loop. EventLoopTest runs a watchdog that stops the loop after twenty seconds, which turns that mistake back into an ordinary assertion failure. Every test driving a loop needs one.

ADR-0021: The example is a separate build

  • Status: Superseded by ADR-0023 for the build arrangement; its reasoning about what the example must prove still stands
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §15, ADR-0007, ADR-0014

Context

Goldberry now has a backend that opens a window, and nothing that opens one. The tests prove the pieces; they do not prove that somebody outside this repository can use them.

That gap is specific, not vague. The toolkit is built as JPMS modules, published as artifacts named goldberry-core rather than core, and loads a native library out of a classifier jar with --enable-native-access naming a module. Each of those can be wrong in a way every existing test passes:

  • a package that is never exportsed compiles fine inside its own module
  • an artifact whose coordinates do not match what applications are told to write
  • a native library that resolves from the build tree and from nowhere else
  • an --enable-native-access argument naming a module that does not exist

A :example module added to the main build catches none of them. It would compile against the source tree through project(':core'), see every package whether exported or not, and find the native library by relative path.

Decision

example/ is its own Gradle build with its own settings file and wrapper, and it depends on io.github.digitalsmile:goldberry-core:<version> — coordinates, not a project path. includeBuild('..') makes it a composite, so those coordinates resolve to the local source tree during development and would resolve from Maven Central without it. The dependency is written the same way either way, which is the point: the example’s build file is a file an application could copy.

The substitution is spelled out rather than left to Gradle. A composite exposes an included project as group:projectName — io.github.digitalsmile:core — while the published artifact is goldberry-core. base.archivesName renames the jar, not the module coordinates. Without an explicit substitute module(...) using project(...), the example asks for a module the composite does not think it has and Gradle goes looking on Maven Central for a version that was never released.

The example is a module, because an application on the module path is exactly the case --enable-native-access=<module> exists for, and it only works if the toolkit’s descriptors are right.

CI runs it under Xvfb and greps the output. A window opens, three frames are presented, the process exits on its own.

Alternatives considered

A :example module in the main build. One build, one command, no substitution to explain. It also proves nothing about exports, coordinates or packaging — the four failures above all survive it. The cost of a second build is a settings file; the cost of not having one is finding out after publishing.

A test in :core that opens a window. It would catch the native-loading path and nothing else, and it would need a display in CI for every test run rather than one job. Golden-image tests will need a window-free path anyway, which is what headless is for (ADR-0019).

Publish to mavenLocal and depend on that. The most faithful reproduction of what a user does. It also means publishToMavenLocal before every example build, a stale-artifact failure mode that is deeply confusing when it happens, and a CI job that is mostly about publishing. The composite gets the same coordinates with none of that.

Skip the run; compiling is enough. Compiling proves the module graph and the coordinates, which is most of the value. It does not prove the native library loads, which is the part with six platform-specific ways to fail — and it is one xvfb-run away.

Consequences

An application-shaped consumer is built and run on every push, so the failures that only appear outside the main build appear here instead of in an issue report.

The example is where new features get their first honest API review: if something is awkward to write in Showcase.java, it is awkward, and that is visible before the widget catalog is built on top of it.

The cost is a second build to keep working. Its Gradle wrapper is a copy, so a version bump touches two places, and the dependency substitution is a piece of build machinery that has to be understood before it can be changed. Both are written down here because neither is guessable from the file.

CI needs a display. xvfb-run is a Linux answer; the equivalent on Windows and macOS runners is different enough that the example job is Linux-only for now. That is a real gap — the native library loads differently on each platform, and this job only proves one of them.

Adding --frames=N to the showcase for CI’s benefit puts a test affordance in example code. It is small and it is honest about what it is, but it is the kind of thing that grows; the moment the showcase needs a second such flag, it wants a proper harness instead.

ADR-0022: Window is the front door

  • Status: Accepted
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §4, ADR-0019, ADR-0020

Context

The SPI works, and writing against it is a chore. Opening a window meant naming a backend, constructing an event loop, writing a switch over five event types, allocating a PixelBuffer at the right physical size, building a DamageRect list, and remembering to present — about forty lines before anything appeared, every line of it identical between applications.

Worse, that surface exposes decisions an application has no business making. Which backend? What size should the buffer be — logical or physical? Who clears the frame request? Each has one right answer and no reason to be asked.

The SPI is not wrong; it is aimed at the wrong reader. It exists so headless and sdl3 can be peers (ADR-0003), which is a toolkit-implementer’s concern.

Decision

Window and Goldberry are the public front door, and they are enough:

var window = Window.open("Hello", 960, 640);
window.onPaint(frame -> frame.fill(0xFF2E3440));
Goldberry.run();

Opening the first window starts the backend and the event loop on the calling thread, which becomes the UI thread. No application names Sdl3Backend, constructs an EventLoop, or handles a BackendEvent.

Painting is a callback taking a [Frame], in logical coordinates. The buffer, its physical size, its format, its damage list and its lifetime are the toolkit’s business. Frame reuses one buffer between frames while the size holds, because repainting is the common case.

The backend packages stay exported. An application that wants to drive the SPI directly still can — this is a front door, not a wall.

Alternatives considered

A launch(app) callback, as §4 sketched. Goldberry.launch(app) inverts control: the toolkit calls the application. It reads well for the single-window case and gets awkward the moment there are two windows, or a window opened in response to a menu item. Window.open plus Goldberry.run() composes without a lifecycle interface to implement.

A builder for every window. Window.builder().title(...).size(...).build() is more extensible and more to type for the common case. WindowSpec already exists for anything beyond title and size, so Window.open(WindowSpec) covers it without a second builder.

Let the application own the backend explicitly. Honest, and it makes the dependency visible. It also means every main starts with three lines that are the same in every application, and the one time somebody writes them differently is a bug. The runtime is created lazily and can still be replaced.

Expose PixelBuffer in onPaint instead of Frame. Fewer types. It also hands the application physical pixels and a stride, which is precisely the information that makes HiDPI code wrong — Frame takes logical coordinates because that is what application code should be thinking in.

Consequences

An application needs two types and about six lines. That is the number that matters for a toolkit nobody has used yet.

The runtime is process-global and started implicitly. Two windows share a backend and a loop, which is right, but it also means the first Window.open decides which thread is the UI thread — surprising if it happens somewhere unexpected. GoldberryRuntime.install exists for tests to substitute headless, and is not public: a setter that must be called before an implicit initialization is a bad shape to publish.

Frame is a placeholder. It has fill and fillRect and nothing else, because that is what a blank window needs; Blend2D replaces it in M1 with a real canvas. The signature — a callback receiving a drawing surface in logical coordinates — is meant to survive that.

Building this surfaced a bug the SPI’s own tests could not have found. present() cleared the pending frame request, so a painter that asked for the next frame while painting this one had its request wiped by the present immediately after — every animation would have stopped after exactly one frame. The SPI tests never repainted from inside a frame; the showcase did it on its first run. A frame request is now consumed when its FrameDue is delivered, which is what “requested” meant all along, and both backends have a test for it.

PixelBuffer now normalises its ByteBuffer to little-endian. The format is BGRA in memory order, so a 0xAARRGGBB int write only lands correctly on a little-endian view — and both ByteBuffer.allocate() and MemorySegment.asByteBuffer() default to big-endian. The old showcase was writing its channels backwards and painting a translucent blue nobody had looked at closely enough to question.

ADR-0023: Log through SLF4J, bind nothing; and fold the example back in

  • Status: Accepted (supersedes the build arrangement in ADR-0021)
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §15, ADR-0007, ADR-0021

Context

Two things, related only in that they both concern what a consumer of Goldberry sees.

The example was invisible to Gradle. ADR-0021 made example/ a separate build so it would consume Goldberry through published coordinates. The reasoning holds and the cost was higher than estimated: ./gradlew projects did not list it, IDEs did not import it, and ./gradlew run failed with “task ‘run’ not found”. The workaround — a root task shelling out to the example’s own wrapper — worked and did not fix the underlying problem, which is that a directory in the repository was not part of the project as far as any tool was concerned. That was reported twice.

Nothing logged. A UI toolkit that fails to find a display, loads the wrong native library, or is handed a surface format it cannot blit into should be able to say so. Until now the only diagnostic was an exception message, and only for the failures fatal enough to throw.

The constraint on logging is specific: an application that configures no logging must see nothing at all. SLF4J 2 does not cooperate by default. With no provider on the path it prints

SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation

to stderr on first use — noise on the console of every application that made a deliberate choice, and noise that reads like a complaint from Goldberry rather than from a library beneath it.

Decision

Goldberry logs through SLF4J and binds no implementation. slf4j-api is an api dependency of every module; no provider is a dependency of any of them. Choosing one is the application’s call, and a library that ships a backend takes that choice away — and starts a fight with whatever the application already uses.

The no-provider warning is silenced at source. Logs sets slf4j.internal.verbosity=ERROR in a static initializer, and every logger in the toolkit comes from Logs.of(...) — which is what guarantees the ordering, since that initializer runs before SLF4J’s Reporter reads the property. It is set only if the application has not, so -Dslf4j.internal.verbosity=INFO still shows everything SLF4J has to say.

Levels are conventional and worth stating so they stay that way: info for things a user would want in a bug report (which library loaded from where, which SDL, a window opening or closing, a scale change), debug for the toolkit’s own lifecycle (runtime start, buffer allocation, window creation flags), trace for per-frame detail, error only where something was swallowed.

example is a subproject again. include ':example' in settings.gradle, implementation project(':core'), no second wrapper, no dependency substitution. Gradle lists it, IDEs import it, ./gradlew run finds its run task the way ./gradlew test finds every module’s tests.

What ADR-0021 wanted is kept where it can be kept without a separate build: the example is still a module, still runs on the module path, and still declares --enable-native-access=io.github.digitalsmile.goldberry.natives. Those are what caught real problems — an unexported package and a wrong module name both fail here and nowhere else.

Alternatives considered

Ship slf4j-nop as a runtime dependency. It removes the warning without touching a system property, and it is what several libraries do. It also is a provider, so an application adding Logback gets SLF4J’s multiple-bindings warning instead — trading a warning nobody asked for against a worse one that appears only for users who did the right thing.

java.util.logging, or System.Logger. In the JDK, no dependency, and System.Logger is the modern answer for exactly this. Rejected because every Java application that logs at all already has an SLF4J bridge configured, and System.Logger output lands in whatever java.util.logging is doing by default — which is usually not where the application’s other logs go.

Leave the warning. It is two lines, once, and it is arguably SLF4J’s business rather than Goldberry’s. It also appears on the console of every application that deliberately configured nothing, and the user’s requirement here was explicit.

Keep the example as a separate build and document the friction. The coordinate check it provided is real and is now lost: nothing verifies that the published artifact is called goldberry-core rather than core until somebody publishes. That is a genuine regression, recorded below, and the price of a project layout that tools understand.

Consequences

An application sees exactly the logging it asked for and nothing else. One that adds Logback gets Goldberry’s diagnostics for free; one that adds nothing gets silence, including from SLF4J itself.

Goldberry sets a system property it does not own. It is scoped to SLF4J’s internal reporting, applied only when unset, and named in one place with a test asserting the string — but it is a global side effect of loading a class, and that is worth knowing about.

Logs is exported unqualified from :natives. A qualified exports ... to io.github.digitalsmile.goldberry.core would say what is meant and does not compile: :core is not on :natives’ compile module path (it depends the other way), so javac warns the target module is unknown and -Werror makes it fatal. The docstring carries the intent the module system cannot.

The published-coordinates check is gone. implementation project(':core') does not care what the artifact is called, so a mismatch between §15’s goldberry-core and the project name core will surface at publishing time rather than now. Publishing is not configured yet; when it is, it should assert the coordinates.

The example’s tests and the toolkit’s now run in one command, and a broken showcase breaks ./gradlew build. That is the intended coupling — a showcase that does not compile is a broken API — and it does mean the example can no longer be left temporarily broken while the toolkit moves.

ADR-0024: A repaint must wake the loop

Context

Resizing the showcase window was visibly poor: the contents lagged the pointer, and freshly uncovered areas showed black until the window caught up.

Four causes, all in the frame path, and the first one dominated.

A frame request did not wake the loop. requestFrame() set a flag. The loop was parked in SDL_WaitEventTimeout with a one-second idle timeout, and nothing told it the flag had changed — so a repaint asked for by an event handler was drawn when the next platform event happened to arrive, or a second later. The showcase’s own log showed it plainly: frames 1→2 took 96 ms, frame 3 took 1004.

The pump collected frame requests before dispatching events. Every repaint that matters is asked for by a handler — a resize, an expose and a scale change all end in repaint() — so those requests were always collected on the next pass, one pump late even once the loop was awake.

Every resize event threw away the frame buffer. handleResize set the cached buffer to null, so a multi-megabyte allocation happened per resize event, which during a drag is per pointer motion.

Filling the frame was per-pixel. Frame.fill wrote one putInt per pixel: over two million calls per frame at 1080p, on the UI thread, before anything could be presented.

And one hazard that only appears under fast resizing: the window can change size between the frame’s dimensions being read and the frame reaching SDL. The backend refuses a frame that no longer matches its surface — correctly, since a mismatched blit is corruption — but that exception ended the event loop.

Decision

requestFrame() wakes the loop, once per outstanding request. On sdl3 that is SDL_PushEvent of a user event, which is the mechanism wakeup() already uses and the one thing SDL’s queue permits from any thread. The guard matters: without it, ten repaint() calls in a batch put ten events on the queue.

The pump dispatches platform events first, then collects frame requests and dispatches those. A resize handled in a pump produces its frame in the same pump. Requests made while handling a FrameDue are deliberately left for the next one — draining until empty here would let a self-scheduling animation hold the loop and starve input.

The frame buffer is not dropped on resize. paint() already reallocates when the size no longer matches, which is the same test one step later and once per actual size change rather than once per event.

Frame fills by building one row and copying it down the rectangle, at memory speed rather than per pixel.

A frame refused because the window changed size underneath it is a dropped frame: Window.paint checks whether the size really did move, logs it at debug, and asks for another. Anything else is rethrown.

Alternatives considered

Shorten the idle timeout. A 16 ms heartbeat would have hidden the latency and made every idle application wake sixty times a second to do nothing — the exact cost the request-driven design exists to avoid. It also would not have fixed the ordering problem, only made it less visible.

Draw on a timer during resize. Some toolkits run a repaint clock while a resize gesture is in progress. It is simpler than getting the event path right and it paints frames nobody asked for; with the wakeup in place the event path is already prompt.

Let present() accept a mismatched frame and scale or clip it. It would remove the dropped-frame case entirely. It would also mean the toolkit silently presenting a frame at the wrong size, which is a worse thing to be able to do than to skip a frame during a drag.

Keep the per-pixel fill and wait for Blend2D. Frame is a placeholder and Blend2D replaces it in M1, so optimising it is work with a short life. It is twenty lines, it removed a visible stall today, and the row-copy shape is what the Blend2D path will want anyway.

Addendum, same day: the copy nobody needed

The latency fixes above made the frame rate right and the resize still felt poor, so the next step was measurement rather than more reasoning. With -Dgoldberry.log.level=TRACE the frame path reports its own stages, and at 1080p they were:

stagecost
paint (fill 8.3 MB)2–4 ms
copy (our buffer → SDL’s surface)2–5 ms
update (SDL → compositor)3–8 ms

Three full-frame copies per frame, and the middle one existed only because the SPI said the toolkit owns the buffer and the backend copies it.

So BackendWindow.acquireFrame() was added: the backend lends its own buffer when it has one, the toolkit paints directly into it, and passing that same buffer back to present skips the copy. headless returns empty and nothing changes for it; sdl3 returns SDL_GetWindowSurface’s memory as a ByteBuffer, which keeps the MemorySegment inside :natives where §3.1 wants it.

Measured after: present 2.6–5 ms, total frame 4.3–9.6 ms against 10–14 ms before. Two things did not help and are recorded so nobody tries them again: making the frame buffer direct rather than heap, and collapsing the row-by-row copy into one bulk copy when the strides match. Both are correct and neither moved the number, because the cost was never the memcpy — it was doing it at all.

What remains is SDL’s own upload to the compositor, which the surface API does not let us avoid.

Consequences

Repaints are prompt: frame-to-frame is 2–3 ms in the showcase where it was up to a second, and a resize keeps up.

A pending frame request now guarantees the next pump returns promptly, whatever the backend’s mechanism. HeadlessBackendTest asserts exactly that, phrased as the observable rule rather than as either implementation, so sdl3’s wakeup and headless’s queued event are both covered by it.

requestFrame now has a side effect on the event queue. It is idempotent while a request is outstanding, but it is no longer a pure flag set, and a backend implementing the SPI has to know that prompt delivery is part of the contract.

Live resize is still not smooth everywhere. On Windows and macOS the platform runs a modal loop during a resize gesture and SDL does not return from event pumping until it ends, so frames stop until the drag does. Fixing that needs SDL_AddEventWatch to draw from inside SDL’s own callback, which is a different shape of frame loop and waits for a renderer worth driving from it. On Wayland and X11 — where this was reported — the event path is enough.

ADR-0025: Where Linker.Option.critical is worth it

  • Status: Accepted
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §3.1, §5, ADR-0010, ADR-0024

Context

Linker.Option.critical(boolean allowHeapAccess) tells the linker that a native function is short-running, does not block, and does not call back into Java. The JVM may then run the call without transitioning the thread out of Java state, which removes the per-call transition cost. With allowHeapAccess = true it also permits passing an on-heap MemorySegment straight to native code, where the default would refuse it — the JVM pins the array for the duration instead of staging a native copy.

The obvious place to reach for it is the frame path, and the obvious hope is that it removes a copy. Both deserve measuring rather than assuming, because the option is not free: a critical call keeps the thread in Java state, so the garbage collector cannot reach a safepoint until it returns. A long or blocking call under critical stalls every thread in the VM for its duration.

Decision

Not in the SDL frame path. Measured, on linux-x64:

  • A round trip through a bound SDL function costs ~6.3 ns once warm (1,000,000 calls of SDL_GetVersion through its binding, including decoding a record from the result).
  • A frame at 1920×1080 makes about ten downcalls: two size queries, a scale query, SDL_GetWindowSurface, SDL_UpdateWindowSurfaceRects, and a handful of event polls.

That is roughly 60 ns of call overhead in a frame that takes 5,000,000 ns — about one part in eighty thousand. critical could remove some fraction of that 60 ns. There is nothing there to win.

And it removes no copy. After ADR-0024 the toolkit paints directly into the buffer SDL lends it, so no pixels cross the boundary at all. The copy that remains is inside SDL_UpdateWindowSurfaceRects, moving the surface into the compositor’s buffer — native code copying native memory, which no linker option can reach. allowHeapAccess would matter only if a heap segment were being passed as a pointer, and none is: the event buffer and the damage rectangles are native arena allocations, and the pixels are SDL’s own memory.

The rule for when it is considered: the function must be short, non-blocking, and free of upcalls, and the call site must be hot enough for a few nanoseconds to matter. In the current bindings the calls that are hot enough are not short, and the calls that are short are not hot:

CallShort?Hot?Verdict
SDL_WaitEventTimeoutno — blocks up to a secondonce per pumpnever; it would stall the VM
SDL_UpdateWindowSurfaceRectsno — 3–6 ms, talks to the compositoronce per framenever
SDL_GetWindowSurfaceno — 72 ms when it allocates a new surfaceonce per framenever
SDL_GetWindowSizeInPixels, SDL_GetWindowDisplayScaleyes~3 per frameallowed, and worth ~20 ns

Where it will pay off is M1. Text shaping and paint call into HarfBuzz and Blend2D per glyph and per path — thousands of short, pure calls per frame, which is exactly the shape critical exists for. allowHeapAccess = true is worth revisiting there too, so a Java glyph array can be handed over without staging it into native memory first. That is a decision to make with a benchmark in front of it, which is why this record exists: to say what the benchmark has to show.

Alternatives considered

Apply it to the safe getters anyway. It is three lines and the calls do qualify. It also buys ~20 ns per frame, adds an option to every reader’s mental model of those bindings, and establishes a habit of reaching for critical without measuring — which is how it eventually gets applied to something that blocks.

Apply it to SDL_UpdateWindowSurfaceRects, the expensive one. This is the tempting mistake and the reason for the table above. The call is expensive because it does real work and talks to the compositor; under critical the whole VM would be unable to collect garbage for the 3–6 ms it takes, every frame. The cost being high is precisely what disqualifies it.

Use SDL_Renderer with a streaming texture instead of the window surface. A different way at the same problem — it would move the upload onto the GPU and make the copy someone else’s. It is a real option for later and has nothing to do with linker options.

Consequences

The bindings stay plain, and the frame path’s cost stays where the measurements say it is: about 60% in SDL’s upload to the compositor, about 35% in Goldberry’s own writes to the frame buffer, and under 5% in everything else including every FFM crossing.

The rule is written down, so the next person to have this idea — reasonably — can check it against a table instead of re-deriving it.

The benchmark behind the 6.3 ns figure was a throwaway. If critical is revisited for the M1 text path it needs a real one, tracked over time (§14 already asks for upcall and shaping micro-benchmarks); a number measured once and quoted forever is how a decision outlives the facts that justified it.

ADR-0026: SDL picks the video driver, and says which

  • Status: Its decision is superseded by ADR-0027 — Goldberry now prefers Wayland. The findings below, and the logging, stand
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §4, ADR-0003, ADR-0024

Context

After the frame path was made prompt and the redundant copy removed (ADR-0024), resizing still felt worse than a native window. The frame numbers said it should not: about 5 ms of work per frame at 1080p, most of it inside SDL’s upload.

The numbers were measuring the wrong thing. Binding SDL_GetCurrentVideoDriver and logging it at start-up answered it in one line:

sdl3 backend started on SDL 3.2.0, video driver x11

On a Wayland session. Every Wayland library was installed, libdecor included, and SDL was compiled with both drivers — SDL had simply chosen X11, which means the window was going through XWayland. XWayland resize is not synchronised with the compositor the way a native Wayland surface is, and looking worse than a native window while resizing is precisely what it does.

SDL’s choice turns out to be deliberate. From SDL_video.c, the Wayland driver is tried before X11 only through Wayland_preferred_bootstrap, and Wayland_IsPreferred returns true only if the compositor advertises wp_fifo_manager_v1. Without that protocol SDL judges its own Wayland presentation to have no reliable frame pacing and prefers X11. GNOME’s Mutter does not advertise it at the time of writing, so on the most common Linux desktop, SDL deliberately chooses XWayland.

Forcing Wayland works and reports higher per-frame numbers — present rises from 3.2–5.8 ms to 4.3–17.6 ms. That is not the backend being slower; it is the compositor pacing the client, which is what a native window experiences too.

Decision

SDL’s judgement stands. Goldberry does not override the video driver by default. SDL knows more about its own backends than this project does, the check exists for a real reason, and forcing Wayland on every machine would trade one platform’s stutter for another’s.

But the choice is visible and available. The chosen driver is logged at start-up at info level, so it appears in any bug report, and -Dgoldberry.backend.videoDriver=wayland sets SDL_VIDEO_DRIVER before initialization for anyone who wants to make the trade the other way. Both paths log what happened.

Alternatives considered

Prefer Wayland whenever WAYLAND_DISPLAY is set. It would very likely make resizing look better on GNOME today, which is the reported complaint. It also overrules an upstream check written by people who measured the failure mode it guards against, on every user’s machine, for a symptom observed on one. If Goldberry is going to disagree with SDL about SDL’s backends, it should be with evidence across several compositors rather than one.

Ship our own Wayland backend. ADR-0003 closed that door on purpose: SDL3 is the permanent desktop windowing layer, and the SPI exists to serve headless and to keep the platform boundary in one place, not to grow hand-written platform backends. Resize smoothness on one compositor is not the thing that reopens it.

Use SDL_Renderer with a streaming texture instead of the window surface. This is the real technical alternative and it is not dismissed — it would move the upload to the GPU and let SDL manage multiple buffers, which is roughly what native toolkits do. It also creates a GPU context for plain UI, which ADR-0002 specifically avoids for start-up time, so it is a decision about §5’s rendering pipeline rather than a fix to reach for while chasing a resize glitch. When there is a real renderer behind it, this deserves measuring properly.

Consequences

The driver is in the log, so the next time windowing behaves oddly the first question is answered before it is asked. That is worth more than it looks: nothing else in the process reveals that a Wayland session is running an X11 window.

Anyone can try the other driver with one property, and the frame path is instrumented (-Dgoldberry.log.level=TRACE) well enough to compare them.

Goldberry now has a property that changes platform behaviour, which is a category that grows. It is deliberately named as a backend detail rather than a general setting.

The reported problem is not fully fixed, and this record should not be read as if it were. What has been established is where the remaining cost lives: not in the toolkit’s frame path, which is a few milliseconds of memory writes and one SDL call, but in the presentation path SDL chose. The next real improvement is a renderer-backed present, not another round of micro-optimisation above it.

ADR-0027: Prefer Wayland, fall back to X11

  • Status: Accepted (supersedes the decision in ADR-0026; its findings stand)
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §4, ADR-0003, ADR-0024, ADR-0026

Context

ADR-0026 established why resizing felt worse than a native window: on a Wayland session SDL was choosing X11, so the window ran through XWayland. It also decided not to override that, on the grounds that SDL’s preference check exists for a measured reason and one report is not enough to overrule upstream on every machine.

That was the right call to make with the evidence available, and the evidence has since changed. The Wayland path was tried on the reported setup — GNOME on Wayland — and resizes better. Not marginally: better enough to be the reason this record exists.

Worth restating, because it is the counter-intuitive part: the Wayland path reports higher per-frame numbers. present goes from 3.2–5.8 ms on X11 to 4.3–17.6 ms on Wayland. That is not the backend being slower. It is the compositor pacing the client — the same thing a native window experiences — and it looks better precisely because it is synchronised rather than free-running.

What SDL is protecting against without wp_fifo_manager_v1 is unreliable frame pacing. What it falls back to is XWayland, whose resize is not synchronised with the compositor at all. On this evidence the fallback is the worse of the two.

Decision

On Linux, when WAYLAND_DISPLAY names a session, Goldberry asks SDL for wayland,x11.

The hint takes a comma-separated list and SDL tries each entry in turn, so this is a preference and not a demand: a machine with no Wayland, or one where the Wayland driver fails to start, gets X11 exactly as before — resolved inside SDL, with no fallback logic in Goldberry to get wrong.

Three things override it, in order:

  1. -Dgoldberry.backend.videoDriver=<name> — an explicit choice wins.
  2. SDL_VIDEO_DRIVER or SDL_VIDEODRIVER already in the environment. SDL reads this hint from the environment too, so setting it unconditionally would silently beat what the user put in their shell.
  3. Not being on Linux. Windows and macOS have one driver each and nothing to choose between.

The chosen driver is still logged at info, which is how any of this was found.

Alternatives considered

Leave it to SDL, as ADR-0026 decided. Defensible until it was tested. The argument was that SDL knows its backends better than this project does, which is true in general and turned out not to be true for this trade on this compositor. A decision made to avoid overruling upstream without evidence should change when evidence arrives; that is what it was waiting for.

Force wayland with no fallback. Simpler to read and it makes the failure mode catastrophic: a session where the Wayland driver cannot start gets no window at all rather than an X11 one. The comma-separated list costs one character over the demand and removes that whole class of report.

Only prefer Wayland when wp_fifo_manager_v1 is absent — that is, invert SDL’s own check. It sounds precise and it is unreachable: the check needs a Wayland registry round trip, which means connecting to the compositor before SDL_Init, which is exactly what SDL is doing internally and not something to reimplement across the FFM boundary for a hint.

Wait for a renderer-backed present instead. ADR-0026 called this the real answer and it still is — SDL_Renderer with a streaming texture would move the upload to the GPU and let SDL multi-buffer. It is also a §5 rendering-pipeline decision that collides with ADR-0002’s “no GPU context for plain UI”, and it is months away. This is one line and helps today.

Consequences

Resizing on a Wayland session is smooth, which is the whole point.

Goldberry now disagrees with SDL about SDL’s own driver preference on one platform. That is a real thing to have taken on: if a future SDL changes the check — or if a compositor advertises wp_fifo_manager_v1 and the calculus flips — this override will be silently stale. It is one constant and a documented reason, which is the cheapest form that debt comes in.

The preference is evidence from one compositor. GNOME is the common case, so being right there matters, but KDE, Sway and the rest have not been tried and this record should not be read as if they had. Anyone who finds the opposite has two documented ways to say so, and the log line to prove which driver they got.

X11-only sessions are unaffected: no WAYLAND_DISPLAY, no hint, no change.

ADR-0028: The start-up timeline

  • Status: Accepted. The last paragraph’s “worth watching” was watched: Logs and Startup moved out of :natives into a module of their own in ADR-0174.
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §1, §14, ADR-0023

Context

docs/ARCHITECTURE.md opens with “starts in milliseconds”, and the whole CPU-rasterization argument in ADR-0002 rests on it: no GPU context for plain UI, because a GPU context costs start-up time. That is a claim with a number in it, and nothing in the toolkit could say whether it was true — or, when it stops being true, which part stopped it.

The recent resize work is the argument for building this now. Two rounds went into the frame path on reasoning before anyone measured, and the measurement turned out to point somewhere else entirely — twice. Start-up will go the same way: someone will guess that the native library load is the expensive part, or that it is class loading, and be wrong.

Decision

Startup records named phases and reports them at trace, with a table printed once after the first frame:

start-up timeline (866.6ms to here):
     533.8ms    +533.8ms  runtime starting
     559.6ms     +25.8ms  libgoldberry mapped (1.9ms)
     722.6ms    +162.9ms  SDL video subsystem up (99.2ms)
     728.0ms      +5.4ms  backend ready (185.5ms)
     742.9ms     +12.9ms  SDL window 4 created
     749.5ms      +6.6ms  window "Goldberry — showcase" open
     866.6ms    +117.1ms  first frame presented

Four decisions in that shape:

Timed from process start, not from the toolkit’s first line. ProcessHandle gives the process’s start instant, so the first row shows what was spent before Goldberry ran at all. Leaving it out would flatter the number by exactly the amount nobody can do anything about — which is the amount most worth knowing. Deltas come from nanoTime, which is monotonic where a wall clock is not.

Recorded always, reported at trace. A mark costs a timestamp and a queue append, so the timeline exists whether or not anyone is listening and can be summarised on demand. Gating the recording on the log level would mean the one run where somebody wants the answer is the one run without the data.

Summarised once, after the first frame. That is the moment the claim is about. A second window is not a second start-up.

Capped at 256 marks. A mark accidentally left in a loop then costs a counter increment and nothing else, and the table stays readable.

Alongside it, Startup.logModules() lists the Goldberry modules the JVM actually resolved. That is not the same question as what is on the module path — a module nothing requires is never resolved — and it is the first thing to check when a package appears to be missing. Today it prints core, natives and example, which correctly shows that widgets and gpu are not loaded because nothing depends on them yet.

Alternatives considered

JFR events. The JDK’s own answer, with tooling, no bespoke code, and far more than this does. It also needs a recording to be started, a file to be moved around, and a viewer — for a question usually asked as “why did that take so long?” in the middle of a debugging session. A trace line answers it in place. JFR remains the right tool for the M1 benchmarks §14 asks for, which are a different job: tracked over time, not read once.

System.nanoTime alone. Simpler and it cannot report the prologue. Half the first measurement above is JVM start-up and Gradle’s launcher; a timeline that started at Goldberry’s first line would have shown a healthy 330 ms and hidden the 534 ms in front of it.

Log each phase at debug and skip the table. The individual lines are already there at trace. The table exists because a timeline is read as deltas — the interesting column is +162.9ms, and reconstructing that from timestamps scattered through a log is exactly the sort of arithmetic people get wrong.

Only instrument what looks slow. That is the mistake this record exists to prevent. The first run already produced a surprise — SDL’s video subsystem costs 99 ms, which is more than mapping a four-megabyte native library by a factor of fifty.

Consequences

The start-up claim is now measurable by anyone, on their own machine, with one property: -Dgoldberry.log.level=TRACE.

The first measurements are on the record. SDL_Init(VIDEO) is ~99 ms and is by some distance the largest thing Goldberry does; mapping libgoldberry is under 2 ms, which is the opposite of what most people would guess. Neither number has been optimised — knowing them is the point of this change, not improving them.

The numbers above are measured under gradle run, which adds a launcher and its own JVM. A real start-up measurement needs the example launched directly. Nothing here does that yet, so the headline figure remains unproven; what is now proven is the shape.

Startup is another exported class in natives.log that exists for the toolkit rather than for applications, for the same reason Logs is (ADR-0023): a qualified export cannot name :core from :natives. The package is becoming the place where that compromise accumulates, and is worth watching.

ADR-0029: Yoga’s node API, and who owns a node

Context

ADR-0017 proved the hardest part of the Yoga binding in isolation: a Java upcall returning YGSize by value is called from C and arrives intact. What it could not prove is that Yoga calls it. The proof went through goldberry_probe_measure, a C function written for the purpose, because at that point there was no node to attach the callback to. The last line of that record says so: what remains is binding Yoga’s node API so the callback is driven by a real layout pass. This is that.

Binding it turns out to be less about function signatures than about ownership. Three things make Yoga’s C API awkward to expose directly:

It has no ownership model. YGNodeFree takes a pointer and frees it. Nothing in the API says which pointers a tree holds, and freeing a parent says nothing about its children — YGNodeFreeRecursive exists precisely because callers get this wrong. On the Java side the hazard is sharper than in C: a YogaNode object holding a freed pointer is a perfectly live Java object that segfaults the process on its next method call, with no stack trace pointing at the mistake.

Its preconditions are abort(). A node may not have both children and a measure function. A node may not be inserted into two trees. A node may not become its own ancestor. Yoga enforces all of these with assertions that call abort(), which takes the JVM with them: no exception, no stack, nothing to catch, and — in the cycle case — a stack overflow inside native code rather than a message.

It disagrees with CSS, quietly. Yoga defaults flex-direction to column where CSS says row, and flex-shrink to 0 where CSS says 1. It also snaps computed positions to whole logical pixels by default, which is exactly wrong on a fractional display: half the edges in a 1.5× window land mid-physical-pixel and the compositor smears them.

And one shape problem: Yoga splits every length-valued property across two or three functions — YGNodeStyleSetWidth, YGNodeStyleSetWidthPercent, YGNodeStyleSetWidthAuto — and expresses “unset” by passing YGUndefined, a NaN, to the first of them.

Decision

YogaNode is the layout engine as the rest of Goldberry sees it. Seven decisions make it up.

The tree owns its nodes, and the ownership is one-directional. A node from YogaNode.create() is a root and the caller owns it. insertChild transfers ownership to the parent, and from then on close() on the child is refused — freeing it there would leave Yoga’s own child list pointing at released memory. close() on a root frees the whole subtree child-first, marking each Java wrapper dead as its pointer goes, so a reference kept to a descendant throws IllegalStateException on use instead of reading freed memory. removeChild hands ownership back: the removed node is a root again, and the caller’s problem.

Every reachable Yoga abort is a Java exception first. Children on a measured node, a second parent, a cycle, an out-of-range index, markDirty on a node with no measure function, a config closed under its nodes. Each is checked in Java and reported with a message that says what was violated and why Yoga cares.

A tree belongs to one thread, and every method checks. Yoga has no locking of any kind. A tree touched from two threads does not fail — it corrupts, which is strictly worse than failing. The UI thread is where layout belongs anyway (ADR-0020), so the check costs nothing that matters and turns the worst failure mode into the most obvious one.

StyleLength puts the split setters back together. A sealed interface — Points, Percent, and a Keyword enum of AUTO and UNDEFINED — dispatched by an exhaustive switch with no default arm, so a kind of length added later fails to compile everywhere it is not handled. UNDEFINED is what reaches Yoga as a NaN, so NaN never appears in the API: it is one state with one spelling, rather than a value that does not equal itself. Where Yoga exports no *Auto function — min-width, max-width, inset — AUTO is refused by name rather than dropped, because a stylesheet silently having no effect is the harder bug.

The binding class is package-private. Sdl and SdlVideo are public because :core drives SDL directly. Nothing above :natives drives Yoga directly, so Yoga — the class holding the sixty-odd MethodHandles — is package-private and YogaNode is the only way in. That means there is no second path to YGNodeFree: no way to free a node without going through the tree that knows which wrappers are still alive.

YogaConfig makes the two CSS deviations explicit. It turns web defaults on by default, and it exposes the point scale factor without guessing one — only the backend knows what display a window is on, and a wrong guess is worse than the default because it looks right until the window moves. This is the mechanism behind the fractional-DPI claim in ADR-0019: set it to the window’s display scale and Yoga rounds to physical pixels, so a 1px border is one crisp device pixel at any scale. A config refuses to close while nodes it made are alive.

Measure failures are collected across the tree, not thrown at the first one. A layout pass can fail in several callbacks, and by the time Yoga returns they are all holding an exception. Throwing at the first one found would leave the others pending, and the next pass would then fail with an exception from the pass before it. So the tree is walked, every pending failure is taken, the first is thrown and the rest are attached as suppressed.

All 48 of Yoga’s enumerators the bindings model are registered in the layout table and checked against the compiled library, under the rule ADR-0010 sets. This is where that rule earns its keep: YGAlignCenter is 2 and YGJustifyCenter is 1, and getting that pair backwards produces a layout that is merely off-centre — never an error, on every platform at once.

Alternatives considered

Bind YGNodeFreeRecursive and let Yoga free the tree. One call instead of a walk, and it is the function Yoga provides for exactly this. It also frees pointers Java wrappers are still holding without those wrappers ever learning about it, which is the entire hazard. Freeing one node at a time is what makes it possible to mark each wrapper dead as its pointer goes.

A pointer-to-wrapper map instead of a Java-side child list. It would avoid keeping two views of the same tree. It costs an identity-map lookup per child access on the layout path, and it does not solve the freeing problem — the map would still have to be walked to mark wrappers dead. The duplicate list is cheaper and the two views are asserted to agree, against Yoga’s own YGNodeGetChildCount.

Cleaner or an Arena for automatic freeing. Attractive, and wrong here: GC order is arbitrary, and a node freed before its parent leaves the parent holding a dangling child. Layout trees are also large and short-lived, so non-deterministic freeing would mean an unbounded amount of native memory waiting on a collection that has no reason to happen.

Expose the split setters as Yoga declares them. setWidth(float), setWidthPercent(float), setWidthAuto(). Honest to the C API, and it puts the choice of function at every call site in the CSS compiler while making “unset this” read as “set this to not-a-number”.

A float with Float.NaN for undefined, no StyleLength at all. Fewer types and no allocation. It also gives one state a spelling that does not compare equal to itself, and it cannot express percent at all without a second parameter — at which point it is StyleLength with worse ergonomics.

Skip the thread check. It is a comparison per call on the layout path. Keeping it is a judgement, not a measurement: the cost has not been benchmarked, and if a profile ever shows it the check can move behind an assertion. Silent corruption is worth more than a comparison until then.

Register only the enum values a widget is likely to use. The registry gains 48 rows for constants most stylesheets will never mention. The ones nothing checks are precisely the ones that will be wrong, and discovering that through a subtly misaligned layout is the failure this whole mechanism exists to prevent.

Consequences

The last functional gap in M0 is closed. The measure callback is driven by Yoga itself, from a real layout pass, with the constraints the flexbox algorithm arrived at — YogaMeasureTest asserts that a leaf inside 200 points of width and 10 of padding is asked to measure at 180. That is proven on linux-x64; the same tests run on every target in CI, so the other five are answered by the next run rather than by argument.

The ABI version is now 5, and the Java and native artifacts must be rebuilt together. The shim gained 48 constant rows and the export list gained 69 symbols, so a libgoldberry built before this change is refused at load time with a version mismatch rather than a segfault. This is the mechanism working as intended, but it does mean a stale local library has to be rebuilt: ./gradlew :natives:cmakeBuild.

The export list is now mostly Yoga. 69 of its 99 symbols. The MSVC .def and Mach-O -exported_symbols_list branches of the export machinery have never run at all, so the first Windows or macOS build is now testing them against a much larger list than the one that motivated ADR-0018.

Two views of the tree exist and can only disagree through a bug. YogaNode keeps its own child list so that children() can return wrappers rather than pointers. A test asserts it against YGNodeGetChildCount after every structural operation, which is the only thing standing between the duplication and a class of bug that would otherwise be invisible.

Nothing in :core uses any of this yet. It is a binding, not a widget tree: the three-tree model in ADR-0004 still has no stateful-widget lifecycle, and nothing decides when a layout pass runs. What is settled now is the layer beneath that decision.

Deliberately not bound. Baseline functions — so Align.BASELINE behaves as FLEX_START until the text stack can supply one; YGNodeClone and YGNodeCopyStyle; node and config contexts; the dirtied callback; errata and experimental features; and the style getters, since the CSS layer is the authority on what a node’s style is and reading it back from Yoga would create a second one. Yoga 3.1 also has no YGNodeStyleSetPositionAuto, so CSS’s inset: auto has nothing to compile to.

ADR-0030: Pin Blend2D and AsmJit by commit SHA

Context

Every upstream in the superbuild is pinned to a git ref, and for four of them that ref is a release tag. Blend2D and AsmJit have no tags at all — Blend2D has shipped from master for years and has never cut one — so both were pinned to the branch name. gradle/libs.versions.toml said so in a comment, and book/src/status.md has carried it as a release blocker since the log began: the build is NOT yet reproducible.

Two things make now the moment to fix it rather than later.

M1 binds Blend2D’s C API. Blend2D’s objects are BLObjectCore unions whose layout the hand-written bindings will model field by field, checked against the compiled library by the layout table (ADR-0010). Against a floating branch, an upstream change to one of those layouts arrives between two builds of the same commit of Goldberry, and the layout table reports it as our bug. The check is only as trustworthy as the thing it checks against.

Nobody could say what the last artifact was built from. The local checkout here was on a Blend2D commit from November and an AsmJit commit from March, and whether CI had the same pair was unknowable — master on the day of the run is not a fact that survives the run.

There was a second problem hiding behind the first. The refs live in four places: the version catalog, the CMake defaults, and three CI workflows, which cannot read the catalog because the manylinux container has no JDK (ADR-0012). Nothing checked that they agreed. That is survivable while every copy reads master, because all four are equally wrong; it stops being survivable the moment they carry a SHA.

Decision

Both are pinned by commit SHA:

Ref
Blend2D6dbc2cefbc996379e07104e34519a440b49b15d7master @ 2025-11-29
AsmJit0bd5787b54b575ed94bf32ac452153b34385c514master @ 2026-03-26

Both are the current head of upstream master, and both are the commits that have actually built, linked and passed the tests on this machine — not the newest thing available, but the pair with evidence behind it.

They are pinned together. AsmJit is not linked against Blend2D; Blend2D compiles its sources directly via add_subdirectory with ASMJIT_EMBED, so the two refs have to suit each other and nothing upstream publishes which pairs do. Pinning one of the two would pin neither.

GIT_SHALLOW is removed from both. CMake’s own documentation is explicit: “If GIT_SHALLOW is enabled then GIT_TAG works only with branch names and tags. A commit hash is not allowed.” It would have appeared to work anyway — a shallow clone fetches --depth 1 --no-single-branch, which contains every branch tip, and these SHAs are the tips of master today. The failure would arrive the day upstream commits anything, on a clean clone, in CI, as Failed to checkout tag with nothing pointing at the cause.

:natives:checkPinnedRefs fails when the four copies disagree, and runs as part of check. It parses the CMake defaults and each workflow’s env block and compares them against the catalog, which is the source of truth. A workflow that does not mention a ref is not at fault — macos.yml has no XKBCOMMON_REF because libxkbcommon is Linux-only.

Alternatives considered

Wait for upstream to tag a release. The cleanest pin is a tag, and Blend2D would have to cut one. It has not in the project’s lifetime, and a plan that begins with someone else changing their release practice is not a plan.

Git submodules instead of FetchContent. Submodules pin by SHA natively and would delete the four-place duplication outright. They also change how the repository is cloned for everyone, including Java-only contributors who never build native code at all, and they would make these two upstreams work differently from the four that are perfectly well served by a tag. The duplication is real but it is now checked; the clone story is not worth trading for it.

Vendor the sources into the repository. Maximum reproducibility, and it makes the licence disclosure in ADR-0015 concrete rather than by reference. It also puts megabytes of someone else’s C++ in the history forever and makes every upstream bump a diff nobody can review.

Pin only Blend2D. What was asked for, and half a pin: AsmJit is compiled into Blend2D, so a floating AsmJit leaves the artifact exactly as irreproducible as before. The two move as one or the pin means nothing.

Generate the workflow env blocks from the catalog. It would remove the duplication rather than police it. It needs a generator, a committed-output check so the generated files cannot go stale, and it still cannot run inside the container. A check that fails in the same place costs a few lines.

Keep GIT_SHALLOW and accept it. Saves a full-history clone per build. Measured, that is 6.6 MB for Blend2D and 11 MB for AsmJit — against a failure that is delayed, misattributed, and lands on whoever happens to build after upstream’s next commit.

Consequences

The build is reproducible for the first time. Every one of the six upstreams now resolves to exactly one commit, and a release blocker listed since the log began is closed. What remains for a publishable artifact is the licence texts in licenses/, which are still placeholders.

The four copies are now checked rather than merely commented. The check has been verified to fail, not just to pass: pointing one workflow back at master produces macos.yml has BLEND2D_REF=master, catalog says 6dbc2ce…. It is wired into check, so a Java-only contributor with no native toolchain still runs it.

Full-history clones cost 17.6 MB across the two. Once per clean build directory, and CI caches _deps. The clean rebuild that verified this pin — re-clone plus a full Blend2D and AsmJit compile — took 43 seconds.

Moving the pins forward is now a deliberate edit in four files. That is the point, and it is also the cost: a security fix upstream no longer arrives by rebuilding. Nothing watches these repositories for us, and nothing here changes that.

The pins are a snapshot of master, not a blessed release. Nobody upstream has said this Blend2D commit is stable, only that it is what master was on a Saturday in November. That is strictly better than “whatever master is when you happen to build”, which is what it replaces, and strictly worse than a tag — which is why this record exists rather than the problem simply being closed.

ADR-0031: Blend2D, and painting into a borrowed buffer

Context

ADR-0002 chose Blend2D as the rasterizer on day one, and until now nothing was bound to it. Frame wrote pixels by hand — a row of bytes built once and copied down a rectangle — with a comment saying it was a placeholder and Blend2D would take over in M1. It is M1.

Blend2D’s C API has three shapes worth deciding about before writing any of it.

One object model, sixteen bytes wide. Every “core” object — BLImageCore, BLContextCore, BLPathCore — is a single BLObjectDetail: a union that overlaps a static payload with a pointer to a dynamic Impl, plus a 32-bit info word. Not a type per object; one union, reused.

BLResult on everything, and no way to ask what it means. Every function returns an unsigned code, zero for success. There is no GetError, no errno, and no result-to-string function to bind: blend2d-debug.h is header-only, so there is no symbol for it.

The buffer already exists. :core does not need Blend2D to allocate a frame. When the platform lends its own surface — which SDL does — the pixels to draw into are the compositor’s, and copying into them afterwards is a full frame of memory traffic per frame.

Decision

The image borrows; it never allocates. Only bl_image_init_as_from_data is bound, with a NULL destroy callback: Blend2D is told to free nothing, because the memory was never its own. BlendImage.wrapping refuses a heap ByteBuffer outright rather than copying one — a heap buffer has no address the collector will not move, and copying would hand back an image that paints perfectly into memory nobody presents.

The display scale is a context transform, not arithmetic. BlendContext.on scales the context once, and everything drawn afterwards is in logical coordinates. This is the fractional-DPI story on the paint side, and it is a real difference rather than a tidier spelling: the old Frame rounded logical to physical with Math.round before writing bytes, so on a 1.5× display every edge moved by up to a third of a logical pixel. Blend2D antialiases the coverage instead — a test asserts that a one-logical-pixel rectangle at 1.5× leaves the second physical pixel about half lit.

Colours are straight alpha; the buffer is premultiplied; Blend2D converts. This inverts what writing pixels by hand required, and is the single easiest thing to get wrong when moving from one to the other. Frame.premultiply is gone — a caller who still premultiplied would apply alpha twice and get a frame that is visibly too dark with nothing reporting a problem. The test asserts the exact number: 0x80402010 in, 0x80201008 stored.

fill replaces, fillRect blends. A background is a replacement — blending a translucent colour over the previous frame composites onto it, so the same call every frame darkens until it is opaque. fillRect is a blend, which is a behaviour change: the hand-written path overwrote pixels and ignored alpha entirely, so a translucent rectangle used to come out solid.

BLResult becomes an exception at the boundary, and only codes worth reading are named. A name in BlendResultCode is a constant the layout probe checks against the compiled library, so naming codes nobody can trigger would be registry weight for no reading; an unnamed code still reports its hex value.

The binding class is package-private, as Yoga’s is (ADR-0029): BlendImage and BlendContext are the only way in, and there is no second path that reaches bl_context_destroy without the wrapper that knows whether the context is still attached.

Blend2D is compiled with default symbol visibility, alone among the upstreams. This is the finding that cost the most to reach, and it is ADR-0018’s lesson in a new costume — a version script cannot promote a symbol that is already hidden. There, --exclude-libs,ALL did the hiding. Here it is Blend2D’s own header:

#if !defined(BL_STATIC)
  ... #define BL_API __attribute__((visibility("default")))
#endif
#ifndef BL_API
  #define BL_API          /* a static build gets nothing at all */
#endif

A static Blend2D defines BL_STATIC, so BL_API expands to nothing, and the CMAKE_CXX_VISIBILITY_PRESET=hidden set for the whole superbuild then applies to every one of its functions. The symptom is precise and misleading: the symbols link in perfectly well — -u pulls each one out of the archive — and arrive in libgoldberry.so as local. nm -D shows no bl_* at all while plain nm shows every one of them as a lowercase t. The exported count sat at 99 against an export list of 112, and the missing 13 were exactly the Blend2D ones.

Yoga and SDL do not have this problem because YG_EXPORT and SDL_DECLSPEC are unconditionally visibility("default"), static build or not.

Alternatives considered

Let Blend2D allocate the image and blit into the surface afterwards. The obvious shape, and what most Blend2D examples do. It is a full frame of memory traffic every frame — the thing ADR-0019’s borrowed-buffer path exists to avoid.

Keep the hand-written pixel loops for flat fills and use Blend2D only for paths. Tempting because a flat fill really is faster as a memcpy down the rows. It also means two paint paths that disagree about whether alpha means anything, and the disagreement shows up as a colour being subtly wrong depending on which one drew it.

Round logical coordinates to physical in Frame, as before. Simpler, and wrong in a way that is invisible in a screenshot at 100%. Every fractional scale moves edges.

Model each core object type separately. BLImageCore and BLContextCore as their own layouts. They are the same union, so the layouts would be identical copies; instead there is one BLObjectDetail and the C table registers all three, which turns “they are all the same shape” from an assumption into an assertion.

Define BL_BUILD_EXPORT for the Blend2D target instead of changing its visibility preset. It is the macro that produces visibility("default") — but only on the !BL_STATIC branch, which a static build is not on, and on Windows it flips dllimport to dllexport, which is meaningless for a static archive and would be one more thing to reason about on the target that has never been built.

Name every BLResult code. Around forty constants, most of them for operations Goldberry does not perform.

Consequences

The toolkit rasterizes through Blend2D. The showcase opens a window and presents three frames through the new path — image, context, scale transform, fills, end, present — on linux-x64 with AsmJit compiling its pipelines at run time.

The ABI version is now 6. The shim gained ten struct layouts and twenty-odd constant rows, and the export list gained 13 symbols. A libgoldberry built before this change is refused at load rather than crashing.

fillRect blends where it used to overwrite. Any code relying on the old behaviour — a translucent colour coming out solid — changes. Nothing in the showcase does.

The visibility fix is untested on macOS and Windows. The Mach-O -exported_symbols_list branch has the same requirement as the ELF version script: a local symbol cannot be exported, so this fix is load-bearing there too and has never run. The MSVC .def branch does not depend on visibility attributes at all, so it is unaffected — which is its own kind of untested.

Apple Silicon’s W^X handling is now actually exercised. ADR-0002 flagged that AsmJit allocates executable memory and that macOS needs MAP_JIT plus pthread_jit_write_protect_np, and asked for it to be verified on the macOS leg. Nothing triggered it before, because nothing created a rendering context. The first frame the macOS build paints is the test.

A context is created and destroyed per frame, and the cost has not been measured. Blend2D compiles its pipelines with AsmJit on first use, so the first frame is dearer than the rest; the start-up timeline (ADR-0028) is where that will show. thread_count is left at zero — synchronous, on the calling thread — so Blend2D’s banded multithreading is a knob away and deliberately not turned until there is a frame worth measuring.

:core tests can now need a native library, and get one by default. :natives publishes the host library’s path as an extension property rather than letting :core re-derive it — the host-target detection stays in one place, so adding a target cannot leave the two projects testing different libraries. :core:test depends on :natives:cmakeBuild under the same bargain :natives already makes, with the same escape hatches: -Pgoldberry.skipNative=true for a Java-only build, and an explicit -Dgoldberry.native.library=<path> for verifying an artifact built elsewhere. Without any library at all the rendering tests skip rather than fail.

Deliberately not bound. Paths, gradients, strokes, images and image codecs, clipping, save/restore, and all of the text API. Fonts and glyph runs are the next piece and are what HarfBuzz feeds.

ADR-0032: Shaping is UTF-16 in, glyphs out

Context

Yoga’s measure callback is bound and driven by real layout passes (ADR-0029), and Blend2D rasterizes frames (ADR-0031). What sits between them is missing: something that turns a string and a width into a set of glyphs and positions. That is HarfBuzz, and it is the last binding M1’s vertical slice needs.

Three things shape the decision.

HarfBuzz speaks UTF-16 natively. hb_buffer_add_utf16 exists alongside the UTF-8 and UTF-32 entry points, and a Java String already is UTF-16. Choosing any other entry point means transcoding on the hottest path in the toolkit — a paragraph is reshaped at every width Yoga proposes — and, worse, means the cluster indices HarfBuzz reports index a re-encoded copy rather than the string the application handed over.

Shaping returns two parallel arrays, read in place. hb_buffer_get_glyph_infos and hb_buffer_get_glyph_positions hand back pointers into memory the buffer owns. Both structs carry members HarfBuzz marks private, which are part of the stride and must never be read.

HarfBuzz reports almost nothing. Most of its functions return void, and the ones that can fail return an object that is empty rather than null: hb_face_create over nonsense bytes gives a face with no glyphs, not an error.

And one practical constraint: Goldberry bundles no font yet. licenses/ is still placeholders, so there is nothing in the repository to shape with, and depending on whatever fonts happen to be installed would make the tests pass or fail according to the machine.

Decision

UTF-16 in. ShapingBuffer.addText takes a CharSequence and hands the code units over unchanged. The cluster values that come back are therefore indices into the caller’s own string, which is what makes a caret position and a selection range meaningful without a mapping table.

The full text is offered as context; a range of it is shaped. addText(text, start, end) passes the whole string with an item range, because that is the distinction hb_buffer_add_utf16 draws and it is not cosmetic: in Arabic a letter’s form depends on its neighbours, so shaping a fragment without the characters either side of it is visibly wrong.

Glyphs come back as parallel int[]s, copied. GlyphRun holds six arrays rather than an array of glyph objects: a paragraph is thousands of glyphs reshaped several times per layout pass, and one object per glyph would be an allocation storm exactly where it hurts. They are copies because the buffer reuses its memory — holding the native arrays across a reset would read the next run’s glyphs.

The buffer is a reusable object. ShapingBuffer is created once and reset() between runs, so the measure callback does not allocate and free native memory on every width Yoga tries.

ShapedFont owns all three HarfBuzz objects — blob, face, font — because nothing above this module has a reason to hold one without the others. Font bytes are always copied (HB_MEMORY_MODE_DUPLICATE): the alternative is promising that a Java array outlives the face, which nothing here can promise.

ShapedFont.empty() is part of the public surface, not a test fixture. It wraps HarfBuzz’s immortal empty face, and it is what makes the shaping tests run on a machine with no fonts installed — glyph counts, cluster mapping, direction handling and the UTF-16 crossing are all exercised by it. It is tracked as borrowed, because that face is a singleton HarfBuzz keeps forever and destroying it would decrement a reference count that was never ours.

The struct strides are registered in the layout table like every other hand-written layout (ADR-0010), with the private members modelled as padding. This is where that discipline earns the most: a stride wrong by four bytes gives a perfect first glyph and garbage for every one after it, which on short text looks like nothing at all.

Alternatives considered

UTF-8, with the string encoded on the way in. The entry point most examples use. It costs an encode per shaping call, and it makes every cluster index refer to a byte offset in a temporary array — so mapping a click back to a character would need the encoding kept alive alongside the glyphs.

Return an array of glyph records. List<Glyph> reads far better at the call site. It is also one allocation per glyph on the path that runs most often, and the first thing a profiler would point at.

Let callers hold HarfBuzz’s arrays directly, as segments, and avoid the copy. Faster, and it makes the lifetime of the result depend on the buffer not being touched — a rule that would be broken by the first person to reuse a buffer, which is the thing buffers exist for.

A font object per shaping call. Simpler lifetimes. Creating an hb_font_t parses tables, so doing it per call would put font loading inside the measure callback.

Skip the shaping tests until a font is bundled. The honest-looking option, and it would have left the UTF-16 crossing, the array strides and the cluster mapping unchecked for as long as the licence work takes. The empty face checks all of them.

Bind hb_shape_full instead of hb_shape. It reports whether the requested shaper list was used, which is a real signal — but the shaper list is not something Goldberry chooses yet, so there is nothing to report about.

Consequences

The M1 vertical slice has all three of its pieces bound. Yoga measures, HarfBuzz shapes, Blend2D draws. What is missing is the thing that joins them: a paragraph type whose measure function shapes at the width Yoga proposes and reports the height, and a paint step that turns a GlyphRun into Blend2D glyph runs. Neither exists yet.

The ABI version is now 7, and the export list has 136 entries.

HarfBuzz needed the same visibility fix Blend2D did, for a blunter reason: HB_EXTERN is defined as bare extern, with no visibility attribute at all, so the superbuild’s global hidden preset applied to every HarfBuzz function. All 24 symbols linked in and arrived local. The fix in ADR-0031 is now a loop over both targets — which is the right shape, because the next static upstream will probably need it too.

Shaping itself is not tested. Ligatures, kerning, contextual forms and right-to-left glyph reordering all need a real font, and the empty face’s fallback path does not reorder — so a test asserting that it did would be asserting a property of the fallback rather than of shaping. There is a test that says so out loud rather than leaving the absence silent. This lands the moment a font is bundled, which the licence work (ADR-0015) gates.

“The font failed to load” and “the text is all boxes” look identical from Java. HarfBuzz answers unparseable bytes with an empty face rather than an error, and there is no way to tell that from a font that genuinely lacks the characters. A test pins the behaviour down so it is at least documented; a real font loader will need its own validation before it gets here.

Deliberately not bound. Font features (hb_feature_t) — the CSS layer has no font-feature-settings to compile into them yet; variable-font axes; the callback-based font funcs, which is how a font’s metrics can be supplied by something other than the file; hb_shape_full and shaper selection; and the face-enumeration API for collections beyond an index.

ADR-0033: Assets are fetched and compiled, not committed

Context

Three things came due at once.

The toolkit had no font. docs/ARCHITECTURE.md §6.1 says Inter and JetBrains Mono ship inside the jar, and §6.2 adds OpenMoji for the emoji slot. None of them were in the repository, which is why ADR-0032 had to record that shaping itself was untested: the HarfBuzz binding could be exercised against an empty face, but ligatures, kerning and real glyph ids need real outlines.

Four licence placeholders were release blockers. ADR-0015 requires the verbatim upstream text for everything bundled, and licenses/inter.txt and its three siblings were notes saying so rather than licences.

Yoga and Blend2D had never met. Both were bound and independently tested — layout in ADR-0029, painting in ADR-0031 — and nothing put the output of one into the other. A join that has never run is a join that does not work.

Decision

A :assets subproject, in Java. The first attempt at this was fifty lines of Groovy in core/build.gradle, and it was the wrong shape by the time it handled its third SVG element: converting 1544 icons from seven shape primitives into path data is real logic, and real logic belongs somewhere it can be unit tested rather than somewhere it can only be run. :assets is a build-time tool with an ordinary main, no module-info, and 23 tests. It is not published, so §15’s artifact list is unchanged.

Assets are pinned by version and SHA-256. The same argument ADR-0030 makes for the native upstreams applies harder here: GitHub permits a release asset to be replaced, and a font that changed underneath us would change how every application renders, with no version number moving to say so. The manifest lives in Java rather than gradle/libs.versions.toml because a version catalog has nowhere to put a checksum, an archive layout, or the list of entries worth extracting — and splitting those across two files is how they drift apart.

Fetched at build time, cached outside build/. The archives total 90 MB and four files are wanted from them. They are cached under .gradle/ so a clean does not mean downloading them again, and they cannot go stale because the checksum is what decides a cache hit.

Icons are compiled, not shipped. Lucide’s 1544 SVGs become one table of path data, name<TAB>path per line, parsed lazily on first use. Shipping SVGs would put an XML parser on the path that draws a checkbox. §6.3 anticipates a “compact binary path table”; this is the same idea in the form that can be read, diffed and grepped, and turning it binary is worth doing when something measures the parse — today nothing draws an icon at all.

An unconvertible icon fails the build. Lucide is uniform by construction — 24×24, 2px round strokes, no transforms, fills or groups — so every shape can become path data. Anything else is refused rather than skipped, because an icon that silently lost a piece is far harder to notice than a build that stops.

Licences are vendored by a separate, manual task. vendorLicences writes the verbatim upstream texts into licenses/ and is run by hand and committed. It is deliberately not part of build: ADR-0015 wants those texts in the repository so it is self-describing, and a generated file inside a tracked directory that nobody committed is worse than no file at all.

Box and BoxPainter join the two engines. A Box is an immutable flexbox-styled rectangle with children; BoxPainter builds a Yoga tree from one, sets the config’s point scale factor from the frame’s display scale, lays out at the frame’s logical size, and walks the result accumulating absolute positions into Blend2D fills.

That scale factor is the piece worth naming. Yoga rounds computed positions to a pixel grid, and the grid is the config’s. Left at 1, every edge in a 1.5× window lands on a whole logical pixel — one and a half physical ones — so half the edges fall mid-pixel and the compositor smears them. This is the first code that sets it, and therefore the first code for which ADR-0019’s fractional-DPI claim is a mechanism rather than an intention.

Alternatives considered

Commit the fonts and icons. A hermetic build, no network, no checksums to maintain. It also puts three megabytes of binary into the history permanently, makes every upstream bump a diff nobody can review, and means the repository carries redistributable assets whose provenance is a commit message. The build already requires network for the native superbuild, so this changes no constraint that was not already there.

Keep the asset logic in the build script. Fewer moving parts, no extra subproject. It also means the SVG conversion — the one piece with arithmetic in it — can only be checked by running the whole build and looking at the output. The 23 tests in :assets exist because that was not good enough.

Put the asset versions in libs.versions.toml. Consistent with the native pins, and it was the first attempt. A catalog entry is a version string and nothing else, so the checksum, the archive layout and the extraction list would have lived somewhere else — which is exactly the split that lets a version bump land without its checksum.

Ship the SVGs and parse at runtime. No build-time compiler and no format to maintain. It also ships an XML parser dependency and puts it on the path that draws a checkbox, for icons that never change after the build.

Bundle OpenMoji’s colour build. §6.2 wants COLRv0 available. It is 2.5 MB against 1.4, and nothing can draw layered outlines yet — so it would be weight in every jar for a feature that does not exist.

Make Box the widget model. It is one small step from here, and it would be inventing the three-tree design a second time. ADR-0004 is still open; Box is deliberately a join between two engines and not a proposal about widgets.

Retain the Yoga tree across frames. BoxPainter builds and frees a tree per paint, which is the wrong shape for a real toolkit: layout should be incremental and only dirty subtrees recomputed. Retaining it is the render tree’s job, and the render tree is blocked on ADR-0004. Building it per frame is honest about being a seam rather than an engine.

Consequences

Shaping is tested with real outlines. The gap ADR-0032 recorded is closed: Inter produces real glyph ids rather than .notdef, a proportional face and a monospace one demonstrably differ, doubling the scale doubles the advance, and emoji resolve through OpenMoji. That last one is the measure function’s input, so the paragraph work now has ground to stand on.

Four of the ten licence placeholders are now verbatim upstream texts — Inter and JetBrains Mono’s OFL, OpenMoji’s CC BY-SA, Lucide’s ISC — and they ship in every jar under META-INF/licenses/. The six native ones remain, so checkLicenses -Pgoldberry.releaseCheck=true still fails, but the release blocker is smaller by the assets.

goldberry-core is now about 3 MB. 859 KiB of Inter, 296 of JetBrains Mono, 1382 of OpenMoji, and 220 for 1544 compiled icons. That is the trade §6.1 makes deliberately: deterministic rendering everywhere in exchange for jar size.

A build with no network cannot produce a usable goldberry-core. The archives cache after the first fetch, so this bites once per checkout — but it bites, and a jar assembled without the asset step produces a toolkit that cannot render text. BundledAssets says so by name rather than throwing a NullPointerException from inside a paint pass.

The showcase lays out through Yoga. It draws a top bar, a quarter-width sidebar and a body, in logical coordinates, at whatever scale the display runs — which is a better demonstration than the two hand-placed rectangles it had, and it means the join is exercised by something other than its own tests.

Nothing draws an icon or a glyph yet. The icons are compiled and reachable; turning path data into a BLPath needs Blend2D’s path API bound, and drawing a GlyphRun needs bl_font_* and bl_context_fill_glyph_run_*. Both are the next piece of work, and both now have their inputs sitting in the jar.

ADR-0034: One size, and the design-unit crossing

Context

All three engines of the M1 slice were bound and none of them had ever met. Yoga lays out (ADR-0029), Blend2D fills rectangles (ADR-0031), HarfBuzz shapes (ADR-0032), and real fonts are in the jar (ADR-0033). Shaping produced a GlyphRun that went nowhere. Nothing had drawn a glyph.

Getting one on screen needs Blend2D’s font chain bound — data, face, font — and bl_context_fill_glyph_run_d_rgba32. That much is mechanical. What is not mechanical is the question the crossing forces, and it has exactly one right answer:

In what units are a glyph’s positions?

Both libraries have an opinion, and they are different opinions about the same four numbers.

HarfBuzz reports advances and offsets in whatever its font’s scale was set to. Set no scale and it reports font design units — the em grid the outlines were drawn on, 2048 for Inter. Set the scale to size * 64 and it reports 26.6 fixed point, which is the convention every FreeType example uses and which ADR-0032 named as the usual choice.

Blend2D reads a BLGlyphRun whose placement_type decides how it transforms those numbers. For BL_GLYPH_PLACEMENT_TYPE_ADVANCE_OFFSET — the one whose memory layout matches what HarfBuzz already produces — it multiplies every placement by the font matrix, which is size / units-per-em. It has to: a BLFont is a face at a size, and applying the size is what it is for.

So if the shaper has also applied a size, the size is applied twice. For Inter at 16 points that is a factor of 2048 / 16 — 128×. The text is drawn, it is simply eight thousand pixels wide and off the edge of the window. Get it wrong the other way, hand Blend2D pixel-space numbers and tell it they are user units, and the run collapses into one illegible pile. Neither reports an error. Both paths through bl_context_fill_glyph_run_* return BL_SUCCESS.

This is the shape of mistake this codebase has now met several times — a wrong enum, a wrong stride, a doubly-premultiplied colour — where the wrong answer renders. It differs in one respect: it is not a fact about the compiled library that the layout table could check. It is a convention between two libraries, and nothing in either of them can be asked about it.

Decision

Shape in design units; put the size on the Blend2D font alone.

The shaping font is left at HarfBuzz’s default scale, ShapedFont.UNSCALED, so a GlyphRun is always in font design units. The BlendFont carries the size. One size, in one place, and the font matrix is the only thing that converts.

Font in :core owns both and is what maintains the invariant. It builds a ShapedFont and a BlendFont over the same bytes, never scales the first, and is the only thing that hands one’s output to the other. A caller wiring the two together by hand can still get it wrong; a caller using Font cannot.

The four numbers cross as a copy, not a conversion. BLGlyphPlacement is an offset and an advance as two BLPointI — four int32s, in exactly the order and width hb_glyph_position_t reports them. So Font.draw copies them straight across with no arithmetic in between, and ADVANCE_OFFSET is chosen precisely because it is the placement type that needs none.

BlendGlyphBuffer stages them and is reused. A BLGlyphRun is a descriptor — two pointers into memory the caller owns, plus each array’s stride — so the glyph ids, the placements and the descriptor all live in one arena and cannot outlive each other. It grows and never shrinks, because a frame draws hundreds of runs and a paragraph reshapes at every width a layout pass proposes.

Java converts design units to logical ones itself, in Font.widthOf, by the same size / units-per-em. That is not duplication of Blend2D’s matrix: it is how a measure function answers “how wide is this?” without asking the rasterizer to draw it first — which is exactly what Yoga will call.

The origin is the baseline, and it is a BLPoint of doubles. Only the _d variant of the fill is bound; the _i one takes integer coordinates, and rounding a baseline is the thing ADR-0031 went to some trouble to stop doing for rectangles. At 1.5× it would quantise line spacing.

ShapedFont gained unitsPerEm() and isDesignUnits(). The invariant is now something an object can be asked about rather than only something a document asserts. hb_face_get_upem is bound for it.

Alternatives considered

Shape at size * 64 and use BL_GLYPH_PLACEMENT_TYPE_USER_UNITS. The FreeType-shaped option, and it works: convert the 26.6 fixed point to doubles, accumulate the pen in Java, and hand Blend2D absolute positions. It costs a conversion and an accumulation per glyph on the hottest path, it makes every shaping result specific to one size — so a paragraph cache would need an entry per size — and it moves pen accumulation, which is subtle for marks and right-to-left runs, out of the library that knows how to do it and into ours.

Shape at the size and let Blend2D’s font be at units-per-em, so the matrix is the identity. Symmetrical, and it inverts the problem rather than solving it: metrics from bl_font_get_metrics would then come back in design units and every baseline calculation would need the same conversion, in more places.

Bind bl_font_shape and drop HarfBuzz for text. Blend2D has its own shaper. It is not HarfBuzz — no Indic reordering worth the name, thinner OpenType coverage — and ADR-0002 chose Blend2D as a rasterizer, not as a text stack. Taking its shaper would quietly narrow which scripts the toolkit can claim to support.

Have BlendContext.fillGlyphRun take a HarfBuzz GlyphRun directly. Fewer types and a shorter path. It also makes the Blend2D binding package depend on the HarfBuzz one, welding together the two halves that docs/ARCHITECTURE.md §6 keeps apart — and the first thing that would break is substituting a different shaper.

Let Font scale the shaper and document the hazard. Cheaper to write. The hazard is invisible at runtime, so documentation is the one mitigation that cannot fail loudly.

Consequences

A glyph reaches the screen. The showcase draws two lines of Inter, and TextPaintTest asserts where the ink landed rather than that there is some: the inked span matches Font.widthOf to within a few pixels, which is an assertion that fails by a factor of 128 if either side of the crossing is wrong.

A shaping result is size-independent. The same GlyphRun is correct at every size, which is what the paragraph cache will want when it arrives, and what FontTest.shapingIsSizeIndependent pins down.

The ABI version is now 8, and the export list has 148 entries. Four new struct layouts are registered — BLPoint, BLGlyphRun, BLGlyphPlacement, BLFontMetrics — plus five BL_GLYPH_PLACEMENT_TYPE_* constants. BLGlyphRun is the layout row carrying the most weight so far: Java writes every field of it, and a wrong size offset would have Blend2D read a byte count as a glyph count.

A Font costs two copies of the font file. HarfBuzz copies the bytes and Blend2D is pointed at a second copy this class owns, because each library holds its own. That is about a megabyte and a half per Font for Inter, and a Font per size — so the showcase’s two sizes cost three megabytes of outlines. A shared face cache is the fix and is not built; nothing above depends on its absence.

Nothing measures text for layout yet. Font.widthOf is the number a Yoga measure function would report, and no measure function calls it: the paragraph — shaping at the width Yoga proposes, breaking lines, reporting a height — is still the missing piece, and it is now the only one. The showcase places its two lines against hand-written constants because the layout tree carries no text.

Line breaking, bidi and font fallback are untouched. A GlyphRun is one run of one direction in one face. Splitting mixed-direction text into runs, choosing between the UI and emoji slots per character, and breaking a line at a legal opportunity are all still ahead, and none of them is HarfBuzz’s job.

Icons still do not draw. The Lucide table holds SVG path data and Blend2D’s path API — bl_path_* and bl_context_fill_path_* — is not bound. It was scoped out deliberately: it shares nothing with the font chain except the context, and bundling the two would have made the units question above harder to see.

Deliberately not bound. bl_font_get_design_metrics and bl_font_face_get_design_metrics — the design-unit metrics, which nothing needs now that the em grid comes from hb_face_get_upem; bl_font_shape and Blend2D’s own glyph buffer; bl_font_get_glyph_outlines, which is how a glyph becomes a path rather than ink; the stroke and _ext style variants of the glyph-run fill; and bl_font_create_from_face_with_settings, which is where variable-font axes and font features will arrive together.

ADR-0035: The catalog is the only place a ref lives

Context

Six upstream refs were written down in five places: gradle/libs.versions.toml, the CMake defaults, and three CI workflows. :natives:checkPinnedRefs compared them and failed when they drifted.

The reason given was that the Linux legs build inside a manylinux container with no JDK, so CI cannot run Gradle to read the catalog (ADR-0012). That is true and it is not the relevant constraint. Reading a version catalog does not need a JDK — it needs something that can parse a text file, and CMake is already doing exactly that a hundred lines further down CMakeLists.txt, where it reads exports/goldberry.symbols line by line.

The cost of five copies was not theoretical:

  • A ref bump meant five edits, and checkPinnedRefs caught the fifth only after a failed build.
  • example.yml was pinning Blend2D to a floating master. The check looked at three workflows and not at that one, so nothing ever compared it. It had been wrong since the file was written. It also turned out to be dead — that job builds through ./gradlew :natives:cmakeBuild, which passed the catalog’s values and ignored the environment entirely.
  • The check could only ever say “these five agree”. It could not say they were right, which is how Blend2D stayed on a floating master through four ADRs with every copy agreeing.

Decision

CMakeLists.txt reads gradle/libs.versions.toml directly. A small function scans the catalog for key = "value", anchored at the start of the line so a [libraries] entry mentioning the same word cannot match. Nothing else passes refs: not Gradle, not the workflows.

There is no default to fall back to. A ref the catalog does not name is a FATAL_ERROR naming the key. A default is a copy, and a copy is the thing being removed.

A floating ref is rejected at configure time. master, main, HEAD, latest and trunk fail with a message pointing at ADR-0030. “Pins, not ranges” was a sentence in a comment for four ADRs while example.yml floated; now it is a mechanism.

The catalog is a CMAKE_CONFIGURE_DEPENDS, so editing it re-runs configure and the new ref is fetched, rather than the previous one being kept from _deps. It is a Gradle task input too, for the same reason at the other layer.

checkPinnedRefs is inverted. It no longer compares copies — it asserts there are none: no set(GOLDBERRY_*_REF "...") in CMake, no *_REF: and no -DGOLDBERRY_*_REF in any workflow, and every key present and non-floating in the catalog. It reads every workflow rather than a hard-coded three, which is what would have caught example.yml.

Alternatives considered

Generate the workflow env from the catalog. A script, and a check that the generated file is committed. It keeps five copies and adds a generator.

Have CI run Gradle to print the refs, then pass them on. Puts a JDK in the manylinux container to read six strings out of a text file.

A separate refs.properties both tools read. Removes the drift and splits the pins from the versions catalog they belong beside, so a contributor has two files to look in and Dependabot-style tooling has one it does not understand.

Leave it, and add example.yml to the check. The smallest fix, and it treats the symptom: the next workflow would have been missed the same way.

Consequences

A ref bump is one edit. The four in this change — Yoga v3.2.1, HarfBuzz 14.3.1, SDL3 release-3.4.14, libxkbcommon 1.13.2 — were made by editing the catalog alone, and everything downstream followed.

Configuring from outside a full checkout needs a flag. The catalog is found at a path relative to the CMake source directory. -DGOLDBERRY_VERSION_CATALOG= overrides it, and the failure says so.

The bump surfaced a toolchain floor the build could not see. libxkbcommon 1.13 requires Meson >= 1.4; Ubuntu 24.04 ships 1.3.2. It failed ninety seconds in, from inside meson, naming neither Goldberry nor the pin that raised the requirement. checkToolchain now enforces version floors rather than mere presence, and example.yml installs Meson from pip rather than apt.

The test tasks did not re-run against a rebuilt library. Found while verifying this change: dependsOn cmakeBuild orders the tasks and nothing more, so a freshly built libgoldberry left every test UP-TO-DATE. Four upstreams moved and the layout verification reported green without running. Both :natives and :core now declare the library as a task input, which is the difference between “built first” and “verified against”. That was the most valuable thing this change found, and it had nothing to do with refs.

ADR-0036: The paragraph is shaped once and wrapped many times

Context

Everything M1 needed was bound and one thing was missing: nothing told Yoga how tall a piece of text was. Layout could place rectangles, text could be drawn on top of them, and the two had no relationship — the showcase positioned its lines against hand-written constants because the layout tree had nowhere to put text.

Yoga cannot see inside a leaf. Its whole contract for content it does not understand is a measure function: here is a width, how big are you? The answer is returned as a YGSize by value, from Java, called from C, several times per layout pass (ADR-0017). So the question is not only “how do we wrap text” but “how do we wrap text cheaply enough to answer that from inside a layout pass”.

The obvious implementation re-shapes: take the width, run the line breaker, shape each candidate line to measure it, count the lines. That is a shaping pass per measure call, and Yoga makes several per node per layout, and a layout runs per frame during a resize. It is the wrong order of magnitude, and it is the one thing docs/ARCHITECTURE.md §6 already flags by calling the paragraph cache “the hot path”.

Decision

Shape once, at construction. Wrap with arithmetic.

Paragraph.of(font, text) shapes the whole text into a single GlyphRun and never shapes again. Wrapping walks prefix sums over that one run, so a measure call costs a scan and no native work at all.

Two int[]s make it possible: advanceBefore[o] is the advance of every glyph whose cluster is before text offset o, and glyphBefore[o] is how many glyphs come before it. The width of any range is one subtraction; the glyph range for any line is two lookups.

This only works because shaping is in font design units (ADR-0034). A run that had a size baked into it would be specific to that size; one in design units is not, which is what makes a single shaping answer every width and every size.

A line is a slice, not a string. TextLine carries a text range and a glyph range into the paragraph’s own run. Re-wrapping produces new ranges over the same glyphs, and painting draws run[glyphStart, glyphEnd) with the pen reset — which is why Font.draw grew a range overload.

Trailing whitespace is in the text range and not in the width. A trailing space advances the pen and draws nothing, so counting it would push visible text left by a space when a line is centred or right-aligned. Selection and caret positioning want the space, so the text range keeps it.

The measure function reports the widest line, not the width it was offered — except under EXACTLY, where the parent has already decided. A paragraph that wrapped well short of its column should not claim the space it did not use, or a centred parent centres the gap.

A one-entry memo, not a map. Yoga asks for the same width repeatedly within a pass and the paint that follows asks once more, so the access pattern is a run of identical widths. One entry serves it; a map would hash a double for the same hit. The global cache §6 describes — keyed by (text, resolved style, width bucket) — needs a style system to key on and belongs with M2.

Box gained text, and a box with text may not have children. Yoga asks a measured node for its size and never lays its children out, so a box that was both would silently lose them. Refused where the box is built rather than where the layout goes quiet.

Right-to-left text is refused at construction. HarfBuzz returns those glyphs in visual order, so prefix sums accumulated in logical order would measure the wrong glyphs and the paragraph would wrap confidently in the wrong places. java.text.Bidi.requiresBidi detects it — the same class that will eventually do the run splitting — and Paragraph.of throws rather than mis-wrapping.

Amended by ADR-0218. The detection stands and the refusal does not: a paragraph does not choose its text, so throwing meant a window lost to a paste. The text is now shaped with the direction forced to LTR — right glyphs, mirrored order — and says so.

Alternatives considered

Re-shape each candidate line. Correct at every boundary, and the cost is a shaping pass inside a measure callback. It is the implementation to reach for if the approximation below ever shows.

Shape the whole paragraph, then re-shape only the final lines for painting. Half the cost of the above and most of the correctness. Worth revisiting; it buys accurate line-boundary kerning without touching the measure path, which is the part that has to be fast.

Cache globally from the start, keyed by text and width. It is what §6 describes and it needs a resolved text style to key on. Building it now would mean inventing that key before the style system exists.

Let the widget layer wrap and hand Yoga a height. Removes the upcall from the hot path entirely and moves flexbox’s job out of flexbox: the width a paragraph should wrap to is the width flexbox is in the middle of deciding.

Break inside over-long words. Every browser does it eventually, and doing it without a hyphenation dictionary means breaking mid-syllable. Overflowing is visibly wrong in a way that gets fixed; a bad hyphen is wrong in a way that ships.

Consequences

M1’s vertical slice is joined. Yoga proposes a width from C, the paragraph wraps and answers with a height through the YGSize upcall, flexbox sizes the box around that answer, and Blend2D draws the lines that were measured. The showcase’s body text re-wraps as the window is dragged, and its layout has two numbers written down — the bar’s height and the padding — with everything else coming from content.

Breaks are not re-shaped, so a line boundary keeps a kern it should drop. Each line is a slice of the whole paragraph’s shaping, so the kern between the last character of one line and the first of the next is included. The error is a fraction of a pixel at the end of a line, and it buys wrapping that costs no shaping.

Right-to-left text throws. Loud, and a real limitation: Arabic and Hebrew do not render at all rather than rendering wrongly. Bidi run splitting is the fix and is the next thing the text stack needs.

Still one font per paragraph. No fallback to the emoji slot mid-run, no style runs, no mixed sizes. Each of those makes a paragraph several runs, which is a change to how the prefix sums are built and not to the idea behind them.

M1’s exit criteria are not all met. The wrapped paragraph exists and resizes; the 60 fps measurement on three machines has not been taken, the upcall benchmark does not exist, and the paragraph cache is a one-entry memo rather than the keyed cache §6 describes. Those are what remain.

ADR-0037: What the text path costs

Context

M1’s exit criteria name two things that are not features: paragraph cache and upcall benchmarks green. Both are claims about cost, and neither could be settled by argument. docs/ARCHITECTURE.md §6 calls the paragraph cache “the hot path”; ADR-0017 proved the YGSize upcall works and never said what it costs; and ADR-0031 established the habit that settles this sort of question — get the number before optimising anything, because the obvious candidate is usually not where the time goes.

Building a cache first would have been building it blind.

Decision

Benchmarks are a tagged JUnit task that prints and does not assert. @Tag("benchmark"), excluded from check, run by ./gradlew benchmark. Nothing asserts a timing: a threshold that passes on a workstation and fails on a shared CI runner teaches nobody anything, and the number is the deliverable. This is what ADR-0028 and ADR-0031 already did informally; it is now a task.

The paragraph cache holds shaped paragraphs, keyed by (font, text), least-recently-used. It caches shaping, because shaping is the only part of the text path large enough to be worth caching. §6 specifies (text, resolved text style, width bucket): today a Font is the resolved style, and the width bucket belongs to Paragraph’s own memo because shaping does not depend on width.

The font is compared by identity. Two Fonts over the same face at the same size agree about everything measurable today and are separate native objects; sharing on the strength of that would stop being true the first time either gets a font feature or a variation axis set on it.

The numbers

Measured on linux-x64 (a VirtualBox VM), Inter at 14 points, a paragraph of about seventy words wrapping to five lines. Medians over 20 000 iterations after 2 000 warm-up.

median
wrapping, memo hit0.02 µsfree
ParagraphCache hit0.05 µsa map lookup
the YGSize upcall crossing~0.3 µsJava called from C, struct by value
wrapping, memo miss4.8 µsbreaking five lines
creating one upcall stub11.0 µsMeasureCallback.of + close
shaping56 µsFont.shape, what the cache avoids
Paragraph.of61 µsshaping, plus building the prefix sums
loading a Font650 µstwo face parses (ADR-0034)

And a whole layout pass over the same tree:

median
layout pass, no text12.5 µs
layout pass, one paragraph40.4 µs

What the numbers say

The upcall is cheap, and that settles a question ADR-0017 left open. A crossing costs about a third of a microsecond — a sixteenth of a wrap, a two-hundredth of a shaping. The fiddliest thing the toolkit asks of FFM is also one of the cheapest things in the text path. Yoga may call it as often as it likes.

Wrapping is free when memoised and cheap when not. 0.02 µs against 4.8 µs, and the miss only happens at a width not seen since the last one. The one-entry memo in ADR-0036 is enough; a bigger cache there would be optimising something already at 20 ns.

Shaping is the only thing worth caching, and the cache is 1200× cheaper than it. 56 µs against 0.05 µs. This is what justifies ParagraphCache existing at all — and equally, what says a cache of layouts would be pointless.

The biggest cost of text in a layout pass is not text. Adding one paragraph takes a pass from 12.5 µs to 40.4 µs, and of those 28 µs, 11 µs is creating an upcall stub — an Arena and a MethodHandle bound into native code — with the rest being two or three memo-miss wraps. BoxPainter builds a fresh Yoga tree per frame and therefore a fresh stub per text box per frame, which it says of itself is “the wrong shape for a real toolkit and the right shape for a join that exists to be exercised”. Now there is a number on it. The retained render tree (ADR-0004) removes it by keeping the node, and this is the first measurement that makes that a performance argument rather than only a design one.

Painting now dominates a frame, which reverses ADR-0031. Over 119 consecutive frames at 960×640 with the wrapped paragraph on screen:

medianp95max
acquiring the buffer0.18 ms0.40 ms7.76 ms
painting5.10 ms10.65 ms19.18 ms
presenting1.92 ms4.10 ms6.96 ms
total7.86 ms14.18 ms23.22 ms

Three frames of 119 exceeded the 16.67 ms a 60 fps budget allows.

ADR-0031 measured painting at ~1.3 ms and present at ~10 ms and concluded that present dominated by an order of magnitude. Text moved paint to 5.1 ms — glyph rasterization is real work — and paint is now 2.7× present.

But only half of that reversal is text. ADR-0031 measured under Wayland; these frames are X11, because the showcase’s Wayland run had just taken the compositor down with it and X11 was the safe way to measure. Most of Wayland’s ~10 ms present was waiting on the compositor, and X11 does not wait the same way. So: the paint increase from 1.3 ms to 5.1 ms is attributable to text; the present decrease from ~10 ms to 1.9 ms is attributable to the driver, and nothing here should be read as having made present faster. The like-for-like Wayland measurement is still owed.

Alternatives considered

JMH. The right tool, and it means a new plugin, a new subproject, a forked-JVM harness and a build dependency, to measure a handful of operations whose costs differ by three orders of magnitude. System.nanoTime around a warmed loop distinguishes 0.02 µs from 56 µs perfectly well. JMH earns its keep when the answer is within noise of the question, and none of these are.

Assert the timings in CI. Tempting — “upcall benchmarks green” sounds like a passing test. It would be a test that fails when the runner is busy, gets a tolerance widened until it cannot fail, and then means nothing.

Cache layouts as well as shapings. Wrapping is 20 ns on a memo hit. There is nothing there to save.

Cache by font equality rather than identity. Would let two equivalent Fonts share, and would silently share between two fonts that stopped being equivalent.

Consequences

M1’s benchmark and cache criteria are met. ./gradlew benchmark produces the table above, and ParagraphCache is built, tested and justified by a measurement rather than by §6 saying so.

The cache has no consumer yet, and that is stated rather than hidden. Nothing rebuilds a widget tree, so nothing currently re-shapes the same text. It exists because the number says it will be needed the moment something does, and because building it after the render tree would mean discovering the 56 µs then.

Two follow-ups now have numbers attached. The retained node saves 11 µs per text box per frame; Blend2D’s thread_count is worth revisiting now that paint is the largest term in a frame rather than the smallest — ADR-0031 explicitly parked it as “only matters if paint ever becomes the bottleneck”, and on these numbers it has.

M1’s remaining criterion is the 60 fps claim itself. These frames come from one machine, and it is a VirtualBox VM with software rasterization on X11. The milestone asks for Linux, macOS and Windows. What can be said from here is that a 960×640 frame with a wrapped paragraph fits in the budget with a factor of two in hand on the median and not on the p95, and that the next thing to measure is Wayland on real hardware.

ADR-0038: The superbuild download is not a hang

  • Status: Accepted
  • Date: 2026-08-16
  • Relates to: docs/ARCHITECTURE.md §3.2, ADR-0008, ADR-0012

Context

The first ./gradlew build on a fresh checkout was reported as hung, and killed. It was not hung. It was cloning.

The superbuild fetches five upstreams — Blend2D, AsmJit, Yoga, HarfBuzz and SDL3 — with FetchContent. Shallow, but still about 330 MB of working tree, of which HarfBuzz alone is 161 MB and SDL3 116 MB. On the machine that prompted this record that was three minutes, and CMake printed nothing for the whole of it. Two defaults conspire:

  • FETCHCONTENT_QUIET is TRUE by default, which swallows the populate step’s output.
  • git clone writes no progress when stdout is not a terminal, and under Gradle’s Exec it never is.

So the console showed > Task :natives:cmakeConfigure and sat there. There is no way for a newcomer to distinguish that from a deadlock, and the reasonable response — Ctrl-C — is the one that guarantees the next attempt starts over.

It started over more often than it needed to for a second reason. FetchContent puts its clones in _deps under the CMake binary directory, which here lives inside natives/build/. ./gradlew clean is therefore a 330 MB re-download, and nothing said so.

Measured afterwards on that same machine, with the sources already present: configure 32.8 s, compile and link 27.1 s. The build is a minute of work behind a download that can be five times longer than it and says nothing.

Decision

Make the download audible, and stop discarding it.

Audible. FETCHCONTENT_QUIET FALSE in the superbuild’s CMakeLists.txt, and GIT_PROGRESS TRUE on every declaration — the latter passes --progress, which is what makes git report into a pipe. Before the silence rather than after it, cmakeConfigure logs at lifecycle what is about to happen, how big it is, where it is going, and roughly how long it takes. That message prints only on a cold cache, so it is information the first time and not noise the twentieth.

Kept. Gradle points FETCHCONTENT_BASE_DIR at natives/.deps/<target-id>, outside build/, so clean no longer touches it. :natives:cleanNativeDeps discards it on purpose. Per target rather than one shared directory, because the base directory also holds the generator-specific <name>-build and <name>-subbuild trees; two targets sharing one would fight over them.

Two corrections came out of the same reading:

The pinned refs are task inputs now. They reached CMake as -D arguments but were declared nowhere, so cmakeConfigure was up to date after a version bump in gradle/libs.versions.toml and the build quietly kept building the old revision. They are an inputs.property each.

Superseded by ADR-0035, which landed in parallel with this and was merged after it. The refs no longer reach CMake as -D arguments at all — the superbuild reads gradle/libs.versions.toml itself — so there is nothing left to mirror into an inputs.property. The catalog is declared as an input to cmakeConfigure instead, which carries the same guarantee for one file rather than six properties, and carries it for a ref this build file has never heard of. The bug described above, and the reasoning about it, stand; only the mechanism changed.

cmakeConfigure no longer declares the whole work directory as its output. That directory contained _deps — 7,597 files — and Gradle fingerprinted all of it on both sides of every invocation. Its real outputs are CMakeCache.txt and build.ninja. Moving _deps out of build/ shrinks the tree anyway; declaring the two files that actually mean “configured” is the honest description.

Alternatives considered

Leave it and document it in the README. The README already says the native build needs CMake, Ninja and a toolchain. It did not say the first run downloads a third of a gigabyte in silence, and adding a sentence would help only the people who read documentation before their first build — not the people who have already hit Ctrl-C. The fix belongs where the silence is.

Vendor the sources, or commit a lockfile of tarballs. Removes the clone entirely and makes builds reproducible offline. It also puts 330 MB of other people’s code in the history, and ADR-0012 already fixes reproducibility at the pinned-ref level. Not worth the repository weight for a one-time cost.

Release tarballs instead of git clones. Materially smaller than a shallow clone for HarfBuzz and SDL3, whose bulk is test fixtures. It does not work for Blend2D and AsmJit, which are pinned at master and so have no tarball; a superbuild that fetched two upstreams one way and three another would be harder to read than what it saves. Worth revisiting when those two get pinned to releases.

A shared cache across targets, in ~/.cache or the Gradle user home. One copy per machine instead of one per checkout. Rejected for the -build and -subbuild collision above, and because a cache outside the working tree is a cache people forget they have — natives/.deps/ is visible next to the thing it belongs to, and .gitignore covers it.

Turn the whole thing off by default and make the native build opt-in. -Pgoldberry.skipNative=true already exists for Java-only contributors. Making it the default would mean the ordinary ./gradlew build no longer tests what it claims to test, which is the trade ADR-0012’s wiring deliberately made the other way.

Consequences

The configure step is noisier. It prints git’s progress meter and a paragraph of explanation on a cold cache, where before it printed one line. That is the point, and it is the cost: anyone parsing configure output now has more to skip.

clean no longer means clean. natives/.deps/ survives it, which is a small surprise in the other direction — mitigated by naming cleanNativeDeps in the message that creates the cache, but a surprise nonetheless. A stale cache is still correct, because FetchContent re-checks the ref against the recorded stamp; it only costs disk.

Version bumps in gradle/libs.versions.toml now actually re-configure. That is a bug fix, and it means the next bump will be slower than the last one was — it will do the work it was previously skipping.

The three-minute figure is one machine on one connection, and the message says “typically a few minutes” rather than a number, because the honest answer is that it depends on the link. What is not machine-specific is the ratio: the download dominates, and the build everyone assumes is slow takes 27 seconds.

ADR-0039: macOS needs the first thread

Context

./gradlew run on macOS failed:

BackendException: SDL could not initialize its video subsystem
Caused by: SdlException: SDL_Init failed: No available video device

That message is wrong in the way that costs the most time: it points at the build. “No available video device” reads as a missing driver, a library linked without Cocoa, a headless session — and the superbuild had just been changed, so the superbuild was where everyone looked. It was not the superbuild.

Three probes settled it, and are worth recording because each one eliminated a whole class of explanation:

  1. A plain C program linked against the same libSDL3.a reported three compiled-in drivers — cocoa, offscreen, dummy — and initialized cocoa. So SDL 3.2.0 builds correctly on macOS 26, and the window server was reachable.
  2. The same C program dlopening libgoldberry.dylib and calling SDL_Init through it also succeeded. So the packaging — static archives, hidden visibility, -dead_strip, a 30-symbol export list — is sound, and _COCOA_bootstrap really is in the binary.
  3. The JVM with -XstartOnFirstThread succeeded, and without it failed.

macOS requires AppKit to be driven from the process’s first thread. The java launcher runs main on a secondary thread unless given -XstartOnFirstThread, so SDL’s Cocoa driver refuses to create a device; no other driver is usable; and SDL reports the only thing it knows, which is that nothing was available. Nothing in that chain mentions threads.

This had not been caught because the macOS CI leg builds and links the library and runs the :natives tests, but does not run the showcase — the leg that opens a window runs on Linux under Xvfb. macOS was verified as far as “it links”, which is exactly as far as the failure was invisible.

Decision

Two changes, at two levels.

The showcase passes the flag. example/build.gradle appends -XstartOnFirstThread to applicationDefaultJvmArgs when the host is macOS. Conditional rather than unconditional: no other platform has the flag, and a build file that hands every platform a macOS-only argument invites the question of why.

The toolkit explains itself. When SDL_Init fails, Sdl3Backend checks whether it is on macOS without the flag and, if so, appends what the flag is and why it is needed. The check reads JAVA_STARTED_ON_FIRST_THREAD_<pid>, which the macOS launcher sets to 1 when it honours the flag — the only way to ask this question from Java.

Deliberately a diagnosis appended to a failure, not a precondition checked up front. The environment variable is set by the launcher, so a JVM embedded through JNI_CreateJavaVM on the real main thread would lack it and would nonetheless work. Refusing to start on that evidence would break a working configuration to protect against a broken one. Everything is therefore phrased as “this is probably why”, and is only ever said after SDL has already said no.

Alternatives considered

Fail fast, before touching SDL. A clearer failure, one step earlier, in the style of :natives:checkToolchain. Rejected for the embedded-JVM false positive above: checkToolchain tests for tools that are genuinely absent, whereas this would test for a launcher flag that is only a proxy for the thing that matters. A proxy is fine for explaining a failure and not fine for causing one.

Re-exec the JVM with the flag when it is missing. Some toolkits do this. It means a library deciding to restart the application’s process, which is a larger power than a UI toolkit should take, and it interacts badly with anything that already owns the process — a test runner, an IDE, an embedder.

Run SDL on a thread we control and pretend. There is no such thread. AppKit’s requirement is the process’s first thread specifically, not “one consistent thread”, so no amount of confinement inside Goldberry can satisfy it. ADR-0020’s one-UI-thread model is compatible with this and does not replace it: the UI thread must be the first thread on macOS.

Document it in the README and stop. The README now does say it, and that helps the person setting up. It does not help the person who already has the stack trace, which is everyone who hits this — and the stack trace was actively misleading. The message is where the fix belongs.

Consequences

./gradlew run works on macOS. That is new; it had never worked, and the status table’s claim that the showcase opens a window was true only on Linux.

Any application embedding Goldberry on macOS must pass -XstartOnFirstThread itself. Goldberry cannot do it for them — a JVM flag is fixed at launch. This is the same requirement LWJGL, GLFW and SWT place on their users, so it is at least a familiar one, but it is a real constraint on the “just add the dependency” story and it belongs in the getting-started documentation for as long as macOS is a supported target.

-XstartOnFirstThread has a further consequence not explored here: it makes the first thread the AppKit thread, which is what AWT/Swing also want. An application mixing Goldberry with Swing on macOS may find they contend. Nothing tests that, and nothing should be claimed about it.

The macOS CI leg still does not run the showcase, so this exact failure could return without CI noticing. Closing that hole means running the example on the macOS runner — worth doing, and not done here.

ADR-0040: Find the native tools by absolute path

  • Status: Accepted
  • Date: 2026-08-16
  • Relates to: docs/ARCHITECTURE.md §3.2, ADR-0012, ADR-0038

Context

A build failed like this:

> Task :natives:checkToolchain
Native toolchain OK; host target is macos-aarch64.

> Task :natives:cmakeConfigure FAILED
> A problem occurred starting process 'command 'cmake''
  Exec failed, error: 2 (No such file or directory)

Two tasks, one second apart. The first ran cmake --version successfully and said so. The second could not find cmake. It was installed, executable, and on the PATH the build itself printed — /opt/homebrew/bin/cmake, a Homebrew symlink, mode r-xr-xr-x.

The two tasks look the executable up differently, and that is the whole bug.

checkToolchain probed with a bare ProcessBuilder and no environment override. That ends in execvp, which searches the PATH the child inherits — the JVM’s own. Gradle’s Exec passes an explicit environment map to every process it starts. That switches the JDK to execvpe, which searches parentPathv: a copy of PATH taken from the JVM’s native environ at start-up, and never refreshed.

In an ordinary program those two are the same string. In a Gradle daemon they are not. The daemon outlives the shell that started it and serves builds launched from anywhere, so Gradle rewrites what System.getenv() reports to match the current client. It cannot rewrite the native environ underneath, and it certainly cannot rewrite a snapshot the JDK took before Gradle’s own classes loaded.

The daemon on the machine that prompted this record had been started by IntelliJ IDEA. Reading its real environment:

PATH=/usr/bin:/bin:/usr/sbin:/sbin

launchd’s default, with no Homebrew in it — while the same daemon reported the full shell PATH to the build. Every Exec in the build was therefore resolving names against launchd’s four directories, and had been since the daemon started.

Bisecting ProcessBuilder inside that daemon confirms it exactly:

working directory setenvironment map setresult
nonofinds cmake 4.3.4
yesnofinds cmake 4.3.4
noyesExec failed, error: 2
yesyesExec failed, error: 2

The environment map is the trigger. The working directory is innocent.

Three things make this worse than an ordinary missing tool:

  • The error names a file that exists. Every obvious check a developer runs — which cmake, cmake --version, ls -l — passes. So does Gradle’s own toolchain check, one line above the failure.
  • ./gradlew --stop fixes it, which makes it look intermittent. The next build from a terminal starts a daemon with a good PATH; the next build from the IDE starts one with launchd’s.
  • checkToolchain was actively misleading. It exists to fail early with instructions, and here it certified a toolchain that the very next task could not use. A check that disagrees with the thing it is checking is worse than no check, because it is believed.

That last point is the one worth generalising. The check and the use were asking two different layers of the JDK the same question and getting two different answers. Nothing about a PATH was going to keep them in step.

Decision

Resolve every native tool to an absolute path, once, and have both the check and the use run that.

execvpe only consults parentPathv for a name with no / in it. An absolute path skips the lookup entirely, so the stale snapshot stops mattering — not worked around, not made less likely, but removed from the code path.

ToolResolver, in build-logic. Ordinary Java with unit tests, for the reason :assets is a module and not a script (ADR-0033): locating a tool across three platforms is real logic, and this failure is not one anybody reproduces by reading. It searches the client’s PATH first — read through providers.environmentVariable, which is the accurate one — and then the directories the supported installers actually use: /opt/homebrew/bin, /usr/local/bin, /opt/local/bin, /Applications/CMake.app/Contents/bin, ~/.local/bin for pip install --user, and the Windows equivalents. PATH wins, because a contributor who put a newer CMake ahead on their PATH meant it.

natives/build.gradle resolves cmake, ninja and meson at configuration time, because Exec.commandLine needs a value then. A tool that is absent resolves to null rather than failing there; checkToolchain still owns that error and now says considerably more.

-DCMAKE_MAKE_PROGRAM is passed explicitly. Otherwise the same disagreement reappears one level down: checkToolchain finds a Ninja in a conventional directory, and CMake — searching only the PATH — does not.

-Pgoldberry.cmake=<path> (also -D, also ninja and meson) overrides the search. Taken at its word, and failing if it is wrong, rather than silently falling back to a copy on the PATH: an override that can resolve to something else is not an override.

checkToolchain prints what it found.

Native toolchain OK; host target is macos-aarch64.
  cmake  4.3.4    /opt/homebrew/bin/cmake
  ninja  1.13.2   /opt/homebrew/bin/ninja

When a machine has two CMakes — a system one, a Homebrew one, a pip upgrade shadowing both — which one did it use is the first question, and the answer costs a line. It also makes the version floors honest: they now measure the executable the build will run rather than whichever copy a bare-name lookup reached.

Verified by rebuilding under the failing condition itself, with PATH=/usr/bin:/bin:/usr/sbin:/sbin and nothing else — a stricter test than the original, where the client PATH was at least correct. Configure, compile, link and install all succeed.

Alternatives considered

Tell people to run ./gradlew --stop, or to launch the IDE from a shell. The actual advice given for this class of bug, and it works. It is also advice that has to be given again every time, to everyone, for a failure whose message points at a file that is present — and it leaves the build’s own toolchain check lying. Fixing the daemon’s environment fixes one machine until the next GUI launch; fixing the lookup fixes the build.

Set the Exec tasks’ environment['PATH'] explicitly. One line, and it would have worked here. It does not survive contact with a PATH that is genuinely missing the tool — a pip install --user cmake, or a CMake.app install — and it leaves execvpe’s snapshot in the path, so the next symptom is the same symptom. Resolving to an absolute path is strictly more of a fix for about the same amount of code.

Use a CMake toolchain plugin from the Gradle plugin portal. Handles discovery, and rather more besides. ADR-0012’s superbuild wiring is deliberately hand-written so that CI can invoke CMake directly on runners with no JDK, and a plugin that owns the invocation makes that harder rather than easier. Not worth a dependency to replace one resolver.

Fail checkToolchain when the daemon’s environ and System.getenv() disagree. Detects exactly this situation and explains it, which is tempting. But it diagnoses a condition that no longer breaks anything once the tools are resolved absolutely, and it would fire on daemons that are working perfectly well. A build that refuses to run because of something it has already handled is a worse experience than the one it replaced.

Put the resolver in natives/build.gradle as a closure. Where the previous probing lived, and no new files. It is also the one piece of this build that is worth a test — three platforms, symlinks, executable bits, empty PATH entries — and Groovy in a build script is the one place in this repository where a test cannot reach.

Consequences

natives/build.gradle no longer runs a tool it cannot name. That is the point, and the cost is one more indirection between reading the build file and knowing what gets executed: commandLine now shows a variable where it used to show 'cmake'.

The conventional-directory list is a maintenance surface. It is a list of places that were true when it was written. A new installer, or a Homebrew prefix change, means editing it — mitigated by -Pgoldberry.cmake, which is the escape hatch for exactly that, and by PATH still coming first, which means the list is only ever consulted when the PATH has already failed.

build-logic has tests now, and :natives:check runs them through gradle.includedBuild('build-logic').task(':test'). A separate build’s tests do not run just because this one does, and a test nobody runs is a test that does not hold. It also means check now depends on a second build, which is a link that did not exist before.

A tool found somewhere the PATH never mentioned is a slight surprise. A contributor who deliberately removed Homebrew from their PATH will still get Homebrew’s CMake. The alternative is failing on a machine that has a perfectly good toolchain installed, which is the failure this record is about; checkToolchain printing the absolute path is what keeps the surprise visible.

CMAKE_MAKE_PROGRAM is pinned in the CMake cache to the Ninja resolved at configure time. Moving Ninja now needs a reconfigure rather than being picked up silently — which is the same trade the absolute cmake makes, and the same answer: a build that changes tools without saying so is the harder problem.

Addendum, 2026-08-16: the superbuild ran a different meson

This record’s own failure mode survived one layer below where it was fixed. checkToolchain resolved meson to an absolute path, checked its version against libxkbcommon’s floor, and printed it — and the superbuild then ran a bare meson, because CMakeLists.txt spelled the ExternalProject commands that way. Three processes down from Gradle, through CMake and Ninja, that resolves against whatever PATH the chain happens to carry.

The symptom was the shape this record describes: checkToolchain printing “meson 1.9.1 /home/…/meson19” immediately followed by the build failing on meson 1.3.2’s build directory. The check and the use were asking different things.

Fixed the same way as Ninja: Gradle passes -DGOLDBERRY_MESON=<absolute path> at configure time, and the ExternalProject uses it, falling back to a bare meson only for a CI leg that drives CMake directly. The general lesson is narrower than “use absolute paths”: it is that any tool named inside a generated build — a CMake command, a Ninja rule, a script — is outside the reach of a check that only looks at what Gradle itself will spawn.

Two other things were found while getting there and are worth writing down:

  • The message this check produces when a tool is too old had never run. checkToolchain built it with a + at the start of a continuation line, which Groovy reads as unary plus on a String — so the branch that was meant to say “meson 1.3.2 is too old, here is how to upgrade” threw No signature of method: java.lang.String.positive() instead. A helpful error path that nothing exercises is not a helpful error path.
  • Upgrading meson is not enough on its own: an ExternalProject build directory configured by an older meson has to be removed, because meson refuses a build.dat written by a version it does not recognise. That is upstream behaviour and correct; it is recorded because the error names a file rather than a cause.

ADR-0041: Three platforms, four artifacts, two backends

  • Status: Accepted
  • Date: 2026-08-16
  • Amends: ADR-0003, ADR-0012
  • Relates to: docs/ARCHITECTURE.md §1, §3.2, §4, §15, §16

Context

Goldberry’s scope has carried three commitments that were written down before anything ran, and each of them costs something on every commit.

A third backend. ADR-0003 listed three implementations behind the Backend SPI: sdl3, headless, and one for an OS compositor that does not exist yet in any form this repository can build or test against. It appeared in the layer diagram, in the SPI’s own javadoc, in the damage-rect documentation, in the keyboard-translation notes, in the theme layer, in the popup and tray designs, and in the M4 milestone — nine places in the design document alone, all of them describing a consumer that has never linked. The --os-* theme variables existed solely for it.

Six native artifacts. Two of them, windows-aarch64 and macos-x64, exist by cross-targeting inside a native toolchain: MSVC at -A ARM64, Xcode at CMAKE_OSX_ARCHITECTURES=x86_64. Neither has ever been built — the Windows workflow has never run at all, and the macOS one runs both legs. Both are rare in the audience this toolkit is for. Windows on ARM is a small and shrinking share of Windows desktops; Intel Macs are a shrinking share of Macs and the last one shipped in 2023. Each costs a CI leg on every push, a row in every matrix document, and — the part that actually bites — a second artifact to explain when the export machinery breaks differently on it.

The cost is not the compute. It is that every one of these appears in prose, and prose about something nothing exercises drifts into fiction. Three of the open questions in book/src/status.md were about paths that had never run.

Decision

Goldberry targets Linux, macOS and Windows, and nothing else.

The backend list is two: sdl3 for all three desktop platforms, headless for tests. The compositor backend is removed from the SPI’s contract, the layer diagram, the milestone ladder and the theme layer. ADR-0003’s reasoning is untouched — SDL3 remains the permanent desktop windowing layer and the SPI still exists to keep the platform boundary in one place, which headless alone is enough to enforce.

The distribution matrix is four rows:

TargetRunnerOutput
linux-x64ubuntu-24.04 + manylinux_2_28_x86_64libgoldberry.so
linux-aarch64ubuntu-24.04-arm + manylinux_2_28_aarch64libgoldberry.so
windows-x64windows-2022goldberry.dll
macos-aarch64macos-14libgoldberry.dylib

One runner per artifact, and no cross-targeting anywhere. ADR-0012’s mechanism — native runners plus a manylinux container for the glibc floor — is unchanged and is precisely what a dropped row would come back through.

NativePlatform enforces the matrix rather than describing it. Its compact constructor refuses macos-x64 and windows-aarch64, so the pair fails where it is named, with a message that lists the four rows. The alternative was an UnsatisfiedLinkError from NativeLibrary three layers later, naming a resource path instead of a decision.

Alternatives considered

  • Keep the compositor backend as a documented aspiration. Rejected. The problem was never that it might be built; it is that nine paragraphs of design document described how it would behave, and none of it could be checked. The SPI is the thing worth keeping, and it survives intact — a backend that arrives later arrives against the same interface, which is the whole point of having one.
  • Keep the two rare targets but stop testing them. Rejected: an artifact published to Maven Central that CI does not verify is worse than no artifact. A user who downloads goldberry-natives-macos-x64 has a right to expect it loads.
  • Keep macos-x64 only, since Intel Macs still exist in the wild. The strongest of the three, and rejected on the same grounds as the other: Rosetta 2 does not help, because an x86_64 JVM on Apple Silicon would need this artifact and an aarch64 JVM would not, so the row buys nothing on current hardware and only serves machines Apple stopped selling.
  • Let NativePlatform keep accepting all six pairs and fail at load. Rejected: classifier() would keep producing macos-x64, a string that now names nothing, and the error would surface as a missing resource rather than as an unsupported platform.

Consequences

  • Two CI legs disappear. The Windows workflow becomes a single job, and the macOS one stops building a dylib nobody downloads.
  • NativePlatform can now throw where it previously could not. Constructing the record — not just of() — validates the pair. Any code that built a platform to ask a question about it, rather than to load a library, has to stop at the two removed pairs. There is no such code today; the tests assert both directions so there cannot be tomorrow without noticing.
  • An Intel Mac and a Windows-on-ARM machine can no longer run Goldberry, and the failure is at start-up with a clear message rather than at link time with an opaque one. That is the intended trade and it is a real loss for those users.
  • Reversing either row is cheap and stays cheap: the cross-targeting flags are recorded in ADR-0012’s amendment note and in the workflow comments, one nativeTargets entry and one switch arm bring a row back.
  • Reversing the backend cut is more expensive — the design document no longer describes shm buffers, dmabuf acceptance, or --os-* theme mapping, and that prose is gone rather than parked. The SPI it would attach to is not.
  • The milestone ladder’s M1 criterion becomes measurable. It used to name a specific laptop model and a compositor that does not run yet; “60 fps on Linux, macOS and Windows” names three things CI can be pointed at.

ADR-0042: Blend2D’s workers, and how many

Context

ADR-0002 chose Blend2D partly because it can rasterize across threads. Nothing has ever used that. ADR-0031 measured a frame at 960×640 — paint ~1.3 ms, present ~10 ms — concluded present dominated by an order of magnitude, and parked thread_count as “only matters if paint ever becomes the bottleneck”. ADR-0037 then measured a frame with text in it: buffer 0.18 ms, paint 5.10 ms, present 1.92 ms, total 7.86 ms median and 14.18 ms at p95 against a 16.67 ms budget. On those numbers it had.

The knob itself is one field. BLContextCreateInfo::thread_count is already in the layout table and already checked against the compiled library; bl_context_init_as already takes the struct. What was missing was not a binding. It was a number, and a reason for it.

Decision

BlendContext takes a worker count, and :core decides what it is.

:natives gains BlendContext.on(image, scale, threadCount). Zero renders synchronously on the calling thread, which is what every existing caller keeps getting. Anything higher renders asynchronously: draw calls are recorded, workers execute them over horizontal bands, and bl_context_end is where the calling thread waits. Frame.end() already ran before present for exactly this reason (ADR-0031) — a rule that was a caution and is now the mechanism.

Threads are requested, not demanded. If Blend2D refuses asynchronous mode — a thread pool at its limit, a process out of threads — the context is begun synchronously instead and threadCount() reports zero. The fallback is in Java rather than through BL_CONTEXT_CREATE_FLAG_FALLBACK_TO_SYNC, so it can be logged and tested, and so the create-info stays a struct with one field set rather than a struct with a magic constant nothing in the layout table checks.

PaintThreads in :core is the policy, and -Dgoldberry.paint.threads=N overrides it. The rules are three, and each is a measurement:

  1. Zero or at least two, never one. A single worker pays for the command queue and the hand-off and gets no parallelism back. At 640×480 it measured slower than synchronous — 0.499 ms against 0.478.
  2. Cap at four. Four was best or tied-best at every size measured, and eight was worse at every size but the smallest.
  3. Nothing under 400×300. Below that the gain is under 50 µs, inside the run-to-run spread, and not worth waking four threads sixty times a second.

The automatic count is therefore clamp(cores - 1, 0, 4), rounded down to zero when that leaves one. A one- or two-core machine paints synchronously.

The numbers

./gradlew :core:benchmark, PaintBenchmark. The showcase’s own scene — a bar, a sidebar, a wrapped paragraph — painted whole per sample, Frame construction and end() included, because that is the unit Window.paint pays. 200 samples after 60 warm-up frames, on linux-x64 with 8 logical processors.

Medians, in milliseconds:

Surface0123468
240×1200.2400.2230.2380.1960.1940.1910.212
400×3000.4120.4380.3140.3010.2700.2690.279
640×4800.4780.4990.4040.3140.3020.3160.323
960×6400.4730.4810.3550.3370.3370.3280.357
1920×10800.5940.5860.4800.3950.3800.3910.415
3840×21606.0344.2453.1513.4822.3372.5002.763

Bold marks the two facts the policy is built on: one worker losing to none, and four being the floor of the curve.

The shape is the same everywhere and the size is not. At 960×640 four workers save 136 µs — real, and 0.8% of a frame budget. At 3840×2160 they save 3.7 ms, which is 22% of one. This decision is worth little today and a great deal at 4K, which is the honest way to hold it: it is bought now because the frame that needs it is a window resize away, not because 136 µs was the problem.

The number that did not match

ADR-0037 measured paint at 5.10 ms for a 960×640 frame with text. The same size and scene here is 0.473 ms. That gap was left open when this record was first written, with the borrowed compositor buffer as the suspect.

It was not the buffer. ADR-0045 chased it down: the cause is present, which leaves the next paint about four times more expensive, and the benchmark never presents. The policy above is unaffected — the in-app sweep reproduces the same shape, one worker losing to none and four winning — but the absolute numbers here are rasterization in isolation and a real frame costs more.

The in-app figures, measured the same day on the same machine over 300 frames:

Workersin-app paint (median)
02.856 ms
13.005 ms
22.363 ms
42.146 ms
82.240 ms

So four workers are worth 1.33× on a real 960×640 frame, against 1.4× in the benchmark. The decision stands and the claim is now measured rather than argued.

Alternatives considered

  • Leave it synchronous. The runner-up, and defensible on the 960×640 numbers alone. Rejected on the 4K row: a 22% saving on a surface a user can produce by maximizing a window is not something to leave on the table, and the cost of taking it is one field and a fallback.
  • BL_CONTEXT_CREATE_FLAG_FALLBACK_TO_SYNC. The C way to say the same thing. Rejected: it is a constant this project has not verified against the compiled library, in a struct field the layout table does not check the values of, and it would make the fallback invisible from Java. Catching the refusal costs four lines and can be logged.
  • One worker per core, uncapped. Rejected by the table: eight workers on an eight-processor machine were worse than four at every size but 240×120, and markedly worse at 4K (2.763 against 2.337).
  • Thread every surface, however small. Rejected, but narrowly — 240×120 did not measure slower threaded. It buys 46 µs and wakes four threads to do it, and a menu or a tooltip is exactly the surface a battery notices.
  • Decide the count in :natives. Rejected on the module boundary that has held so far: :natives is mechanism. How many threads a frame deserves is a question about surfaces and machines, which is :core’s to answer.

Consequences

  • Painting is now concurrent, and the rule that made it safe was already there. Frame.end() before present was documented as a caution against a context with work in flight; it is now the synchronization point. Anything that reads pixels before end() returns is a bug that did not exist yesterday — ThreadedPaintTest asserts a threaded frame is pixel-identical to a synchronous one, at 1, 2, 3, 4 and 8 workers, every pixel compared rather than sampled, because a band seam is one wrong row in three hundred.
  • Up to four threads now wake per frame on a machine with three or more cores. On a laptop drawing an idle window at 60 fps that is a real power cost, and the size floor is the only thing limiting it. Damage tracking would limit it far better, by not painting the frame at all — this makes that work more valuable, not less.
  • The p95 is not improved as much as the median. At 3840×2160 the median falls 2.6× and the p95 only 2.6× as well (10.987 → 4.163), which is better than feared; at 960×640 the p95 barely moves (0.550 → 0.457). The tail is where M1’s 60 fps claim lives, so this helps that claim at 4K and hardly at all at the size it was measured.
  • A new way for a frame to be slow. With workers, a frame’s cost includes waiting for the slowest band. A machine whose cores are busy with something else will now see paint times that depend on what else is running, where synchronous painting only competed for one core.
  • The in-app measurement is owed. Taken, and it moved the whole frame of reference rather than just this number — ADR-0045.

ADR-0043: Icons are stroked paths, and SVG is the format

  • Status: Accepted
  • Date: 2026-08-16
  • Relates to: docs/ARCHITECTURE.md §6; ADR-0033, ADR-0034

Context

ADR-0033 put Lucide’s 1544 icons in goldberry-core: fetched at build time, pinned by checksum, compiled by :assets into a table of SVG path data in a 24×24 box. ADR-0034 then drew a glyph and closed with the note that “the icon half is untouched: the Lucide table holds SVG path data and Blend2D’s path API — bl_path_* and bl_context_fill_path_* — is not bound. It was scoped out on purpose, because it shares nothing with the font chain except the context.”

That is still true, and it is what makes this a decision rather than a chore. Two things had to be settled.

An icon is a stroke, not a fill. Lucide is drawn as 2px round-capped, round-joined strokes on a 24×24 grid, with no fill at all. Most of its shapes are not closed — check is three points and two line segments. Filling that path produces a triangle. So the stroke options are as much a part of an icon as its geometry is, and bl_context_fill_path_* alone would have been the wrong half of the API to bind.

Something has to read SVG path data. Blend2D has no path-data parser; it has commands. The table is d attributes, and their grammar is not whitespace-separated numbers — 1.5.5 is two numbers, 1-2 is two numbers, and A5 5 0 011 1 packs two flags and a coordinate into 011 because SVG defines the arc flags as single characters. A parser that split on whitespace and commas parses most icons and quietly mangles the rest.

Decision

Bind the path commands SVG has, and no more. Seventeen symbols: the path lifecycle, move_to, line_to, quad_to, cubic_to, smooth_quad_to, smooth_cubic_to, elliptic_arc_to, close, the two stroke-and-fill calls, and the three stroke options. Every SVG command maps onto one of them:

SVGBlend2D
M L H Vbl_path_move_to, bl_path_line_to
C Qbl_path_cubic_to, bl_path_quad_to
S Tbl_path_smooth_cubic_to, bl_path_smooth_quad_to
Abl_path_elliptic_arc_to
Zbl_path_close

The two rows worth arguing about are the last three:

  • A maps directly. Converting an elliptic arc to cubics is a page of arithmetic with four degenerate cases — a zero radius, an out-of-range radius, coincident endpoints, a rotation. Blend2D has that code and is tested on it. Writing it again in Java to avoid one binding would be trading a symbol for a class of bugs that only show on the icons that use arcs, which is most of the rounded ones.
  • S and T map directly. Blend2D reflects the previous control point itself, against the command it recorded. A caller tracking “the last control point” in Java agrees with it until a Z or a bare M intervenes, and then silently does not.

SvgPath in :core is a reader, not a geometry library. It scans a command letter, scans its arguments with SVG’s number grammar, and calls the corresponding method. Malformed data is refused with the index and a bounded excerpt rather than half-drawn: the icon set is a checksummed archive compiled by our own :assets, so a parse failure means the compiler emitted something the reader cannot read, which is a build problem worth hearing about.

An icon belongs to a size, the way a Font does and for the reason ADR-0034 gives. Icon.bundled(name, size) parses the 24×24 data pre-scaled, so the coordinates handed to Blend2D are the ones it rasterizes and there is no transform at draw time. The stroke width scales with it — Lucide’s 2px in a 24×24 box, so a 48px icon strokes at 4. Drawing the same symbol at two sizes is two Icons.

The stroke style travels with the call. Frame.strokePath takes the width, cap and join per call rather than holding them as frame state. Blend2D’s are context state, and a frame that set them once would leak the last icon’s weight into whatever drew next — a bug that manifests as a hairline somewhere else.

Alternatives considered

  • Normalize the path data at build time, so :assets emits an absolute, arc-free, M/L/C/Z stream and :core needs four calls and a trivial reader. Genuinely attractive: it moves the grammar to build time, where a failure is a build failure. Rejected because arc-to-cubic conversion has to happen somewhere, and doing it in :assets means writing exactly the code the A binding avoids — and then owning it, without Blend2D’s tests.
  • Fill the paths instead of stroking them. Rejected by the icon set: most Lucide shapes are open outlines. It is mentioned because fill is bound too — for shapes that are closed and want it — and because filling is the obvious first thing to try and produces a recognisable-looking blob.
  • One Icon for all sizes, scaled by a context transform at draw time. Rejected on ADR-0034’s grounds: it puts the size in two places. It would also mean saving and restoring the context transform around every icon, when the context’s transform is currently only the display scale and is safer that way.
  • A general SVG renderer. Rejected, as IconCompiler already rejected it: transforms, groups, gradients and fills are not what an icon set needs, and supporting them badly is worse than refusing them. SvgShapes handles the seven basic shapes and this handles path data; between them that is all of Lucide.

Consequences

  • Icons draw. The showcase puts three down its sidebar, and the last ADR-0034 loose end is closed.
  • The layout table grew a struct and six constants. BLPathCore is checked to be BLObjectDetail-shaped, and the three stroke caps and three joins are checked against C. That matters more than it looks: both enums number positionally and neither is intuitive — BL_STROKE_JOIN_ROUND is 4 while BL_STROKE_CAP_ROUND is 2, with a reversed round at 3 — and a drifted constant would draw every icon in the set with the wrong corners, on every platform at once, returning BL_SUCCESS.
  • BlendPath.close() is not SVG’s Z. A path has two closes — finish this figure, give the memory back — and they are closeSubPath() and close() respectively. Naming them alike would make try-with-resources draw a segment.
  • A drawing command after Z issues an implicit move. SVG says a new sub-path starts at the closed one’s start point; Blend2D says it more firmly, by refusing a line_to with no figure to extend. This was found by a test and not by reading either specification, which is the argument for the test.
  • Every bundled icon is asserted to parse. SvgPathTest walks all 1544 and requires geometry from each. It costs about a second and it is the only thing standing between an unhandled command form and one checkbox in a future showcase being mysteriously empty.
  • An icon is not a Box yet. The showcase draws them over the sidebar rather than laying them out in it, because nothing decides an icon’s intrinsic size until the widget model does (ADR-0004). That is the next thing this needs.
  • Nothing caches a parsed icon. Building an Icon parses path data and allocates a Blend2D path, so it belongs outside the frame loop — as the showcase does it. A cache keyed by (name, size) is the obvious follow-up and is deliberately not built until something rebuilds a widget tree, which is the same reason ParagraphCache has no consumer (ADR-0037).

ADR-0044: One face, many sizes

  • Status: Accepted
  • Date: 2026-08-16
  • Relates to: docs/ARCHITECTURE.md §6; ADR-0034, ADR-0037

Context

docs/ARCHITECTURE.md §6 says “one font buffer feeds both hb_face_t and BLFontFace”. ADR-0034 built the thing that was supposed to and did not: a Font owned a ShapedFont and a BlendFont over the same bytes, and each of them copied those bytes, because each library owns its own memory. The guarantee behind the sentence held — the two copies are byte-identical, so their metrics cannot disagree — but the memory did not.

Inter is about a megabyte and a half. A Font was two copies of it, and there is a Font per size, so the showcase’s two sizes cost six megabytes of the same outlines. A real application with a title, a body, a caption and a code face is into double figures for a handful of files.

The cost is not only memory. Font.bundled measures at 681 µs — a parse of the file by each library, plus two copies — and it is paid per size.

Nothing above depended on it staying that way, which is why ADR-0034 wrote it down and moved on. What makes it worth fixing now is that ADR-0043 has just added a second kind of per-size object, and the pattern was about to be repeated rather than fixed.

Decision

Split the typeface out. FontFace in :core is a typeface — everything about a font except the size — and Font.on(face, size) is a size over one.

The split is not arbitrary; it follows what the two libraries already do. Both HarfBuzz and Blend2D model a font in three layers — the file’s bytes, the typeface in them, and the typeface at a size — and the size is only ever on the third. So:

ObjectWhere it lives nowWhy
hb_blob_t, hb_face_t, hb_font_tFontFaceAll three, not just the face: Goldberry never sets a scale on the shaping font, so a shaping result is in design units and correct at every size (ADR-0034)
BLFontData, BLFontFaceFontFace, via the new BlendFontFaceSize-independent, and the expensive two
BLFontFontThis is the size — it carries the font matrix
ShapingBuffer, BlendGlyphBufferFontScratch space, cheap, and reused per call

The whole shaper moving to the face is the part worth noticing. It is only correct because ADR-0034 put the size on Blend2D’s side alone; had the shaper been scaled, it would have had to be per-size and this would have saved the Blend2D half only.

Faces are owned explicitly, not cached globally. FontFace.bundled(UI) returns a face the caller owns and closes, and the natural scope is the window that draws with it:

try (var face = FontFace.bundled(BundledFont.UI);
        var title = Font.on(face, 18);
        var body = Font.on(face, 14)) {

Font.bundled(font, size) and Font.of(bytes, size) still exist and still parse a face of their own, closing it with the font. That is the right shape for one size and the wrong one for four, and it is said so in their javadoc.

The numbers

./gradlew :core:benchmark, TextBenchmark, on linux-x64:

median
Font.bundled — parse and copy, per size, as before680.9 µs
FontFace.bundled — the parse, once429.9 µs
Font.on — another size over a face that exists4.4 µs

A second size went from ~681 µs to 4.4 µs, and from two copies of the file to none. Four sizes of Inter cost three megabytes rather than twelve.

(The first two rows do not quite add up — 430 + 4.4 is not 681 — and the reason is measurement, not accounting: they run at 200 iterations against Font.on’s 2000, so they are less warmed. The number that matters is the third, and it is the well-warmed one.)

Alternatives considered

  • A process-wide cache keyed by the font bytes. The obvious reading of “shared face cache”, and rejected on lifetime. These objects are thread-confined — ShapedFont and BlendFont both check their owner — so a process-wide cache would have to be per-thread, and a ThreadLocal holding native memory has no hook that runs when the thread ends. It would be a leak per thread that ever built a font, in exchange for saving the caller from naming a variable.
  • Reference-count the face, so Font.close() releases it and the last one out frees it. Sound, and rejected as the wrong default: it makes the ordering invisible rather than correct, and the failure mode of getting it wrong — freeing a face another font is reading — becomes a use-after-free instead of a compile-time-visible scope. Explicit ownership is what every other native-backed object in the toolkit already uses.
  • Key a cache on the byte[] by identity. Cheap and quietly useless: BundledAssets.font() returns a fresh array each call by design, so the bundled faces — the whole point — would never hit.
  • Hash the bytes to key a cache. A megabyte and a half of hashing per lookup to avoid a variable. Rejected on that alone, before the lifetime problem.
  • Leave it. Defensible while there were two sizes. Rejected because ADR-0043 had just added per-size icons, and the shape of “one expensive parse per size” was about to become the house style.

Consequences

  • A second size is effectively free — 4.4 µs and no copy of the file — which is what makes a design system with five text sizes affordable at all.
  • A new ordering rule. A face must outlive every font over it. Nothing enforces it, deliberately (see the alternatives), so it is documented on both classes and the scoped form is what makes it automatic. FontFaceTest asserts both halves: closing one size leaves the others shaping, and a font that parsed its own face still closes it.
  • BlendFont no longer owns its bytes. BlendFontFace does, and BlendFont.on(face, size) borrows. BlendFont.fromBytes still works and now makes a private face it closes — so nothing outside :core had to change.
  • Font.face() is public, which means an application can hold a font and reach the face it came from. That is deliberate: it is how a caller that was given a Font adds a second size without being given the face too.
  • The §6 sentence is now true. “One font buffer feeds both hb_face_t and BLFontFace” is still not literally true — each library still copies — but the claim it was making, that a typeface is loaded once, is.
  • Still two copies per face. HarfBuzz and Blend2D each own their memory and neither takes a borrowed buffer for font data the way Blend2D does for pixels (ADR-0031). Halving that would mean giving one of them a pointer into the other’s arena and reasoning about which frees first, for 1.5 MB per family. Not worth it, and written down so it is not rediscovered as a bug.

ADR-0045: A frame is not a benchmark iteration

Correction. The dummy/offscreen row below, and the consequence drawn from it, do not reproduce: under dummy, present is 0.03 ms and paint is 0.61 ms. See ADR-0046. Everything else in this record stands — and the corrected control confirms its central claim rather than weakening it.

Context

ADR-0042 shipped with a hole in it. Its benchmark measured a 960×640 frame at 0.473 ms painted synchronously; ADR-0037 had measured the same size and the same scene inside the showcase at 5.10 ms. An order of magnitude, unexplained, with the borrowed compositor surface named as the likely cause and explicitly labelled a hypothesis.

A benchmark that is 10× optimistic is worse than no benchmark. Every conclusion drawn from it — which knob to turn, what fits in 16.67 ms, whether damage tracking matters — is drawn against the wrong denominator.

What was measured

Every step reproduced on linux-x64, 8 logical cores, over 300 frames of the showcase, taking the last 100 to exclude warm-up.

The gap is real and it reproduces. In-app paint is 2.15 ms median at four workers; the benchmark says 0.34 ms. Warm-up accounts for part of ADR-0037’s 5.10 ms — the first twenty frames median 5.94 ms against the last hundred’s 2.66 ms — but steady state is still 6–8× the benchmark.

Then, one hypothesis at a time:

SuspectTestResult
The borrowed compositor bufferForce the fallback path, paint into a heap bufferRefuted. 2.28 ms heap vs 2.22 ms borrowed
The three new iconsBenchmark the scene with and without themRefuted. +0.010 ms
The display serverSame build under Wayland and under X11Refuted. 2.22 ms vs 2.07 ms
Compositor contentionSDL’s dummy and offscreen drivers — nothing compositesRefuted. 2.00 ms and 2.03 ms — struck; does not reproduce, see ADR-0046
Per-frame loggingMove the showcase’s LOG.info out of the timed regionReal but small: ~0.4 ms
A cold cacheRotate 24 buffers (59 MB, past the 32 MB L3)Real but small: 1.3–1.4×
The environment as a wholeRun the benchmark’s exact loop inside the live application, on the UI thread, between two real framesRefuted. 0.49 ms, while the real frames on either side were 2.06 and 2.25 ms

That last row is the one that turned the investigation around. Same JVM, same JIT state, same thread, same scene, same machine load, same buffer type — and the loop was still 4× faster than the frames surrounding it. Nothing about the environment was responsible. The difference had to be the shape of the loop.

The last variable was present. With it skipped and everything else unchanged, paint fell from 2.193 ms to 0.574 ms — the benchmark’s number, recovered inside the running application.

Decision

Record that present makes the next paint about four times more expensive, and treat the benchmark accordingly.

The mechanism is cache and TLB pollution rather than anything present does to the buffer it is handed. Present moves megabytes and crosses into the kernel; by the time the next frame begins, Blend2D’s pipelines, the destination pixels, the glyph caches and the page tables that reach them have all been displaced. A synthetic data-cache eviction — walking 96 MB between iterations — reproduces about 1.6× of the 3.8×, so data caches are part of it and not all of it.

Two things follow, and they are the decision:

  1. PaintBenchmark measures rasterization in isolation, and says so. It is the right tool for comparing worker counts, surface sizes and algorithms against each other, because it holds everything else constant. It is the wrong number to quote as “what a frame costs”. Both numbers now appear in ADR-0042, labelled.
  2. A claim about frame cost has to come from a frame. The in-app worker sweep is now in ADR-0042 alongside the benchmark sweep, and it agrees on the shape: one worker loses to none, four is the floor, eight is worse.

Window’s trace line now splits paint into begin, draw and end, because that split is what localised the cost — and it is what the next person will need.

Alternatives considered

  • Make the benchmark present. It has no window; :core’s tests deliberately run without one so they need no display. Adding a windowed benchmark means the benchmark cannot run in CI’s headless containers, which is most of where it would be useful.
  • Insert a synthetic eviction pass into every benchmark iteration, to approximate a real frame. Tried, and rejected as a default: it reproduces 1.6× of 3.8×, so it would trade a number that is honestly wrong for one that is dishonestly close. It is kept as an explicit comparison (paintCostWhenTheBufferIsNotAlreadyInCache) rather than folded into the headline figures.
  • Quote only the in-app number and delete the benchmark. Rejected: the in-app number cannot isolate anything. It could not have told us that four workers beat two, because a 0.2 ms difference is inside the frame-to-frame spread of a live window.

Consequences

  • ADR-0031’s conclusion needs revisiting, and this record does not do it. That ADR measured present at ~10 ms against paint at ~1.3 ms and concluded present dominates “and most of it is waiting on the compositor rather than copying”. Present is 6.5 ms here, and where it goes was the open question this record left. ADR-0046 answers it: a quarter of it is a copy SDL makes on our behalf, and three quarters is a swapchain wait. ADR-0031 was half right.
  • Damage tracking is worth more than it looked. If a present poisons the next paint, then not presenting the whole window is worth the paint time as well as the present time. The two costs compound instead of adding. — Measured since, in ADR-0046: about 1 ms a frame at 960×640. Damage buys the copy, not the wait, so the two compound less than this projected.
  • Every performance number in the book now needs its context stated — in a loop, or in a frame. The two differ by 4× on this workload and there is no reason to think that factor is stable across others.
  • The showcase logs three frames and then every fiftieth. A console write per frame sat inside the paint callback, and therefore inside what Window reports as paint, at about 0.4 ms a frame. The instrument was changing the reading.
  • goldberry.paint.noBorrow and goldberry.paint.noPresent are not kept. They were how two of the rows above were measured and they are a few lines each to reinstate; leaving a flag in the frame loop that silently stops the window updating is worse than re-adding it the next time somebody needs it.

ADR-0046: What present actually does

Context

ADR-0045 closed one question and opened a bigger one. It measured present at 6.5 ms against a paint of 2.2 ms — the largest single item in the frame — and then reported that present still cost 6.6 ms under SDL’s dummy driver, where nothing composites. It concluded: “Whatever that time is, it is not waiting for a compositor. That is a new open question and a bigger one than the one this record closes.”

This record answers it. The answer is that the dummy row was wrong, and that present is doing considerably more than the name suggests.

What was measured

linux-x64, 8 logical cores, a 2560×1315 virtual display at 59.96 Hz, Wayland. The showcase over 250–300 frames, last 100–150 taken.

The dummy row does not reproduce. Under SDL_VIDEODRIVER=dummy, present is 0.03 ms, not 6.6 ms — and paint falls to 0.61 ms, which is PaintBenchmark’s number reproduced inside a live window. ADR-0045’s central claim survives its own broken control: present really does make the next paint about four times more expensive, and with present made trivial the two numbers converge exactly as that record predicted.

present is not Goldberry’s code. Splitting Sdl3Window.present the way ADR-0045 split paint:

StepMedian
physicalSize()0.022 ms
Damage validation and marshalling0.021 ms
SDL_UpdateWindowSurfaceRects6.495 ms

Goldberry’s own overhead in present is 43 µs. There is nothing here to tune.

Three quarters of it is a block, not work. Wall clock against ThreadMXBean.getCurrentThreadCpuTime() on the UI thread:

Median
present wall6.43 ms
present CPU1.61 ms
Blocked4.82 ms (75%)

The CPU quarter is a copy. Sweeping the window size:

SizeMpixelspresent CPUBlocked
480×3200.1541.00 ms4.30 ms
960×6400.6141.70 ms4.36 ms
1440×9601.3823.43 ms4.68 ms
1920×12802.4584.74 ms2.45 ms

CPU scales linearly at about 1.65 ms/Mpixel — 2.4 GB/s, the throughput of a memory copy. The block does not scale with size at all, because it is not data-dependent.

What SDL is doing

src/video/wayland/ has no CreateWindowFramebuffer or UpdateWindowFramebuffer hook. The Wayland backend does not implement the window-surface API at all, so SDL_GetWindowSurface falls through to SDL’s generic fallback in SDL_video.c — SDL_CreateWindowTexture. That fallback:

  1. creates a full hardware SDL_Renderer behind the window,
  2. SDL_mallocs a plain heap buffer and hands that back as the “window surface”.

SDL_UpdateWindowSurfaceRects then reaches SDL_UpdateWindowTexture, which does SDL_UpdateTexture (the copy, into a streaming GPU texture), SDL_RenderTexture, and SDL_RenderPresent (the block).

Two consequences follow, and both contradict things the codebase currently says.

The borrowed buffer is not the compositor’s. ADR-0031 and BackendWindow.acquireFrame’s contract both state that the pixels Blend2D draws into are the platform’s own memory, and that this removes a full-frame copy. On Wayland that is false: the buffer is SDL_malloc’d by SDL, and SDL copies it into a texture on every present. The copy ADR-0031 set out to remove is still paid — just on the other side of the SPI, where nothing in this repo could see it. What acquireFrame genuinely buys is one copy instead of two; it does not buy zero.

Damage already matters, and is already wired up. SDL_UpdateWindowTexture uploads only SDL_GetSpanEnclosingRect of the damage list, so the damage Goldberry passes is honoured today — and Window always passes DamageRect.all(). Forcing partial damage:

Damagepresent CPUBlocked
whole frame1.76 ms5.75 ms
half1.36 ms5.73 ms
quarter0.98 ms5.38 ms
a tenth0.87 ms5.74 ms

So damage tracking is worth about 1 ms a frame at this size, and no more: it buys the copy, not the block. That is less than ADR-0045 hoped when it wrote that the two costs “compound instead of adding” — they do compound, but the block is not one of the terms it can reach.

Decision

Record the mechanism, and correct the two records that state otherwise.

  1. present on the Wayland/SDL surface path is a texture upload plus a render pass plus a swapchain wait. It is not a blit to the compositor. Roughly, at 960×640: 1.05 ms of copy that scales with damage, 0.7 ms of fixed render-and- present, and 4.8 ms of blocking that scales with nothing.
  2. ADR-0045’s dummy row is struck, and its conclusion that the cost “is not waiting for a compositor” with it. It is.
  3. ADR-0031’s zero-copy claim is narrowed to what it actually delivers: one copy instead of two, and a guarantee that Blend2D never allocates.
  4. PaintBenchmark’s number is not an artefact. Under dummy, in-app paint is 0.61 ms against the benchmark’s 0.57 ms. The benchmark measures rasterization correctly; ADR-0045 was right to keep it and right to label it.

Alternatives considered

  • Tune Sdl3Window.present. There is 43 µs in it. Rejected as not worth finding.
  • Blame the compositor and stop. This is what ADR-0031 did — it measured present at ~10 ms and concluded “most of it is waiting on the compositor rather than copying”. Half right, and the half it got wrong is the half that can be fixed: a quarter of present is a copy nobody knew was happening.

Consequences

  • The frame loop produces frames nobody sees. Paint plus present is ~9.5 ms, so the showcase runs at ~105 fps into a 59.96 Hz display: about two frames in five are painted, uploaded, and discarded. Nothing in requestFrame is paced to the display, though BackendWindow.requestFrame’s own contract promises “vsync-aligned where the platform offers it”. That promise is currently not kept, and it is the largest remaining win — it costs a whole frame’s paint and present, not a millisecond of one.
  • Owning the renderer would remove the copy and the indirection. SDL is already creating an SDL_Renderer; Goldberry could create it instead, and SDL_LockTexture would give Blend2D genuinely mapped staging memory to paint into. That is the only route to the zero-copy path ADR-0031 believed it had. It is an architectural change to the sdl3 backend and is not decided here.
  • Damage tracking is worth about 1 ms a frame, not the compounding win ADR-0045 projected. Still worth having; no longer the first thing to build.
  • These numbers are from a virtual display. The mechanism is read out of SDL’s source and holds anywhere the Wayland backend is used; the absolute costs — especially the block, which depends on a virtualized GPU — should be re-measured on real hardware before anything is sized against them.
  • The probes are not kept. A CPU-versus-wall split of present, a damage fraction override, and a three-way split inside Sdl3Window.present are how the tables above were produced. Each is a few lines, and the rule from ADR-0045 applies: a flag that silently changes what reaches the screen is worse than re-adding it when somebody next needs it.

ADR-0047: A frame nobody sees costs full price

Context

ADR-0046 took present apart and found nothing in it worth tuning: 43 µs of Goldberry’s code, ~1 ms of SDL copying the frame into a texture, and ~4.8 ms blocked on the swapchain. It also found the thing that was worth fixing, one level up — the loop was producing frames faster than the display could show them:

Paint plus present is ~9.5 ms, so the showcase runs at ~105 fps into a 59.96 Hz display: about two frames in five are painted, uploaded, and discarded.

BackendWindow.requestFrame has always documented itself as “vsync-aligned where the platform offers it”. It was not. Sdl3Window.requestFrame sets a flag and wakes the loop (ADR-0024); the next pump emits FrameDue as fast as the previous frame finished. Nothing anywhere consulted the display.

A discarded frame is the most expensive kind of waste available, because it costs a whole paint and a whole present. Every other saving on the table — damage tracking’s ~1 ms, the 43 µs in present — is a fraction of one frame.

Decision

Pace the frame loop to the display, by two mechanisms, because one of them cannot be relied on.

1. Ask SDL to hold each present until vertical blank. Goldberry creates no renderer, so SDL_HINT_RENDER_VSYNC looks like somebody else’s setting. It is not: where the video driver implements no window surface — Wayland is one — SDL_GetWindowSurface falls back to a hidden SDL_Renderer, and every present ends in that renderer’s SDL_RenderPresent (ADR-0046). The hint is the only channel that reaches it. Set before SDL_Init, on by default, and -Dgoldberry.backend.vsync=false turns it off.

This is the correct fix, it costs one hint, and it needs the display to be real.

2. A FramePacer in the pump, for when it is not. The hint is accepted and has no effect on this machine: the GL stack is VMware SVGA3D on LLVM, which does not honour a swap interval. Virtualized drivers, llvmpipe, and compositors with a deep swapchain all behave this way, and on any of them mechanism 1 is a no-op.

So Sdl3Backend holds FrameDue back until the frame is due, and — the part that is easy to leave out — shortens its own SDL_WaitEventTimeout to match. Without that second half the loop defers a frame and then sleeps in the event wait until something unrelated arrives, which on an idle window is the event loop’s one-second heartbeat: the frame would be held for a second rather than for the rest of its interval.

3. The number comes from the display, not from a guess. SDL_GetDisplayForWindow and SDL_GetCurrentDisplayMode are now exported and bound, and SdlVideo.refreshRate reads refresh_rate out of the mode. The pacer starts unpaced and adopts whatever the display reports on the first pump.

Three things about that number are load-bearing:

  • Zero is an answer, not an error. SDL documents refresh_rate as 0.0f when unspecified and some drivers never fill it in. It means “do not pace”, not “fail to open a window” — so refreshRate() returns 0 rather than throwing, and the loop free-runs exactly as it did before.
  • It is cached per window, and dropped on a scale change. Reading it is a native call and the loop reads it every pump; the one case where the cached value goes stale is the window moving to another monitor, which is what WINDOW_DISPLAY_SCALE_CHANGED usually is.
  • Two windows take the fastest of their displays. They share one loop, so pacing to the slower one would starve the window on the faster. Overshooting costs the slow window a discarded frame; undershooting costs the fast one a missed refresh, and the second is the one the user sees.

-Dgoldberry.frame.rate still overrides, for measuring an unpaced loop (0) or pinning a rate a driver reports wrongly. It is no longer how pacing is turned on.

What it bought

The showcase, 960×640, last 150 of 300 frames, back to back in one session, with the rate read from the display rather than supplied:

fpspaintpresentframe path per second
-Dgoldberry.frame.rate=0111.12.25 ms5.51 ms862 ms
Paced from the display58.81.61 ms1.20 ms165 ms

SDL reports the panel as 60.0 Hz and the loop settles at 58.8 fps — the interval is 16.67 ms and a frame costs a shade under 3 ms, so each one lands just past its deadline and takes the next.

present fell 4.6×, from 5.51 ms to 1.20 ms, and that is the result worth reading twice: ADR-0046 measured 1.61 ms of CPU inside a present whose wall time was 6.43 ms. The block did not shrink. It disappeared, because there was no longer a queue to wait behind.

Paint fell too — 2.25 ms to 1.61 ms — which ADR-0045 predicts and this record did not: a present that is not thrashing cache leaves less of a mess for the next paint. The two costs really do compound, in both directions.

The UI thread now spends 165 ms of each second in the frame path instead of 862 — a fifth of the work — and shows the user the same frames, because the ~50 fps that vanished were never scanned out.

Alternatives considered

  • Default the pacer to 60 fps. Rejected: wrong on every display that is not 60 Hz, and silently so. A toolkit that caps a 144 Hz panel is worse than one that paints too many frames. This is why the pacer starts unpaced and waits to be told, rather than starting at a plausible number.
  • Read the rate once at window creation. Rejected: a window that moves to another monitor would keep the old pace for its whole life, and moving windows between monitors of different refresh rates is the ordinary case on a desk with two of them.
  • Use SDL_GetDesktopDisplayMode. Rejected in favour of the current mode: the desktop mode is the one the display was configured at, not the one it is running, and they differ whenever anything has changed it.
  • Pace in EventLoop rather than in the backend. EventLoop does not mint FrameDue and does not own the pump timeout, so it would have to hold an event it had already been handed. The backend has both, and headless is deliberately left unpaced — a test that has to wait for a frame clock is a slow test.
  • Let the swapchain throttle us, since present blocks anyway. Measured: it does not. The block is 4.8 ms and the loop still reached 105–145 fps, because the swapchain is several buffers deep. Backpressure is not pacing.

Consequences

  • requestFrame’s documented promise is now kept. The contract has said “vsync-aligned where the platform offers it” since ADR-0019 and nothing implemented it.
  • SDL_DisplayMode is in the layout probe, so the offset refreshRate() reads is checked against the compiled library rather than trusted. That check is not decorative here: refresh_rate and pixel_density are adjacent floats, and swapping them deliberately produces SDL_DisplayMode.refresh_rate: Java offset=16, C offset=20 — without the probe it would have paced the loop at 1 fps and looked like a hang (ADR-0010).
  • A layout added to the bindings must be added to Layouts.registry(). It was missed on the first pass here: the struct was declared and registered in goldberry_shim.c, the suite stayed green, and nothing was being verified. The registry is the list the probe iterates, and a layout outside it is unchecked rather than failing.
  • The two display symbols are bound optionally, unlike every other call in SdlVideo. A libgoldberry built before they were exported logs one debug line and runs unpaced, rather than failing to open a window — which is what binding them with the usual downcall did, and it is too high a price for an optimization whose “unavailable” path is already defined.
  • goldberry.frame.rate is a frame-rate cap, not a frame-rate target. It never makes the loop draw faster, and it does not smooth jitter. A window that cannot paint in 16.7 ms still misses.
  • Damage tracking got smaller again. It buys ~1 ms of present’s CPU (ADR-0046); paced, present is 1.63 ms total. The remaining prize there is under a millisecond a frame.
  • PaintBenchmark is unaffected, and should be: it has no window and paces nothing. ADR-0045’s rule still holds — that number is for comparing options, and a frame’s cost comes from a frame.

ADR-0048: The showcase ships as a runtime image

  • Status: Superseded by ADR-0340 — the jlink image is gone; the showcase ships as a native image on the GitHub Release
  • Date: 2026-08-16
  • Relates to: docs/ARCHITECTURE.md §15; ADR-0021, ADR-0023, ADR-0039, ADR-0041

Context

The showcase could only be run the way it was developed: clone the repository, install a C toolchain, build libgoldberry, then ./gradlew :example:run. That is a reasonable ask of a contributor and an unreasonable one of somebody deciding whether the toolkit is worth their afternoon.

It also left a class of bug with nowhere to fail. :example:run puts the application on the module path from the build tree, where every jar is present because Gradle put it there. A module the toolkit forgets to export, an --enable-native-access naming a module that no longer exists, a libgoldberry that resolves only because a Gradle system property pointed at the superbuild’s output — each of those works in run and breaks the moment the application is packaged.

Decision

Build a self-contained runtime image per platform, and run that in CI.

:example:showcaseImage produces a directory holding a jlink-trimmed JDK, the application modules, libgoldberry, and a launcher. It needs no JDK, no Gradle and no arguments on the machine that unpacks it. On linux-x64 it is 57 MB unpacked and 31 MB compressed.

jlink, not jpackage. jpackage produces a .deb, a .dmg and an .msi, which means a code-signing identity on two of the three platforms and a notarization story on one. The showcase is something to download from a CI artifact and double-click, not something to install; jlink needs no signing identity to produce it. jpackage is the right tool the day the toolkit ships an application, and this is not that day.

One runner per platform, not one runner cross-linking three images. jlink can target another platform given that platform’s jmods, so a single job could in principle emit all three. It would still need three libgoldberry builds, and those genuinely cannot be cross-compiled here (ADR-0041) — so the runner is already committed and the cross-linking buys nothing.

The launcher is written by hand, not by jlink --launcher. jlink’s launcher bakes a fixed command line, and the path to libgoldberry is only known relative to wherever the image is unpacked. The generated script resolves its own directory and passes -Dgoldberry.native.library — which is the override NativeLibrary already documents, rather than a new mechanism.

Logback is named in --add-modules. Nothing requires it — that is the point of ADR-0023 — so module resolution leaves it out of the image and the application starts with no logging at all. Naming it explicitly is also what binds SLF4J’s ServiceLoader provider.

Alternatives considered

  • A fat jar. The toolkit is a module graph and its correctness depends on being one (ADR-0007): flattening it onto the classpath tests the opposite of what is claimed.
  • Ship the JDK separately and just zip the jars. Smaller, and it moves the “install a JDK 25” problem onto the reader, which is most of what this record is trying to remove.
  • Put libgoldberry in a classifier jar inside the image. It would ride along as a module resource and need no launcher trickery — but classifier jars are not modular, and jlink will not link an automatic module. Shipping the library beside the image and pointing at it costs one line in a script.

Consequences

  • CI runs the packaged artifact, on all three platforms. example.yml still runs the showcase from the source tree under Xvfb — that job is about the module path being right in development. showcase.yml is about the thing a user would actually download, and asserts the same three painted frames.
  • The images are uploaded as .tar.gz and .zip, not as directories. upload-artifact zips whatever it is handed, and the zip it writes does not carry the executable bit — which would hand somebody an image whose bin/java will not run. Verified by round-tripping the tarball and launching from the extracted copy, not by reasoning about it.
  • -XstartOnFirstThread is in the macOS launcher. The same flag run needs (ADR-0039), and forgetting it in the packaged form would fail with “No available video device”, which mentions neither threads nor the flag.
  • This is not a distribution channel. The showcase remains unpublished (§15); these are CI artifacts, and nothing versions or signs them.

ADR-0049: The CSS engine stops at ComputedStyle

Context

M2 opens with “CSS engine”. That is not one decision, and the interesting ones are not about parsing — a tokenizer follows a specification. They are about where the engine ends, what it refuses, and what it hands to the rest of the toolkit.

The engine also had to be buildable before the thing it styles exists. ADR-0004 is accepted but its element tree is not written, and waiting for it would have meant designing the cascade against an imagined API.

Decision

The engine ends at [ComputedStyle], and produces no pixels and no YGNode. Tokens in, typed values out. §8 already called the layout/paint property split a design invariant; making it the shape of one record is what stops it being a convention that erodes. Box.style(ComputedStyle) is the whole join: the layout half lands on the fields Yoga reads, the paint half on the ones Blend2D reads, and nothing in between is a string.

The subset is enforced, not approximated. [attr], +, ~, ::before, @supports and unknown pseudo-classes are hard errors with a line and column. The CSS specification says to discard what you do not understand, because a browser must render pages written for browsers that did not exist yet. A toolkit reads a stylesheet its own application shipped, and there a dropped rule is a widget that is the wrong colour with nothing in the log. :hovered should not be a rule that silently never matches.

Strictness stops at the frame loop. Parsing throws; resolving does not. An unresolvable var() drops one declaration and warns; a value that will not parse into its property drops that declaration and warns; an unknown property is ignored at debug level. Those three run per node per restyle, inside the frame loop, and taking a window down over one typo in a stylesheet is worse than painting one thing wrong. Parse errors happen once, on load, where a stack trace is useful.

Matching is right to left, and backtracks. [Selector] stores its parts rightmost-first because that is the order matching reads them: the key compound is the only one that must match the element itself, and the rest is a walk up the ancestor chain. The walk backtracks, which is not optional — for .a > .b .c against .a > .b > .b > .c, a greedy walk finds the inner .b, fails to find .a as its parent, and wrongly reports no match.

[StyleElement] is the seam, and its smallness is the design. Four questions: type, id, classes, parent, state. There is no nextSibling() and no indexInParent(), so +, ~ and :nth-child cannot be expressed — which is why §8’s subset stops where it does. Each of them forces the matcher to know about ordering, and ordering is what makes invalidation expensive.

Layers sit after specificity. The cascade key is (important, specificity, layer, order). §8 says “later layer wins at equal specificity”, which makes a layer an extension of source order rather than the override @layer gives: a sharper toolkit rule still beats a vaguer application one, exactly as two rules in one stylesheet would.

A theme is a stylesheet and nothing else. nord-light.css and nord-dark.css contain no selector but :root and no property a widget rule reads directly. Two tiers: the raw palette is theme-invariant — --nord8 is a fact about Nord — and the semantic tokens are what differ and what widgets consume. A widget asks for --gb-bg and never learns which theme answered.

Alternatives considered

  • A property table driving reflection into ComputedStyle. Less code than the switch, and it would make every property a row rather than a case. Rejected for now: the switch is where the type of each property lives, and a table would have to encode that anyway in a form the compiler could not check.
  • Keyword-to-Yoga-enum mapping by hand. Rejected: the two vocabularies already agree — Yoga’s enum constants are the CSS keywords — so space-between to SPACE_BETWEEN is a name transform, and a table would be a second place for them to drift.
  • Resolving em/rem inside Yoga. Yoga has no concept of a font size, so they are resolved at compute time against a [CssLength.Context]. The cost is that the caller must supply one; the alternative is a unit Yoga would have to be taught.
  • Shipping the full 148 CSS colour names. Goldberry’s palette comes from Nord through custom properties. A stylesheet reaching for papayawhip is not using the theming mechanism, and 148 names is 148 chances for grey/gray to look like a toolkit bug. The 16 Level 1 names are there, with both spellings of grey.

Consequences

  • ComputedStyle is deliberately shorter than §8’s property list. It carries what Box can express and no more. A property that resolves into nothing is a property whose tests assert nothing, so each arrives with the thing that paints it: flex-wrap, margin, min/max, position, borders, shadows, transitions and the font properties are all still to come.
  • opacity resolves but is dropped by Box.style. The group opacity CSS specifies needs a layer to composite through, not a colour to multiply into. Resolving it now means the parser and the tests are already right when that layer exists.
  • Token.cssText() exists because text() is not a round trip. A hash holds ff0000 without its # and a dimension holds 16 without its unit. Anything reassembling a value — a serialized style, a hot-reload diff, an error naming the value that failed — needs the spelling back.
  • :root is in the pseudo-class set although §8 does not list it. §10 makes a theme a custom-property layer, and the only place to hang properties that everything inherits is the root. Without it the engine could not express its own themes. It is structural rather than a state, so unlike the other six it can never invalidate a subtree.
  • Nothing is cached yet. No rule index by class or id, no memoised ComputedStyle, no invalidation beyond §8’s “recompute the subtree”. The cascade currently walks every rule in every stylesheet for every element. That is fine at the scale anything is tested at and will not be at widget scale; it is deliberately left until there is a tree big enough to measure against.

ADR-0050: Golden images have a tolerance, and why

Context

Every test in the suite until now asserted about one stage: a token, a specificity score, a glyph advance, a cascade winner. None of them looks at a pixel. A stylesheet can parse correctly, cascade correctly, compute correctly and still paint the wrong thing, and nothing would have said so.

§14 asks for golden-image tests that “run identically in CI on all three OSes”. The design questions are what to compare, how exactly to compare it, and what to do about the fact that identical is a strong word.

Decision

Compare rendered output against committed PNGs, with a tolerance.

An exact match is the obvious design and it is wrong across platforms. Blend2D compiles its rasterizer pipelines at run time with AsmJit (ADR-0030), specialized to the CPU it finds: AVX2 on one runner, SSE2 on another, NEON on an Apple Silicon one. Those pipelines agree about what they draw. They are not required to agree about the last bit of a blended subpixel. Demanding bit-equality would fail on whichever architecture the goldens were not generated on, and the obvious fix — three sets of references — is three things that can rot independently.

So two gates, and both must pass:

  • Per-channel tolerance: 2 of 256. A rounding disagreement between two SIMD pipelines lands at one.
  • Area: at most 2% of pixels may differ at all. Antialiased edges are where pipelines disagree, and an edge is a small fraction of a frame.

The second gate is the one that does the work, and it is not obvious that it is needed until you try to defeat the first. Changing one fill from #bf616a to #bf616b — a difference no human would call a different colour — produces a worst channel delta of 1, comfortably inside the tolerance, while moving 29% of the image. The area gate catches it. Either gate alone is a test that can be walked past.

The scenes are driven through CSS, not by building Boxes. A golden that runs stylesheet → cascade → var() → ComputedStyle → Box → Blend2D is one image that fails if any of six stages breaks. Two of them render the same tree under nord-light and nord-dark, which cannot both be right unless custom properties inherit and the theme layer wins (ADR-0049).

PNG is read and written here, in java.base. ImageIO would do it in two lines and lives in java.desktop. Goldberry’s whole claim is that it does not go through AWT (ADR-0003); a harness that drags AWT in to check that claim would be a strange thing to own. The writer is 8-bit RGBA, unfiltered, one IDAT — and since every golden is a file this code wrote, that is also all the reader has to handle.

No display is involved. Frame paints into memory (ADR-0031), so these run in the existing per-platform verify jobs — the ones that already download the shipped libgoldberry and have no C toolchain. No window, no compositor, no Xvfb.

Alternatives considered

  • Exact match, three sets of goldens. Rejected above: three references, three rot rates, and a contributor on a fourth CPU with no way to run the suite.
  • A perceptual metric (SSIM or similar). More faithful to “does this look the same” and much harder to explain when it fails. Two integers a reader can check by hand beat a score they have to trust.
  • Assert on sampled pixels instead of whole images. That is what the existing rendering tests already do, and it is why a golden was still needed: sampling asserts what you thought to look at.
  • Store goldens as raw pixels. Simpler still, and unreadable. The reason to commit a PNG is that a reviewer can open the diff in the pull request.

Consequences

  • -Dgoldberry.golden.update=true rewrites every golden. It is a review step, not a fix: the image diff in the pull request is the only thing that says whether the change was intended. The flag had to be forwarded explicitly in core/build.gradle, because a system property otherwise reaches the Gradle daemon and stops there — the same trap example/build.gradle already documents, and it wasted the first attempt at generating these.
  • Failures upload three images: expected, actual, and a diff that scales the delta into magenta so a two-level difference is visible at all. “It differs” is not actionable.
  • The goldens are small and few. Six scenes at up to 240×80. They are meant to be readable in a review, not to be a screenshot gallery; a golden nobody looks at is a golden that gets --updated past.
  • This is the first cross-platform claim the suite can actually check. If AVX2 and NEON ever disagree by more than a rounding step, the Linux, macOS and Windows legs are where it surfaces — and the tolerances above are the statement of how much disagreement is acceptable.
  • Text is in a golden, at 1.5× scale. Every HiDPI bug hides at 100%, and a scale applied twice or not at all shows in glyph positions first. It depends on the embedded Inter being pinned by checksum (ADR-0033); nothing here reads a system font, which is what makes the image reproducible at all.

ADR-0051: KDL is parsed here, and reloading is forgiving

Context

§9 makes KDL 2.0 the markup language and calls the schema “the stable contract”. §1 and §8 say markup and stylesheets are hot-reloadable at runtime. Neither says where the parser comes from, and neither says what “reloadable” does when the file being reloaded is halfway through being typed.

Decision

The KDL 2.0 parser is written here. KDL 2.0 landed recently and has no mature Java implementation; the language is small, and the parser has to produce the source positions §9 requires — which general-purpose parsers usually discard, because most consumers do not need to say where a node was. Same reasoning as ADR-0010 applied to a document format.

Three things about KDL 2.0 that a parser written from memory of 1.0 gets wrong, and that have tests naming them:

  • Keywords are #-prefixed. #true, #false, #null, #inf, #nan. Bare true is not a boolean and is not a legal argument at all.
  • Raw strings are #"…"#, fenced by the number of #, not r"…".
  • Block comments nest. /* a /* b */ c */ is one comment. A scanner that stops at the first */ then chokes on the remainder.

The subset refuses two things by name. Type annotations ((u8)123) and multi-line strings ("""…"""). Both are real KDL and neither has a use in a widget schema; refusing them with a message that says so beats accepting and discarding, which is how a document that says something the toolkit ignores looks like it worked. Same stance as the CSS engine (ADR-0049).

The inflater is generic in what it builds. A Factory<T> takes a node and its already-inflated children. The widget tree does not exist (ADR-0004) and the inflater does not need it to: the showcase can inflate to a Box, a widget tree will inflate to widgets, and neither requires this class to change. Depth-first, so a factory is never handed children it has to inflate itself.

Registering a name twice is refused; replace() is how you shadow. §9 says built-ins and application widgets register identically, which means an application can override a built-in. Silently, at whichever point its registration happened to run, is not a good way to discover that it did.

Reloading is forgiving, and loading is not. This is the decision worth the record.

ReloadableSource.load is strict: a broken stylesheet at start-up is a bug, there is no last good value to fall back to, and rendering unthemed while saying nothing is the worst available outcome.

reload() is not. A file being edited is broken more often than it is whole — every keystroke between { and } is a parse error — so a failed reload keeps the last good value, logs once, and waits for the next save. Three details make that actually work:

  • The failed text is not remembered as “last seen”. Otherwise a file edited into an error and then fixed back to its previous contents would compare equal and never reload.
  • Identical text produces nothing. Watchers report one save several times and editors autosave; restyling for an unchanged file is work nobody asked for.
  • A file that cannot be read at all is not a failure either. A rename-into- place caught mid-flight looks exactly like a deleted file, and resolves itself on the next event.

The watcher hands its callback to an [Executor], and the default is the UI thread. Watching blocks, so it runs on a daemon thread; applying must not, because everything it touches is UI-thread-confined (ADR-0020). A reload that restyled from a background thread would be a data race that only appears under somebody’s autosave.

Alternatives considered

  • Depend on a KDL library. Rejected: none is mature for 2.0 in Java, and §9 makes the markup schema a stable contract — owning the parser is what lets the toolkit hold that promise rather than inherit somebody else’s interpretation.
  • Reload by re-reading only the file the watch event named. Rejected: an editor that renames a temporary file into place reports a change to a name that is not the one being watched. Re-reading every source costs a string compare.
  • Fail loudly on a broken reload, like loading does. Rejected, and the reason is the whole point of the feature: the file is broken because somebody is typing in it.
  • Debounce by waiting a fixed delay after the first event. Rejected in favour of a quiet period that restarts on every event, because a large file saved in several writes takes longer than any fixed delay worth choosing.

Consequences

  • Hot reload works for stylesheets today and for markup the moment there are widgets. ReloadableSource is parameterised on the parser, so a Stylesheet and a List<KdlNode> reload through the same type; both are tested.
  • The §9 example document is a test. The settings window in ARCHITECTURE.md is parsed and asserted on, so the documented markup cannot drift from what the parser accepts.
  • On macOS a change can take seconds to be noticed. The JDK’s WatchService has no kernel backend there and polls. Nothing here can fix it; it is in the class documentation because “hot reload is broken on my Mac” is otherwise a puzzle.
  • bind and action are not implemented. §9 wants Kdl.inflate(doc).bind(controller) with explicit wiring and no reflective handler lookup. The lookup half — finding a node by id, and refusing a duplicated one — is here; binding needs a widget with an action to bind, and arrives with the controls.
  • Nothing enforces the parity invariant yet. §9 requires every built-in widget to be constructible as Java, as KDL and as CSS-styleable, “enforced by test”. There are no built-in widgets, so there is nothing to enforce it over; the test belongs with the first control.

ADR-0052: State lives on the element, and rebuilds are deferred

Context

ADR-0004 chose the three-tree model and then said so, in its own consequences:

Open, and the largest gap in the current design: the state and rebuild API. […] the stateful-widget lifecycle, the rebuild scheduling, and how a state change marks the tree dirty are not specified. This is the API every user touches and it needs its own record before M2.

This is that record. It covers the widget shapes, where state lives, what setState does, when rebuilds happen, and how the element tree became the thing the CSS cascade talks to.

Decision

Three widget shapes, not one

Widget.Stateless, Widget.Stateful, Widget.Leaf — three interfaces rather than one with nullable methods, so the switch in Element.describe() is exhaustive and a widget that implements none of them fails loudly instead of rendering as nothing.

Leaf is toolkit-facing: it produces children directly and paints. Stateless and Stateful are what applications write.

State lives on the element, and is created once

createState() runs when an element is first mounted, never on a rebuild. That is the entire reason the element layer exists — ADR-0004’s rejection of the two-tree model was that “there is nowhere to hang state and lifecycle across rebuilds”.

State.widget() is re-read rather than captured, because a rebuild can hand the same state a new widget value when a parent re-describes it with different arguments. didUpdateWidget(previous) is the hook for reacting to that.

setState after dispose() throws. A callback that outlived the widget that registered it is a leak, and the alternative — silently doing nothing — is the version nobody finds.

setState mutates now and rebuilds later

The mutation runs immediately; only the rebuild is deferred. Code after setState sees the new value, which is what every author expects, while the build is coalesced with everything else in the frame. Ten setState calls in one handler cost one build, which is asserted.

This is the same shape as the rest of the toolkit’s frame discipline: a repaint request is coalesced (ADR-0024) and a frame is paced to the display (ADR-0047). Rebuilding inside setState would mean a handler that touches three fields builds three times and paints frames nobody sees.

Flush is shallowest-first, and gives up

ElementTree.flush() sorts dirty elements by depth before building. A parent’s rebuild can replace a child’s whole subtree, so building the child first is work thrown away — or worse, a build on an element about to be unmounted.

A setState during a build is legal and has to settle before the frame paints, so flush repeats. It stops after ten passes and warns: a build that dirties itself every pass is an application bug, and a frozen window with nothing in the log is a terrible way to report one.

Reconciliation is by type and key, and keys win

Type mismatch or key mismatch replaces the element. Keyed children are matched by key wherever they moved; unkeyed ones by position.

The subtle rule, and the one with a test named after it: an unkeyed description may not adopt an element that a key claimed. Without that, reordering [keyed, plain] into [plain, keyed] lets the unkeyed description at position 0 steal the keyed element, and two nodes silently swap their state.

The element tree is what the cascade talks to

Element implements StyleElement. This is what makes ADR-0049’s engine usable: the cascade asks an element for its type, classes and ancestors and gets answers that survive a rebuild. Pseudo-classes live on the element, not the widget, so a button does not stop being hovered because its parent re-described it.

Element.type() returns null for a widget that is not Styled, and that is deliberate. An earlier version derived a kebab-case name from every widget class, which would have put every private composition class into the cascade as a selectable type — renaming an internal Wrapper would break a stylesheet that never named it. A node with no type matches no type selector and carries no classes, so it is invisible to everything but a descendant combinator passing through it, exactly as an unstyled <div> is.

Alternatives considered

  • Hooks, in the React sense. Rejected: they need a stable call order per build and a scheduler that owns the notion of “current component”, both of which are far more machinery than a mutable object on an element that already exists.
  • setState rebuilds immediately. Rejected above — it defeats coalescing and it makes the cost of a handler proportional to how many fields it touches.
  • Rebuild the whole tree from the root each frame. Simple, and it throws away the reason for having an element tree. Also quadratic in depth for a leaf-level change.
  • State as an observable Property<T> only, with no setState. §9 does want a Property<T> for KDL’s bind, and it will be built on this rather than instead of it: a property that marks its element dirty is exactly setState with a nicer face.
  • Deep-first flush. Rejected: a shallower rebuild can unmount the deeper element that was about to be built.

Consequences

  • bind, focus and semantics now have somewhere to live. All three need node identity across rebuilds, which is what the element tree provides and what §7.2’s retained focus and §13’s accessibility tree were waiting for.
  • flush() has no caller in a window yet. The frame loop does not consult needsBuild(), because there is no widget-driven window to consult it for. The hook is one call and it lands with the first control.
  • Nothing renders yet. ADR-0004’s third tree — render objects owning a YGNode and a ComputedStyle — is not here. Element produces no Box. That is the next piece, and it is the one that makes the parity invariant testable, because it needs widgets that actually paint.
  • The Element API is wider than an application should need. rebuild(), update() and unmount() are package-private; markNeedsBuild() and setPseudoClass() are public because input and animation will call them from outside. If that turns out to be the wrong line it is a cheap one to move.
  • Widget.key() is Object, compared with equals. A String, an Integer or a record all work. Typed keys would be tidier and would make the common case — a list index or an entity id — noisier to write.

ADR-0053: The render tree is a box tree, for now

Context

ADR-0004 describes three trees. Two of them are built: widgets, and the element tree of ADR-0052. The third is specified as “one render object per visual node. Owns a YGNode, a ComputedStyle, and the paint logic.”

A Box already owns a style and BoxPainter already builds Yoga nodes from a box tree — that pairing predates the widget layer and is what every rendering test and every golden image runs through. Writing a second, retained render tree now would mean two ways to get pixels out of a style, and the older one is the one that is tested.

Decision

The render tree is materialized as a Box tree per frame, and this record says so plainly rather than letting it look like the design.

A widget that appears on screen implements Paints: given the ComputedStyle the cascade resolved for its element and the boxes its children produced, it returns its box. WidgetRenderer walks the element tree, resolves a style per node, and assembles the result.

Three things fall out that are worth stating:

  • Composition nodes contribute nothing. A Widget.Stateless describes others and produces no box, so the renderer passes through it. The box tree is therefore shallower than the element tree, and a wrapper costs nothing at paint time — which is what makes composition free enough to use liberally.
  • A widget owns the part of its layout that is its identity. Row sets flex-direction: row after applying the style, so a stylesheet cannot turn a row into a column. Everything else — colour, padding, gap, size — is the stylesheet’s. A name that a stylesheet can falsify is worse than no name.
  • Spacer defaults to flex-grow: 1 unless the cascade set a grow. Taking the free space is what a spacer is for, and requiring spacer { flex-grow: 1 } in every stylesheet would make the widget pointless.

Alternatives considered

  • Retained render objects now, each owning a YGNode. The end state, and premature. It duplicates BoxPainter while nothing yet measures the difference, and ADR-0045 exists precisely because this repository optimized against a number it had not taken.
  • Skip the render layer and paint from elements directly. Rejected: paint would then need Yoga node lifetimes threaded through the element tree, and the element tree would own two responsibilities that invalidate on different schedules.
  • Let stylesheets set flex-direction on row. Rejected above. It is the one property these widgets refuse to delegate.

Consequences

  • The parity invariant is now enforced. §11 requires every built-in to be a Java record, a KDL node and CSS-styleable, “enforced by test”. WidgetParityTest iterates the built-ins and checks all three, including that a Java-built and a KDL-built widget are equals — which records make a checkable claim rather than a slogan.
  • A golden image now covers the whole stack. widget-tree.png goes KDL → widgets → element tree → cascade → boxes → Blend2D. Six stages, one image.
  • The box tree is rebuilt every frame, and that is the cost to reclaim. No render object survives a frame, so nothing knows what changed — which is exactly what damage tracking needs (ADR-0046 measured it at about a millisecond) and what retained render objects would provide. That is the argument for building them, and it should be made with a measurement.
  • Paints.Context exists to be widened. Today it offers a font. The display scale, an icon catalog and a text-measurement cache all belong there, and putting an interface in the signature now means adding them will not touch every widget.
  • Five primitives, not §11’s full catalog. text, row, column, panel, spacer. Enough to make the invariant testable and the stack demonstrable; button, checkbox and the rest need input, which does not exist yet (§7).

ADR-0054: Hit testing runs against the painted frame

Context

§7 wants pointer events hit-tested against the render tree, dispatched capture → target → bubble with consume(), and :hover derived from pointer flow. §8 already has the pseudo-classes; §7.2 wants focus, and :focus distinct from :focus-visible.

The awkward part is that ADR-0053 materializes the render tree as a Box tree per frame, and a Box had no way to say which node produced it. Without that, a rectangle on screen leads nowhere.

Decision

A Box carries an opaque owner tag. Typed Object, not Element, so the layout package keeps knowing nothing about widgets — it is set by the renderer and read by hit testing, and nothing between the two looks at it. One extra component on a record whose construction is entirely internal, which is what made it cheap.

Hit testing runs against a snapshot taken while painting, not a fresh layout pass. This is not an optimization; it is the only correct answer. A pointer event is about what the user can see, and what they can see is the last frame that was painted. Laying out again to answer would test against a frame that does not exist yet, and every drag would be one frame ahead of the thing being dragged.

The topmost box wins, and untagged boxes are skipped. capture records parents before children — paint order — so scanning backwards finds the box the user can actually see. A box nobody tagged is scenery: an event delivered to it would have nowhere to go, so hit testing passes through to whatever is behind.

Regions are logical, not physical. The window scale is applied when the frame is rasterized (ADR-0031), and an application’s coordinates are logical everywhere else. There is a test at 200%, because a hit test out by the display scale is the kind of bug that works perfectly on the machine it was written on.

:hover applies to the whole ancestor chain, and only the difference changes. .card:hover .title has to work, so hover is not just the deepest node. The router compares the old chain with the new one and marks only what differs — which is what stops a pointer moving one pixel inside a widget from invalidating its ancestors. §8’s invalidation is coarse (a pseudo-class change recomputes the subtree), so which nodes change matters more than it looks.

Focus walks up to the nearest focusable ancestor. Clicking the text inside a button focuses the button, not the text. isFocusable() defaults to false, because most nodes are scenery and a Tab traversal that stopped on each would be unusable.

:focus and :focus-visible are set separately, per §7.2: a pointer press sets :focus only, and the focus ring is a stylesheet’s reaction to :focus-visible. Input therefore never learns what a ring is.

State lives on the router and the element, never on the widget. Widgets are rebuilt constantly and could not remember who is hovered. This is the same argument ADR-0052 made for state, arriving at the same place from a different direction.

Alternatives considered

  • Re-run layout to hit-test. Rejected above: it answers about a frame that has not been shown.
  • A parallel array of elements in paint order, instead of a field on Box. It works and it is fragile: the array’s order and forEachBox’s traversal have to agree forever, with nothing checking that they do. A field cannot drift.
  • Type owner as Element. Rejected: layout would then depend on widget, and the box tree is deliberately usable without one — every golden image builds boxes directly.
  • Dispatch to every node and let widgets filter. Rejected: Handles is opt-in, so dispatch costs the number of interested nodes rather than the depth of the tree.

Consequences

  • button is now buildable. Press, release, hover, active and focus all reach a widget, which is what §11’s controls were waiting on.
  • The backend does not send pointer events yet. BackendEvent has no pointer cases and the sdl3 backend translates none, so nothing calls PointerRouter from a real window. The router is tested by driving it directly. That plumbing is the next piece and it is mechanical: SDL’s motion and button events into the sealed event type, which will break GoldberryRuntime’s exhaustive switch until it handles them — by design.
  • Keyboard, text input, wheel and cursor are not here. §7.1’s KeyEvent / TextEvent split, libxkbcommon’s xkb_compose for dead keys, pixel-precise wheel deltas, and §7.3’s cursor shapes are all still to come. The split matters for IME later and should be built when there is a text input to receive it.
  • Pointer capture on drag is not implemented. §7.1 asks for it. Without it a drag that leaves a widget’s bounds stops being delivered to it, which is wrong for a slider — and a slider is the first widget that will need it.
  • takeStylesDirty() clears on read. So the question it answers is exactly “did a pseudo-class change since the last frame”, which is what a frame loop wants to ask before restyling.

ADR-0055: SDL owns keyboard translation, so libxkbcommon goes

Context

§7.1 specified that on Linux “the translation is libxkbcommon (xkb_state + xkb_compose for dead keys)”, and the superbuild has built and pinned libxkbcommon since ADR-0008. When keyboard input was actually implemented, it was built on SDL’s SDL_EVENT_TEXT_INPUT instead — which already carries the layout, dead keys, compose sequences and IME conversion, applied by the platform.

That raised the question of whether libxkbcommon was needed at all. It is not, and it turned out it had never been used.

What was measured

Against the built libgoldberry.so:

CheckResult
xkb symbols in goldberry.symbols0 — the section header had nothing under it
Java code binding xkbnone
Exported xkb symbols0
Undefined xkb symbols (dynamic linkage)0
DT_NEEDED for libxkbcommonabsent
String libxkbcommon.so.0 presentyes

The superbuild did link it — target_link_libraries(goldberry PRIVATE .../libxkbcommon.a) — but a static archive contributes only the objects that resolve an undefined symbol, and nothing referenced one. All 1.7 MB was discarded at link time. The surviving string is SDL’s own dlopen name: SDL loads the system libxkbcommon when it needs a keymap, and always has.

So the library was being cloned, configured, built and linked on every Linux build, and thrown away by the linker every time.

Decision

Remove libxkbcommon from the superbuild, and let SDL own keyboard translation.

SDL already does it on all three platforms — libxkbcommon on Linux, the platform’s own translation on Windows and macOS — and delivers the result as committed text. Binding it a second time would duplicate work SDL does correctly, in one place, for one platform.

§7.1’s split survives intact and is the reason this is comfortable: KeyEvent and TextEvent are still separate, because one character can take several keys. What changes is only who performs the translation, not that it is performed.

Meson goes with it. libxkbcommon was the only meson-built dependency, and so the only reason checkToolchain demanded Meson ≥ 1.4. That floor was not theoretical: Ubuntu 24.04 ships 1.3.2, it broke CI, and it cost a toolchain upgrade to work around before this was understood. The superbuild’s tools are now CMake and Ninja.

The headers stay, for SDL. libxkbcommon-dev and xkb-data are still checked and still installed in CI — SDL’s Wayland backend needs the headers to build and the keymap data to run. What changed is the reason: they are SDL’s dependency, not Goldberry’s, and the toolchain check now says so.

Alternatives considered

  • Link it properly and bind it, as §7.1 originally described. It is a real design — owning the translation would give tighter control over IME preedit later. Rejected: it duplicates what SDL does on one of three platforms, and §7.1’s stated goal is the key/text split, which SDL’s events already provide. If preedit ever needs more than SDL exposes, SDL’s own IME API is the next place to look, not a parallel xkb stack.
  • Keep building it in case it is wanted later. Rejected: it was dead weight with a live cost — a git clone, a meson build, and a version floor on a tool nothing else needed.
  • Configure SDL to link its dependencies statically so the artifact carries its own libxkbcommon. Rejected here as a separate question: it applies equally to libwayland, libGL and a dozen others SDL dlopens, and deciding it for one of them would be arbitrary.

Consequences

  • The shipped Linux artifact depends on the system libxkbcommon at run time. It already did — the static copy was never linked — so nothing changes for anyone. It is now stated rather than accidentally true.
  • The superbuild has four pinned upstreams, not five. libs.versions.toml loses its xkbcommon entry, and GOLDBERRY_XKBCOMMON_REF and GOLDBERRY_MESON are gone from the CMake surface (ADR-0035 still holds for the rest).
  • The licence disclosure drops an entry. libxkbcommon is no longer redistributed in object form, so it leaves THIRD-PARTY-NOTICES.md, NOTICE and licenses/ (ADR-0015). checkLicenses verifies the two sides still agree.
  • Verified by rebuilding from scratch with meson absent from PATH. The native build, all 950 tests and the packaged showcase all pass, and the resulting libgoldberry.so contains zero xkb symbols — which it also did before, which was the whole point.
  • §7.1 and the CMake comment described a design that was never built. Both are corrected. The lesson is narrower than “check your dependencies”: a static archive that nothing references links silently and successfully, and the only way to notice is to look at the symbols.

ADR-0056: The wheel is lines, and the sign is ours

Context

§7.1 asks for “wheel/scroll: pixel-precise deltas with line-based fallback”. ADR-0054 closed with wheel listed among the things still missing, and it is the last pointer event a scroll view needs.

Three things about SDL_MouseWheelEvent decide the shape of this:

  1. There is no pixel-precise delta. SDL reports x and y as floats in scroll “detents” — what CSS calls lines. Wayland and macOS both have a pixel-precise axis underneath, and SDL does not surface it. §7.1’s “pixel precise with a line-based fallback” describes an API SDL does not offer.
  2. The sign is a platform preference. When the user has “natural scrolling” turned on, SDL sets direction to SDL_MOUSEWHEEL_FLIPPED and leaves x and y inverted, documenting that the caller should multiply by -1. A reader that ignores the field is correct on its own machine and backwards on the machines of everyone who changed the setting.
  3. Vertical positive means away from the user, which is the opposite of the direction a document scrolls in and of what every scroll view is written against.

There is also a fourth, quieter one: a wheel event carries its own mouse_x and mouse_y, at different offsets from the motion arm’s x and y. On a wheel event, the offset the motion arm keeps its position at holds the vertical delta — so a reader that reused the motion accessor would get a plausible float that is not a coordinate.

Decision

The unit is lines, and the API says so. PointerEvent.deltaY() is documented as lines, not pixels, because inventing a pixel number would mean multiplying by a line height this layer does not know. A scroll view multiplies by whatever a line is worth in the thing it is scrolling. If SDL ever exposes the precise axis, this becomes a second pair of accessors rather than a redefinition of these.

Fractions survive. The delta is a float and is routinely fractional: a touchpad reports a fraction of a detent per frame, and a toolkit that rounded would scroll in visible jerks. integer_x/integer_y — SDL’s accumulation into whole clicks — are deliberately not read.

The flip is undone in :natives, and the sign convention is applied in the backend. Two steps, in two places, on purpose:

  • SdlEventBuffer.wheelX()/wheelY() negate when direction is FLIPPED, and otherwise report SDL’s own numbers in SDL’s own convention. A binding that silently redefined a field would be a binding nobody could check against the header.
  • Sdl3Backend.translate negates y once more, turning “away from the user” into “down the document”. That is the CSS convention and the one every scroll view is written against, and doing it at the boundary means no two backends have to agree on anything harder.

The event carries the position SDL gave it, read from mouse_x/mouse_y rather than from the last motion. Scrolling with the pointer parked over a window that has not seen a move since it was focused is ordinary, and the last motion would be stale or absent.

A wheel is a PointerEvent with a WHEEL kind, not a type of its own. §7.1 lists it under pointer events; it travels the same capture → target → bubble path and consume() means the same thing on it. That is what makes nested scrolling work: an inner list consumes while it still has somewhere to go, and the page behind it does not lurch.

SDL_MouseWheelEvent and SDL_MOUSEWHEEL_* join the layout registry (ADR-0010). The struct’s offsets and the two direction values are checked against the compiled SDL, because the failure mode here is silent: a wrong offset scrolls by the pointer’s coordinates and looks like a working scroll view until the pointer moves.

Alternatives considered

  • Report pixels, multiplying by an assumed line height. Rejected: the assumption would be wrong for every widget with a different line height, and it would bake a guess into the SPI where a caller could make an informed one.
  • Pass SDL’s sign through and let widgets negate. Rejected: every widget would have to know, and the ones that forgot would scroll backwards. One negation, at the boundary, once.
  • Normalize the flip in the backend rather than the binding. Rejected: the flip is a fact about the struct — the field is right there in the same event — and leaving it to a caller means every caller of the binding has to remember.
  • A separate WheelEvent type and an onWheel method. Rejected: it would need its own consume(), its own capture and bubble path, and its own interaction with pointer capture — three copies of machinery that already exists, to gain a delta field.
  • Read integer_x/integer_y for a detent count. Rejected: it throws away the touchpad’s resolution, which is the case that most needs it.

Consequences

  • A scroll view is now buildable, which is what M3’s scroll was waiting on.
  • The wheel follows pointer capture. A wheel event during a drag goes to the captor — see ADR-0058.
  • A wheel does not move :hover. The pointer did not move, so nothing about what is hovered has changed. This is a deliberate difference from moving.
  • Horizontal scrolling is delivered and nothing consumes it yet. deltaX is populated from a shift-wheel or a horizontal touchpad gesture; there is no widget to receive it until scroll exists.
  • §7.1’s “pixel-precise” is not met and cannot be met through SDL. It is written down here rather than left as an unread promise: the honest statement is lines with fractional precision. Reaching real pixel deltas means going around SDL to the platform, which is a much larger decision than this one.

ADR-0057: The cursor rides on the painted box

Context

§7.3 wants a standard cursor shape set mapped to native cursors by the backend, set from CSS (cursor: pointer) or from code. The awkward question is not the mapping — SDL has SDL_CreateSystemCursor — but where the shape lives between the stylesheet that declares it and the pointer motion that needs it.

The style that decided the shape does not survive the frame. ADR-0053 materializes the render tree as a Box tree per frame and throws it away; WidgetRenderer resolves a ComputedStyle per element per frame and keeps none of them. So by the time the pointer moves, “what cursor does this element want” has no one left to ask — unless something wrote it down.

Decision

The shape is a property of the painted rectangle. Box carries a Cursor, HitTest.Region records it while capturing, and the router reads it off the rectangle under the pointer. That is the same route hit testing already takes and for the same reason (ADR-0054): what the cursor should be is a question about what is on screen, and the box tree is what is on screen.

Inheritance is the stack of rectangles, not the element tree. HitTest.cursorAt scans backwards and takes the first rectangle that asks for anything other than DEFAULT. So a cursor: pointer on a button applies to the label inside it without the label repeating it — which is what CSS’s inherited cursor means to a user — arrived at by walking the only structure input has at that point. A box with no owner still counts: scenery is not clickable but it is visible, and an overlay that says cursor: wait means it.

Cursor lives in the SPI package, and its names are CSS’s. NOT_ALLOWED, EW_RESIZE, GRAB — so cursor: ew-resize resolves through the same uppercase-and-underscore rule that already maps space-between onto Yoga’s SPACE_BETWEEN, with no translation table to drift. It sits beside PixelFormat and DisplayScale rather than in input because both the render tree and the input router name it, and neither should have to depend on the other to do so.

cursor is a third category in ComputedStyle, and the record says so. §8’s property split has two halves — compiled to Yoga, resolved for paint — and this is neither: it compiles to no engine and is read by input. Rather than pretend it is a paint property, the field is documented as the exception it is.

The router pushes; it does not know what it is pushing to. PointerRouter.onCursorChange(Consumer<Cursor>) is wired to the backend window by Window. The router still knows nothing about the platform, and a test wires it to a list.

Only changes are reported. This is asked on every pointer motion — every pixel of a drag — so the notification is edge-triggered, and SdlCursors additionally skips SDL_SetCursor when the shape is already showing. The headless backend counts changes rather than calls, so a regression here is a number that climbs with the mouse.

The shape is frozen during a capture. A drag decides what the pointer looks like when it starts. A cursor that flickered as the pointer crossed the widgets underneath would be advertising things the user cannot currently interact with.

Cursors are process-global, because they are. SDL_SetCursor sets what the mouse looks like everywhere; X11, Wayland and Win32 all work this way, and the pointer is over one window at a time. So the cursors are owned by the backend, created on first use and destroyed together, and the window that asks is by definition the one the pointer is in. The SDL_Cursor * never leaves :natives: it would otherwise be a lifetime two modules shared.

Missing shapes fall back rather than fail. grab and grabbing are a CSS invention with no system cursor in SDL_SystemCursor, X11’s cursor font, or Win32’s IDC_* set; they map to move, which says the same thing less precisely. A shape SDL declines for any other reason — a stripped-down cursor theme — leaves the pointer as it is, logged at debug. Nobody’s window should fail to open because they wanted a hand instead of an arrow.

The cursor calls are optional at link time. Bound lazily, and an UnsatisfiedLinkError disables the feature rather than the backend — the same argument SdlVideo.optionalDowncall makes for the display-mode calls (ADR-0047). A libgoldberry built before these symbols were exported keeps opening windows.

SDL_SYSTEM_CURSOR_* joins the constant registry. They are ordinals in an enum upstream has already inserted into the middle of once — POINTER is 11 in SDL3 and did not exist in SDL2 — and a wrong one shows the user the wrong cursor and reports no error at all.

Alternatives considered

  • Keep a Map<Element, Cursor> from the last render. Rejected: a second structure to keep in step with the box tree, with nothing checking that it is. The same argument ADR-0054 made against a parallel array of owners.
  • Ask the element for its style at pointer time. Rejected: it does not have one. Re-resolving the cascade on pointer motion would also mean styling against a frame that has not been painted, which is the mistake ADR-0054 exists to avoid.
  • Put Cursor in input and have layout import it. Rejected: input already depends on layout, and the reverse edge would make a cycle out of two packages that are otherwise cleanly ordered.
  • A per-window cursor in the SPI, hiding SDL’s global one. Rejected: it would be a lie that costs bookkeeping. The platform model is one pointer with one shape, and the SPI method is on BackendWindow only so that a backend which genuinely is per-window can implement it that way.
  • Walk the element tree for inheritance. Rejected: it gives a different answer from the one the user sees whenever an element paints outside its parent, and the rectangles are already ordered by what is on top.

Consequences

  • cursor: pointer works end to end, stylesheet to platform, and a golden image is not needed to prove it: the shape is a value on a rectangle.
  • The showcase sets a cursor, and that is not decoration. It is the only thing in the repository that makes SDL_CreateSystemCursor and SDL_SetCursor actually run, and CI drives the showcase under Xvfb on all three platforms — so the calls are exercised rather than left as bindings nothing has ever called.
  • Box and ComputedStyle each gained a component, which touched every wither and every branch of ComputedStyle.with. That is the cost of records with positional construction, and it is paid once per property.
  • grab and grabbing are not the shapes their names promise until custom image cursors ship (§7.3). Written down here so the fallback is a decision rather than a surprise.
  • Hiding the cursor is bound and unused. SdlCursors.hide() exists because a text editor hiding the pointer while typing is ordinary; nothing calls it yet.
  • Nothing recomputes the cursor when the tree changes under a still pointer. A widget that becomes disabled without the pointer moving keeps the old shape until the next motion. Fixing it means re-running cursorAt after each paint with the last known position, which is worth doing when something can actually change that way.

ADR-0058: A press captures the pointer, and a key falls through to the window

  • Status: Accepted
  • Date: 2026-08-16
  • Relates to: docs/ARCHITECTURE.md §7.1, §7.2; ADR-0054, ADR-0055

Context

Two questions about who gets an event, both left open by ADR-0054.

The pointer. Dispatch targeted whatever was under the pointer at that instant. A press on a slider’s thumb followed by a drag off the track sent the motion somewhere else, and the release went to whatever the pointer had landed on. The thumb stopped following, and — worse — it never learned the press had ended, so :active stayed on it. ADR-0054 named this and deferred it.

The keyboard. §7.2 asks for a per-window accelerator map. Tab traversal was already handled in the router, because traversal is a property of the tree rather than of any node in it; an accelerator has the same character and no home.

Decision

A press takes the pointer implicitly, and the release gives it back. From press to release, every pointer event — motion, wheel, the release itself — goes to the element that was pressed, wherever the pointer is. This is what browsers do for a button-down drag and it is what makes a slider work.

An explicit capturePointer(element) outlives the release. A gesture that continues past the button coming up — a drag that ends on Escape, a click-move-click ruler — needs a capture only its owner can end. So capture records how it was taken: implicit capture is released by the matching release, explicit capture by releasePointer().

:hover still follows the pointer during a capture. Capture decides who is told, not what is highlighted. What the user can see is still what the pointer is over, and freezing hover would leave a highlight stuck under a moving cursor. The cursor shape, by contrast, does freeze — see ADR-0057 — because it describes what the drag is doing rather than what is underneath it.

Capture survives the pointer leaving the window. A drag that overshoots an edge and comes back is one gesture, and the platform keeps sending the motion. Releasing on exit would drop the second half of it.

Accelerators fire after the focused chain declines the key. The order is: capture down the focus chain, bubble back up, then the window’s accelerator map, then Tab traversal. A text field keeping Ctrl+A for “select all” is the case this is for — it consumes, and the window’s binding does not steal it. The alternative order would make every focusable widget’s key handling conditional on what the window happened to bind.

Modifiers must match exactly. Ctrl+S does not fire on Ctrl+Shift+S, because that is a different shortcut and applications bind both.

A held shortcut repeats. Holding Ctrl+Z repeats the undo, which is what the platform’s own key repeat is for. A shortcut that must not repeat checks KeyEvent.repeat().

Shortcut.of("Ctrl+S") parses the string a menu prints. That text is going to appear beside the menu item anyway, and two spellings of one shortcut is one more thing to keep in step. It is refused at construction if it names no key — a shortcut that silently never fires produces a bug report of “the menu item does nothing” with no error anywhere.

Cmd is not quietly remapped to Ctrl on macOS. A toolkit that did would make Ctrl+C mean two different things depending on where it ran. An application that wants the platform convention is better served asking for it than having it guessed.

Letters and digits joined Key, and accelerators are the reason. ADR-0055 put the letter a user typed in the text event, and Key deliberately named only the keys that do something rather than type something. Ctrl+S is the counterexample: a modified letter produces no text event on any platform, so the letter has to come from the key event or the one shortcut every application has could not be expressed. They are the layout’s letters, not the keyboard’s positions — SDL’s default latin_letters translation means the key where A sits on a Cyrillic or Thai keyboard still arrives as A, while on AZERTY a shortcut stays where the user’s own layout puts it. An uppercase keycode folds to the lowercase one, because SDL documents platforms that report only modified keycodes.

Alternatives considered

  • Capture only when a widget asks. Rejected: every clickable widget would have to ask, and the ones that forgot would be subtly wrong in a way that only shows up when the user drags — which is exactly when they are least likely to report it precisely.
  • Freeze :hover during a capture too. Rejected above: hover describes what is under the pointer, and the pointer is still moving.
  • Accelerators before dispatch. Rejected: it makes the window’s bindings override every widget’s, so a text field cannot keep Ctrl+A.
  • An accelerator map on Window rather than the router. Rejected: the router already owns focus and key dispatch, and splitting the two would mean the ordering above spanned two objects.
  • Match modifiers loosely, ignoring extra ones. Rejected: Ctrl+Shift+Z would then fire Ctrl+Z’s undo as well as redo.
  • Name shortcuts with an enum-only API (new Shortcut(Key.S, ...)). Kept — the record’s canonical constructor is public — but of(String) is the one that matches how shortcuts are written down everywhere else.

Consequences

  • A slider is now buildable, and so is any drag gesture: capture is the piece M3’s split-pane and scroll thumb were both waiting on.
  • :active cannot get stuck. The release reaches the captor even when the pointer is elsewhere, and clearing :active is what it does with it.
  • Accelerators are per window, which is the scope a user means: Ctrl+W closes this window.
  • Menu accelerators are not registered automatically yet. §7.2 says a menu item declaring an accelerator should register itself; there are no menus (M3). The map they will register into exists.
  • Arrow-key group navigation is still missing. §7.2 also asks for it inside composites — radio groups, menus, lists — and it needs those composites before it means anything.
  • Only one pointer. There is no pointer id anywhere in the SPI, so capture is a single slot. Multi-touch would make it a map; nothing needs that yet.

ADR-0059: A control is a record, a node and a rule

Context

:widgets held one module-info.java. Everything under it existed — the three trees, the cascade, KDL, input, focus — and nothing had ever been assembled into a control, so none of it had been asked the questions a control asks.

button first, because it is the smallest thing that touches every seam at once: the §11 parity invariant, the cascade including pseudo-classes, pointer and keyboard activation, and the action half of §9. Whatever shape it takes is the shape the other twelve controls copy, so it is worth deciding rather than discovering twelve times.

Decision

A control is a Java record, a KDL node and a CSS type, and the test says so. ButtonTest builds the same button both ways and asserts the two values are equal. That is the §11 invariant made mechanical: two constructors for one widget drift the first time either grows a field, and a test that compares them is the only thing that notices.

Variants are classes, not an enum. button.primary in a stylesheet, class="primary" in markup, new Button("Save").styled("primary") in Java. An enum would read better in Java and would be a second vocabulary that KDL and CSS could not use — and the parity invariant would then be comparing two things that are not the same value.

Nothing visual is in the widget. The height, the padding, the colours and all four variants are in controls.css in the toolkit-base layer. What the record owns is behaviour: focusable, activates on a click and on Space/Enter, consumes what it acts on. A control that hard-coded its own colour would be one a theme could not reach.

Metrics are the base layer’s, colours are the theme’s. docs/design-system.md §3 says component metrics ship as component-token defaults; this splits them by who can know the answer. Height 32 and padding 0 12px are theme-invariant and sit in the base rule. What a hover looks like is not: it lightens on Nord dark and darkens on Nord light, so --gb-button-bg-hover is defined in each theme file. An application overriding a component token restyles every button without touching a rule, which is what §3 asks for.

The action is a field on the immutable record. A Runnable is not state, it is part of the description — rebuilding a button with a different action means it now does something else, which is what the author intends. Nothing in the widget remembers a press; the press belongs to the router, on the element (ADR-0052, ADR-0054).

Markup names an action; it cannot be one. button press="save" resolves against an Actions registry. KDL is data, and a markup file that could name a Java method would be code with a different syntax — hot-reloading it would mean hot-reloading code. The indirection is also what makes reload work: a reloaded document re-resolves every name against the same registry, so the new tree’s buttons are wired to the handlers the old one had (ADR-0051).

A registry is strict by default and lenient on request. An unbound name is a typo, and a button that silently does nothing is the hardest kind of bug to notice — no error, no log line, and it looks perfectly normal. But a preview or a document mid-edit needs to inflate with handlers not yet written, so Actions.lenient() exists and Controls.inflater() uses it. Same asymmetry ADR-0051 drew between a first load and a reload.

The router synthesizes CLICKED; controls do not derive it. A click is a press and a release on the same node. A release is not: dragging off a button and letting go is how a user cancels, and it is a gesture people rely on. The release still reaches the captor, because the captor has to stop looking pressed (ADR-0058) — but it is not an activation. Deriving that in each control would mean each one re-deciding what “the same node” means; here it means the release landed on the pressed element or inside it, so releasing on a button’s own label is a click on the button.

Only the primary button clicks. A right-click opens a context menu; a control that activated on one would be a menu that also pressed the thing under it.

Space and Enter activate, and a held key does not repeat. Holding Space is one activation, because a button is not a key. A control that wants the opposite — a spinner’s arrows — will say so, and KeyEvent.isRepeat() is what it will say it with.

Padding became four edges. padding: 0 12px is the button’s own metric from the design system, and ComputedStyle carried a single StyleLength. So there is now an Insets, CSS’s 1–4 value shorthand, and the four longhands. A shorthand with one bad part is dropped whole: half of it applied is harder to see than none of it, because two edges move and two do not, and that reads as a layout bug rather than a typo.

Insets lives beside StyleLength in natives.yoga rather than in layout or css. Both of those name it — a ComputedStyle carries one and a Box is built from one — and layout already depends on css; putting it in either would make that dependency mutual for one record.

Alternatives considered

  • A Variant enum on the record. Rejected above: it cannot be spelled in KDL or matched in CSS, so it would break the invariant it was meant to serve.
  • Activate on RELEASED and let the widget check bounds. Rejected: the widget does not know its bounds — that is the hit-test snapshot’s, one layer down — and every control would need the same wrong guess.
  • A Clickable interface with an onClick method, instead of a CLICKED kind. Rejected: it would need its own capture and bubble path and its own consume(), duplicating machinery that already exists to gain a method name.
  • Put the control’s colours in the base layer. Rejected: a base rule can only state one value, and hover lightens on one theme and darkens on the other.
  • Ship button with no variants until borders and radii exist. Rejected: the variants cost four rules and a dozen tokens, and they are what proves classes are the right mechanism. What is genuinely missing is stated below rather than approximated.
  • Assert the appearance only as values. Height, padding and resolved colour per state are what the cascade produces, and checking them is cheap and exact — but they cannot catch a padding applied to the wrong edge or an icon drawn at the wrong origin, because both resolve to the values asked for. So there are golden images as well. §14 ties the corpus to showcase screens, and those still do not exist; a control’s own images are the smaller thing that can be built first.

Consequences

  • The pattern is set and the next control is mechanical. toggle, checkbox, radio and the rest are the same four pieces: a record, a registry line, a base rule, a parity test.
  • What Box cannot express is now visible in a shipped stylesheet. The 8px radius, the 1px border on ghost, the body-strong weight, and the 2px --gb-focus ring at 2px offset are all in docs/design-system.md and none of them can be drawn. controls.css says so in a comment rather than approximating them, and :focus-visible stands in with a background change so keyboard focus is at least visible. Each arrives with the thing that paints it.
  • An icon is a Box now, which closes what ADR-0043 left open — “nothing decides an icon’s intrinsic size until the widget model does”. The answer turned out simpler than the question: an icon is built at a size and that size is its intrinsic one, so unlike text it needs no measure function and no callback into C. Button takes a label, an icon, or both. The icon is borrowed: a widget is a value rebuilt every frame and must not own something with a close(), so markup names an icon against a registry rather than building one — a document reloaded on every keystroke would otherwise leak one per reload.
  • :disabled is the one pseudo-class a widget owns. :hover, :active and :focus are facts about the pointer and the keyboard and the router derives them; disabled is a fact about the description. WidgetRenderer mirrors Styled.isDisabled() onto the element before the cascade is asked, so the stylesheet, the hit test and the activation cannot disagree. A disabled control still lays out, paints and hit-tests — it just does not act, and it leaves the Tab order, because a focusable control that never responds strands a keyboard user on it.
  • Four golden images. The variants on both themes, the five states side by side, and the icon layout. Value assertions check what the cascade resolved; these check what Blend2D drew, which is a different question and the one that catches a padding applied to the wrong edge. GoldenImage, TestFrames and RendererRequirement moved into :core’s test fixtures to get there — shared rather than copied, because two golden comparators would drift on the tolerance and the tolerance is the whole argument of ADR-0050.
  • The showcase is a widget tree. It was hand-built Boxes; it is now a stateful widget with a bar, a sidebar, wrapped prose and a row of buttons — which is what makes setState, reconciliation, theme switching, focus traversal and :hover repaints run outside a test at all. Window now repaints itself when input changes a pseudo-class, because otherwise every application would have to remember and the one that forgot would have buttons that never light up.
  • bind is still missing — the read half of §9. action is here; a control that shows a value rather than triggering one needs the other.
  • Two more themes’ worth of tokens. Every control that ships adds component tokens to both Nord files. That is the cost of the split above, and it is paid per control rather than per theme.

ADR-0060: A resize draws from inside SDL’s event watch

  • Status: Accepted
  • Date: 2026-08-16
  • Relates to: docs/ARCHITECTURE.md §4, §12; supersedes the open question in ADR-0024

Context

Windows and macOS run a modal loop while a window is being resized. WM_SIZE arrives from inside DefWindowProc’s move/size loop; AppKit runs its own event-tracking mode for the duration of the drag. In both cases the platform takes the thread when the gesture starts and does not give it back until it ends.

Goldberry’s frame loop is a while (running) around SDL_WaitEventTimeout (ADR-0020). During a drag that call does not return, so the loop does not iterate, so nothing translates the resize, lays anything out, or presents a frame. What the user sees for the length of the drag is the last frame drawn before they grabbed the edge — stretched, cropped, or blank, depending on the compositor. Wayland and X11 have no such loop and were prompt from the first day, which is exactly why this went unnoticed for so long: it is invisible on the platform the toolkit is developed on.

The loop is not stalled in any sense we can fix from Java. It is not slow, not blocked on a lock, and not waiting for work. It is inside SDL, which is inside the platform, which is running its own message pump — several frames deep in C, on our thread.

What the platform does keep doing inside that loop is pumping events. SDL sees every one of them. And SDL offers SDL_AddEventWatch: a callback invoked as each event arrives, from inside whatever pump is running, before the event reaches the queue.

Decision

Install an event watch, and draw from inside it. SdlEventWatch binds SDL_AddEventWatch/SDL_RemoveEventWatch with an FFM upcall stub; Sdl3Backend installs one at start-up and, when a WINDOW_RESIZED or WINDOW_EXPOSED arrives, translates it, hands it to the sink that the current pumpEvents published, and emits any frame that is due — all before the callback returns to SDL, and therefore before the platform’s resize loop takes the thread back.

Four guards decide whether the callback does anything at all, and every one of them exists because a watch is called in circumstances a pump never is:

  • Not on the UI thread. SDL runs the watch on whichever thread pushed the event, and wakeup() pushes from background threads by design (ADR-0020). Painting there would be a data race on every object the frame touches.
  • No active sink. Between pumps there is nowhere for an event to go. The queue will deliver it in the ordinary way.
  • Already inside the watch. Painting asks for the next frame, requestFrame wakes the loop, and the wakeup is a pushed event — which runs the watch again, on this thread, from inside the paint it would restart.
  • Not a window event. A modal loop starves frames, not input: keys and pointer events are delivered by the pump as usual, and translating them twice would double every click.

The event still reaches SDL’s queue afterwards, so the pump translates it again when the drag ends. Sdl3Window.resizedTo is what keeps that from costing a second layout pass: a resize to the size the window already has is not news, and is reported once.

The frame pacer applies inside the watch as well as outside it. A drag that outran the display would be spending frames nobody scans out (ADR-0047), and a frame held back during a drag is emitted by the next resize event, of which there are many.

Alternatives considered

Leave it, and document it. What ADR-0024 did — “waits for a renderer worth driving from it”. Two years of that reasoning would still leave a toolkit whose windows go blank when resized on two of its three platforms, and the renderer it was waiting for has arrived: Blend2D paints a 960×640 frame in 1.6 ms, so there is nothing left to wait for.

Drive the whole loop from the watch. Make the callback the frame loop and let pumpEvents become a shell. This is what a toolkit built around SDL’s callback API would do, and it is a larger change than the problem justifies: the loop would then be re-entrant everywhere rather than in one guarded place, and every invariant that currently holds because the loop is a loop would need restating.

A timer thread that paints during the drag. Rejected outright — it would paint from a thread that is not the UI thread, which is the one rule the whole SPI is built on, and AppKit would refuse it anyway.

SDL_SetEventFilter instead of a watch. The filter runs at the same point and can drop events by returning false. That is more power than this needs, there is one filter per process where there may be many watches, and a filter that accidentally returns false eats input.

Handle the resize only in the watch, and stop queueing it. Not possible: a watch cannot remove an event, only a filter can, and using the filter for this would mean the drag’s events never reach the queue — so a pump that ran without a frame outstanding would never learn the window had changed size.

Consequences

Windows and macOS redraw while the window is being dragged. That is the whole point, and it is the half of this record that cannot be tested here: it needs a human dragging a window on a platform that has a modal loop. What CI proves is everything up to that — that the watch is installed, that SDL calls it, that a translated event and a frame come out of it re-entrantly — because a test can push an event from inside an event handler, which puts the callback in exactly the situation a modal loop puts it in (ADR-0061).

The event path is re-entrant now, in one place. Sdl3Backend has two fields it did not have — the active sink and a re-entrancy flag — and a bug in either is a bug that only shows up during a resize on a platform with a modal loop. The guards are cheap; the reasoning behind them is the expensive part, which is why each one is written down next to the code as well as here.

A resize is now reported once rather than twice. Independently correct — SDL sends WINDOW_RESIZED liberally and a re-layout to the size the window already has is pure cost — but it is load-bearing here, so resizedTo cannot be “tidied up” without live resize doing double work.

Every event now crosses into Java twice. Once through the watch, once through the pump. The watch’s own cost is four field reads for the events it declines, which is nothing next to what the pump already does per event — but it is a cost paid on every event, including the ones this exists for none of.

A libgoldberry without the two new symbols still works. The watch is optional in the same way and for the same reason the cursors are: an artifact built before these symbols were exported loses live resize, not the ability to open a window.

The upcall stub lives in a shared arena, not a confined one. SDL calls the watch on whichever thread pushed the event, and a confined arena’s stub invoked from another thread is a failed crossing rather than a callback that declines. Shared arenas are more expensive to close; this one is closed once, at shutdown.

ADR-0061: The events a test cannot produce are pushed

  • Status: Accepted
  • Date: 2026-08-16
  • Relates to: docs/ARCHITECTURE.md §7, §14; answers the open question in ADR-0056

Context

Sdl3Backend.translate turns SDL event numbers into BackendEvents. Every branch of it was covered except one, and the gap was recorded honestly rather than papered over: the MOUSE_WHEEL branch had never run. Everything around it was checked — the struct offsets by the layout probe, the buffer’s readers by a fabricated event written at those offsets, the route from a BackendEvent to a widget on the headless backend — but the eight lines joining them had never executed anywhere, because a test cannot turn a wheel, the showcase scrolls nothing, and CI’s Xvfb run therefore never reached them.

That is not a small gap in an important place. It is a small gap in the only place the two halves meet: the accessor is right, the router is right, and the line that picks wheelPointerX() rather than pointerX() is the one nothing had ever run. Reading a wheel event’s position through the motion arm’s accessor returns a plausible float — it is the vertical delta, which lands at exactly that offset — so the failure mode is a scroll that works and a hit test that lands somewhere else.

ADR-0060 then added a second path with the same shape: the event watch fires from inside a resize gesture, and no test can drag a window either.

Decision

Fabricate the event and push it through SDL. SdlEventBuffer.writeWheel and writeWindowEvent fill the buffer the way SDL fills one, at the offsets the layout probe has already checked against the compiled C, and SdlVideo.push hands it to SDL_PushEvent. The event joins SDL’s own queue, comes back out of the ordinary pump, and takes the shipping route — the real translate, the real window lookup, the real sink — rather than a test’s imitation of it.

The tests run under SDL’s dummy video driver, so they need no display, no compositor and no window manager, and run in CI on all three platforms.

The same mechanism reaches the event watch, and this is the part worth stating plainly: pushing an event from inside an event handler puts SDL in exactly the state a modal resize loop puts it in — a push, from the UI thread, while a pump is already running. The watch fires, finds an active sink, translates, dispatches and emits a frame, all before the push returns. A test can assert that the resize and the frame arrived re-entrantly, which is the property that matters and the one that separates this from “the resize arrived eventually”.

Alternatives considered

Call translate directly with a hand-filled buffer. Cheaper, and it tests the branch — but it skips SDL entirely, so it proves nothing about whether SDL delivers a wheel event to this pump at all, and it cannot reach the watch, which only SDL can invoke.

A robot: SDL_WarpMouseInWindow and friends. SDL has no API to synthesize a wheel turn; warping the pointer is the closest thing and it produces motion, not scroll. Going below SDL to the platform’s own injection APIs — SendInput, XTestFakeButtonEvent, CGEventPost — means three implementations, a macOS accessibility permission prompt, and a test that fails on a locked screen.

Make the showcase scroll something, and check it by hand. Worth doing when there is a scroll widget to scroll (M3), and it is not a test: it moves the evidence from “CI asserts it” to “someone remembered to try it”.

Leave the branch uncovered and keep the entry in the open questions. The honest option, and the one that had been taken until now. It stops being defensible once the same technique is needed for the resize watch anyway.

Consequences

Two open questions close, and one narrows. The wheel branch runs, on every platform, on every CI run. The watch’s re-entrant path runs with it. What is still unproven is the platform half of ADR-0060 — that Windows’ and macOS’ modal loops really do pump events during a drag — and that needs a human with a mouse, not a better test.

SDL_PushEvent is now part of the public binding surface, not just the cross-thread wakeup’s private business. That is fair — synthesizing input is what the call is for, and accessibility tooling and UI automation are the same need — but it is API, so it is documented as API rather than as a test hook.

A fabricated event is only as good as its offsets. The writers use the same Layouts entries the readers do, so a wrong offset writes and reads the same wrong place and the test passes. What stops that is the layout probe, which checks those entries against the compiled C — this decision leans on ADR-0010 rather than duplicating it.

The tests construct a real Sdl3Backend, which initializes and quits SDL. Under dummy that is fast and harmless, but it is process-global state in a test suite: the video driver and frame rate are set as system properties and restored afterwards, and a test that forgets to restore them changes what a later test measures.

A pushed event runs every event watch, including Goldberry’s own. That is what makes the re-entrancy test possible and it is also a trap: a test that pushes an event while a pump is running is not simulating the modal loop, it is the same code path, and anything the watch does — including painting — really happens.

ADR-0062: bind is a path, and nothing else

  • Status: Accepted, amended by ADR-0063 — binding is one-way, and the writing half described below is withdrawn
  • Date: 2026-08-16
  • Relates to: docs/ARCHITECTURE.md §9, §17; completes the markup contract begun in ADR-0051 and the registry pattern of ADR-0059

Context

§9 gives markup two ways to reach the application: action, which names something to do, and bind, which names a value to follow. The first shipped with the button. This is the second.

§17 left one thing open: how much of an expression a bind attribute may contain — “dotted paths only vs. mini-expressions”. It is a real fork. Once bind="!prefs.enabled" is legal, so is bind="a && b", then comparisons, then string interpolation, and the markup contract has acquired an expression language that has to be specified, parsed, error-reported, versioned and kept stable forever — in a file format whose whole justification is that it is data, and that is reloaded from disk on every keystroke.

The second question is where the binding lives once it is resolved. The obvious answer — a Bound widget that wraps the real one and rebuilds it — is wrong here, and it took drawing the element tree to see why: a wrapper is an element, an element is a link in the chain the cascade walks, and panel > text would then match an unbound text and miss a bound one. The same node, styled differently, for a reason no stylesheet can see.

Decision

A path, and nothing else. A bind value is identifier(.identifier)* — frost, prefs.frost, prefs.window.opacity — enforced by a regular expression at the registry, so bind="!prefs.frost" fails at inflation with the text quoted rather than resolving to nothing. Negation, comparison and formatting stay in Java, where they are already expressible and already testable.

A Property<T> is a cell with listeners, and that is all: get, set, subscribe. No computed values, no dependency tracking, no streams — those are what a framework brings, and §9 asks for a binding that needs none. set compares with equals and does nothing when the value is unchanged, which is what makes two properties mirroring each other terminate instead of recursing.

Bindings is the third registry, alongside Actions and Icons, deliberately the same shape: markup names, the registry resolves, and strict is the default because a control bound to nothing looks exactly like a control bound to something that never changes. A document reloaded at runtime re-resolves every path against the properties the application already holds, so the values survive the reload along with the markup.

The binding lives on the widget, and the subscription on its element. Widget.binding() returns the property a widget follows, defaulting to null; Element subscribes when it is mounted, follows the property across rebuilds by identity, and closes the subscription when it unmounts. A change calls markNeedsBuild, which is the same route setState takes — so ten changes in one frame cost one build, and the coalescing was already written (ADR-0052).

What a bound value means is the widget’s business. For text it is the content, read at render rather than captured at build. For a future checkbox it will be the checked state.

Amended. This record originally continued: “…which that control will also write back to — which is the whole of ‘two-way’.” That plan is withdrawn. Binding is one-way, enforced by handing widgets an Observable rather than a Property, and a control reports what the user did through its action instead (ADR-0063). Everything else in this record stands.

Alternatives considered

Mini-expressions. The expressive option, and the one that keeps disabled bind="!prefs.enabled" out of Java. Rejected because the cost is not the parser — it is that an expression language in a reloadable data file is a second programming language in the product, with its own semantics to specify and its own errors to report at 3 a.m. from a file someone was mid-edit in. A mirrored property costs one line of Java; the negation operator that saves it costs a grammar.

Dotted paths plus !. The tempting middle. Rejected for exactly the reason it is tempting: it is the first step of the argument above, and there is no principled place to stop after it. If negation earns its keep it can be added later, and adding an operator to a grammar of none is a smaller change than removing one.

Reflection over a model object — bind(controller) walking prefs.frost with getPrefs().isFrost(). Rejected on §9’s own terms: it says wiring is explicit and there is “no reflective #handler magic”, and a path that resolves through reflection is exactly that magic, with a refactor that renames a getter breaking a markup file that names no Java at all.

A scoped model, where prefs is a sub-model that can be handed to a subtree. Deferred, not rejected: the registry is flat today and the dots are part of the name. Nothing yet renders a subtree against a different model, and adding scopes later is additive. This is written down as a floor rather than left implicit, because a flat registry that quietly grew scopes would break paths.

A Bound wrapper widget. The design most toolkits reach for, and the one this started as. It would have needed the cascade’s ancestor chain to skip elements with no CSS type — a defensible change, and one that would fix the same latent problem for every composition wrapper — but it is a change to how everything is styled, made in service of a feature that does not need it. The binding went on the widget instead, and the cascade was left alone.

Firing the listener on subscribe. Rejected: a widget subscribes while it is being built, and firing there would mark it as needing a rebuild before its first build had finished. A subscriber reads get() when it is ready.

Consequences

bind is finished for the widgets that exist, and specified for the ones that do not. text bind="user.name" works from KDL and from Java, with the parity test extended to cover it. What is not here — and, after ADR-0063, will not be — is a control that writes: a control reports what the user did through its action, and the application sets the property.

A malformed bind fails loudly, including on reload. That is the intended behaviour and it has a cost: hot reload is otherwise deliberately forgiving (ADR-0051), and a half-typed bind="prefs." now stops that document from inflating until it is finished. Refusing is still right — the alternative is a control that silently never updates — but it is a place where the forgiving path and the strict one disagree.

Every widget now answers binding(). One default method on the core interface, and one subscription field per element. The cost is a null check per mount and per update on every element in the tree, which is nothing; the risk is that binding() is now a place where a widget can hold a reference to something the application owns, and an element that failed to unsubscribe would keep a whole subtree alive. That is why the unsubscribe is unconditional in unmount, ahead of the state’s own dispose, and why there is a test that counts listeners.

A property change asks for a build, not a frame. The element is marked dirty and tree.needsBuild() reports it — but nothing repaints the window, exactly as nothing repaints it for a setState. The application wires that today (the showcase passes window::repaint), and it stays that way until the retained render tree makes the host own it (ADR-0053). It is the same gap in both directions rather than a new one, but a bound value that appears a frame late has a more obvious owner than a setState does, so it is worth naming here.

A Property holds a value, and a mutable object is not one. set with the same list instance notifies nobody, however much the list changed inside. Records and immutable values are the intended contents; anything else works until it quietly does not.

ADR-0063: Data flows down, events flow up

  • Status: Accepted
  • Date: 2026-08-16
  • Relates to: docs/ARCHITECTURE.md §9, §11; narrows ADR-0062; amends §9’s “one/two-way binding”

Context

§9 says bind “is one/two-way binding against an observable model”. ADR-0062 shipped the reading half and described the writing half as waiting for a control that writes — a checkbox resolving a typed property and calling set from its own click handler.

That plan is at odds with the rest of the toolkit. A widget is an immutable description (ADR-0004); state lives on the element and changes only through setState (ADR-0052); a build must be pure. A control that writes to the application’s model breaks that in the one place it is hardest to see: the write does not come from application code at all, it comes from a bind= attribute in a data file, so the answer to “what changed this value?” is a string somebody typed into markup — possibly while the window was open, since markup hot-reloads (ADR-0051).

Two-way binding is also the feature that makes the update graph a graph. Once a control writes to a model that other controls read, the order of updates is a property of the binding topology rather than of the code, and every framework that shipped it — WPF, Angular 1, Knockout — grew a vocabulary for controlling it: modes, triggers, delays, UpdateSourceTrigger, $digest cycles. Goldberry’s entire update story today is “mark dirty, flush once per frame”, which is comprehensible because it is one direction.

Decision

Binding is one-way, and the type system says so. Observable<T> is the half of a Property<T> that can be read and watched; Property<T> adds set and is what the application keeps. Bindings.resolve hands out an Observable, and Widget.binding() returns one — so a widget built from markup cannot write to the model, because there is no method to call.

A control reports, and the application decides. What the user did travels back up the way it already does: as an action, through the Actions registry (ADR-0059). A checkbox will be checkbox bind="prefs.frost" change="toggleFrost" — the value flows down through bind, the intent flows up through change, and the one line that mutates anything is Java the application wrote.

The registry is not a way back to a writable handle. Bindings.bound() returns observables, and there is no writable(path). A path is how a value is published to markup; something that could also fetch it back for writing would make the registry a service locator, and any code holding the Bindings object could then mutate any model it names.

§9’s “one/two-way” is amended to say one-way. That is a change to the architecture document, made deliberately and recorded here rather than by editing history.

Alternatives considered

Two-way, as §9 originally said. The reason it is in the document is real: checkbox bind="prefs.frost" with no handler is less to write than a bind plus a change, and for a settings dialog of thirty toggles that difference is thirty handlers. Rejected because the saving is at the wrong end — it saves typing in the easy case and costs comprehensibility in the hard one, and the hard one is a control writing a value another control’s bind reads, mid-frame, from markup.

Two-way as an opt-in, bind versus a bind-two-way. Rejected: an opt-in is still the feature, with all of its semantics to specify, plus a second spelling. An escape hatch that is used once is a feature that has to work forever.

Convention, not types — hand widgets the Property and write down that they must not call set. That was the position ADR-0062 shipped with, and it is the weaker one: the rule holds until somebody in a hurry reaches for the method that is right there. The split costs one interface.

A read-only wrapper rather than a supertype, so Observable cannot be cast back to Property. Rejected as disproportionate: it allocates per resolve and breaks identity comparisons, to stop a downcast that a widget author could only write on purpose. This is a design boundary, not a security boundary — see below.

Consequences

Controls are “controlled” in the React sense, and this is the consequence worth understanding before writing the next widget. A checkbox draws the value it is bound to. Clicking it does not move the tick; it raises a change, and the tick moves when the application sets the property. A handler that forgets to set produces a control that visibly does nothing — which is the same class of bug React’s controlled inputs have, and the same defence applies: the UI is a function of the state, so a control that will not move means the state did not change, and that is exactly where the bug is.

Local, ephemeral state stays local. Nothing here says a control may not have state of its own — a text field’s caret and selection, a spinner’s mid-edit text, an IME’s preedit — that is what State on the element is for. The rule is about the model: what the application owns, only the application writes. text-input will be where this line has to be drawn precisely, and M5’s IME work is where it will hurt if it was drawn wrong.

The type split is defeatable by a cast. Property implements Observable, so a determined widget can (Property<?>) binding() and write. Nothing prevents that, and nothing tries to: the point is that the honest path is one-way and the dishonest one has to be typed out deliberately. A test pins the signatures, so widening resolve or binding() back to Property fails the build rather than being noticed later.

§9 is now narrower than it was, and any reader who took “one/two-way” as a promise will find one direction. That is why this is a record and not a silent edit: the document is amended, ADR-0062’s plan for the writing half is withdrawn, and both say so.

Property.set remains fully public. It is the application’s API, used from a button handler, from a completion on the UI thread, from a hot-reload callback. One-way is about which layer may write, not about ceremony around writing.

ADR-0064: A rounded rectangle is four cubics, and opacity is a multiply

  • Status: Accepted
  • Date: 2026-08-16
  • Relates to: docs/design-system.md §1.5, §2.1, §2.2; docs/ARCHITECTURE.md §8; extends ADR-0049 and ADR-0053

Context

The design system pins four numbers that nothing in the toolkit could draw:

  • Radii — 4, 8, 12, full (§1.5). Box filled axis-aligned rectangles.
  • The focus ring — 2px --gb-focus, 2px offset, following the control’s radius (§2.2). controls.css faked it with a background change and said so in a comment.
  • Borders — the checkbox’s glyph is an outlined square before it is a filled one, and there was no outline.
  • :disabled at 45% opacity, never colour-remapped (§2.1). ComputedStyle had parsed opacity since the CSS engine landed and Box.style dropped it on the floor with a comment explaining that group opacity needs a layer.

Every one of these blocked button from complying with its own §3 metrics row, and three of the four blocked checkbox from existing at all. They arrive together because they are drawn by one shape: a rounded rectangle, filled for the background, stroked inward for the border, stroked outward for the ring.

Decision

The path is built from cubics, not bound

Blend2D has a round-rect geometry call. Using it would mean adding a symbol to the export list, and that list has caught the same class of bug three times — --exclude-libs,ALL, then Blend2D’s BL_STATIC making BL_API expand to nothing, then HarfBuzz’s bare HB_EXTERN — each of which linked a symbol in and left it local, and each of which was only answered by a CI run across four targets. The MSVC .def and Mach-O exported_symbols_list branches are still answered by the next run rather than by argument.

bl_path_cubic_to is already exported and already exercised by 1544 Lucide icons. A quarter circle as a cubic with control points at κ = 0.5522847498 of the radius is off by about one part in 10,000 — at a 12px corner, a thousandth of a pixel, well inside the golden images’ tolerance (ADR-0050). So RoundRect builds the path and no new symbol crosses the boundary. The corner works on every platform on the first run, rather than on the run after the one that found out.

The radius is clamped to half the shorter side, which is CSS’s own rule and what makes border-radius: 9999px a pill rather than an error — so §1.5’s full is expressible without a keyword.

Six properties, one record

border-radius, border-width, border-color, outline-width, outline-color, outline-offset, plus the border: and outline: shorthands. They live in one Decoration record on both ComputedStyle and Box rather than as six components on each, because they are only ever read together: the painter that strokes a border needs the radius to stroke it along, and the ring needs both to sit outside them.

Percentages are refused rather than carried. A percentage radius resolves against the box’s own size, and the box has no size until Yoga has run — long after the cascade. border-radius: 50% is a dropped declaration with a warning naming it, rather than a corner that is silently square.

The ring is outline rather than a widget’s own drawing for one reason: a widget that drew its own would have to know its own radius to follow it, and §2.2’s numbers would then live in as many places as there are controls. As a rule in the toolkit-base layer it is written once:

button:focus-visible,
checkbox:focus-visible {
  outline: 2px solid var(--gb-focus);
  outline-offset: 2px;
}

CSS outlines are drawn outside the border box and take no space, which is exactly what a ring at a 2px offset needs — it cannot move a control by existing.

Opacity multiplies alpha down the subtree

BoxPainter accumulates opacity as it walks, and hands the visitor a box whose colours already include every opacity above it. Box.fade(alpha) scales the alpha of the background, the text, the icon, the mark, the border and the ring; alpha >= 1 returns the same box, so an ordinary frame allocates nothing.

This is not CSS group opacity, and the difference is stated rather than hidden. The specification renders the subtree into a layer and composites that layer once, so two overlapping children at opacity: .5 each show the backdrop rather than one showing through the other. Multiplying per box differs exactly where children overlap, and nothing in the widget canon overlaps: a control is a mark, an icon and a label placed side by side by Yoga. The design system asks for opacity in one place — :disabled at 45% — and there the two are indistinguishable.

stack (§11, z-layering) is the widget that will make the difference visible, and a compositing layer is what it will need. It is also what damage tracking and the animation overlay (§1.7’s “layer promotion”) want, so the three arrive together or not at all.

A disabled control does not light up

Removing the colour remap exposed a second problem: button.danger:disabled and button.danger:hover no longer fought, so a disabled button lightened under the pointer. CSS would spell the fix :not(:disabled):hover, and :not() is not in §8’s subset. Writing it out is a rule per variant per state per control — a dozen selectors, each able to be wrong on its own.

Instead PointerRouter.mark — the single choke point for :hover and :active — refuses to set a state on a disabled widget. Clearing always goes through, so a button that disables itself in its own press handler does not keep the state. The ENTERED/EXITED events are not suppressed: this is about what a control looks like, not what it is told, and a tooltip explaining why something is disabled needs the event.

Consequences

  • button complies with §3’s metrics row: height 32, padding-x 12, gap 6, radius 8, the design system’s ring, and 45% opacity when disabled. Four golden images were regenerated and the diff is the argument.
  • Disabled is one number instead of eight tokens. --gb-button-disabled-bg, --gb-button-disabled-text and --gb-button-bg-focus are gone from both themes: the first two were the colour remap §2.1 forbids, and the third was the focus stand-in. A disabled danger button now still reads as dangerous, which a remap to one grey surface had made impossible.
  • One BlendPath is allocated per paint call and reset between shapes, rather than one per rounded corner per box. A path is a native allocation and an arena; a window of forty rounded controls would otherwise make eighty of them a frame to draw the same four arcs.
  • ComputedStyle.with is now written with per-field withers instead of fourteen-argument positional constructor calls. The old form was correct and unreadable — a reader could not tell a case that set width from one that set height without counting commas, which is precisely the mistake the shape invites.
  • Still not expressible, and absent rather than approximated: the body-strong weight on a button’s label, which needs a second Inter face (or the variable font’s wght axis) and the typography-token scale of §1.4; and every transition in §1.7, which needs a frame clock and an animation overlay. Both are in book/src/status.md as open, not faked with a value that looks close.
  • Untested claim: the arcs have only been rasterized on linux-x64. Blend2D JITs its pipelines per CPU, so the corners on AVX-512, on an Apple Silicon NEON path and under MSVC are answered by the next CI run — which is what the golden images with their per-channel and area tolerance are for.

ADR-0065: A part is styleable and not constructible

  • Status: Accepted
  • Date: 2026-08-16
  • Relates to: docs/ARCHITECTURE.md §11; docs/core-widgets.md §3; docs/design-system.md §3; extends ADR-0059; applies ADR-0063

Context

checkbox is the second control, and the first with a problem button did not have: it has two surfaces a theme must style differently. The control is 32 tall, holds the label and is the hit target (§1.3: “hit targets ≥ 32×32 even when the visual is smaller — checkbox glyph 16px, hit area 32”). The glyph is 16 square, has its own radius, its own border, and is the thing that turns blue.

A ComputedStyle carries one background, one radius and one border. One cascade node cannot describe both. The options were:

  1. Hard-code the glyph in Java. Then no stylesheet can touch it, which contradicts §11’s “colours and metrics only via --gb-* tokens — themes restyle everything”, and makes a high-contrast theme (§4, “an alias swap, not a special code path”) impossible for this control.
  2. Make the glyph the checkbox’s own box and hang the label beside it. Then the gap, the alignment and the hit height have nowhere to come from, and the focus ring goes round a 16px square rather than the control.
  3. Give the glyph a cascade node of its own.

Decision

The glyph is a part: check-indicator is a CSS type selector, and is deliberately not registered in the KDL inflater.

Checkbox is a Widget.Leaf whose children() are a CheckIndicator and a text — child widgets, not child boxes, because a child widget becomes a child element and an element is what the cascade can reach.

This is an exception to the parity invariant, and a stated one rather than an oversight. §11 says every widget is a Java record, a KDL node and a CSS type. That invariant is about the widgets in the catalog: an author picks one from a list and puts it in a document, so all three forms have to agree or two of them drift. A part is not in the catalog and has no independent existence — a check-indicator outside a checkbox is a 16px square that means nothing, and registering the node would let a document create exactly that.

What an author wants from a part is to restyle it, and a type selector is the whole of that. So Controls.controlTypes() lists checkbox and not check-indicator, and the parity test is never asked to inflate a node with no meaning. The test asserts the absence, so the exception cannot become an accident later.

Three states, and :indeterminate

docs/core-widgets.md asks for tri-state. MIXED is a real state: a “select all” over a partial selection is neither on nor off, and drawing it as either is a lie about the data.

It matches :indeterminate, not :checked — an eighth pseudo-class in a set the contract lists as seven. Two pseudo-classes cannot describe three states, and the alternative (“checked plus a modifier”) makes every stylesheet that wrote checkbox:checked and meant “the tick is showing” silently wrong for the mixed case. Styled gains isChecked() and isIndeterminate(), mutually exclusive, mirrored onto the element by WidgetRenderer on the same pass and by the same argument as :disabled: they are facts about the description, so the stylesheet, the hit test and the semantics cannot disagree about them.

Toggling never produces MIXED. Mixed is a state the application can describe and the user cannot reach — clicking a partial selection asks for “all of them”, which is CHECKED. Every desktop toolkit agrees, and the alternative is a control that cycles through a state nobody wants.

The mark is a toolkit shape, not an icon

The tick, the mixed-state dash and (ahead of radio) the dot are a closed Box.Mark enum drawn by the painter, not Icons. An Icon owns native memory and must be closed exactly once; a widget is a value rebuilt every frame and cannot hold one, which is why markup names an icon against a registry (ADR-0059). Asking an application to register a Lucide icon in order to get a tick inside its own checkbox would be absurd.

The mark is drawn in style.color() — the foreground of that node, exactly as text is the foreground of a text node — so check-indicator:checked { color: … } is the one rule that moves it. Its proportions are of the box rather than absolute, chosen against the 24×24 Lucide grid (§1.6), so a tick beside a Lucide icon reads as the same hand at any size a stylesheet asks for.

The value is controlled

Straight application of ADR-0063: bind is where the value is read from and change is what the click runs.

checkbox bind="prefs.frost" change="toggleFrost" "Frosted sidebar"

The widget holds the read-only Observable half and has no method with which to write. The tick moves when the application moves it. A checkbox whose change handler does nothing does not move — which looks like a bug and is one, in the application, exactly where it should be. A test asserts precisely that: a click on a bound checkbox with an empty handler leaves the property and the tick alone.

A bound property may hold a Value or a Boolean, because an application modelling a binary preference should not have to import a tri-state enum to bind one. A null — a property that has not loaded — falls back to the markup’s own value rather than guessing that “not loaded” means “off”.

Space, and deliberately not Enter

button takes both, because activating it is the only thing it does. Enter belongs to a dialog’s default action (§2.3), and a checkbox that swallowed it would leave a form with no keyboard route to submit once focus was on one. Every desktop platform draws the line in the same place.

A click anywhere in the control toggles, label included, per docs/core-widgets.md §3 — which matters more than it sounds, since a 16px square is a small target and the label is usually five times as wide.

Consequences

  • checkbox ships: record, node, CSS type, three states, bind + change, keyboard, :disabled, and three golden images across both themes.
  • Parts are now a category, and radio, slider, select and tabs all have one waiting. Box.Mark.DOT is already there for radio, which is the next control and the one that brings §7.2’s roving arrow-key focus with it.
  • :indeterminate is the first pseudo-class added since the CSS engine was written, and docs/core-widgets.md’s “states are pseudo-classes” list is one longer than the document says. The document is what should change.
  • The showcase carries three checkboxes: one bound and wired — clicking it really does remove the paragraph, so the round trip is visible rather than described — one MIXED, and one disabled.
  • Open: a part inherits :disabled by being handed the flag, not by the cascade. docs/core-widgets.md says “disabled state propagates down the tree; a disabled container disables its descendants”, and nothing implements that generally. Passing the flag works for a control that builds its own parts and will not work for form or group-box, which is where it will have to be faced.

ADR-0066: A weight is a face, and color inherits

  • Status: Accepted
  • Date: 2026-08-17
  • Relates to: docs/design-system.md §1.4, §3; docs/ARCHITECTURE.md §6.1, §8, §10.1; extends ADR-0044 and ADR-0049

Context

Two things arrived together because one turned out to be the other’s blocker.

The bug. A checkbox’s label rendered black on the dark theme — invisible. controls.css says checkbox { color: var(--gb-text) }, and the label is a text child element that no rule names, so it resolved to ComputedStyle.INITIAL’s black. button had never shown this because Button.render copies style.color() onto its child boxes by hand and bypasses the cascade entirely; checkbox was the first control whose content was real elements.

The cause: StyleResolver inherited custom properties only. Ordinary CSS inheritance did not exist. Nothing had needed it, because until checkbox every piece of text in the toolkit was either styled by an explicit rule or handed its colour in Java.

The gap. docs/design-system.md §3 puts body-strong on a button’s label and controls.css said, in a comment, that it could not express it. Every one of §1.4’s seven typography tokens is a font-size, a line-height and a font-weight — and all three inherit. So typography could not land until inheritance did.

Decision

Inheritance is a named half of the property set

ComputedStyle.of(declarations, context, parent) seeds the inherited properties from parent and starts everything else at INITIAL. The inherited half is color and typography, listed in one private method so that adding to it is one edit.

Two properties are deliberately excluded although CSS inherits them:

  • cursor. Goldberry inherits it through the stack of painted rectangles instead — hit testing reads it off whichever box the pointer is over (ADR-0057). Inheriting it here as well would be two mechanisms for one property, and they would disagree the first time a box was styled with no element behind it.
  • opacity, which CSS does not inherit at all — its effect does, and the painter accumulates it down the box tree (ADR-0064). Inheriting the value would apply it once per level: a label under a control at 45% would be drawn at 20%.

WidgetRenderer now resolves styles on the way down and builds boxes on the way up. That is the shape inheritance forces: a child’s color is its parent’s unless it says otherwise, so the parent’s style has to exist first. A node that is neither Styled nor Paints passes its ancestor’s style straight through rather than resolving one — it has no type, no id and no classes, so no selector names it and a cascade walk per composition node per frame would buy nothing.

A weight is a face, not an axis

Inter and JetBrains Mono ship as variable files, and instancing wght at runtime is the smaller download and the more general answer. It needs symbols bound in both libraries — HarfBuzz’s hb_font_set_variations and Blend2D’s variation settings — which means a new struct layout in the probe and three new export branches: the ELF version script, the MSVC .def and the Mach-O list. That machinery has caught the same local-symbol bug three times (ADR-0018, ADR-0031) and is only ever answered by a CI run across four targets.

§1.4 ships exactly two weights, 400 and 600, and Principle 3 says a screen needing a third extends the system rather than improvising one. So Inter-SemiBold.ttf is extracted alongside the variable file: 400 KB, no native change, and the whole shipped scale covered. BundledFont.Weight is a closed pair, and Weight.nearest resolves any CSS number the way CSS’s own font matching does — font-weight: bold gets SemiBold rather than nothing. The axis stays a real optimisation for the day an intermediate weight is specified.

font-weight is therefore resolved to a face in the cascade rather than carried as a number, so a weight no file can honour is discovered while styling and not inside a paint pass.

Fonts is a book, owned like everything else native

The cascade resolves a Typography per node; the painter needs a Font. Without a cache, font-size: 20px on one heading would re-parse Inter — 681 µs and a second copy of a megabyte and a half — every frame, because the widget tree is rendered from scratch each time.

Fonts caches faces by BundledFont and fonts by (face, size), which are the two levels ADR-0044 established. It is an ordinary object an application opens and closes, not a global: these are thread-confined and hold native memory, so a process-wide cache would have to be per-thread, and a per-thread cache of native memory is a leak with no hook to free it.

The size is quantized to a thousandth of a pixel before it is used as a key. Two 13.000000000000002s from different em chains are the same font to any reader, and a cache that disagreed would open a font per frame and look exactly like a leak.

Paints.Context becomes Font font(ComputedStyle) rather than Font font(), because the font is now a property of the node and not of the window.

Only the first family of a list

font-family: Inter, sans-serif takes Inter and discards the rest. §6.1 is explicit that there is no fallback cascade in v1 — a character outside the bundled faces is .notdef on purpose, because a cascade across arbitrary system fonts is what makes text look different on every machine. Honouring the list would be pretending to a mechanism that does not exist.

A family that matches nothing bundled falls back to Inter rather than throwing: that is a stylesheet naming a font nobody shipped, and drawing it in Inter beats a window with no text in it.

Consequences

  • The label bug is fixed, with a regression test that asserts the resolved colour on both themes rather than eyeballing a golden.
  • button is fully §3-compliant. body-strong was the last of the four things controls.css said it could not express; only §1.7’s transitions remain in that comment.
  • The theme’s type tokens now match §1.4. They did not: heading was 16 where the table says 15, body was 14 where it says 13, and there were no line-height tokens at all. The seven sizes, their line heights and the two weights are in both themes, and controls.css exposes them as classes — .body, .body-strong, .caption, .mono — so text class="heading" picks a token rather than a number. The mapping is theme-invariant and the numbers are the theme’s, which is what keeps a large-text or high-contrast theme an alias swap rather than a code path (§10.1, §4).
  • ComputedStyle.INITIAL’s typography is §1.4’s body — Inter 400 at 13/18 — rather than something neutral. A window with no stylesheet should read as the design system; the alternative is a toolkit whose out-of-the-box text is a size nobody chose.
  • Every golden image was regenerated twice: once for the label colour, once for the weight and the corrected sizes.
  • WidgetRenderer keeps a single-Font constructor for benchmarks and for tests that are about something other than typography. It ignores font-family, font-size and font-weight, and says so.
  • Open: text has no style="body" attribute yet. docs/core-widgets.md §2 asks for one; what ships is the class, which is the same thing spelled the way CSS already spells it. Whether the attribute is worth a second spelling is a question for when field and form need labels.
  • Open: em and rem still resolve against CssLength.Context’s fixed numbers rather than against the node’s own resolved font-size. Nothing in the toolkit’s own stylesheets uses em, so this has no effect today — but font-size: 1.2em currently means 1.2 × 16 and not 1.2 × the parent’s size.

ADR-0067: Motion is an overlay on a frame clock

  • Status: Accepted
  • Date: 2026-08-17
  • Relates to: docs/design-system.md §1.7, §3.1; docs/ARCHITECTURE.md §5, §8; extends ADR-0049 and ADR-0052

Context

controls.css had said, in a comment, that it could not express a single one of §1.7’s transitions. Everything snapped: a hover, a press, a disable. §1.7 is not a short section — three duration tokens, two easing keywords, a property whitelist, an animation overlay, layer promotion, an enter/exit lifecycle, OKLCH interpolation, reduced motion, and a virtual clock for tests — and it is the last piece of the design system with nothing behind it.

Decision

The overlay is the whole design

§1.7, verbatim: “Animated values live in a per-node animation overlay applied at paint time, never written back into computed style, so style recomputation and animation can’t fight.”

Every frame the cascade resolves each node’s target style from the stylesheets and its current pseudo-classes. Animations holds where each moving property has actually got to; apply returns a style with the in-flight values substituted, and the target is what the next frame diffs against and what children inherit.

Writing the animated value back is the obvious shortcut and it does not work: the next cascade would see the halfway colour as the node’s real one, diff that against the target, and start a second transition from it. The control would approach its hover colour asymptotically and never arrive. A test asserts that apply does not mutate its argument for exactly this reason.

Retargeting follows: “retargeting mid-flight starts from the current animated value — values never jump”. A pointer leaving a button 50 ms into a 100 ms fade returns from where the colour is, not from the colour it never reached.

The state lives on the element

For the same reason setState and :hover do (ADR-0052): a widget is rebuilt constantly and could remember nothing, so a transition held by one would restart on every rebuild and never finish. Held on the element, it survives every rebuild that keeps that element and dies with the element — which is the right lifetime, because an animation that outlived its node would be animating something nobody can see.

Created lazily. Most nodes never animate, and an Animations per element for a static tree is an allocation for nothing.

A clock, not a frame counter

§1.7: “animations are functions of the frame timestamp, not frame counts”. A frame-counting animation runs at a different speed on a 144 Hz panel than a 60 Hz one and slows down whenever a frame is late, turning a dropped frame into a visibly slower transition.

The clock is read once per frame and every node animates against that one value. Two properties §3.1 says “arrive together” — a toggle’s thumb and its track — would otherwise arrive microseconds apart and drift further the longer they ran.

Clock.system() is nanoTime, not currentTimeMillis: an animation must not jump because NTP stepped the wall clock.

Clock.virtual() is what makes any of this testable. A golden image of a mid-animation frame is impossible against a wall clock — the test would have to sleep and would then be asserting on whatever the scheduler gave it, which on a loaded CI runner is a different frame every run. clock.advance(50) gives exactly the frame at 50 ms, on every machine. button-hover-midway.png is three buttons showing the start, the middle and the end of one transition in a single frame, which is a picture no wall clock can take.

The whitelist is refused, not ignored

Transitions.Animatable is a closed enum: opacity, background-color, border-color, color. §1.7 says layout properties never transition, because animating a width would run Yoga on every frame of every transition — on a CPU renderer, the difference between a transition and a stutter.

So transition: width 200ms is a dropped declaration with a warning naming it, not a rule that silently never fires. The author asked for something the system deliberately refuses and needs to be told. One bad entry drops the whole list, for the same reason a bad padding shorthand does: half a transition list is worse than none, because the author sees two of their three properties moving and has nothing to say which one was refused.

Asymmetric timings need no new mechanism

§1.7’s rule 1 is “input feedback is instant — press states apply in 0ms, release fades out in fast”. That is expressible in CSS as written, because the timing that applies is the one on the style being moved to:

button { transition: background-color var(--gb-motion-fast) ease-enter }
button:active { transition: background-color 0ms }

Entering :active reads the pressed rule’s zero duration and snaps; leaving it reads the resting rule’s and eases. It matters more than it looks — a press that faded in makes every button feel disconnected from the finger that pressed it.

OKLCH, measured

§1.7 specifies OKLCH for colour interpolation and it is worth the arithmetic. Nord’s danger red and success green:

MidpointResultChannel spread
sRGB#b18f7b54
OKLCH#bf9152109

sRGB is gamma-encoded and not perceptually uniform, so the mean of two encoded values is pulled towards grey — the more saturated the ends, the further. A colour with no chroma has a powerless hue: its angle is noise, so a fade to grey takes its partner’s hue rather than sweeping through hues neither end has. Hue takes the shorter arc.

(An earlier draft of this record claimed the sRGB midpoint is also “darker than both”. The test written to prove it failed: it is not. What is true is the chroma loss, and that is what is claimed now.)

Reduced motion keeps the declarations

§1.7’s rule 6 collapses every transition to 0 ms. Transitions.reduced() sets each duration to zero and keeps the entries rather than removing them, so the machinery still runs and still ends and a reduced-motion user reaches the same states by the same route rather than taking a different code path through the toolkit. §4 asks for the same shape from the high-contrast theme — an alias swap, never a special case.

Consequences

  • Hover, press and disable animate on both controls, to §3.1’s table, with the durations as --gb-motion-* tokens in the theme layer so a slower or reduced-motion theme is an alias swap.
  • The frame loop stays idle. renderer.isAnimating() is the whole of §1.7’s “no polling, no battery cost”: an application asks for another frame only while something is moving. The showcase does exactly that, and a test asserts the loop goes quiet the frame after a transition ends.
  • The first frame starts nothing. A control appearing is not a control changing, or a window would fade every control in from black when it opened. §1.7’s enter/exit animations belong to overlays, which announce themselves.
  • transform is not implemented, and it is in §1.7’s whitelist. It is what checkbox’s specified check animation (“scale 0.6→1 + opacity”) needs for its scale; the opacity half ships and the scale does not. Box carries no transform, and adding one means the painter and hit testing — which needs the inverse to map a pointer back through it and silently mis-routes clicks if it does not. That is a correctness trap worth arriving on its own rather than inside this.
  • Layer promotion is not implemented. §1.7 promotes a node animating opacity/transform to a repaint-boundary layer so per-frame cost is compositing only. There are no layers (ADR-0064 deferred them for group opacity, and damage tracking wants the same thing). Today every animating frame repaints the window, which at 960×640 is affordable and at 4K will not be. The three want one mechanism and should get it together.
  • The enter/exit lifecycle is not implemented. opening → open → closing → removed, with input disabled the instant closing starts, applies to menus, popovers, tooltips, dialogs and toasts — none of which exist. It is a specification for M3 rather than a gap in M2.
  • The explicit AnimationController is not implemented. §1.7 has it driving indeterminate progress, the spinner and toast reflow, none of which exist. It is the same clock when it arrives.
  • Reduced motion is not detected, only obeyed. renderer.reducedMotion(true) is the switch; nothing reads the OS setting, because SDL exposes no query for it. An application that knows sets it.
  • Untested claim: the easing solver and the OKLCH conversions have run only on linux-x64. They are pure arithmetic with no native code under them, so unlike the rounded corners there is no per-CPU JIT to differ — but button-hover-midway.png is compared on all three platforms like every other golden, which is what would show it.

ADR-0068: The transform stack is accumulated in Java, and hit testing inverts the matrix the painter used

  • Status: Accepted
  • Date: 2026-08-17
  • Relates to: docs/design-system.md §1.7; docs/ARCHITECTURE.md §8, §11; closes the transform gap left open by ADR-0067; extends ADR-0054 and ADR-0064

Context

transform is on §1.7’s whitelist of properties that may animate, and it is the only one of the five that was not implemented. ADR-0067 named it as a gap and said why it was being deferred rather than shipped inside that change:

Box carries no transform, and adding one means the painter and hit testing, which needs the inverse to map a pointer back through it and silently mis-routes clicks if it does not. A correctness trap worth arriving on its own.

That is the whole of the problem. A transform is not hard to draw — Blend2D has had the machinery since the display scale was first applied to a context. It is hard to draw and route input through consistently, because the two run in different places at different times: painting happens inside the frame callback with a live rendering context, and hit testing happens on the input path against a snapshot of the last frame, with no context anywhere near it (ADR-0054).

The failure mode is what makes it worth its own record. A transform applied by the painter and ignored by hit testing produces no error and no wrong pixel. The control is drawn exactly where the stylesheet asked. It simply does not respond where it looks like it should, and it does respond somewhere invisible. Nothing in a screenshot, a log or a test of either half on its own would show it.

Two further things were unresolved and had to be decided here, because both are consequences of the same question:

  • What a computed transform is. Every other property in ComputedStyle is finished when the cascade produces it. This one cannot be: translate(50%) and the transform-origin default of 50% 50% are proportions of the element’s own border box, and the box has no size until Yoga has run.
  • How a transform interpolates. §1.7’s one named use is the check mark’s scale 0.6→1, and the obvious implementation — interpolate the six matrix entries — is wrong in a way that only shows on rotation.

Decision

1. Blend2D gets an absolute matrix, and no new symbol crosses the boundary

Blend2D’s bl_context_save and bl_context_restore are not on the export list. The natural implementation of a transform stack — push on the way into a subtree, pop on the way out — is therefore not available without adding two symbols to a boundary that has caught the same class of local-symbol bug three times (--exclude-libs,ALL, then BL_STATIC making BL_API expand to nothing, then HarfBuzz’s bare HB_EXTERN), each answered only by a CI run across four targets (ADR-0064).

It turns out not to be needed. bl_context_apply_transform_op is already exported — it is how the display scale reaches the rasterizer — and its operation set includes BL_TRANSFORM_OP_ASSIGN, which replaces the context’s transform with a BLMatrix2D rather than composing onto it. So the stack is accumulated in Java and each box states its whole matrix. Zero new symbols; one new enumerator on a call that already crosses.

This is the same trade ADR-0064 made for the rounded rectangle, and it is being recorded as a pattern rather than as a coincidence: before adding to the export list, check whether an already-exported call has an operand that does the job.

The one cost is real and is stated: BL_TRANSFORM_OP_ASSIGN’s operand crosses as const void*, so nothing on either side checks that six doubles are what it reads. BLMatrix2D is therefore in the layout registry, and the probe compares its six offsets against what the C compiler computed for the library actually loaded. That check is not ceremonial — the named members live in an anonymous struct inside a union with a double[6], and the upstream header carries a TODO to remove the union.

2. The matrix type is Java’s, and hit testing inverts that instance

Affine is a record of six doubles with compose, invert, map and decompose. It lives in :core, not in :natives, and Blend2D never sees it — what crosses is the six numbers, the same way a BLRect is four numbers rather than a type.

The load-bearing part is what happens to the inverse. HitTest.Region carries it, and it is computed once, while painting, from the very matrix that was handed to Blend2D. It is not recomputed on the input path from the same inputs by different code. That is the whole point: two derivations of an inverse that must agree exactly is precisely how the silent failure above gets in, and the second derivation is the one nobody tests because the first one looks right.

Region.contains then maps the pointer backward rather than mapping the box’s corners forward. That is not only less arithmetic — it is the only version that is correct, because a box under nested transforms is a general quadrilateral on screen and a bounding-box test would claim its corners while a corner-mapping test would need a point-in-polygon routine that is a second implementation of geometry the painter already did.

A box whose transform has no inverse is dropped from the snapshot. scale(0) collapses the plane onto a point: there is nothing on screen for a pointer to be inside of, and the alternative — every point in the window mapping into it — would route every click in the application to an invisible control.

3. A computed transform is a function list, not a matrix

ComputedStyle.transform() carries the functions an author wrote plus the origin, and Transform.matrix(width, height) resolves them when the rectangle is known, inside the paint walk. Two independent reasons:

  • Percentages need a box. Above.
  • Interpolation needs the parts. Halfway between rotate(0) and rotate(180deg), interpolated entry by entry, is a matrix of zeroes — a box collapsed to a point. Keeping the functions means the common case interpolates the numbers an author wrote, which is both correct and predictable: scale(0.6) → scale(1) is scale(0.8) at the midpoint.

Where the two lists have different shapes, the shorter is padded with the identity of the other side’s function — so none → scale(1.1) grows from scale(1) rather than from a zero matrix. That is not a detail: it is the transition every :hover rule will write, because the resting state has no transform at all.

Where two functions at the same position are different kinds, the value swaps at the halfway point. CSS resolves that case by multiplying both sides out and decomposing, which needs a box to resolve percentages against, and interpolation runs in the animation overlay — before layout. Nothing in the design system asks for it. A stylesheet that does gets a jump, and this sentence, rather than a shape that is in neither end state.

4. Layout runs first, and the transform moves the result

Yoga never sees a transform. The walk accumulates positions from the layout pass and applies matrices to what comes out, which is CSS’s rule and is the reason transform can be on the animation whitelist at all: a control that scales on hover moves no sibling and costs no layout pass. It is the same argument that made transition: width a dropped declaration with a warning in ADR-0067 — and §1.7’s answer to “but I want to move something” has always been “use a transform”. Now there is one.

Consequences

  • transform and transform-origin parse, cascade, inherit their effect down the box subtree exactly as opacity does, animate through the overlay, and route input correctly. Transitions.Animatable has its fifth and last member.
  • No new native symbol. The export list is unchanged; one enumerator and one struct layout were added, and both are checked against the compiled library by the probe.
  • Animations.Running now holds an Object rather than a double. Four of the five animatable properties are numbers — a colour is a number, because a double holds every 32-bit integer exactly — and transform is the first that is not. A second map keyed by the same enum was the alternative, and would have given observe, apply, settle and currentOr a second half to keep in step with the first.
  • The 2D subset only: translate, scale, rotate, skew, matrix and the axis variants. The 3D functions need a projection the painter has no concept of, and perspective on a CPU rasterizer is a different feature wearing this one’s name.
  • em and rem in a transform resolve against CssLength.Context’s fixed numbers rather than the node’s own font-size — the same known gap the rest of the cascade has (ADR-0066), inherited rather than newly introduced.

What this does not do

  • The check mark still does not scale. §1.7 specifies the checkbox tick’s animation as “scale 0.6→1 + opacity”, and the opacity half has shipped since ADR-0067. The scale needs the mark to be a cascade node of its own — a second part beside check-indicator — because the mark is drawn onto the indicator’s box and scaling that box would scale the 16px square with it. Adding a part is a decision ADR-0065 took carefully once, and it should be taken carefully again rather than as a side effect of this change.
  • Layer promotion does not exist, so a frame with an animating transform repaints the window. §1.7 promotes an animating node to a repaint-boundary layer so the per-frame cost is compositing only. That mechanism is the same one CSS group opacity needs (ADR-0064) and the same one damage tracking wants, and — unlike everything in this record — it does need new exports: bl_context_blit_image_d at minimum, and bl_context_set_global_alpha to composite the layer at anything but full opacity. That is the reason it is not in here: it is a change to the native boundary, answered by a CI run across four targets, and it deserves the record that goes with one.
  • None of this has been rasterized off linux-x64. transforms.png is the eleventh golden image resting on arcs and matrices that AVX-512, Apple Silicon’s NEON path and MSVC have never drawn. Blend2D JITs its pipelines per CPU; the next CI run is what answers it, which is what the goldens’ per-channel and area tolerance is for (ADR-0050).

ADR-0069: The render tree is retained, and reconciled against a box tree

  • Status: Accepted
  • Date: 2026-08-17
  • Relates to: docs/ARCHITECTURE.md §5, §11; supersedes the “for now” in ADR-0053; completes ADR-0004; acts on the measurements in ADR-0037; follows the method of ADR-0045

Context

ADR-0004 specified three trees. Two were built. The third — “one render object per visual node, owns a YGNode, a ComputedStyle, and the paint logic” — was deliberately not, and ADR-0053 said why, and what it would take to change:

The box tree is rebuilt every frame, and that is the cost to reclaim. No render object survives a frame, so nothing knows what changed. That is the argument for building them, and it should be made with a measurement.

So the measurement came first. FrameBenchmark renders a tree the shape of the showcase — a bar, a sidebar, wrapped prose, seven measured leaves — through the whole pipeline, at 960×640 on linux-x64.

median
frame CPU before rasterization354 µs
render (cascade + boxes)199 µs
layout + walk (Yoga tree built, laid out, freed)190 µs

“Before rasterization” is not a hedge, it is what changed. Blend2D’s own raster cost is identical either way, and at 960×640 on one thread it is about 310 µs — larger than everything above it. Folding it into the headline would dilute the thing this record is about by a constant nobody touched. The whole-frame figures with it included are in the consequences.

Two things were being thrown away and rebuilt every frame, and both had already been measured in isolation by ADR-0037 without anything acting on the numbers:

  • Shaping. Widgets.Text.render called Paragraph.of per frame — 56 µs against 0.05 µs for a cache hit. ParagraphCache had been built for exactly this and status.md recorded it as having no consumer.
  • The measure callbacks. A MeasureCallback is a confined Arena and a MethodHandle bound into native code: 11 µs to create, against 0.3 µs to call through. ADR-0037 called it “the largest cost of text in a layout pass”.

Decision

1. The render tree is retained, and it is reconciled against a Box tree

RenderObject owns a YGNode and holds the Box last applied to it. RenderTree owns the root, the YogaConfig, and the walk. An application holds one for the life of a window.

The input to reconciliation is still the per-frame Box tree that widgets describe. That is the part worth defending, because the obvious alternative — have Paints mutate a render object directly — was rejected:

  • A Box is immutable, and an immutable tree is the ideal thing to diff. There is no question of when it was last read or whether someone else is holding it.
  • It keeps a widget’s job “describe yourself”, which is the whole of ADR-0004’s programming model. Not one widget changed for this.
  • The box tree is already what every golden image and every rendering test runs through, so the retained path can be asserted to produce identical layout to the throwaway one, which is the first test in RenderTreeTest.

So ADR-0053’s box tree was not a detour. It turned out to be the diff input.

2. Every Yoga setter is guarded, and the guards are the point

Yoga dirties a node when a style is set on it, not when the value differs. A retained tree that re-applied every style every frame would dirty every node every frame, Yoga’s layout cache would never hit once, and it would cost exactly what throwing the tree away costs — plus the memory management of keeping it.

So RenderObject.apply compares each property against the box already applied and calls the setter only on a difference. That single decision is what turns “the tree is kept” into “the layout is skipped”:

layout + walkmedian
throwaway tree190 µs
retained, nothing changed9.1 µs
retained, a fresh box tree every frame7.2 µs

The third row is the one that matters and it is the one that could have gone wrong. A real application rebuilds its boxes constantly; if the guards compared by identity rather than by value they would never fire, and this row would look like the first. It looks like the second, which says the values compare equal and Yoga does nothing. 20×, and the CPU a frame spends before rasterizing goes from 354 µs to 148 µs.

3. ParagraphCache gets its consumer, and identity is load-bearing

Paints.Context grew a paragraph(style, text) method and the two widgets that shaped their own text now go through it. ADR-0053 said that interface existed to be widened; this is the widening.

It saves the 56 µs, and it does something less obvious that the retained tree depends on: the paragraph that comes back is the same instance as last frame’s. RenderObject compares by identity to decide whether the measure callback it has is still the right one. An equal-but-distinct paragraph would rebind an upcall stub per text node per frame, which is the other 11 µs — so the cache is not an optimisation layered on top of retention, it is a precondition for it.

4. One layout pass, two readers

Window code was calling BoxPainter.paint(frame, boxes) and then HitTest.capture(frame, boxes) — two complete Yoga trees built and laid out per frame, one to paint and one to find out where it had painted. HitTest gained a capture(RenderTree) overload that reads the pass update already ran. The showcase now does one.

This was not in any of the measurements above, because the benchmark measured a single path at a time. It is a straight halving of the layout cost of a real window, and nobody had noticed it.

The bug retention introduced, which is the honest part of this record

RenderTreeTest caught a wrong layout on the first run: a paragraph replaced with much longer text reported a one-line height, laid out over six lines of prose, with no error anywhere.

Yoga does not dirty a node when its measure function is replaced. It dirties on a style change, and text is not a style — from Yoga’s point of view nothing about the node changed, so it reused the height cached for the previous paragraph. The fix is one call to YGNodeMarkDirty, and YogaNode.markDirty’s own documentation had described this exact situation since ADR-0029: “Yoga marks a node dirty by itself whenever a style changes, but it cannot know that the text changed.”

It could not happen while the tree was thrown away, because a node built this frame has no cached measurement to reuse. It is the first bug in this repository that exists only because state is now kept, and it will not be the last — which is the standing cost of this record and is why the equivalence test (“a retained tree lays out identically to a thrown-away one”, asserted again on the tenth frame) is the first test in the file rather than an afterthought.

Consequences

  • The CPU a frame spends before rasterizing falls from 354 µs to 148 µs, and the layout half of it from 190 µs to 7 µs. With rasterization included and Blend2D pinned to one thread, a whole frame goes from about 490 µs to 320 µs — the difference is the same, and painting is now most of what is left. Against the 16.67 ms budget none of it was a problem at 960×640; this buys headroom for 4K and for trees far larger than seven text nodes, which is where the throwaway path was going to become one.
  • A caution about the benchmark itself. Blend2D’s threaded context queues its work and only blocks when the frame ends, so a timing loop around paint on one measures submitting a frame rather than drawing it — 6.8 µs for a 960×640 window, which is not a number to believe. The whole-frame rows are taken with the worker count pinned to zero for that reason. This is ADR-0045’s lesson arriving for the second time, from a different direction.
  • BoxPainter.paint still works and is still what the goldens use. It builds a throwaway RenderTree, uses it once and closes it — one implementation, two lifetimes, rather than two implementations. Having two was what ADR-0053 rejected, and it would have left the golden images testing a path applications do not take.
  • Render-object identity now exists, which is what ADR-0068 said layer promotion was waiting for. A promoted node needs somewhere to keep its cached raster between frames and there was nowhere; now there is. Damage tracking wants the same thing plus YGNodeGetHasNewLayout, which the wrapper already exposes and nothing yet reads.
  • A display-scale change rebuilds the whole tree. Yoga rounds every computed edge onto the config’s pixel grid, a config change does not dirty anything, and there is no call that means “re-round”. Dragging a window to a monitor with a different scale therefore costs one throwaway frame, which is the right price.
  • A RenderTree must be closed, and before the fonts it draws with: a render object holds a measure callback that closes over a paragraph, and a paragraph over a font. Closing them the other way round leaves a native stub pointing at a freed face.
  • Reconciliation matches children by position, checked against owner identity and measured-leaf-ness. That is enough because the element tree has already done the keyed diff (ADR-0052): by the time a box tree exists the order is stable. A mismatch costs a rebuilt subtree, never a wrong result.

What this does not do

  • Nothing is cached at the paint level. The retained tree skips layout; it still walks every node and issues every Blend2D call every frame. Damage tracking and layer promotion are what would change that, and they now have the identity they were missing.
  • The cascade still runs per node per frame. It did, at 135 µs of the 148 µs left — the largest remaining term by a wide margin, and the next thing to measure rather than layout. Taken immediately afterwards in ADR-0070.
  • ParagraphCache is per renderer and bounded at 256 entries. A window showing more distinct strings than that thrashes it, and thrashing costs both the shaping and a rebound measure callback. Nothing measures the hit rate in an application yet.

ADR-0070: The cascade resolves invalidated nodes, and invalidation is a subtree

  • Status: Accepted
  • Date: 2026-08-17
  • Relates to: docs/ARCHITECTURE.md §5, §8; builds directly on ADR-0069; leans on ADR-0052 for node identity and on ADR-0049 for what a resolved style is

Context

docs/ARCHITECTURE.md §5 has described the frame loop the same way since before any of it was built:

→ style resolution (invalidated nodes) → Yoga layout (incremental)

The parenthesis was aspirational. What ran was style resolution of every node, every frame: for each one, StyleResolver.cascade matched every selector in every stylesheet right-to-left with backtracking, and customPropertiesFor walked to the root doing the same cascade again at every ancestor — so a node at depth d against R rules cost O(d × R) selector matches, per frame.

It did not matter while the Yoga tree was rebuilt every frame, because that was the same order of cost. Once ADR-0069 took layout from 190 µs to 7 µs, the cascade was 135 µs of the 148 µs left — the whole frame, essentially, and by a wide margin the next thing to measure.

Split three ways on the same showcase-shaped tree (15 elements, linux-x64):

median
resolve — selector matching and var()~330 µs
of which customPropertiesFor alone~140 µs
ComputedStyle.of — tokens to typed values~48 µs

(Measured in a tight loop, so the absolute numbers run high against the same work inside a frame — ADR-0045’s effect. The split is what they are for: matching dominates, and the walk to the root is a large part of it.)

Decision

A node’s resolved style is cached on its element, and thrown away when something that could decide it changes. Elements already persist across rebuilds and already carry the pseudo-classes the cascade reads (ADR-0052), so they are the only place with the right lifetime.

The cache is checked against two things, both by identity:

1. The resolver — which makes a theme swap free

An application changing theme builds a new WidgetRenderer over the new stylesheets. Every cached style was produced by the old resolver, so every entry misses at once. There is no invalidation call anywhere, no “stylesheets changed” event to remember to fire, and no way for a hot reload to leave a stale style behind. The same is true of the reload path in ADR-0051.

2. The inherited style — which makes inheritance invalidate itself

A child caches against the instance it inherited from. Because the parent’s style is cached too, an unchanged parent hands down the same instance every frame; a parent that re-resolved hands down a different one, and its children re-resolve without anything telling them to. color and the typography inherit (ADR-0066), and this is the whole of keeping them correct.

3. Invalidation is a subtree, and that is not conservatism for its own sake

Element.setPseudoClass invalidates the node and everything under it. The cheaper thing — invalidate only the node whose state changed — is wrong, and wrong silently:

checkbox:hover check-indicator { border-color: var(--gb-checkbox-border-hover) }

Hovering the checkbox restyles the indicator. The checkbox’s own resolved style need not change at all, so the inherited-identity check in (2) cannot see it, and the indicator would keep a stale style for the life of the window. That rule is not hypothetical — it is in controls.css today.

The same applies to a rebuild: a new widget can carry different classes or a different id, which changes what matches it and, through a descendant combinator, what matches anything below it. Element.update invalidates the subtree rather than diffing attributes, because a comparison that missed a case would produce a node styled by a rule that no longer applies to it.

Working out which descendants a given rule could actually reach is real machinery — an invalidation set per rule, keyed by the selector’s rightmost compound. It would be worth building if invalidation were hot. It is not: the walk is pointer-chasing over the element tree against a cascade pass that costs hundreds of times more, and it happens at most once per pointer move.

4. One hook, not six

Every route that can change what a selector matches goes through setPseudoClass: :hover and :active from the router, :focus and :focus-visible from focus traversal, :disabled, :checked and :indeterminate mirrored from the widget by the renderer. So that is the single place that invalidates, rather than six places that each have to remember.

It only fires on an actual change, and that matters more than it looks: WidgetRenderer mirrors three pseudo-classes onto every styled element on every frame. If a redundant set invalidated, the cache would miss on every frame for every control and this record would be worth nothing. There is a test for exactly that.

Consequences

  • The CPU a frame spends before rasterizing falls from 148 µs to 3.5 µs, and the cascade half of it from 135 µs to about 2.5 µs. Taken with ADR-0069 that is 354 µs → 3.5 µs, a factor of a hundred, for a static frame of a 15-element tree.

    frame CPU before rasterizationmedian
    before any of this354 µs
    with the paragraph cache260 µs
    with the retained render tree148 µs
    with the invalidation-driven cascade3.5 µs
  • Rasterization is now the frame. With Blend2D pinned to one thread a whole frame is about 320 µs at 960×640, essentially all of it painting. That is what damage tracking and layer promotion are for, and after two rounds of this it is the honest next target — there is nothing else left of consequence.

  • Layout got faster too, for free. layout + walk with a fresh box tree each frame fell from 7.2 µs to about 4.2 µs, because a cached ComputedStyle hands Box.style the same Decoration, Transform and Insets instances every frame, so ADR-0069’s guards compare equal on a reference check instead of field by field.

  • Stale styles are the new failure mode, and they are silent: a stale style is a perfectly valid style. StyleCacheTest is therefore written in pairs — one test that the cache is used, one for each way it has to be dropped — and it ends with an equivalence test asserting that a renderer ten frames warm agrees with one seeing the tree for the first time.

  • A composition node caches nothing, because the renderer passes its ancestor’s style straight through rather than resolving one. So a null cache on a node says nothing about the subtree below it, and invalidateStyle must not short-circuit on null. It does not, and there is a test that fails if it ever does.

  • WidgetRenderer.resolver() is package-private and exists for the test. The alternative was asserting on colours, which would also be right for the wrong reason — a cache that never hit would pass every behavioural test in the file.

What this does not do

  • customPropertiesFor still walks to the root and re-runs the cascade at every ancestor when it does run. It is now amortised almost to nothing by the cache, but the first frame and every invalidated subtree still pay it, and it is O(depth × rules) where it could be O(rules) with the same caching applied per ancestor. Worth doing when a deep tree makes a first frame visible.
  • Nothing measures the hit rate in a real application. The benchmark shows what a static frame costs; a window where the pointer is moving invalidates a subtree per motion event, and no number says how often that is. A counter on the renderer would be the cheap way to find out.
  • The invalidation is per element, not per property. A :hover that changes only background-color re-resolves the whole style, including the typography and the layout half that no :hover rule in the toolkit touches. Splitting the cache by property would be a much finer instrument and is not obviously worth its complexity.

ADR-0071: A layer is a subtree’s raster, and damage is where it moved

  • Status: Accepted
  • Date: 2026-08-17
  • Relates to: docs/design-system.md §1.7; docs/ARCHITECTURE.md §5; answers the group-opacity question left open by ADR-0064; needs the render-object identity from ADR-0069; the first additions to the export list since ADR-0018 and ADR-0031 caught its third local-symbol bug

Context

Three separate features had been deferred with the same sentence for three records running: they all want a layer.

  • Group opacity. ADR-0064 shipped opacity as an alpha multiplied into each box’s colours, and said plainly that this is not CSS: the specification renders the element and its descendants into a buffer and composites that once, which differs exactly where two children overlap. It predicted stack would make the difference visible.
  • Layer promotion. docs/design-system.md §1.7 promotes a node animating opacity or transform to a repaint boundary so a frame of the animation costs a composite rather than a repaint.
  • Damage tracking. §5 has always described “a frame re-rasterizes only dirty layers and blits the rest; damage rects flow to the backend”.

ADR-0068 named the blocker as the missing exports; ADR-0069 supplied the other half, the render-object identity a cached raster can hang off. And after ADR-0069 and ADR-0070 took the CPU before rasterization from 354 µs to 3.5 µs, rasterization is the frame — about 320 µs at 960×640 on one thread — so this is the only remaining term of consequence.

Decision

1. Two new exports, and only two

bl_context_blit_image_d and bl_context_set_global_alpha. That list has caught the same class of bug three times — --exclude-libs,ALL, then BL_STATIC making BL_API expand to nothing, then HarfBuzz’s bare HB_EXTERN — each of which linked a symbol in and left it local, visible to nm and absent from nm -D, and each answered only by a run against a real library. So BlendLayerTest exists: seven pixel assertions that cannot pass unless both symbols really exported.

What is not exported is an image constructor. Blend2D’s bl_image_init_as would allocate the pixels itself; a PixelBuffer allocated in Java and wrapped with the already-exported bl_image_init_as_from_data costs nothing and keeps the principle the export list states in its own comment — Goldberry never asks Blend2D to allocate pixels. img_area crosses as NULL, which Blend2D reads as the whole image, so no BLRectI crosses either and no layout row is needed for one.

2. Promotion is opacity < 1 and having children

Stated in one place, RenderObject.isPromoted, because a promotion policy spread across a renderer is a policy nobody can check.

A node with children is exactly where CSS’s group opacity and a per-box multiply disagree. A translucent leaf is deliberately not promoted: its own background, border and text can overlap each other, so a layer would differ there too — by a fraction of a level along an antialiased edge — and paying an allocation and a blit for every faded label to fix that is a poor trade.

The three goldens with a :disabled control at 45% (§2.1) changed when this landed, and the change is the correction. The diff is confined to the disabled control and to the region where its own shapes overlap; everything else in every image is untouched. That is the review step the golden workflow exists for, and it was reviewed rather than accepted.

3. The subtree is drawn at full strength, untransformed

Two decisions that look like details and are the whole feature:

  • Full strength. The layer holds what the subtree looks like, not what it looks like at this alpha. The alpha is applied once to the finished raster. That is what makes it a group, and it is also what makes the raster reusable while the alpha moves.
  • Untransformed. The transform is applied to the blit, not inside the layer. So a node animating a transform re-blits a raster it already has rather than re-rasterizing itself through a new matrix — which is the §1.7 promise.

4. The layer’s bounds are the subtree’s, not the border box

Three things reach outside a node’s own rectangle, and a layer sized to the border box would clip each of them away — a visible bug rather than a rounding difference:

  • a focus ring, which CSS draws outside the border box by design;
  • a child transformed out from under its parent;
  • a child that simply overflows, which flexbox allows.

So the bounds walk maps all four corners of every descendant through the accumulated transform, because a rotation turns a rectangle into one that is not axis-aligned and taking two corners would miss half of it.

5. Damage is the union of where a node was and where it is

Both, because a node that moved leaves a hole behind it, and damaging only its new position is the classic partial-repaint artefact where the old drawing stays on screen. RenderObject remembers its last rectangle for exactly this.

The flag it reads is selfChanged, not the subtree’s changed. They have to be separate: a parent whose child moved is “changed” for a layer’s purposes and has not itself moved a pixel, so damaging its whole rectangle would report the entire window dirty every time anything in it did anything.

An empty damage list is a real answer — “nothing changed, upload nothing” — and Window distinguishes it from null, which is a caller that has not said anything and must get the whole frame. Conflating the two is how damage tracking ends up either useless or wrong, in opposite directions.

The bug a resize found, which the tests did not

Dragging a window’s edge ended the event loop:

java.lang.IllegalArgumentException:
    damage 2066x1103+0+0 falls outside the 2065x1102 px frame

A node’s remembered rectangle was measured against the previous frame. Every rectangle computed this frame is clamped to it — but union(before, now) puts the old one back, and a window dragged one pixel narrower produces a union that fits neither frame. The backend refused it, correctly, and took the loop with it.

The fix is to clamp on the way out, where it holds regardless of which frame a rectangle came from, rather than only where each is computed. Nothing is lost by clipping: the part of before outside the new frame is not on screen any more, so there is nothing there to repaint.

Worth recording for what it says about the test suite rather than the fix. Every damage test used one frame size, because that is the natural thing to write — and a resize is the one moment the remembered rectangle and the current frame disagree, which is the entire premise of remembering it. DamageTest now resizes by one pixel between frames, because that is what a drag actually produces and a test that only jumped by fifty would have passed against a fix that only handled large changes.

It is also the second time in this pair of records that keeping state produced a failure keeping nothing could not — after ADR-0069’s measure function. That is the standing cost of a retained tree, and it is being counted.

The bug this introduced, caught by its own tests

The first version of collectDamage returned early on the first frame, when a node had no remembered rectangle to compare against. It therefore never recorded its children’s rectangles — so the next frame found them null too, reported the whole window, and did it again forever. Damage tracking that was fully implemented, fully wired, and did nothing.

DamageTest’s “a frame that changed nothing damages nothing” is what caught it, and it is the reason that test asserts on an area rather than on “some damage was reported”: a version that always answers “everything” is correct by every loose assertion anyone would write.

Consequences

  • opacity is CSS’s. group-opacity.png is two overlapping squares under a parent at 50%: the overlap is the upper square and the lower one does not show through it. LayerTest asserts the overlapping pixel equals the non-overlapping one, which is true for a layer and false for a multiply. ADR-0064’s open question is closed, and stack no longer has to wait for it.
  • A promoted subtree that did not change is a blit. RenderTree.rootChanged is what decides, and it is exposed because that is the only thing a caching test can honestly assert on — inferring it from pixels would pass whether the raster was reused or redrawn.
  • Damage rects reach the backend. Window.damaged(...) is how an application reports them and the showcase does. Where the platform lets it, the upload shrinks to what moved.
  • The frame is still painted in full. This is the honest limit: damage says what an upload has to carry, not what the rasterizer may skip. Painting less needs the context clipped to the damage — a third export — and a promise from the backend SPI that the buffer it lends back holds last frame’s pixels. SDL’s window surface does; the SPI does not say so, and a partial repaint against a backend that hands over a fresh buffer would draw one control on a field of uninitialised memory. Stating that contract is the next step and it is a change to the SPI, not to this.
  • A fading group still re-rasterizes. opacity lives on the promoted node’s own box, so changing it counts as a change to that box and invalidates the raster — which is precisely the case §1.7 wanted promotion for. The fix is to exclude opacity from the comparison for a promoted node, and it is small; it is written down rather than done because it wants a benchmark showing the animation is actually cheaper, and this record has enough unmeasured claims in it already. LayerTest asserts the current behaviour so that changing it is deliberate.
  • A layer is a full-size allocation. Bounds-sized rather than frame-sized, so a disabled button costs a few tens of kilobytes rather than 2.4 MB — but a window of many translucent groups allocates one each, and nothing bounds the total. A pool belongs here when something makes it matter.
  • None of it has been rasterized off linux-x64. Two new symbols across three export mechanisms — the ELF version script, the MSVC .def and the Mach-O -exported_symbols_list — plus a twelfth golden resting on a blit path that AVX-512, NEON and MSVC have never run. This is exactly the class of change the export list has caught three times, and the next CI run is what answers it.

ADR-0072: A partial repaint needs a promise, and a fading group needs three flags

  • Status: Accepted
  • Date: 2026-08-17
  • Relates to: docs/design-system.md §1.7; docs/ARCHITECTURE.md §3.1, §5; finishes the two things ADR-0071 named as unfinished; the third and fourth symbols added to the export list

Context

ADR-0071 shipped layers and damage tracking and ended with two things stated as not done:

A fading group still re-rasterizes. opacity lives on the promoted node’s own box, so changing it counts as a change to that box and invalidates the raster — which is precisely the case §1.7 wanted promotion for.

The frame is still painted in full. Damage says what an upload has to carry, not what the rasterizer may skip.

Both are finished here. They are one record because they share a shape: each is a case where the machinery was right and one question was being answered by the wrong thing.

Decision

1. One flag was answering three questions

A promoted node’s opacity and transform are applied to the blit, not drawn into the raster — that is ADR-0071’s decision and the reason a layer is worth having. But RenderObject had a single selfChanged flag, and the raster’s validity was read from it. So an opacity transition invalidated the raster on every frame of itself: promotion did exactly the work it exists to avoid.

Three questions, and they genuinely have three answers:

QuestionFlagDoes the node’s own opacity/transform count?
Does the screen look different? (damage)selfChangedYes
Does an ancestor’s raster need redrawing?changedYes — an ancestor bakes in this node’s finished blit
Does this node’s raster need redrawing?contentChangedNo — they are applied to the composite

The asymmetry in the middle row is the one worth pausing on, and it is why this could not be fixed by simply dropping opacity from the comparison: a descendant’s opacity is drawn into the raster, because only the promoted node’s own is deferred. There is a test for exactly that.

Measured on the showcase’s tree wrapped in a group at 45% — which is :disabled on a real control (§2.1), the thing that actually fades in this toolkit:

a frame of the fademedian
raster rebuilt each frame554 µs
raster reused199 µs

2.8×, with layersRepainted reporting 0 of 1. I would not have made the change without that number, because a layer costs an allocation and a blit and reusing its raster has to beat re-rasterizing by more than those.

2. layersRepainted() is exposed, because pixels cannot answer this

A cached raster and a freshly drawn one produce the same image. Every assertion anyone would naturally write — on a golden, on a pixel — passes whichever happened. That is precisely how the bug above survived a test file written specifically about layer caching.

So RenderTree reports how many promoted layers it rasterized in the last paint, and the tests assert on that. It is the outcome rather than the flag behind it, which is what makes it worth being public API rather than a test hook.

3. A partial repaint is only correct if the backend promises it

Damage tracking can say precisely which region changed. Repainting only that region is correct only if everything outside it is still on the buffer — and nothing in the SPI said whether it is. Against a backend that hands over a fresh or recycled buffer, a partial repaint draws one control on a field of whatever was there before.

So BackendWindow.retainsFrameContents() is a question a backend answers, and it is false by default: a backend that says nothing gets a full repaint, exactly as every backend did before this existed. sdl3 answers true, on both branches of SDL_GetWindowSurface — where the platform lends mapped memory it is the platform’s own surface, and where SDL falls back to a heap buffer and copies into a texture on present (ADR-0046) that heap buffer is equally persistent.

Window then checks three things, because they fail independently:

  1. the backend promises it;
  2. it is the same buffer as last frame — a backend may promise retention and still rotate between two, and identity is what catches that;
  3. the size is unchanged.

And a fourth case falls out: when the backend lends nothing, the buffer is Window’s own — allocated there, reused there, disturbed by nothing between frames — so it retains by construction whatever the backend says about its own. That is why the check is not simply a delegation.

4. The clip is one rectangle, and that is a choice

Blend2D’s clip is a rectangle, so honouring several damage regions separately would mean one full tree walk per region. The union is used instead. damage already merges overlapping rectangles and gives up past a handful, so in every case that reaches here the union is close to the rectangles themselves.

one small box changed, 960×640median
repaint the whole frame367 µs
repaint only the damage117 µs

3.1× — and worth reading carefully, because the damaged area was 1440 of 614400 pixels, which is 0.23%. The saving is nothing like proportional: the clip saves rasterization, and the tree walk still visits every box and issues every call for Blend2D to clip away cheaply. Skipping the traversal too means testing each box against the damage, which is a further change and is not made here.

Consequences

  • Two more exports: bl_context_clip_to_rect_d and bl_context_restore_clipping. restore_clipping rather than a save/restore pair, because there is only one clip depth in this frame path and bl_context_save is still not exported.
  • RenderTree.paint(frame, damage) clips and paints; empty damage draws nothing at all, which is the best case rather than a degenerate one — a window sitting still costs no rasterization. A test paints a different tree under empty damage and asserts the old pixels are untouched.
  • A clipped repaint is asserted pixel-identical to a full one, every pixel of a 200×200 frame. That is the invariant the whole second half rests on: if a clipped frame differs anywhere, damage is not an optimisation, it is a rendering bug with a performance excuse.
  • The application chooses. window.canRepaintPartially() is read inside the paint callback and the caller picks paint(frame, damage) or paint(frame). Deciding inside Window would mean Window knowing what a RenderTree is, and the two are deliberately independent — BoxPainter.paint still works with neither.
  • The traversal is still full. Above.
  • canRepaintPartially is false on the first frame of every window and after every resize, which is correct and is also the path that gets exercised least — the tests cover both explicitly for that reason.
  • None of it has run off linux-x64. Four symbols now, across the ELF version script, the MSVC .def and the Mach-O -exported_symbols_list. This is the class of change the export list has caught three times, and it is still the next CI run that answers it rather than any argument here.

ADR-0073: A composite is one Tab stop, and the selection is the roving position

  • Status: Accepted
  • Date: 2026-08-17
  • Relates to: docs/ARCHITECTURE.md §7.2, §9, §11; docs/core-widgets.md §3; docs/design-system.md §3, §7.2; extends ADR-0059; applies ADR-0063; confirms ADR-0065

Context

button and checkbox are single controls: one node, one Tab stop, one value. radio-group is the first widget that is a set, and three things that were trivially true for a single control stop being true for it.

Traversal. docs/design-system.md §7.2 is explicit: “composites (radio groups, menus, lists, tabs) are one Tab stop with roving arrow-key focus inside”. Six options that take six Tab presses to cross is the thing that rule exists to prevent. Nothing in the toolkit could express it — PointerRouter.moveFocus collected every focusable node in the tree in document order, and a radio is a focusable node.

The invariant. “Exactly one of these is on” is a fact about the set. No radio can hold it. A radio that owned its own checked state would let a document describe two selected options, or none, and every consumer of the group would then need a rule for what that means.

The action. button press="save" needs no argument — there is one thing to say and the button is it. radio-group change="pickTheme" is the first case where the handler is useless without knowing which option was picked, and Actions mapped a name to a Runnable.

Decision

Traversal is the router’s, and a composite says so with one method

Handles.focusScope() — false by default. A scope contributes exactly one entry to the Tab order, and the arrow keys move focus within it.

Both halves are the router’s, not the widget’s, by the same argument already written on Tab: which node an arrow key reaches is a property of the group’s shape, and the radio the focus is currently on cannot see its siblings. A widget also has no route to the router — Handles receives events, not the object that dispatched them — so a widget-side implementation would have needed a new back-channel before it could have been wrong for the right reason.

Arrow keys are handled after the focused chain has declined the key, in the same place accelerators are. A slider stepping its value and a text field moving its caret both consume the arrow and keep it, and neither has to know it is inside a group. Home and End reach the ends. A modified arrow is not traversal.

A widget that is both focusable and a scope contributes one stop, not two: the scope is asked first and its entry is what goes into the order. Asked the other way round such a widget would be reachable twice by Tab, and the second arrival would have no arrow keys at all, because a scope is found strictly upwards from the focused node. radio-group is not focusable — the ring belongs on the option the user is about to pick — but a toolbar plausibly is, and this is the kind of thing that is free to get right now and expensive to notice later.

Both axes rove. A group’s direction is the stylesheet’s — flex-direction on radio-group, which .inline flips — so input cannot know which pair of arrows the user is looking at, and answering to only one pair would be wrong half the time. That is also ARIA’s rule for a radio group. A composite that genuinely has an axis (a tab list along the top, a menu bar) will have to say so; nothing needs that yet.

The entry point is derived from :checked, not remembered

Tab into a group lands on the focusable descendant matching :checked, or on the first if none does. Computed fresh on every traversal.

This is the decision worth the record. The obvious implementation of “roving focus” is a stored roving position — a map from scope to last-focused child — and it is wrong in a way that only shows up later: it is a second piece of state beside the selection, and the two disagree the first time an application sets the value itself. Tab would then return the user to the option they last looked at rather than the one that is on. There is no event that would fix it, because the application setting a property does not know a router exists.

Deriving it means the selection is the roving position. Nothing to invalidate, nothing to leak when an element unmounts, and no way for the two to drift, because there is only one. A composite whose items are not selectable — a toolbar — has no :checked anywhere and always enters at the first, which is the right answer for it too.

FocusScopeTest.selectionIsTheMemory is the test that would fail for the stored version: focus leaves the group, the model changes underneath, and Tab comes back to the option that is now selected.

Selection follows focus, through the application

Handles.onFocusChanged(focused, fromKeyboard). A radio raises its change the moment keyboard focus lands on it.

It does not move its own tick. The value goes up as an event, the application sets the property, and the tick comes back down through the group’s binding — straight ADR-0063. So an arrow key on a group whose handler does nothing moves the focus ring and leaves the selection where it was, which is the visible form of “the state did not change” and is where the bug is.

The fromKeyboard half is load-bearing and not decoration. A mouse focus deliberately does not select, because a press moves focus and the click that follows it activates: a radio that acted on both would fire its change twice for one click. That is the same distinction :focus-visible already draws, reused rather than reinvented.

Re-picking the option already on is a no-op rather than a toggle, which is what makes Tab returning into a group harmless — the entry raises a change for the value already held, and Property.set swallows a value it already has.

The group holds the invariant, on every build

RadioGroup.children() rewrites each Radio with whether its value matches the resolved one, what picking it does, and whether the group is disabled. Nothing is stored, so there is no path by which two options are on at once.

selected is therefore not a KDL attribute. A document that could mark an option selected could mark two. A radio inflated from markup starts unselected and unwired, which is exactly the value a Java caller writes — so the parity invariant holds without an exception.

A bound value that is not a String is compared by its toString, so an enum or an Integer in the model works against the strings a document wrote. That is a coercion and it is the narrow kind: it never guesses what an object means, only how the author would have spelled it. A null — a model that has not loaded, or a value from a newer document — selects nothing rather than falling back to the first option, because a group that guessed would report a choice the user never made.

A child that is not a Radio is laid out and left alone, so a group can carry a heading. Silently dropping it would be a document whose text disappeared with no error.

An action can be told which one

Actions.bind(String, Consumer<String>), resolved by Actions.resolveValued(name). The argument is the picked option’s value — the string the document already wrote down, so it crosses no type boundary and needs no coercion rule. An application that wants an enum parses it in Java, where a bad value is a bug it can see.

A plain Runnable resolves against change too, adapted to ignore the value: change="refresh" is reasonable when the handler reads the model itself, and making an author pick the matching bind overload would be a distinction only the registry cares about. The reverse is refused — a valued action named by a press= throws, naming which half of the registry it is in, because calling it would mean inventing an argument here.

The alternative was one action per option, which would make adding an option an edit in Java as well as in markup, and would put a name in the registry for every value in the model.

:active reaches the whole ancestor chain

A bug found by trying to write §2.1’s pressed state, not by a test.

:hover walked the ancestor chain from the beginning — .card:hover .title has to work. :active did not: it was set on the single deepest element the press landed on. So pressing a checkbox’s 16px glyph lit up check-indicator, pressing its label lit up text, and checkbox itself matched only in the sliver of padding between them. checkbox:active has been in controls.css since the control shipped and was very nearly a dead rule.

§2.1 requires every control to render a pressed state, and a control whose pressed state depends on which of its own parts you happened to hit does not have one. setPressed now moves :active across chains exactly as updateHover does, comparing them so a press that moves within one widget does not invalidate its ancestors.

This is why radio:active radio-indicator and checkbox:active check-indicator are written against the control rather than the glyph: pressing the label now darkens the glyph, which is what a user pressing anything in a 32px row means.

The mark stops being a mark, so it can scale

§3.1 gives checkbox and radio one row: “check/dot: scale 0.6→1 + opacity, base · color fast”. The opacity half shipped with ADR-0067; the scale half did not arrive with transform in ADR-0068 and has been an open question since.

transform was never what was missing. A Box.Mark is drawn onto the box that carries it, so scaling the indicator scales the 16px glyph along with the tick — the ring grows with the dot, which is not the animation and reads as a bug. The mark needs a transform of its own, a transform belongs to a ComputedStyle, and a ComputedStyle belongs to an element.

So check-mark and radio-dot are elements — the third and fourth parts, and the first two justified by something other than “two surfaces need two backgrounds”. The argument is the same one in the animation dimension: two things have to move independently, and the unit of independent movement is a cascade node. That is what §1.7’s whitelist is for.

Two consequences worth stating because neither is obvious:

  • The mark is built in every state, including unchecked. A node that appears along with the value has no previous style to move from, and a newly built element deliberately starts no transition (ADR-0067’s “a control appearing is not a control changing”) — so a mark that came into existence checked would snap. It is present throughout and hidden with opacity: 0. An unchecked control therefore costs one fully transparent box, which is the price of the specified animation.
  • Unchecked draws a tick, not nothing. CheckMark has to pick a shape for a state where none is visible, and it picks CHECK because unchecked → checked is the common transition. Going to MIXED swaps to the dash instantly and then fades it in, which is right: the kind of mark is not on the whitelist, and a tick that morphed into a dash is not what §3.1 asks for.

radio-group-scaling.png is the frame at 80 ms of a 160 ms transition, with one dot growing in and the one it replaced shrinking out. The assertion it carries is that all three rings are the same 16px circle — which is exactly what the naive fix gets wrong, and which no still frame of a settled control can show.

radio-indicator is the second part

ADR-0065 asked that the part argument be made again rather than assumed, on the grounds that the second instance is where a pattern either holds or turns out to have been a special case. It holds, for the same two reasons and no new ones: a radio has two surfaces a theme must style separately (the 32-tall row, the 16-square glyph) and a ComputedStyle carries one background; and a radio-indicator outside a radio is a circle that means nothing, so registering the node would let a document create exactly that.

The circle needed no new drawing code. border-radius: 8px on a 16px box is a circle, rounded by the four cubics ADR-0064 already ships — so a theme can square a radio off without a Java change, and no native symbol was added. Box.Mark.Kind.DOT was put in the enum with CHECK and DASH and painted then; this is its first caller.

There is no mixed state. A group’s “nothing selected yet” is no option matching, not an option in a third state, which is why :indeterminate appears nowhere in the radio rules.

The rest of the design system’s numbers, checked rather than assumed

Reading §1.3, §1.5, §2.1 and §2.2 against what had shipped turned up four more divergences, all now closed and all applied to checkbox as well — §3 gives the two controls one metrics row, so a rule that holds for one and not the other is a spec that has stopped being true:

  • border-radius: 4px on both controls. §1.5 puts small controls at 4 and §2.2 says the focus ring follows the control’s radius; neither carried one, so both drew a square ring beside button’s 8px one.
  • Hover changes a surface, not only a border. §2.1: “hover states change surface (one surface step)”. The glyph is the control’s surface at 16px, so the step lands on its background and its border together.
  • A pressed appearance at all — see the :active section above for why there effectively was none. Checked controls press from their filled state, so the accent darkens rather than reverting to the empty surface.
  • radio-group gap 8, .inline gap 16. It shipped at 4, which is on §1.3’s ramp but is not what §1.3 says: options are related controls, and those are 8. The inline variant takes “between groups 16” because side by side each glyph-plus-label is a unit — at 8 the previous label sits as close to the next glyph as to its own, and reads as belonging to the wrong option. Stacked, no such ambiguity exists, which is why the two directions legitimately differ.

Two bugs the work uncovered, neither of them about radio

An unnamed key crashed the window. keyPressed built a Shortcut from every key that reached it, to use as a map key. Shortcut refuses to hold Key.UNKNOWN — an accelerator on it could never fire, so the constructor is right to say so — and the resulting IllegalArgumentException went up the UI thread with nothing above it to catch it. This was not an edge case: Key names the keys a shortcut might use, so every letter, digit and punctuation mark that arrives as text is UNKNOWN, and the crash was one keystroke away at all times. The accelerator tests never saw it because they only ever pressed keys that had names. The lookup is now skipped for an unnamed key rather than attempted.

The glyph’s rest colour was a surface token. --gb-checkbox-bg was nord1, which is --gb-surface — the exact colour of the panel a control normally sits on — so an unchecked checkbox was invisible in the place it is most often put. The light theme had the identical defect with #ffffff. The token’s own comment (“one step up from the window so an unchecked box reads as a well”) explains it: it was measured against --gb-bg, and almost nothing sits directly on the window.

Both glyphs now take the button’s ramp — --gb-button-bg / -hover / -active values on each theme — rather than a ramp of their own. That is the scale §2.1’s “one surface step” is already defined by, it has somewhere to go in both directions, and it is one ramp to keep correct instead of two.

The reason CI never caught it is worth more than the fix: every golden image in the repository paints on --gb-bg. A control that vanishes on --gb-surface was invisible to the whole suite. controls-on-surface-{dark,light}.png put a checkbox and a radio group on a surface panel in both themes, which is the missing axis rather than one more scene.

Consequences

  • radio and radio-group ship: records, nodes, CSS types, the invariant, bind + valued change, keyboard, :disabled, and five golden images across both themes. Four of thirteen controls.
  • §7.2’s group-navigation gap is closed as a mechanism, not as a special case. tabs, menu, select’s popup list and a toolbar all get one Tab stop and arrow keys by returning true from one method. FocusScopeTest is written against bare widgets in :core rather than against radio, because the next three users will look nothing like a radio.
  • Options are content-sized, not stretched: align-items: flex-start on the group. A column’s flex children stretch on the cross axis by default, which would have run the focus ring and the click target out across empty space while .inline — a row, whose cross axis is height — kept hugging its label. The same widget would then have had two different hit targets depending on a class. The golden image is what showed it; no value assertion would have.
  • Actions.bound() now returns Map<String, Object> rather than Map<String, Runnable>, because the registry holds two kinds of action. It had no callers. It also keeps insertion order now, which its own documentation had always claimed and Map.copyOf had never provided.
  • Open: a scope has no axis. Both arrow pairs rove, which is right for a radio group and will be wrong for a menu bar, where Down should open a menu rather than move along the bar. That is a decision for menu, and it will need to distinguish the two rather than adding a second mechanism beside this one.
  • §3.1 is now satisfied for every control in controls.css. The check/dot scale was the last row with a half missing, and closing it for radio closed it for checkbox too, because the mechanism is one mechanism. Four parts exist where there was one.
  • checkbox moved, and deliberately: it gained the radius, the hover surface step, a working pressed state and the scaling tick. checkbox-states-dark and -light are pixel-identical — the mark refactor changes nothing at rest, which is the check that it was a refactor — and only checkbox-interaction moved, by exactly the ring radius and the hover step.
  • Open: a disabled group fades correctly only by an explicit undo. The group is 45% and passes disabled down to every option, so without radio-group:disabled radio:disabled { opacity: 1 } the fade would apply twice and land at 20%. The general fix is the one ADR-0065 left open — docs/core-widgets.md’s “a disabled container disables its descendants” — and form and group-box are where it will have to be faced properly.

ADR-0074: Density is a token swap, and regular is no stylesheet at all

  • Status: Accepted
  • Date: 2026-08-17
  • Relates to: docs/design-system.md §1.3, §3; docs/ARCHITECTURE.md §8, §10; uses the mechanism of ADR-0049; applies to every control ADR-0059 ships

Context

docs/design-system.md §1.3 specifies a density preference:

Density: --gb-density regular (default) | compact — control heights 32 / 28, list rows 32 / 26. A user preference applied app-wide; token-conformant apps adapt with zero code.

Nothing implemented it. Every control’s height was a literal 32px in controls.css, so there was nothing for a density to swap — the tokens the promise depends on did not exist, and “adapt with zero code” was a sentence about a mechanism that was not there.

This is deliberately being done at four controls rather than at thirteen. It is per-control plumbing: every control written before the token exists is a control that has to be revisited, so the change costs three edits now and ten later. That is the whole reason it is scheduled ahead of the fifth control rather than after the catalog.

Decision

The height is a token; nothing else is

controls.css declares §1.3’s regular column at :root and every control sizes itself from it:

:root {
  --gb-density: regular;
  --gb-control-height: 32px;
  --gb-list-row-height: 32px;
}

button   { height: var(--gb-control-height) }
checkbox { height: var(--gb-control-height) }
radio    { height: var(--gb-control-height) }

Padding, gap and radius stay literal. The obvious next move is to tokenise them too “for symmetry”, and it is wrong: §1.3’s density row names control heights and list rows and nothing else, so a --gb-control-padding that a density moved would be inventing a scale the design system does not define (Principle 3, “token or extend” — and extending means editing the table first). DensityTest.onlyHeightMoves asserts that padding, gap and radius are identical at both densities, which is what keeps a later change honest.

Compact is a theme-layer stylesheet, and there is no fifth cascade layer

density-compact.css is a :root block of three custom properties, parsed into CascadeLayer.THEME — the same slot nord-light and nord-dark go into.

The alternative was a fifth layer between TOOLKIT_BASE and THEME, and it was rejected because the theme layer is defined by what it holds, not by what it is called: custom properties that the toolkit-base rules read, swapped as a user preference, meaning nothing until a base rule reads them. That is a description of a density as exactly as it is a description of a theme. A fifth layer would differ from the fourth in its name and in nothing else, and CascadeLayer says in its own documentation that four layers everyone knows beats an open-ended mechanism.

The layer is also what makes the override work, and this is worth stating because it looks like list order and is not. Both blocks are :root, so they carry identical specificity; the cascade compares important → specificity → layer → order, and layer is therefore the only term that separates them. A compact sheet parsed into TOOLKIT_BASE by mistake would tie all the way down to order, and the winner would be an accident of sort stability rather than a decision. DensityTest.compactIsAThemeLayer asserts the layer for that reason, rather than asserting the resolved height and calling it covered.

There is no conflict with the theme sharing the slot: no theme declares --gb-control-height, and no density declares a colour. compactDeclaresOnlyTokens holds the second half of that — a density that grew a rule would be styling controls behind the theme’s back, and switching one would restyle rather than resize.

Density.REGULAR ships no stylesheet

Density.stylesheets() returns a List<Stylesheet>, empty for REGULAR and one sheet for COMPACT. There is no density-regular.css.

This asymmetry is the fact rather than an omission. §1.3 spells regular “(default)”, and a default is the absence of an override — regular is not something an application applies, it is what the toolkit already is. Writing 32 in controls.css and in a density-regular.css would be one number in two files, which is the arrangement this repository has already been bitten by twice: §10.1 carried a typography table that disagreed with §1.4’s, and the checkbox carried a surface ramp beside the button’s that disagreed with it (ADR-0073). One number, one place.

The return type is a list rather than a Stylesheet for the same reason. An empty stylesheet returned to keep two shapes matching is a thing that parses, sorts and cascades every frame in order to do nothing, and Stylesheet.empty was available — it was not used, because the honest statement is “regular contributes no stylesheets”, not “regular contributes an empty one”.

The consequence an application sees is the good one: an application that never mentions density gets regular, because regular is the base and there is nothing to remember to add.

--gb-density is a marker, not the mechanism

§1.3 names the property, so it is declared. Nothing in the toolkit reads it.

A keyword custom property cannot select a number in §8’s subset — there is no @container style() here, no @media, and there is not going to be either, so --gb-density: compact cannot by itself make anything 28 tall. The two length tokens beside it are what switch; this one says which set is in force, and custom properties inherit, so any element can be asked. It ships because an application that needs to branch in Java — or a list that has to pick a row height — should read the answer rather than be told it out of band.

Density lives in :widgets, and Theme stays in :core

A density sizes controls, and :core’s primitives have no height for one to move: row, column, text, panel and spacer are sized by their content and their application’s rules. A theme is in :core for the opposite reason — text reads --gb-text and panel reads --gb-surface, so the colour tokens have consumers on both sides of the module boundary and the height token has consumers on one.

Controls.stylesheets(theme, density) assembles the three in order, for the reason the rest of that class exists: the order matters, getting it wrong is silent rather than loud, and an application should not have to know that a density goes above a theme in a list.

Compact is below §1.3’s own hit-target floor, deliberately

§1.3 says two things that cannot both hold:

Hit targets ≥ 32×32 logical px even when the visual is smaller. Density: … control heights 32 / 28.

A compact control is 28 tall. The floor gives, and it gives because that is what the preference is: a user who asks for compact is asking to trade the comfort margin for more on screen, and a density that refused to go below 32 would be a density that does nothing. The ≥ 32 rule is therefore the regular default rather than an invariant, and this record is where that is written down.

Two things bound the trade:

  • The glyph does not shrink. A checkbox’s tick and a radio’s dot stay 16px at either density; only the row around them closes. Compact costs 4px of margin around the target, not a smaller target — a density that scaled its contents would look plausible in a screenshot and be a zoom rather than a density. theGlyphHoldsStill asserts it.
  • Compact is never the default. It is reached only by an application setting it, on a user’s instruction. Nothing in the toolkit chooses it, and no OS setting is read to infer it.

Consequences

  • §1.3’s density row is implemented and its “zero code” promise is real: the showcase switches density on Ctrl+D and not one widget in that file mentions a height. There is deliberately no button for it in the tree — a density is an application-wide preference, so it belongs in a menu or a settings screen, neither of which exists yet.
  • Every existing golden image is byte-identical. The token swap changes nothing at regular density, which is the check that it was a refactor — the same check ADR-0073 used when the mark became a node. Two new images, controls-density-{regular,compact}.png, are the same scene at both, so the pair is the assertion: three controls four pixels shorter and nothing else moved.
  • The height assertions are written over the catalog rather than per control, because a density that moved button and not checkbox would pass three per-control tests and be exactly the divergence §3’s shared metrics row exists to prevent. A control added with a literal height fails DensityTest on the day it is added, which is the point of scheduling this at four controls.
  • theTwoDiffer exists because the two height tests cannot cover each other: if the token were dropped and both densities fell back to one literal, one of them would still pass in full.
  • Open: --gb-list-row-height has no consumer. list is M3. It ships now because the density a list will have to honour is decided here rather than there, and an application building its own rows today has the token it would otherwise hard-code. That is the same argument ADR-0037 made for ParagraphCache, which shipped a year of frames before anything called it.
  • Open: nothing detects the user’s preference. An application that knows sets it, exactly as with reduced motion (ADR-0067) — SDL exposes no query for either. The difference is that reduced motion is an accessibility setting the OS really does hold, while density is usually the application’s own preference, so this one may never need detecting.
  • Open: the typography does not move with the density. A 28px control still carries a 13/18 label, which fits (18 of 28, against 18 of 32) and is what §1.4 specifies unconditionally. Whether a compact density should also take a step down the type scale is a question §1.3 does not answer, and inventing an answer here would be the “improvise a third value” mistake ADR-0066 declined to make.

ADR-0075: A gesture’s origin is the router’s, and a drag asks for a state

  • Status: Accepted
  • Date: 2026-08-17
  • Relates to: docs/core-widgets.md §3; docs/design-system.md §1.2, §3, §3.1; extends ADR-0058; applies ADR-0063; third instance of ADR-0065; uses ADR-0068

Context

toggle is the fifth control, and it is next because of the half that is not like checkbox. docs/core-widgets.md §3 asks for “switch; drag or click/Space”, and everything shipped so far responds to a click, a key or a focus change — all single events. A drag is a sequence, and the toolkit had no way to describe one.

The obstacle is not the pointer plumbing, which ADR-0058 already settled: a press takes an implicit capture, so every move until the release reaches the pressed node wherever the pointer goes. What is missing is where the gesture started. A widget cannot remember it — a widget is a value, rebuilt every frame, and the Toggle instance that sees the release is a different object from the one that saw the press. There is nowhere on it for an origin to live.

Decision

The router reports the origin, because it is the only thing that can

PointerEvent.dragX() / dragY(): how far the pointer has travelled since the button went down.

The router records the press position and hands it to every event it dispatches while the button is held. This is the argument already written twice — on Tab (ADR-0054) and on arrow keys (ADR-0073) — reaching a third case: the router owns what the widget cannot see. It is also the component whose lifetime already matches, since the interval a drag offset is defined over is exactly the interval the implicit capture spans.

The alternative was making Toggle a Widget.Stateful so its State could hold the origin. That is a real mechanism and it is the wrong one here: it would put a State object, an extra element and a rebuild lifecycle behind every switch in order to remember two floats that the router already has, and slider, knob, split-pane and a scrollbar would each need their own copy of it. The test is therefore written against a bare widget in :core rather than against toggle, because the next four users will look nothing like a switch.

It is NaN and not zero when no button is held

Zero is a real answer — it is what a press with no movement gives — so a widget reading zero cannot tell “did not move” from “no gesture in progress”.

NaN can carry that distinction, and it carries it in a way that does not need a guard: Math.abs(Float.NaN) >= 8 is false, so an event with no origin reads as not a drag through the arithmetic itself. A widget that forgets to check gets the safe answer rather than a wrong one, which is not true of zero — with zero, forgetting to check makes every stray event look like a press that did not move.

The PRESSED event itself reports a zero drag rather than NaN: the origin is recorded before the dispatch, so a handler that reads dragX() on every pointer event does not have to special-case the first one.

ENTERED and EXITED carry no origin even mid-drag. They are hover events derived from where the pointer is rather than steps in a gesture, and the router raises them whether a button is down or not.

A drag asks for a state; a click asks for the other one

One comparison against half the thumb’s travel:

  • moved ≥ 8px — the user dragged, and the value they asked for is the direction: right is on, left is off, however far past the track they went.
  • moved < 8px — the user clicked, so the value flips.

Eight is travel / 2 from §3’s “travel 16” rather than a number chosen by feel: it is the point at which a thumb dragged from either end has passed the middle, so the value asked for is the one the thumb is nearer to.

The distinction matters and is the thing a naive implementation gets wrong. Dragging right on a switch that is already on asks for on, not for off. A drag is a request for a particular state, which is why the handler is a Consumer<Boolean> and not a Runnable — the second valued action in the toolkit after radio-group’s. Space, which has no direction, is the one place this widget reads its own value.

Through markup the value crosses as a String, through the one valued shape Actions already has. A second shape would have to be a Consumer<Boolean>, and erasure makes bind(name, Consumer<String>) and bind(name, Consumer<Boolean>) ambiguous for every implicitly typed lambda — so it would cost an awkwardly named method or a bespoke interface. ADR-0073 already wrote the rule this follows: the value crosses as the string a document would have written, and an application that wants another type parses it in Java, where a bad value is a bug it can see. slider and knob arrive at the same door.

There is no cancel gesture, and that is deliberate

Every other control here acts on CLICKED — a press and a release on the same node — because dragging off a button and letting go is how a user cancels (ADR-0058). toggle acts on RELEASED, and is the only control that does.

For a switch, dragging is the interaction. A drag that ends far from the control is still a drag in that direction, which is how every platform switch behaves. Acting on the click instead would mean a drag that left the track did nothing, and acting on both would fire twice for one gesture.

toggle-track and toggle-thumb are the fifth and sixth parts

The track is ADR-0065’s argument a third time and it holds unchanged: two surfaces a theme must style separately, and one ComputedStyle carries one background.

The thumb is ADR-0073’s argument — two things must move independently, and the unit of independent movement is a cascade node. §3.1 asks for “thumb translate base; track color base”, and a transform applies down its whole subtree, so a thumb drawn onto the track would slide the track with it. This is the same trap the check mark hit, arriving from the other direction.

Where the thumb travels to is the stylesheet’s decision: toggle-track:checked toggle-thumb { transform: translate(16px) }. Nothing in ToggleThumb knows that it moves, so a theme can change the travel, or stop it, without a Java change. The pill needed no new drawing code either — border-radius: 10px on a 20px box is §3’s full, through the four cubics ADR-0064 already ships, so no native symbol was added.

The four numbers in §3’s row are one arithmetic statement: 2 + 16 + 16 + 2 = 36 across and 2 + 16 + 2 = 20 down. The padding is 2 because that is what makes the travel 16, and ToggleTest.metricsAddUp asserts the leftover rather than the padding, so changing the track width fails the test that says why.

The thumb’s colour animates, which §3.1 does not list

§3.1 says “thumb translate base; track color base (same clock — they arrive together)” and elsewhere “anything not listed does not animate”. The thumb’s background is not listed, and it is animated anyway.

It has to be, for any theme whose thumb differs between the two states — see below. nord-light’s does, so a thumb whose colour snapped would arrive before the thumb did and break the one thing that row actually states. nord-dark’s does not, and pays nothing for the declaration. Listing it is what makes §3.1 self-consistent here, and it costs nothing either way: background-color is already on §1.7’s whitelist and already running on this duration.

2px of pill is the whole colour problem, and it moved the accent

A thumb has two constraints where the checkbox’s mark has one. It must read against its own pill, and it must differ from the window — because only 2px of pill, partly antialiased, separates a 16px disc from whatever is behind the control, where a mark is surrounded by its fill on every side.

This took two attempts and both failures looked like the same bug: the thumb appeared to be breaking out of the pill. It never was. Measured off the golden image, the disc is exactly concentric with the pill’s cap and 2px inside it all the way round; what the eye was reading was the thumb merging with the window across those 2px.

  • The first attempt took --gb-checkbox-mark-checked, which is nord0, which is also --gb-bg. Identical to the window.
  • The second took nord3, which is merely near it — better, and still read as a hole punched through the switch.

Every dark value in Nord is near --gb-bg, so on a light accent pill there is no dark thumb that works. The fix is therefore not a thumb colour at all: on the dark theme the on pill is nord10 rather than --gb-accent, and the thumb is the same near-white in both states.

That is the one place a control here departs from the shared accent ramp, and the switch’s geometry is what earns it: a checkbox can use a light accent because nothing sits inside its fill, and a toggle cannot because something does. nord10 is the same frost family and is exactly what the light theme already uses for its primary.

Both thumb tokens survive and both hold nord6 on the dark theme. Two tokens because a theme may need two — nord-light does, nord3 off and #ffffff on — not because this one does. Identical values are the Nord answer and not the mechanism, which is what the radio’s block already says of the checkbox’s.

The light theme’s off switch therefore has a dark thumb, which is not what iOS looks like. §1.2 decides it: a white thumb on nord5 is 1.4:1 and cannot be seen, and looking conventional is not one of the principles.

Every one of these was caught by looking at a golden image, never by a test, and that is now three separate occasions — --gb-checkbox-bg was --gb-surface (ADR-0073), then the thumb twice. A colour that equals another colour is a passing assertion, and a disc that is provably inside its container can still look like it is not.

--gb-button-height and friends

§3’s preamble asks for metrics as “component-token defaults (--gb-button-height etc.); app stylesheets may override component tokens, never structure”. ADR-0074 shipped one --gb-control-height for §1.3’s density, which is what §1.3 asks for and not what §3 does.

Both, in two levels: --gb-button-height: var(--gb-control-height). A density moves all of them at once, and an application can still pin one control without writing button { height: … } — which is overriding structure, the thing that sentence rules out.

Consequences

  • toggle ships: a record, a node, a CSS type, bind + a valued change, the drag, Space, :disabled, the shared focus ring, and four golden images. Five of thirteen controls.
  • The drag mechanism is :core’s, not the toggle’s. slider, knob, split-pane and a scrollbar get a gesture origin by reading one accessor, and DragOriginTest is written against a bare widget so it stays that way.
  • The showcase’s switch is bound to the same property as one of its checkboxes, so dragging the switch moves the checkbox’s tick. Two controls on one value is ADR-0063 made visible: neither owns the state and both are showing what the property says.
  • A disc can be provably inside its container and still look like it is not. The clearance is 2px of antialiased pill, which is not enough to separate two similar tones — so a colour that would be fine on a larger surface reads as a containment bug here. Worth remembering the next time a part sits inside another part: slider’s thumb on its track is the same geometry.
  • Open: the thumb does not follow the pointer during the drag. It slides to its new position when the gesture ends rather than tracking the finger, so a drag reads as a switch-with-a-threshold rather than as a thing being pushed. §3.1’s “slider/knob: drag 1:1, no animation” is the row that says what tracking looks like, and toggle has no such row — but a real switch does track. Doing it needs the thumb’s position to come from the pointer rather than from :checked, which means an animated value the widget supplies, and there is no route for that today. slider will have to build one.
  • Open: --gb-toggle-height and the other three component tokens have no test that they are honoured individually. DensityTest asserts every control moves with the density, which passes whether the indirection exists or not. Worth an assertion when the first application actually overrides one.
  • Open: the toggle does not shrink with a compact density, and that is read off §3 rather than decided here: the rows that have a compact value carry it in parentheses and the toggle row does not. The 32-tall row around the pill does shrink, because that is --gb-toggle-height. Whether a 28-tall row with a 20-tall pill in it is what §1.3 intends is a question for whoever writes the compact screenshots.

ADR-0076: A glyph does not negotiate

  • Status: Accepted
  • Date: 2026-08-17
  • Relates to: docs/ARCHITECTURE.md §8; docs/design-system.md §1.3, §3; fixes what ADR-0075, ADR-0073 and ADR-0059 each shipped without

Context

Reported as “the knob is outside the pill when I resize the window”, and it was not a toggle bug. Narrowing the window shrinks the pill, and the thumb inside it does not shrink by the same amount, so the disc hangs over the end.

The cause is one line nobody wrote. YogaConfig.create() asks for CSS’s defaults (useWebDefaults), and CSS’s default is flex-shrink: 1 — so every node in the toolkit gives up width when its row runs out of room. A width: 36px was never a width; it was a preferred width that a cramped parent could take back.

docs/ARCHITECTURE.md §8 lists flex-grow/shrink/basis in the layout subset. Only flex-grow was implemented. flex-shrink was in the specification, absent from the engine, and its default was the wrong one for every fixed-size thing in the catalog — so there was no way to say so and nothing had noticed.

Measured at 40px of room, which is absurd and is the point — a bug that needs the window dragged to exactly the wrong size is a bug that reaches a user and not CI:

whatspecifiedat 40px
toggle-track3616
check-indicator1610
radio-indicator1610 (an ellipse — border-radius follows the box)
control height, in a short column3213

The reported symptom was the only one of the four that is obvious at a glance. The last row is the worst: §1.3’s “hit targets ≥ 32×32” quietly became 13.

Decision

flex-shrink is implemented, because §8 already said it was

ComputedStyle.flexShrink, Box.flexShrink, and one guarded setter in RenderObject, exactly as flex-grow is plumbed. No native symbol was added and no binding was written: YGNodeStyleSetFlexShrink is already on the export list and YogaNode.setFlexShrink already existed — this was a gap in the CSS engine alone, not at the boundary. So it costs nothing at the layer where a change is expensive, and no CI run across four targets is needed to believe it.

The default is 1, in ComputedStyle.INITIAL and in Box.of(), because that is CSS’s default and Yoga’s under useWebDefaults. A box built by anything that predates this field therefore behaves exactly as it did.

The controls declare flex-shrink: 0, once, over a type list

button, checkbox, radio, toggle,
check-indicator, check-mark, radio-indicator, radio-dot,
toggle-track, toggle-thumb { flex-shrink: 0 }

Written once rather than beside each width, for the reason the focus ring is: the rule is “a control’s metrics are fixed”, and a copy per control is how that stops being true. A control added to the catalog joins this list, and one that forgets to now fails ControlShrinkTest rather than shipping a glyph that squashes.

The label is deliberately absent and still shrinks. Text is the one thing in a control that should give: a text that refused would push the glyph out of the window rather than ellipsing, which is worse than the bug being fixed. §3 sizes the glyph and says nothing about the label, and that asymmetry is the correct reading of it.

Why not turn useWebDefaults off

flex-shrink: 0 everywhere by default is Yoga’s own convention, and it would have fixed this in one line. It is refused for the reason ADR-0013-era decisions keep landing on: §8 promises a CSS subset, and a stylesheet whose flex behaviour silently differs from every author’s expectation is a worse trap than the one being fixed — it would move the surprise from “my glyph squashed” to “my flexible row does not flex”, which is harder to see and impossible to look up. The defaults stay CSS’s; the controls say what they mean.

The golden scenes were sized by the bug

Six radio images moved, and the reason is worth recording: their frames were 300×132 for content that needs 136 — three options at 32, two 8px gaps, 12px of padding each side. They fitted only because the options were being squashed.

They are 300×140 now. A frame 4px too short used to quietly shrink all three options; it now clips the last one, which is the visible failure that scene should always have had. The comment on paint says what the arithmetic is, since those numbers are load-bearing rather than round.

Consequences

  • Every fixed metric in §3 is now actually fixed, and §1.3’s 32×32 hit target holds at any window size. Four defects closed by one property, only one of which had been reported.
  • ControlShrinkTest runs over the whole catalog, not over the control that was reported. The reported symptom was toggle’s, and three of the four failures were in checkbox and radio — a test written about the switch would have fixed the switch and left the rest.
  • The test frames are deliberately absurd — 40px for a row that wants ~200. A regression here is a function of window size, and a test at a plausible size is the one that cannot fail.
  • flex-shrink is now available to applications, which §8 had promised and the engine had not delivered. flex-basis remains unimplemented and is the last of that trio; nothing needs it yet.
  • Open: nothing else in the toolkit declares it. :core’s five primitives — row, column, text, panel, spacer — all still shrink, which is right for containers and unexamined for spacer. A spacer with a fixed size is presumably meant to keep it.
  • Open: no minimum size anywhere. flex-shrink: 0 stops a control being squashed and does not stop it being clipped — at 40px of room the switch now overflows its parent rather than deforming, which is CSS’s behaviour and is what a scroll view or an ellipsis is for. Neither exists yet, so a window narrower than its content overflows silently. That is M3’s problem and is named here because this change is what makes it visible.

ADR-0077: Disabled propagates for input and not for paint

  • Status: Accepted
  • Date: 2026-08-17
  • Relates to: docs/core-widgets.md (states); docs/design-system.md §2.1; closes the question left open by ADR-0065 and ADR-0073; extends ADR-0059

Context

docs/core-widgets.md is one sentence: “Disabled state propagates down the tree; a disabled container disables its descendants for input and semantics.”

What shipped was a control passing the flag to the children it builds itself — checkbox to its glyph, radio-group to its options. That is enough for a control that constructs its own subtree and useless for form and group-box, which contain widgets they did not build and know nothing about.

radio-group also had a rule that should have been read as a warning:

radio-group:disabled radio:disabled { opacity: 1 }

The group is 45% and passed disabled to every option, so without that undo the fade applied twice and landed at 20%. A rule whose only job is to undo its own mechanism is the mechanism telling you it is the wrong one, and ADR-0073 recorded it as an open question rather than a fix.

Decision

Input propagates; paint does not

The sentence says “for input and semantics”. It does not say “for paint”, and paint is where the double-fade came from.

  • Input — a descendant of a disabled container is unreachable: no press, no click, no wheel, no focus, no keys.
  • Paint — :disabled stays on the node that declared it. The container’s own 45% already fades everything under it, because the painter multiplies opacity down a subtree. A descendant that also matched would be faded twice.

That split is what deletes the undo rule rather than generalising it, and radio-group stops pushing disabled onto its options entirely. An option’s own flag is kept, because a document may disable one option in a group that is otherwise available.

It costs nothing in expressiveness: §2.1 requires disabled to be 45% opacity on the whole control and never a colour remap, so a descendant has no disabled-specific appearance to express in the first place.

Effective disabled is derived, not stored

PointerRouter.isDisabled(element) walks up the ancestors and asks each widget’s own Styled.isDisabled().

The obvious alternative is to push a flag down the tree on every build, or to mirror it onto each element as :disabled the way the renderer mirrors :checked. Both are a second copy of a fact the tree already holds, and ADR-0073 has already been through what that costs: the two disagree the first time something changes without telling the thing that cached it, and there is no event that fixes it because a widget being rebuilt does not know a router exists.

Derived, there is nothing to invalidate, nothing to leak when an element unmounts, and no frame ordering to get wrong — the answer cannot be stale because it is computed from the tree at the moment it is asked. It costs a walk up the ancestors, on input events only, which is the same walk chain() already does for every dispatch.

The router is the choke point, not the widget

One guard in dispatch, and isFocusable gaining && !isDisabled(element).

This is the argument ADR-0073 already made for :hover: one choke point, every control, forever. A control’s own disabled check becomes a second line of defence rather than the only one, and a control written without one is still unavailable inside a disabled container. DisabledPropagationTest’s widget deliberately has no disabled check and deliberately reports isFocusable() == true regardless, so the tests cannot pass by the widget quietly opting out.

The keyboard needed no guard at all, and that is the part worth noticing: focus is the only route a key event has, so a subtree that cannot be focused cannot be typed into. One line about focus covers onKey, onKeyCapture and onText together.

The cut is input versus observation

PRESSED, RELEASED, CLICKED and WHEEL are refused. MOVED, ENTERED and EXITED still arrive, and hit testing and the cursor are untouched.

That line keeps ADR-0059’s two cases working, both of which “drop every event” would have broken:

  • a disabled control still hit-tests, so a click cannot fall through to whatever is behind it — unavailable is not invisible;
  • a tooltip explaining why something is unavailable needs the enter and the exit, and that is the one case that most wants an event from a disabled thing.

cursor: not-allowed also still resolves, because the cursor rides on the painted box (ADR-0057) and never asked about input.

Consequences

  • form and group-box can be built without inventing anything: they declare isDisabled() and everything inside them becomes unavailable. So can a dialog running §1.7’s closing phase, which asks for “input disabled the instant closing starts (no ghost clicks)” — the same mechanism.
  • The undo rule is deleted rather than generalised. Generalising it would have needed :not(), which is not in §8’s subset, or a universal selector and a descendant combinator — and either would have been machinery in service of a design that was wrong.
  • The test lives in :core against bare widgets, because its users — form, group-box, dialog — do not exist yet and will look nothing like a radio group. Same reason as FocusScopeTest and DragOriginTest.
  • Open: semantics is half the sentence and there is no semantics layer. AccessKit is M5. When it arrives, “disabled” for a11y should read the same derived walk rather than a second copy — which is the whole reason this one is derived.
  • Open: a control’s own disabled check is now redundant. Every control in the catalog still has one and none of them is reachable. They are kept: they are one line, they make a widget correct when called directly from a test, and removing them would make the widgets depend on the router for correctness rather than merely for reachability.
  • Open: Widgets.Row, Column and Panel cannot be disabled. None of them has a disabled flag, so today the only container that exercises this is radio-group. That is a gap in the primitives rather than in this mechanism.

ADR-0078: A focus scope has an axis

  • Status: Accepted
  • Date: 2026-08-17
  • Relates to: docs/design-system.md §7.2; docs/core-widgets.md §3, §5, §7; closes the question left open by ADR-0073

Context

Handles.focusScope() was a boolean, and both arrow pairs roved inside any scope. ADR-0073 shipped it that way deliberately and wrote down why it would not last:

Both arrow pairs rove, which is right for a radio group — its direction is the stylesheet’s, and .inline flips it — and will be wrong for a menu bar, where Down should open a menu rather than move along the bar.

A radio group genuinely has no axis. Everything else in docs/core-widgets.md that will be a scope does: menu, tabs, select’s popup list, a toolbar.

Decision

Handles.focusScope() returns a FocusScope: NONE, HORIZONTAL, VERTICAL or BOTH. radio-group answers BOTH; the default is NONE.

The axis is the widget’s, even though traversal is the router’s

ADR-0073’s argument was that traversal belongs to the router because which node an arrow reaches is a property of the group’s shape, and the radio the focus is on cannot see its siblings. That still holds and is unchanged — the router still walks the tree, still finds the scope, still derives the entry point from :checked.

What the widget adds is one fact only it has: what it means by the other pair. A vertical menu’s Right opens a submenu; a menu bar’s Down opens a menu; a tab list’s Down moves into the panel. The router cannot know any of that, and the widget cannot do the traversal. Each says the part it knows.

It only matters on the path where the widget declines

This is the subtle half, and it is why the boolean survived four controls.

Arrows are dispatched to the focused chain first and only reach the router if nobody consumed them. So a menu bar that handles Down itself works fine under BOTH — the router never sees the key.

The axis decides what happens when the widget declines: a menu item with no submenu does not consume Right, and a BOTH scope would then quietly slide focus to the next item. The user asked to open something and the selection moved instead, with no error anywhere. That is the failure mode this prevents, and it is the kind that reads as a toolkit bug rather than as a missing feature.

So HORIZONTAL and VERTICAL are about the arrows a scope leaves alone, as much as the ones it answers. moveFocusWithinScope returns false for an axis its scope does not rove, and false means unhandled — nothing happens, which is the correct behaviour for a key the widget already declined.

Home and End belong to no axis

They reach the ends of any scope, on any axis, because they name a position in the set rather than a direction on screen. The router passes a null axis for them, and FocusScope.roves(null) is true for every scope but NONE.

NONE means “not a composite”, not “a composite that roves on nothing”

The default has to be the first, or every focusable node inside any widget would stop being its own Tab stop the moment someone added an enum value. Asserted directly, because the two readings differ only in a case no arrow key visits.

Why not a per-key hook on the widget

The alternative was to let a widget handle arrows itself and have the router do nothing — no scope, no axis. That is what a widget can already do by consuming the key, and it is not enough: it puts the traversal back in the widget, which is the thing ADR-0073 established the widget cannot do, because a menu item cannot see its siblings any more than a radio can.

Consequences

  • menu, tabs, select’s popup and a toolbar can each declare the axis they actually have. Four widgets unblocked by an enum.
  • radio-group is the one composite in the catalog that legitimately answers BOTH, and now says so explicitly rather than by being the only implementer of a boolean. Its reason is on the method: its direction is its stylesheet’s, and .inline flips it.
  • The tests assert the unhandled result and not just the focus position — assertFalse(keyPressed(...)) — because “focus did not move” is also true of a scope that moved it and moved it back, and the distinction is the whole decision.
  • Open: nothing declares an axis yet. radio-group is BOTH and no other scope exists, so HORIZONTAL and VERTICAL are covered by FocusScopeTest’s bare widgets and by nothing shipping. That is deliberate — the mechanism is cheap now and expensive once four widgets have worked around its absence — but it means the first real menu is where the enum earns its keep or turns out to need a fifth value.
  • Open: a scope cannot say “the other axis leaves the scope”. ARIA’s tab list moves focus into the panel on Down; here that is the widget’s job to implement by consuming the key, and it has no way to ask the router to move focus somewhere outside the scope. tabs is where that gets faced.

ADR-0079: A continuous value is placed by ratio, and the router says where you are

  • Status: Accepted
  • Date: 2026-08-17
  • Relates to: docs/core-widgets.md §3; docs/design-system.md §1.3, §3, §3.1; extends ADR-0075; relies on ADR-0073 and ADR-0078; fifth instance of ADR-0065

Context

slider is the sixth control and the first whose value is a number rather than a state.

Every control before it has a value a stylesheet can name. A checkbox is on or off; a switch is one of two positions, and toggle-track:checked toggle-thumb { transform: translate(16px) } is literally how its thumb moves — the stylesheet owns where, and ToggleThumb does not know that it moves at all. That is a design worth keeping, and it stops working the moment the value is 37.4.

Two things follow, and neither has an answer in the toolkit as it stands:

  1. Where does the thumb go? No rule can name a position that came out of a model.
  2. Where is the pointer along the track? §3.1 asks for “drag: 1:1, no animation”, which needs the pointer’s position relative to the control — and a widget is a value with no idea where it was laid out.

Decision

The thumb is placed by flex ratio, because a transform cannot express it

The track’s children are a fill, the thumb and a spacer:

[ slider-fill grow=f ][ slider-thumb 16 ][ slider-rest grow=1-f ]

Yoga hands free space out in proportion to the grow factors, so the thumb lands exactly f of the way along whatever width the track turned out to be — and nothing in Java ever learns that width.

transform: translate was the obvious first answer and it is not merely awkward, it is unable: CSS percentages inside translate are a proportion of the moving box, so translate(50%) moves the thumb by half a thumb rather than to the middle of the track. Expressing it as pixels would need the track’s width, which is Yoga’s answer and does not exist until after layout — ADR-0068 says exactly this about translate(50%), arriving at the same wall from the other side.

The ratio also produces the filled portion for free, as a box the cascade can reach. A slider with no fill reads as a groove with a dot on it; the fill is what says “this much”, and it costs nothing because it is the flex child doing the positioning anyway.

slider-rest paints nothing and exists so that Yoga has something to give the remaining space to. It is a node rather than a number because a theme that wants to style the unfilled groove separately should be able to.

The router reports where inside a widget an event landed

PointerEvent.local() — the position relative to the widget currently handling the event, and that widget’s size, with fractionX() and fractionY() on top.

This is the direct sibling of ADR-0075’s dragX(), and the argument is the one already written three times: the router owns what the widget cannot see. A widget does not know where it was laid out; the router is holding the hit-test snapshot that says.

Relative to the handler and not to target(), which is the part that took thought. Dispatch bubbles, so one event reaches a chain of widgets: a press on a slider’s thumb targets the thumb, and the slider handling that press wants the position along itself. So the router re-points local() before each handler runs, rather than computing it once. The alternative — making the parts un-hit-testable so the slider is always the target — would have changed how every existing part behaves, mid-stream, to avoid a three-line loop.

Local.UNKNOWN is zero-sized rather than null, so a widget poked directly by a test reads fractionX() == 0 instead of dividing by zero. Same shape of decision as dragX() returning NaN: the degenerate value has to behave sensibly under the arithmetic a caller will actually write.

The control snaps and clamps; the application does neither

What travels up through change is already snapped to step and clamped to the range. A widget that reported a raw fraction would make every application repeat the same arithmetic and get it slightly differently wrong.

Three rules, and each is a choice rather than an obvious consequence:

  • Steps are counted from min, not from zero. A slider from 1 to 10 stepping by 2 offers 1, 3, 5, 7, 9 — the values reachable from where the track starts. From zero it would offer 2, 4, 6, 8, 10 and make min unreachable, which is the more surprising of the two and hides at the end of the track.
  • An arrow offers the next reachable value, not the current plus a step. Nothing snaps a value on the way in — snapping what the application set would be the control overruling the model — so a slider stepping by 25 can be showing 40, and Right should offer 50 rather than 40 + 25 rounded to 75. The two readings agree whenever the value is on the grid, which is every other time.
  • The ends are always reachable. 0 to 10 stepping by 3 has a grid of 0, 3, 6, 9, and a user who presses End and lands on 9 has been told the end of the track is not the end. max is a value the slider promises; the grid is a convenience over the values between.

A value from the model is clamped but never snapped: out of range is an application bug, and a thumb drawn off the end of its track is a worse way to report it than one pinned at the end.

It is the first control that relies on arrows reaching it first

ADR-0073 put focus-scope traversal after the focused chain declines a key, and wrote down that it was for “a slider stepping its value”. This is that slider.

The arrows are consumed even when the value did not move — a slider at its maximum still owns Right. Letting it through would hand the key to an enclosing scope and move focus off the control the user is adjusting, which is a strictly worse outcome than nothing happening.

Repeats are honoured, and this is the first control where they are: holding an arrow to run a value up is how a slider is used, while holding Space on a checkbox to flutter it is not.

fader is a class, not a widget

docs/core-widgets.md calls fader a vertical slider. It ships as slider.vertical, for the reason radio-group.inline is a class: the widget names the semantics and the stylesheet names the axis.

flex-direction: column-reverse on the track is the whole of it — that puts the minimum at the bottom, where a fader’s minimum belongs — and the widget inverts the pointer fraction to match. The two have to agree, and a golden image of a fader at 25% is what says they do.

fractionY() is deliberately not inverted at the router. Zero is the top, because that is where zero is on a screen; a control whose maximum is at the top is a fact about the control.

The groove was --gb-surface, for the fourth time

It shipped as nord1 on the dark theme — chosen as “a step down from the button’s nord2”, because a groove reads as something cut into a surface. nord1 is --gb-surface, so the unfilled part of the groove was invisible on any panel.

A slider hides this better than anything before it: the fill and the thumb still show, so the control looks like a control and merely appears to have no track. It is --gb-border now — a 4px groove is an edge, which is the same question the checkbox’s border answers, and a theme that moved its border colour would want this to follow.

That is the fourth instance of one defect: the checkbox’s glyph (ADR-0073) and the switch’s thumb twice (ADR-0075). What is different this time is the golden that exists for it already existed: controls-on-surface-{dark,light} was added by ADR-0073 precisely so a control could not vanish against a panel, and it had simply not been extended to the new control. The axis was covered and the control was not.

So everySurfacelessControlIsCovered now asserts that every entry in Controls.controlTypes() appears in that scene, with button exempt and saying why — it paints its own opaque surface in every variant, so there is no panel it can disappear against. The scene is extracted into one helper the golden and the guard share, because two lists of what is in a scene is the shape of the mistake the scene exists to catch.

Consequences

  • slider ships: a record, a node, a CSS type, bind + a valued change, drag, arrows, PageUp/PageDown, Home/End, :disabled, the shared focus ring, and four golden images. Six of thirteen controls, and fader with it.
  • PointerEvent.local() is the primitive knob, split-pane and a scrollbar each need next, and none of them will look like a slider.
  • Four new parts — slider-track, slider-fill, slider-rest, slider-thumb — bringing the total to ten. ADR-0065’s argument holds a fifth time and was not restated.
  • slider is deliberately absent from the shared transition rule, asserted by a test. §3.1’s “drag: 1:1, no animation” is a requirement a stylesheet can break silently, and a thumb that eased toward the finger would lag it.
  • A control joining the catalog now has to join the surface scene, or a test fails naming it. Three of the four instances of the invisible-control defect were found by a human looking at a window; this is the first mechanical guard against the fourth.
  • Open: no tick marks and no value label. §3 asks for both as optional. The label is the awkward one: it would sit inside the control’s own box, so the pointer-to-value mapping would stop being “along the control” and would need the track’s rectangle rather than the slider’s. Worth doing when something needs it, and it is a reason to be glad local() is per-handler.
  • Open: fader’s dB scale is not implemented. §3 asks for “optional dB scale mapping”, which is a non-linear value curve — the same shape of thing knob will want for its taper. It belongs on the widget as a mapping function and is not invented here for one caller.
  • Open: the pointer maps over the control’s full width, so at the extremes the thumb’s centre is up to 8px from the finger. Mapping over the travel needs the thumb’s width, which is the stylesheet’s (slider-thumb { width }) and not the widget’s. The mapping is monotonic and reaches both ends exactly, which is what matters; closing the gap means a widget being told a resolved metric, and that is a bigger door to open than this is worth.

ADR-0080 — A value is measured along a part

Accepted, 2026-08-17. Supersedes nothing; extends ADR-0079.

Context

slider shipped with three things docs/core-widgets.md §3 asks for and it did not have: “optional tick marks and value label”, and, for fader, “optional dB scale mapping”. They look like three small additions to one control. They are not, and the reason is that each of them breaks a different thing the control was resting on.

ADR-0079 put the thumb at a fraction of the track by flex ratio, and read the pointer back with PointerEvent.local().fractionX() — where inside the widget currently handling this event did it land. Both halves assume the same sentence: the control is the track. It was true, because a slider had nothing in it but a groove.

A value label is what makes it false. [ track ──────── ] 40 is one control and two boxes, and the one the value lives along is the shorter one. The status log predicted this when the label was deferred:

The label is the awkward one: it would sit inside the control’s own box, so the pointer-to-value mapping would stop being “along the control” and would need the track’s rectangle rather than the slider’s.

Tick marks break something else. A mark names a position the thumb can sit on, so a scale is a claim about where another part ends up — and the thumb’s centre does not travel the full width of the track. It travels the width less its own 16px, because it is a box in a flex row rather than a point.

And the dB scale breaks the arithmetic in the middle: min + f × (max − min) is written twice, once each way, in two methods that must stay inverses of each other.

Decision

A widget may name the part its pointer position is measured against

Handles.localPart() returns a CSS type name, or null. The router resolves it to the first descendant element with that type and reports local() against that rectangle. Slider returns "slider-track".

Named as a CSS type because that is the vocabulary a part already has (ADR-0065) — the same string the stylesheet writes, resolved against the same tree. Resolved by the router because the widget cannot see its own elements: the identical argument to dragX() (ADR-0075) and to Tab (ADR-0073) — the router owns what the widget cannot see.

The fallback is on the rectangle, not on the element, and that distinction is the whole of the fallback being useful. A part is in the element tree from the first build and has no region until the first paint, so an element-level check finds it and then hands back Local.UNKNOWN — a zero-sized box, whose every fraction is 0, which for a slider means the user asked for the minimum. A control missing its label for one frame would jump to zero. Falling back to the control’s own box is wrong by the label’s width; the other answer is wrong by the whole range.

The control is not the box the value lives on, and the anatomy says so

slider-track was the 4px groove. It is now the full-height box the value is measured along, and the groove is a part inside it called slider-groove:

slider
└── slider-track          grow 1, the hit target, and what localPart names
    ├── slider-groove     4px, and the flex ratio ADR-0079 describes
    │   ├── slider-fill   grow f
    │   ├── slider-thumb  16
    │   └── slider-rest   grow 1-f
    └── slider-ticks      the scale, if any
└── slider-value          the readout, if any

The rename is the point rather than a side effect. Two boxes were doing one job under one name, and the day a third thing joined the control they stopped being the same box — so the names now say which is which: you drag along the track, and the groove is the channel you can see.

Every existing golden image is byte-identical after this restructure, which is what says it was a refactor and not a redraw.

The scale hangs out of a zero-height row, moved by a transform

Two things had to be true at once, and each rules out the obvious implementation of the other:

  • The marks must clear the thumb. A scale drawn under a 16px disc is a scale you cannot read where it matters most.
  • Adding a scale must not move the groove. The track centres its column, so anything the scale contributes to that column pushes the groove up — and two sliders in one settings list, one with a scale and one without, would sit at different heights for a reason no reader could see.

So slider-ticks is height: 0 — it takes no part in the centring — and each mark is moved clear by transform: translate(0, 10px). A transform costs no layout (ADR-0068), which is exactly the property needed: the mark moves and the line it hangs from does not exist. Ten is half the thumb’s 16 plus the two a mark straddles its own line by, and it buys two pixels of air.

A mark is centred by a cell with no width

The marks are spread by justify-content: space-between, and each one sits in a synthesized 0×0 box that it overflows out of, centred.

That wrapper is the whole of why the scale lines up. Spread five 2px marks across the free space directly and their centres land at i × (C − 2)/4 + 1 rather than at i × C/4: the first a pixel right of where it belongs, the last a pixel left, every one of them a pixel off the thumb centre it is supposed to name. A mark’s own width has no business being in the spacing arithmetic, and at zero it is not.

The cell is zero on both axes rather than on the main one, and that is what keeps the widget from knowing which axis it is on: a fader flips the row to a column in the stylesheet, and a 0×0 cell is already correct in either. The widget names the semantics and the stylesheet names the axis — ADR-0079’s rule, applied to the one part that would otherwise have needed a vertical flag of its own.

The cells are boxes and not widgets. They carry no style, match no selector and mean nothing to an author; a part is what an author can restyle, and there is nothing here to restyle.

The tick row’s padding: 0 8px is half of §3’s thumb 16, and it is one arithmetic statement with the thumb the way the toggle’s 2 + 16 + 16 + 2 = 36 is one with its travel: a mark names a position the thumb’s centre reaches, and that centre stops half a thumb short of each end.

Marks are counted along the travel, not along the value

ticks=5 is five marks, both ends included, evenly spaced along the travel.

Two alternatives were rejected. One mark per step is what most toolkits do and it puts twenty-one marks on a 0–100 slider stepping by 5, which is a wall rather than a scale. Marks at even values are the same list as even positions on a linear slider and a useless one on a scaled fader — five marks at gains 0, 0.25, 0.5, 0.75 and 1 land at 0%, 80%, 90%, 96% and 100% of a decibel travel, which is four marks huddled at the top and one at the bottom.

One mark is refused at construction. A scale is its two ends and what is between them.

The value label is a format string, and it is fixed-width

format is a java.util.Formatter pattern held on the record, not a DoubleFunction<String>. §11’s parity invariant asserts that the Java-built and KDL-built forms of a control are equals, and two lambdas doing the same arithmetic never are. A pattern is a value, so format="%.0f%%" in markup and in Java produce the same slider.

It is validated when the slider is built — by formatting min with it — so a %d against a double fails at inflation with the pattern quoted, rather than throwing an IllegalFormatConversionException out of a paint on whichever frame first has a value to draw. That is ADR-0062’s rule applied to a format string.

Formatted in Locale.ROOT. Not tidiness: the default locale would draw 0,5 on a machine set to de_DE where CI drew 0.5, and the golden that failed would be a pixel diff nobody could reproduce anywhere else. A locale-aware readout is the application’s to pass in already formatted.

slider-value has a fixed width in the stylesheet, and that is the decision rather than a default. A label that sized itself to its content would take three pixels off the track between 9 and 10 — which moves the value under the finger that is setting it, at the moment it is being set.

A scale is a value, and the dB one is a taper

Scale is a sealed interface with two methods that are inverses — toFraction and toValue — and two implementations: Linear and Decibels(floorDb). Every place the slider converted between a value and a position now goes through it, which is three places rather than the two that were obvious (the thumb, the pointer, and the arrow keys).

Records rather than lambdas, for the parity reason above: scale="db" and Scale.decibels() are the same value.

Decibels places a linear gain at a position that is linear in dB, which is what a mixing desk’s fader does and what §3 means by “dB scale mapping”. A gain of 0.5 is 6 dB down, which is 90% of the way up a 60 dB travel and half way up a linear slider. That difference is the feature: placed linearly, everything a fader is used for happens in its top inch.

The bottom of the travel is min exactly rather than max × 10^(floor/20), because the thing a fader must be able to do is go silent. It is a discontinuity of 0.001 of full scale at one end of the control — the difference between −60 dB and nothing, which is not audible. A fader that bottomed out at “very quiet” is a fader with a bug.

scale="dB" is refused rather than resolved quietly to linear, like every other name a document can write (ADR-0062): the alternative is a fader that works and is wrong.

A continuous slider steps along the travel; a stepped one keeps its grid

An arrow moves a hundredth of the travel and a page a tenth, when step is 0. On a linear scale those are the range’s hundredth and tenth and nothing changes. On a fader they are not: a hundredth of the gain is a hair at the top of the travel and a third of it at the bottom, so a fader would step unevenly under a key held down.

A slider that does have a step keeps stepping in value space, because a grid is what the author asked for and the values on it are theirs rather than the screen’s.

Consequences

Three new parts (slider-ticks, slider-tick, slider-value), one renamed (slider-track → slider-groove) and one repurposed (slider-track). One new method on Handles, defaulting to null, which every other widget ignores.

SliderGeometryTest is a new kind of test in this repository and the change is what needed it. The claims the marks rest on are geometric relations between two parts — a mark under the thumb’s centre at both ends and at any width, a scale that clears the thumb, a groove that does not move when a scale is added — and each of them is a number that no stylesheet states and no value assertion can reach. They come out of the flexbox algorithm, and every wrong version of them lays out perfectly and draws a plausible picture. It lays a tree out through the real RenderTree and asserts against the captured rectangles, which is the same route hit testing takes.

Two of its six assertions failed on the first run, and both were real: the tick row’s padding-top was pushing the groove up by five pixels, because Yoga adds padding to a box with an explicit height: 0; and the label’s width was not coming off the track at all, because a slider in a row collapses to its content width and the test’s own scene was the thing that was wrong. The transform came out of the first of those.

What this does not do, and each is named rather than left to be discovered:

  • A slider still maps the pointer over the track’s full width, so at the extremes the thumb’s centre is up to 8px from the finger. Closing it means a widget being told a resolved metric — the thumb’s width — which is a bigger door than this is worth. The marks do not have this problem, because their inset is the stylesheet’s own and sits beside the thumb’s width in the same file.
  • The readout is left-aligned in its box, because §8’s subset has no text-align (ARCHITECTURE.md §8.1 says so deliberately). This is the one thing here that is a gap rather than a decision.
  • A slider with a readout is wider in its row and one with a scale is no taller, which is the trade the zero-height tick row makes: the marks are drawn inside the control’s 32px, in the space below the groove that the hit target was already claiming.
  • knob’s taper is what Scale was built general for, and there is no knob.

ADR-0081 — A perpetual loop has no state

Accepted, 2026-08-17. Extends ADR-0067. Its sentence about @keyframes is reversed by ADR-0353; the loops stay clock functions.

Context

progress and spinner are the seventh and eighth controls, and they are the first two whose motion is not a transition.

Everything that has moved so far moved between two styles the cascade resolved: a button’s hover colour, a switch’s thumb, a check mark’s scale. ADR-0067 built that — a per-node overlay, interpolated on the frame clock, driven by CSS transition declarations — and it covers every state change in the catalog.

An indeterminate progress bar has no two states. Neither does a spinner. §3.1 asks for “sweep loop 1.2s linear” and “rotation 900ms linear loop”, and §8’s CSS subset has no @keyframes and is not going to grow one: a loop is not a declaration about a state, and every mechanism in the engine is built around diffing one style against another.

§1.7 does name a mechanism for this:

Explicit = the Animation API. AnimationController (forward/reverse/repeat/stagger) on the same frame clock — used internally by indeterminate progress, spinner, toast reflow, and available to apps for canvas work.

So the expected shape of this change was: build AnimationController, give each of the two controls one, start it when the element mounts, stop it when the element unmounts.

Decision

No controller. A loop that never ends is a function of the clock

static double phaseAt(double now) {
    var phase = (now % SWEEP_PERIOD) / SWEEP_PERIOD;
    return phase < 0 ? phase + 1 : phase;
}

That is the whole mechanism. There is nothing to start, nothing to stop, nothing to dispose, and nothing that can leak — and the widgets stay what every other widget in the catalog is, a value with no state on it.

The argument is ADR-0073’s, for the third time. A roving focus position was derived from :checked rather than stored, because a second copy of a fact the tree already holds disagrees with it the first time something changes without telling the thing that cached it. An effective disabled was derived by walking ancestors for the same reason (ADR-0077). Here the fact is the time, the tree already holds it — the renderer reads the clock once per frame — and a controller would be a second copy of it, per element, each one remembering when its own element happened to mount.

And the stored version has a visible symptom that the derived version cannot have. Two spinners in one window, mounted a frame apart, are permanently out of phase: two rings turning at the same speed and never at the same angle. It looks wrong and it does not look broken, which is the worst kind of defect — nobody files it, and nobody finds the cause when they do. Derived from the clock, being in step is not something anyone has to arrange. progress-sweeping.png is two bars in one frame at the same position, and it is a picture that only passes for the derived version.

AnimationController is therefore not built

§1.7 names it and this change does not add it, deliberately. Its remaining subjects are the ones with a lifecycle — “toast reflow”, and the opening → open → closing → removed sequence §1.7 gives every overlay — where there really is a start, an end, an interruption to reverse from, and a state to hold. None of those widgets exist; they are M3.

Building it now, for two callers that do not need it, would be inventing an API against no requirement and then shaping the requirement to fit it. Principle 3’s rule, and ADR-0074’s density-regular.css refused for the same reason: the absence of a thing is a design position, and this one is on the record so that whoever builds the controller for toast builds it for toast’s problem.

A widget reads the frame’s time, and says it wants another frame

Two additions, both small:

  • Paints.Context.nowMillis() — the time the renderer read once for this frame, so two spinners see the same number rather than two calls to System.nanoTime a few microseconds apart. reducedMotion() joins it, because a widget that animates itself has no declaration for the renderer to collapse.
  • Paints.isAnimating(), default false — §1.7’s idle frame loop stops the frame after the last transition settles, and a spinner has no transition to settle. Without it the loop would paint a spinner once and go to sleep in front of it.

isAnimating() is a property of the description: a bar is indeterminate because it was built that way, and one that has been given a value stops asking. Nothing is started or stopped here either.

The sweep is a transform, and it stays inside its track

The bar moves by transform: translate(…%), never by width or margin. Animating either of those would run Yoga on every frame of a loop that never ends, which is exactly the cost §1.7’s whitelist is a closed enum to refuse.

The percentage is a proportion of the moving box — CSS’s rule, and here it is the convenient one: the bar’s own width is the natural unit for its travel. That is the same rule that made translate unable to place a slider’s thumb (ADR-0079). Two controls, one rule, opposite conclusions, and the difference is only that one of them has a thumb sharing its track.

The bar reverses at the ends rather than running off them, which is a divergence from the usual drawing and is forced: the off-one-end-and-in-at-the- other version depends on overflow: hidden, and nothing in this toolkit clips a box. A bar that ran past its track would be drawn across whatever is beside it, and the wrap from one end to the other — which clipping is what hides — would be a visible jump once every 1.2 seconds. A bar that turns has no wrap to hide. Linear each way, so the only thing that happens at the turn is that the direction changes.

A spinner is a mark, and the arc is three cubics

The obvious implementation is an icon and it is wrong twice: an Icon owns native memory and a widget is a value rebuilt every frame — the argument Button’s borrowed icon makes — and it would put the toolkit’s own spinner behind an asset the application has to register.

So it is a Box.Mark, like a tick and a dot, and the arc behind it is built from cubics through the already-exported bl_path_cubic_to. No symbol was added to the export list, which is ADR-0064’s rule and the fifth time it has held. Arc is the general form of what RoundRect does at fixed angles: quarters, because KAPPA is the answer for 90° and a single cubic over 270° is visibly not a circle.

Three quarters rather than a whole ring, because a spinning circle is a circle: the gap is the entire reason the rotation can be seen. spinner-turning.png and spinner-half-turn.png are the same three spinners 450 ms apart, which is what says both that the gap moved and that the three of them are in step.

Reduced motion stops the movement rather than slowing it

§3.1 gives both controls the same answer — “reduced-motion: opacity pulse” — so both stop moving. A slower sweep is still a sweep.

What ships is the stopping, and not the pulse: a pulse is a loop between two opacities, and §8 has no @keyframes to write one with. A reduced-motion user gets a bar holding still across a third of its track — a control that says “working” rather than an empty groove — and that is recorded here as a divergence rather than presented as compliance.

Consequences

Two controls, one new part (progress-fill), one new mark kind (ARC), one new file in :core (Arc), and two methods on interfaces that every existing implementation ignores.

Paints.Context gained two abstract methods rather than two defaults, so every hand-written implementation had to be updated — there is one, in the test fixtures. A default nowMillis() returning zero would be a stopped clock nobody notices they inherited, which is worse than a compile error.

A golden image containing a spinner needs a virtual clock, and until now no golden needed one unless it was a picture of a transition. Every scene in the repository was deterministic under the system clock because nothing in it moved on its own; controls-on-surface-* now contains a control that draws itself from the frame time, and under a wall clock it is a different ring on every run. It duly was — 84 pixels apart, on the first regeneration after the spinner joined that scene. ADR-0067’s argument for the virtual clock was about photographing a moment; this is the same clock answering a different question, which is whether an image is reproducible at all.

What this does not do:

  • There is no AnimationController, so an application cannot drive its own animation imperatively yet. It has Clock and its own onPaint, which is what the two controls here use.
  • The reduced-motion pulse is absent, as above.
  • progress has no :disabled and no label. §3 gives it neither, and a progress bar is not interactive, so :disabled would mean “this progress is unavailable”, which is not a state anything has asked for.
  • Neither control carries semantics yet — §3 says “Semantics: progressbar” and “decorative unless labeled” — because the semantics tree (ARCHITECTURE.md §13) does not exist for any control.
  • A window containing a spinner never idles, which is not a regression but is worth stating plainly: §1.7’s “the frame loop is fully idle when no animation is active” now has a control that keeps one active for as long as it is mounted. That is the cost of something on screen that moves, and the showcase demonstrates it by having one.

ADR-0082: A preflight check that cannot fail is not a check

Context

:natives:checkToolchain exists so that a missing Linux development package is reported in one second, in terms of apt, instead of two minutes into a CMake configure in terms of a CMake option. It did not do that. A local build failed with:

CMake Error at .../sdl3-src/cmake/macros.cmake:433 (message):
  Couldn't find dependency package for XSCRNSAVER.  Please install the needed
  packages or configure with -DSDL_X11_XSCRNSAVER=OFF

after checkToolchain had printed Native toolchain OK and not so much as warned.

Three separate things were wrong, and each one alone would have been enough.

The module name did not exist. The table probed pkg-config --exists xss. No distribution ships an xss.pc; the module is xscrnsaver, which is also what SDL asks for (cmake/sdlchecks.cmake, set(Xss_PKG_CONFIG_SPEC xscrnsaver)). Debian’s libxss-dev and RHEL’s libXScrnSaver-devel both install xscrnsaver.pc and neither installs xss.pc. The probe therefore returned “absent” on every machine ever, whether the package was installed or not — a row that cannot distinguish the two states is not measuring anything.

The row was marked optional. SDL’s X11 driver does not degrade. Every SDL_X11_* sub-feature is dep_option(... ON ...) and every one ends in SDL_missing_dependency, which is a FATAL_ERROR. There is no build without XScrnSaver; there is only no build. Because the row was optional, even a probe that worked would have printed a warning and let the build proceed to fail.

XTest was not in the table at all. It is the very next hard stop after XScrnSaver, in that order, so installing one package moves the error down one line and no further.

The uncomfortable part is that none of this was unknown. Both CI workflows already install libxss-dev/libXScrnSaver-devel and libxtst-dev/ libXtst-devel, and both carry comments explaining that SDL treats them as hard dependencies — comments written by whoever hit this in CI, twice, once per package. linux.yml says so in as many words: “They come in that order, so adding the first only moved the failure one line down — which is what happened.” The knowledge existed in three places and reached the check in none of them. The table and CI are two statements of the same fact, and nothing made them agree.

Decision

Move the dependency table out of natives/build.gradle into LinuxDependencies in build-logic, and unit-test it against what CI installs.

The table gains a Necessity — HARD_STOP, NEEDED or OPTIONAL — in place of a boolean, because the three cases are genuinely different and the difference is what the failure message needs to say:

NecessityWhat SDL doesWhat the check does
HARD_STOPStops the configure with SDL_missing_dependencyFails, and says the configure will stop
NEEDEDDrops a backend silently and configures successfullyFails
OPTIONALBuilds without a feature Goldberry does not useWarns

NEEDED covers SDL’s Wayland check, which is one pkg_check_modules over five specs: lose any one and the Wayland driver is not compiled in, the configure succeeds, and the first symptom is a user on Wayland with no window. That is worth failing on here because SDL will not fail on it. It also added a row that was missing for the same reason xtst was: egl, the one spec in that list nothing else pulls in.

LinuxDependenciesTest then asserts the invariant that actually broke:

  • every required package is installed by the workflows that run checkToolchain through Gradle (example.yml, showcase.yml), or CI would fail its own preflight;
  • every HARD_STOP package is installed by linux.yml, which runs CMake directly inside the manylinux container with no JDK — so checkToolchain never runs there and this list is the only thing between it and SDL’s FATAL_ERROR;
  • no row uses xss, named as a regression rather than left to the sweep.

This is the same move ADR-0040 made for ToolResolver, for the same reason: the logic is real, the bug is one nobody reproduces by reading, and a Groovy literal in a build script has nowhere to put a test.

Alternatives considered

  • Configure with -DSDL_X11_XSCRNSAVER=OFF, as the CMake error suggests. Rejected, and both CI workflows had already rejected it in comments: XScrnSaver is how SDL keeps a screensaver off a window that is playing something, and XTest is how it warps the pointer on X11 — which §7.3’s drag-to-resize and slider behaviour want. Switching them off removes a capability silently, on one platform only, and makes a developer’s local library differ in behaviour from the published one. A build that stops and names a package is better than a library that is quietly less capable.
  • Fix the two rows in place and leave the table in Groovy. Rejected: it repairs the instance and not the mechanism. The table drifted from CI once already, in a direction nobody could see, and the next dependency SDL turns into a hard stop will drift it again.
  • Derive the table from SDL’s CMake at configure time, parsing dep_option and SDL_missing_dependency out of the vendored sources. Rejected: it needs the sources cloned, which is the 330 MB step this check runs before, and it still could not produce the distribution package names — which is the half of the mapping that makes the message actionable.
  • Have CI generate its install list from the table. Attractive, and a real option later. Rejected for now because the workflows install more than the superbuild needs — xvfb, wayland-protocols, the pip toolchain — so the generated part would be a fragment spliced into a hand-written command, and a test asserting the two agree buys most of the benefit for none of the machinery.

Consequences

  • A missing header now fails in under a second with the exact command:

    Missing development headers the superbuild needs:
      xscrnsaver -- SDL3 screensaver inhibition (SDL stops the configure without it)
      xtst -- SDL3 pointer warping on X11 (SDL stops the configure without it)
      egl -- SDL3 Wayland backend
    
    sudo apt install libegl1-mesa-dev libxss-dev libxtst-dev
    
  • Some machines that used to configure will now be refused. egl was never checked, so a machine without libegl1-mesa-dev previously produced a library with no Wayland backend and no indication of it. Those builds now stop. This is the intended cost: the alternative is shipping a Linux toolkit that silently does not run on Wayland.

  • Adding a dependency now means editing Java in a second build and possibly a workflow, rather than one line in a build script. The test says which workflow.

  • The drift guard reads .github/workflows from a unit test, which couples build-logic’s tests to the repository layout. build-logic/build.gradle passes -Dgoldberry.repoRoot; the test falls back to walking up from the working directory so an IDE run still works, and throws rather than skipping when it finds nothing — a guard that skips when it cannot find what it guards is a green tick over an unchecked invariant.

  • linux.yml is only guarded for hard stops, not for NEEDED. It installs no mesa-libEGL-devel and no xkeyboard-config, so whether the published manylinux artifacts carry SDL’s Wayland driver is an open question this record does not answer — it needs a look at the container, not at this table. It is listed in book/src/TODO.md.

ADR-0083: On GNOME/Wayland, libdecor is not a fallback

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/ARCHITECTURE.md §3, §15; ADR-0003, ADR-0012, ADR-0082
  • Follows: ADR-0082 (the dependency it adds is one that check could not have caught, for a new reason)

Context

A window opened on real hardware — Ubuntu, GNOME, a Wayland session — had no titlebar, no minimize or close button, and could not be resized. Nothing in the build log or the run log mentioned it. The Java side was innocent: WindowSpec.of is documented as “a resizable, server-side-decorated window”, Sdl3Backend.createWindow adds SDL_WINDOW_RESIZABLE and withholds SDL_WINDOW_BORDERLESS exactly as it should, and SDL accepted both.

The cause was in the generated SDL_build_config.h:

/* #undef HAVE_LIBDECOR_H */
#define SDL_VIDEO_DRIVER_WAYLAND 1

Wayland has no decoration protocol of its own. A toplevel is decorated one of two ways: the compositor draws them, negotiated through zxdg_decoration_manager_v1, or the client draws them itself. GNOME’s Mutter declines to draw them — a long-standing and deliberate position — so on GNOME the second way is the only way, and SDL’s implementation of the second way is libdecor. Every use of it in SDL_waylandwindow.c sits behind #ifdef HAVE_LIBDECOR_H, and that macro is set only if libdecor-0.pc was present when SDL was configured.

Without it SDL builds a complete, working Wayland driver that opens an undecorated toplevel — which also explains the second symptom, because on Wayland a resize is a client-initiated xdg_toplevel.resize and the thing that decides a pointer is on a resize edge is the decoration. No decoration, no edge, no resize. One missing header, both symptoms.

Two things made this land now rather than earlier.

It was uncovered by ADR-0082. That record added egl to the dependency table, which is what turned SDL_VIDEO_DRIVER_WAYLAND on. Before it, this machine’s builds had no Wayland driver at all, SDL fell through to X11 under XWayland, and Mutter decorated the X11 window normally. Fixing the Wayland backend is what made the missing decorations visible. The bug was always there; nobody could see it.

It is a third failure mode, which ADR-0082’s table had no way to express. That record split dependencies into HARD_STOP (SDL refuses to configure) and NEEDED (SDL drops a whole backend silently). libdecor is neither: the backend is built, initializes, opens a window, pumps events and paints. What is missing is a capability of a window that otherwise works. Nothing logs it, nothing warns, and SDL_GetWindowFlags still reports the window as bordered — SDL asked for a bordered window and does not know it did not get one.

Decision

Add libdecor-0 to LinuxDependencies as NEEDED, so a build without it fails in checkToolchain with the package name, and install libdecor-0-dev in the workflows that build through Gradle.

NEEDED rather than a new necessity of its own. The category means “SDL will not tell you, so we will”, and that is exactly right here; what differs is the size of what is lost, not who reports it. A fourth value would split the table on a distinction that changes no behaviour.

Rejecting the alternative explicitly: Goldberry does not draw its own decorations, and this record does not start. SdlWindowFlag.BORDERLESS is documented as “client-side decorations are drawn by Goldberry on top of this”, which describes a design that does not exist yet and is not what a default window uses. Nothing here commits to building it.

Alternatives considered

  • Prefer X11 on GNOME, by narrowing PREFERRED_LINUX_DRIVERS from wayland,x11. Rejected: it trades a missing titlebar for XWayland’s blurry fractional scaling, which is precisely what SDL_WINDOW_HIGH_PIXEL_DENSITY and the whole fractional-DPI path exist to avoid. It also fixes GNOME by punishing every compositor that does decorate properly.
  • Draw the decorations in Goldberry, using BORDERLESS as the doc comment imagines. A genuine long-term option — it is the only way to get a titlebar that matches the toolkit’s own design system, and it is what GTK and Qt both do. Rejected now because it is a feature, not a fix: it means window-move and eight-way-resize hit testing, a shadow, a maximize/snap protocol, and a titlebar widget, on a milestone ladder that has not reached window chrome. A package that already does it correctly is available today.
  • Ship a bundled libdecor in the superbuild, alongside Blend2D and the rest. Rejected: SDL dlopens libdecor by soname at run time rather than linking it, so vendoring it would mean shipping and installing a shared library for SDL to find — a distribution problem, not a build one. The headers are all the build needs.
  • Detect it at run time and warn, by exporting the compiled-in value of SDL_HAVE_LIBDECOR through the shim and logging when the Wayland driver starts without it. Not rejected — deferred. It is the only thing that helps someone running a published jar, who never runs checkToolchain at all, and it is listed as an open question rather than half-built here.

Consequences

  • A machine without libdecor-0-dev now fails checkToolchain in a second, naming the package, instead of building a window nobody can close.
  • Installing the package is not enough on its own. CMake caches HAVE_LIBDECOR_H in CMakeCache.txt, and Gradle sees no changed input, so a plain rebuild after apt install silently keeps the old answer. The configure directory has to be discarded. This is a sharp edge and this record does not remove it — see the open question below.
  • The published Linux artifacts are not covered. linux.yml builds in a manylinux AlmaLinux 8 container, which runs CMake directly with no JDK and so never runs checkToolchain; whether libdecor-devel even exists in its repositories is unverified. The drift guard in LinuxDependenciesTest only holds that workflow to HARD_STOP rows, so it will not catch this — by design, since a guard that fails on a package that cannot be installed is a guard that gets deleted. Tracked in book/src/status.md together with ADR-0082’s unanswered question about mesa-libEGL-devel, because they are the same question: what the release container actually compiles into its Wayland driver.
  • The table now describes three genuinely different failure modes with two necessity values, and libdecor-0 is the row where the naming strains. Worth re-reading if a fourth case turns up.

ADR-0084: The GTK plugin cannot decorate a JVM’s window

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/ARCHITECTURE.md §3; ADR-0003, ADR-0020, ADR-0083
  • Amends: ADR-0083 (its fix is necessary and not sufficient)

Context

ADR-0083 concluded that a GNOME/Wayland window has no titlebar because SDL was built without libdecor, and added libdecor-0-dev to the toolchain check. That was correct and incomplete. With libdecor compiled in and libdecor-0-0 installed, the window still had no titlebar and still could not be resized, and the run printed:

Failed to load plugin 'libdecor-gtk.so': failed to init
No plugins found, falling back on no decorations

libdecor draws nothing itself. It loads a plugin, and Debian and Ubuntu ship two: libdecor-gtk.so, pulled in as a dependency of the library, and libdecor-cairo.so, a separate package almost nobody installs.

libdecor-gtk.so’s constructor opens with this, at offset 0xbb45, before it touches GTK, D-Bus or Wayland:

call   getpid
call   gettid
cmp    %eax,%ebx
jne    bcc5        ; -> xor %r14d,%r14d ; ret   (NULL, no message)

getpid() == gettid() is the Linux test for “am I the process’s initial thread”, which is what GTK requires. The stock java launcher does not satisfy it. It runs main on a thread it creates, so that the primordial thread’s stack size does not limit Java. The jump target lands past the plugin’s own fprintf, which is why it fails without a word and why SDL_LOGGING=*=verbose shows nothing.

This record originally said a JVM never satisfies it. That was wrong, and the distinction turns out to be the whole answer to “how do I get native decorations on Wayland”. It is a property of the launcher, not of the VM. A launcher that embeds the VM — its own main calling JNI_CreateJavaVM and then the Java main — runs Java on the primordial thread, and there the GTK plugin loads and draws decorations that match the desktop. Measured with one variable changed, same libdecor 0.2.5, same GNOME session, same GTK-only plugin directory:

launchermain runs onlibdecor
stock javaa created threadfailed to init, then No plugins found
embedded JNI_CreateJavaVMthe primordial threadsilent — GTK plugin loaded

jpackage’s launcher does not help: it goes through JLI_Launch, which calls ContinueInNewThread like the stock one.

This is new behaviour, and that matters more than it looks. The check was added by commit 74839e51 on 2025-01-21 and first shipped in libdecor 0.2.3 (2025-05-13); every release up to 0.2.2 (2024-01-15) has no gettid in the GTK plugin at all. On a distribution carrying an older libdecor, a JVM on GNOME/Wayland gets real GTK decorations that match the desktop exactly, with no warning and no fallback — which is why this can be remembered as having worked, on the same compositor, with the same code.

It was not working. It was crashing intermittently for other people, which is why the check exists. libdecor issue #72 is titled “guvcview (with SDL backend, which uses libdecor) crashes in GTK CSS code on startup on Wayland”; the backtraces land in GTK’s CSS refcounting on g_assert_not_reached(), GTK’s maintainers declined it as a libdecor problem, and libdecor’s fix was to stop the GTK plugin running where GTK cannot. Lutris and Bottles are linked from the same issue with variants of it.

So downgrading libdecor is not a workaround for the decorations, it is a reintroduced memory-corruption bug. Recorded here explicitly because it is the obvious next idea for anyone who saw the old behaviour and wants it back.

This was confirmed by reducing it to one variable. The same binary, in the same session, against the same libdecor:

Callerpid/tidResult
the process’s initial thread51011 / 51011libdecor_new -> 0x5c16005d62d0, decorated
a pthread51018 / 51019Failed to load plugin 'libdecor-gtk.so': failed to init

SDL 3.4 knows about the restriction. Wayland_LoadLibdecor deliberately initializes libdecor on a secondary thread “so that it will not use its GTK plugin, but instead will fall back to the Cairo or dummy plugin” — but only when SDL_CanUseGtk() is false, and that function checks the SDL_ENABLE_GTK hint and setuid/setgid, never the thread. So SDL believes GTK is usable, takes the direct path, and the plugin fails anyway. Cairo is the fallback SDL itself names.

That libdecor is the only path here was measured rather than assumed, by enumerating the compositor’s globals on a GNOME 4x session:

compositor globals relevant to decorations:
  xdg_wm_base (v7)

No zxdg_decoration_manager_v1. should_use_libdecor returns false only when that global is present, so on GNOME there is no server-side path to fall back to — unlike KDE and wlroots, which advertise it and never reach libdecor at all.

Nothing in CI could have caught this: example.yml and showcase.yml both run under xvfb-run, which is X11, where the window manager draws the decorations and libdecor is never reached.

Decision

Detect the condition and warn, loudly and once, naming the package. Do not throw and do not change the video driver.

Throwing was considered and rejected: the window opens, paints and receives input correctly, and a fullscreen or kiosk application does not care about a titlebar. Refusing to start would turn a cosmetic problem into an outage. Automatically falling back to X11 was rejected for the reason ADR-0083 gave for not preferring X11 in the first place — XWayland’s fractional scaling is what SDL_WINDOW_HIGH_PIXEL_DENSITY exists to avoid — and because a driver that changes underneath the application is worse than a message telling it what to do.

The verdict is inferred, because SDL cannot be asked. libdecor_new returns a valid context even when every plugin failed — it falls back to drawing nothing — so SDL sees success, marks the surface WAYLAND_SHELL_SURFACE_TYPE_LIBDECOR and exposes no property saying the frame is empty. What is knowable is the input to that decision: which plugin files are installed. Since the GTK plugin is guaranteed to fail in this process, “the GTK plugin is the only one” is equivalent to “there will be no decorations”.

WaylandDecorations is therefore three-valued rather than boolean, and the third value is the load-bearing one:

  • UNDECORATED — a plugin directory was found and nothing in it can decorate here.
  • DECORATED — some non-GTK plugin is present, or the GTK plugin is present and this is the initial thread, where it will load.
  • UNKNOWN — not Wayland, no plugin directory could be located, or the thread could not be determined and the answer depended on it. Says nothing.

The thread is measured, not assumed. /proc/thread-self is a symlink to <pid>/task/<tid>, so one readlink yields both numbers and no native call is needed. Assuming it instead — which this record did in its first draft — makes the warning fire under an embedded launcher, against a window that has a titlebar the user is looking at. That is the worst kind of wrong for a diagnostic, and it was caught only by running the embedded launcher rather than reasoning about it.

A message that is sometimes wrong is worse than no message, because the next person to see it will not believe it. Where libdecor keeps its plugins is a distribution’s choice; guessing wrong must produce silence, not a warning about a problem that is not there.

Alternatives considered

  • Throw a BackendException with the same text. The house style (ADR-0082) favours failing loudly, and an undecorated non-resizable window is arguably broken. Rejected: unlike a build-time check, this runs on an end user’s machine, where refusing to open a working window is a bigger failure than the one being reported.
  • Set SDL_ENABLE_GTK=0 so SDL takes its own non-main-thread branch. Rejected: it changes nothing here. That branch exists to make the GTK plugin fail on purpose; it already fails. Without the Cairo plugin installed there is still nothing to fall back to.
  • Ask SDL through a window property. Rejected because it cannot answer — see above. This was checked before inferring rather than after.
  • Detect the thread directly, since gettid() != getpid() is the actual cause. Rejected: it is always true in a JVM, so it discriminates nothing. The plugin listing is the only part of the condition that varies.
  • Bundle a decoration plugin, or draw decorations in Goldberry. Both remain open, and ADR-0083 already records the second as deferred. This record does not start either.
  • Fake the thread check with an LD_PRELOAD shim interposing glibc’s gettid to return getpid for the plugin’s one call. It would work — for a process that uses GTK nowhere else it reproduces exactly the pre-0.2.3 behaviour, which is what ran happily in a VM for months. Rejected as anything Goldberry ships or documents as a remedy: it is a native artifact anyway (so it buys nothing over a launcher), it re-enables behaviour upstream deliberately disabled, and the “GTK nowhere else” premise is not Goldberry’s to guarantee — an application is free to embed WebKitGTK or a file chooser portal fallback in the same process, which is precisely the two-threads-in-GTK case issue #72 is about.
  • Wait for upstream’s out-of-process GTK plugin. libdecor MR 176 (active, last updated 2026-07-10) moves all GTK work into a dedicated child process that tunnels Wayland traffic, drawing the decorations as a subsurface. In that design the host process’s thread no longer matters, so the restriction — and this whole record’s problem — dissolves for every JVM app with no change on our side. Not a plan, because it has no date; but it means the launcher below is a bridge, not a permanent investment, and arguing against building anything elaborate here.
  • Ship a launcher that embeds the VM, so Goldberry applications run main on the primordial thread and get the GTK plugin. This is the only route to decorations that match the desktop on Wayland, and it is now demonstrated rather than theoretical. Not decided here, because it is a distribution change rather than a diagnostic: it means a native binary per platform, JNI_CreateJavaVM argument handling, and an answer to what ./gradlew run and a plain java -jar should do — none of which belongs in the fix for a missing warning. Recorded as the answer, and left for its own record. The warning names it as the third remedy so nobody has to rediscover it.

Consequences

  • The failure now announces itself, next to libdecor’s own cryptic line, with the command that fixes it. It fires once, at the first window that asked to be decorated — a borderless window is unaffected and is not warned about.
  • The fix is a run-time package, not a build-time one. sudo apt install libdecor-0-plugin-1-cairo requires no rebuild; ADR-0083’s libdecor-0-dev is still needed, at build time, to compile the support in at all. Two packages, two phases, and installing either alone leaves the window bare.
  • The warning can fire on a compositor that would have decorated the window anyway. should_use_libdecor returns false when the compositor offers zxdg_decoration_manager_v1, so KDE and wlroots decorate server-side and never reach libdecor. Goldberry cannot see that protocol from Java, so a KDE machine with only the GTK plugin installed gets a warning about a problem it does not have. The message is worded to stay true in that case — it says GNOME is the compositor that declines — but it is noise there, and that is the price of inferring rather than asking.
  • The plugin directory is searched by convention (LIBDECOR_PLUGIN_DIR, then the multiarch, lib64 and lib paths). A distribution that puts it somewhere else gets UNKNOWN and silence, which is the intended failure mode rather than a bug.
  • CI still cannot catch a regression here. Every CI leg is X11 under Xvfb. WaylandDecorationsTest covers the decision from a plugin listing, which is the part that can be tested without a compositor; that no job exercises the real Wayland path is unchanged and remains an open question in book/src/status.md.

ADR-0085: A window that closes beats a sharper one that cannot

Context

ADR-0084 established that a Wayland window in a stock-launched JVM has no titlebar and cannot be resized unless a non-GTK libdecor plugin is installed, and chose to report that rather than act on it. Two arguments were given for not acting: a driver that changes underneath the application surprises people, and Wayland was preferred deliberately in ADR-0027 for the resize quality XWayland gives up.

Both still hold. What changed is the weight on the other side, once the full shape of the problem was known:

  • There is no in-process fix. libdecor master still carries the unconditional thread check, and the loader reads only LIBDECOR_FORCE_CSD, LIBDECOR_PLUGIN_DIR and XDG_CURRENT_DESKTOP — none of which affects it. The remedies are all outside the process: install a package, change the launcher, or change the driver.
  • The default is the broken one. libdecor-0-plugin-1-gtk is pulled in as a dependency of libdecor; libdecor-0-plugin-1-cairo is a separate package almost nobody installs. So the out-of-the-box state on Debian and Ubuntu, for every Goldberry application, is the undecorated one.
  • A warning does not fix a window. The person who sees it is usually not the person who can act on it — the toolkit’s user, running someone else’s application, cannot install a plugin into a machine they do not administer.

Weighed against that, XWayland’s cost is a resize that is visibly worse and fractional scaling that is blurrier. Neither prevents using the window. Having no close button does.

Decision

On a Linux Wayland session, when a Wayland window is known to come up undecorated, ask SDL for x11,wayland instead of wayland,x11.

The verdict comes from the same WaylandDecorations that produces the warning, through a new verdictForWayland that takes no driver name — because the decision has to be made before SDL_Init, when there is no driver to ask about yet. Everything it depends on is available then: the libdecor plugin directory, and whether this is the process’s initial thread. That timing is the only real constraint in the change, and it is why the check was split rather than reused as-is.

Only a definite UNDECORATED turns the preference around. A plugin directory that could not be located, or a /proc that could not answer, leaves the ordinary wayland,x11 in place. The fallback trades away real quality, and doing that on a guess would degrade machines that were working — the same reasoning that makes the warning stay silent when it is unsure, applied to an action instead of a message.

Wayland stays on the list, behind X11. x11,wayland rather than x11: a session with no XWayland must still get a window, and an undecorated window beats SDL_Init failing outright. When that happens the driver ends up as wayland after all, and the ADR-0084 warning fires exactly as before — the two mechanisms compose without either knowing about the other.

-Dgoldberry.backend.videoDriver=wayland pins Wayland despite all of this. No new property was added: the existing override is checked first and short-circuits the whole routine, so the escape hatch already existed.

Alternatives considered

  • Keep reporting only, as ADR-0084 decided. Rejected on the “a warning does not fix a window” argument above. The warning stays; it is now the thing that explains a pinned Wayland session rather than the only response to a broken one.
  • Fall back only on GNOME, by reading XDG_CURRENT_DESKTOP. Rejected: the condition that matters is “libdecor has no plugin that works here”, which is measured directly and is true or false regardless of desktop. Desktop-sniffing would add a second, weaker signal that can disagree with the first.
  • Drop Wayland from the list entirely when falling back. Rejected: it turns a cosmetic problem into a failure to start on a session without XWayland.
  • A dedicated opt-out property. Rejected as redundant — goldberry.backend.videoDriver already overrides everything, and a second knob for the same job is a second thing to keep in step.

Consequences

  • Out of the box on Debian and Ubuntu, a Goldberry application on GNOME/Wayland now gets a decorated, resizable window with the desktop’s own titlebar. That is the visible outcome and the point of the change.
  • It is a silent driver change, which is exactly what ADR-0084 argued against. Mitigated rather than avoided: it is announced at INFO with the reason, the package that would restore Wayland, and the flag that pins it. Anyone debugging a scaling or presentation difference will find that line before they find this record.
  • Installing libdecor-0-plugin-1-cairo now changes the video driver, because the machine stops meeting the fallback condition. Correct, and still surprising: a package install moves an application from X11 back to Wayland. The INFO line names the package for exactly that reason.
  • The quality that ADR-0027 bought is given up on affected machines. That record’s measurements are unchanged and its preference is still the default; this is a narrower rule sitting in front of it, for the case where the sharper window cannot be closed.
  • Both mechanisms now read the same verdict, so they cannot disagree about whether decorations are available — but they run at different times, and verdictForWayland is asked before SDL exists while verdict is asked after. A test asserts the two forms agree across every input combination, because a drift between them would produce a fallback with a warning, or neither.
  • When upstream’s out-of-process GTK plugin (libdecor MR 176) ships, machines with it will stop meeting the condition and return to Wayland on their own, with no change here. The rule is written against the symptom rather than the version, so it expires by itself.

ADR-0086: X11 is the Linux default, for now

Context

ADR-0085 made the X11 preference conditional: Goldberry probed the libdecor plugin directory and the calling thread before SDL_Init, and asked for x11,wayland only when it could prove a Wayland window would be undecorated. It shipped and worked.

The condition is doing less than it looks. Every input to it is stable for the whole of the current situation:

  • The GTK plugin cannot run under the stock java launcher, and that is upstream’s deliberate position, unconditional in libdecor master (ADR-0084).
  • The Cairo plugin — the one case where the probe says “Wayland is fine” — draws a generic titlebar that matches no desktop. It reads no GTK settings, no GSettings and no portal. So the branch the probe protects does not deliver native decorations either; it delivers different non-native ones.
  • Under XWayland the window manager decorates the window itself, which is the only configuration today that produces a titlebar matching the desktop.

So the conditional bought a Wayland session whose decorations are generic, at the cost of a probe that has to be right about a distribution’s filesystem layout. On the machine that prompted all of this, installing the Cairo plugin silently moved the application from X11 back to Wayland and changed how the titlebar looked — recorded as a consequence in ADR-0085 and, in use, simply confusing.

Decision

On a Linux Wayland session, ask SDL for x11,wayland unconditionally. Delete the pre-init probe.

The preference is now one constant with one reason, and the reason holds for every machine the probe used to distinguish. x11,wayland rather than x11: a session with no XWayland must still get a window, and an undecorated one beats SDL_Init failing outright.

“For now” is part of the decision, not a hedge. Three things would each end it, and the title says so to keep the record honest:

  • Goldberry drawing its own decorations, which is what every non-GTK toolkit on GNOME/Wayland does and what SdlWindowFlag.BORDERLESS already describes;
  • libdecor’s out-of-process GTK plugin (MR 176) reaching distributions, after which the GTK plugin works in any JVM;
  • a decision to ship a launcher that embeds the VM (ADR-0084).

The diagnostic stays. WaylandDecorations still runs after SDL_Init and still warns, because Wayland is still reachable — through -Dgoldberry.backend.videoDriver=wayland, through SDL_VIDEO_DRIVER in the environment, and on a session with no XWayland at all. Those are exactly the cases where someone needs the explanation. What was removed is wouldBeUndecorated, the entry point that existed only to be asked before SDL_Init.

Alternatives considered

  • Keep ADR-0085’s conditional. Rejected above: it distinguishes cases that no longer differ in the way that matters, and its own consequence — a package install changing the video driver — is a surprise nobody asked for.
  • Ask for x11 alone. Rejected: it turns a session without XWayland from a cosmetic problem into a failure to start.
  • Prefer X11 on every Linux session, not just Wayland ones. Rejected as noise: with no WAYLAND_DISPLAY there is nothing to prefer away from, and SDL already picks X11. Leaving that path untouched keeps the change to the case it is about.
  • Add goldberry.backend.preferWayland. Rejected as redundant. goldberry.backend.videoDriver is checked first and short-circuits everything, so the escape hatch already exists and there is only one of it.

Consequences

  • Linux users get XWayland by default, and with it the desktop’s own titlebar. This is the visible outcome and the point.
  • ADR-0027 is reversed in practice, for now. Its measurements stand and its reasoning is unchanged — an XWayland window resizes visibly worse and scales blurrier. That record is not superseded, because what changed is not the measurement but which axis wins while decorations are unobtainable on the better one.
  • Anyone who wants Wayland says so: -Dgoldberry.backend.videoDriver=wayland. The INFO line at start-up names that flag, so the default is discoverable from a log rather than from this record.
  • verdictForWayland now has only one caller. It stays split from verdict rather than being inlined, because it is the shape a conditional fallback would need again, and re-deriving it later is more work than leaving the seam.
  • Nothing in CI exercises either driver on Wayland — every leg runs under Xvfb, so the default is now the configuration CI has always tested, which is a small incidental gain in how much the CI result means.

ADR-0087: A semantic fill brings its own foreground

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/ARCHITECTURE.md §10, docs/design-system.md §1.2 §1.5 §3, docs/core-widgets.md §3

Context

badge is the ninth entry in §3’s control table and the first that is not a control: “count/status chip, typically composed inside stack. Semantics: text.” No focus, no value, no keyboard map, no states. On the face of it the smallest widget in the catalog after spinner.

It is also the first widget that is allowed to use colour. §1.2 is strict about the aurora hues:

Aurora hues (nord11–15) appear only with semantic meaning (danger/warning/success/info, chart series) or in expressive surfaces — never as decoration on controls.

Every widget so far has obeyed the never half. A status chip is the first one whose entire job is the only half, so badge.danger and badge.success are the point of the widget rather than a skin on it.

Which puts it straight into the other half of §1.2:

Every text/surface pair meets WCAG 4.5:1 (3:1 for large text ≥ 20px). Contrast is validated in CI against both themes.

Nothing validated anything. There was no contrast check in the repository at all, and the sentence had been true-by-assertion since the design system was written.

The moment a chip is filled with a semantic hue, the theme’s text token is wrong. On the dark theme --gb-text is --nord6, a near-white, and:

fillwith --nord6with --nord0
--gb-warning (--nord13)1.358.00
--gb-success (--nord14)1.776.13
--gb-info (--nord9)2.344.64
--gb-danger (--nord11)3.553.05

Three of the four hues need the opposite end of the palette from the one the theme is built on. And --gb-danger needs something that is not in the palette at all: it carries no legible small text in either direction.

Writing the check turned up the same defect in shipped code. Seven button colour pairs are below the floor — button.danger in both themes and button.primary on light — and --gb-button-danger-text: var(--nord6) on --gb-button-danger-bg: var(--nord11) is the 3.55:1 above. It has been there since the first control shipped.

Decision

A filled element’s foreground is a property of its fill, not of its theme. Each badge variant pins its own --gb-badge-*-text beside its --gb-badge-*-bg, and for the three light aurora hues that token is --nord0 on both themes — including the dark one, where every other text token is --nord6. §1.2’s palette is theme-invariant, so a pairing that works for --nord13 works for it everywhere; the theme does not get a vote.

Where no palette entry works, the fill is derived until one does. --gb-badge-danger-bg is #a0414a — --nord11 taken down in lightness until it clears 4.5:1 under --nord6 (it reaches 5.44) — and on the light theme --gb-badge-accent-bg is #4a678b, which is --nord10 taken down the same way and is the value --gb-button-primary-bg-active already derives. Both are written beside the palette entry they came from, the convention --gb-button-primary-bg-hover already uses.

§1.2’s CI validation now exists, as ContrastTest. It resolves every text-on-fill pair the toolkit ships through the real cascade — base layer, theme, the same StyleResolver a window uses — and computes WCAG 2.1’s ratio from the background and color that come out. The seven failing button pairs are in a KNOWN_FAILURES list that is asserted exactly: a pair that gets fixed fails the test until it is removed, and a pair that newly breaks fails it immediately.

And §3’s table gained a badge row before any number was written into controls.css: height 20, padding-x 8, radius full, caption. Principle 3 — “if a screen needs a value that isn’t a token, the system gets extended deliberately”.

Alternatives considered

Let badge inherit --gb-text like everything else. This is what the cascade does for free and it is what a first draft looks like. It produces white text on a pale yellow chip at 1.35:1 — text that is not merely hard to read but genuinely invisible at 11px on a laptop screen. §1.2’s floor is not advisory.

Make the semantic variants tinted rather than filled — the hue as text and border on the theme’s own surface, no coloured fill. This is a common badge style and it dodges the whole question. It does not survive the numbers: --nord11 as text on --gb-bg is 3.05:1, so the variant that most needs to be readable is the one that fails hardest. Tinting moves the problem, it does not solve it.

Ship danger as --nord11 anyway and note the exception. The chip that says a service is down is the one chip a user must be able to read. An accessibility floor with an exception carved out for the highest-urgency case is not a floor.

Instance the whole palette against a contrast solver at theme-load time, so any fill gets a computed foreground. It removes the hand-written token pairs and would generalise to application themes. Rejected as premature and as a determinism risk: §1.1’s fifth principle is that the same markup renders identically on every machine, golden images are the arbiter, and a colour arrived at by an algorithm at runtime is a colour that changes when the algorithm is tuned. Two derived hex values reviewed once are cheaper to trust. The seam is open — the tokens are the only thing widgets consume, so a solver that emitted them later would change nothing above.

Fix the seven button pairs in this change. It is the same defect and the fix is known — each ramp’s :active end already passes, so the resting fills move one step darker. Rejected here because it recolours the most visible control in the toolkit and moves every button golden, and a change that adds a badge should not also be the change that repaints button.primary. It is recorded in status.md and in KNOWN_FAILURES, where it is loud rather than forgotten.

Scope ContrastTest to badges, so it ships green. This is ADR-0082’s trap verbatim: a check narrowed to what already passes is not a check. The sweep covers everything and the exemptions are enumerated.

Consequences

§1.2 is enforced for the first time, and it found something on the first run. Any future control that puts text on a fill is checked at the moment it is written, in both themes, with no image to eyeball.

A theme is now a slightly harder thing to write. An application supplying its own theme must supply eleven --gb-badge-* tokens, and getting them wrong is a legibility bug rather than a visual one. Mitigated by the check: an application that runs ContrastTest’s arithmetic over its own theme gets the same answer. Not mitigated by the toolkit — nothing validates a third-party theme, and that is a real gap.

Two colours in the theme files are not palette entries. #a0414a and #4a678b are derived values, and a future Nord revision would have to re-derive them. They are commented with what they came from and what they measure, which is the most that can be done without a solver.

The KNOWN_FAILURES list is a debt that announces itself. It cannot rot silently — it is asserted as an exact set — but it is still seven shipped pairs below an accessibility floor, in the toolkit’s most-used control, and that is the honest cost of not fixing them here.

badge is in the no-shrink list despite not being a control. The list’s stated rule is “a control’s metrics are fixed”; a badge is there for a different reason, that its whole content is two or three glyphs and a chip that gave width back would ellipse 99 into 9 — a clipped label is a nuisance, a clipped count is a wrong number.

There is still no minimum width, so a single-digit chip is a stadium rather than the circle a badge usually is. §8’s subset has no min-width at all; badge-digits.png records it rather than a comment claiming it, and it joins the existing open question about minimum sizes.

Nothing about a badge animates, and that is §3.1 being followed rather than an omission: it has no row, and the preamble says anything not listed does not animate. BadgeTest asserts the empty transition set, so a transition added to the shared control rules cannot reach a chip by accident.

ADR-0088: A fill that carries text moves away from it

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/design-system.md §1.2 §2.1, docs/ARCHITECTURE.md §10

Context

ADR-0087 built badge and, in the process, built the contrast check docs/design-system.md §1.2 had always claimed CI ran and which had never existed. Its first run found seven shipped button colour pairs below §1.2’s 4.5:1 floor:

pairmeasured
nord-dark button.danger3.55
nord-dark button.danger:hover2.95
nord-dark button.danger:active4.38
nord-light button.primary3.50
nord-light button.primary:hover4.18
nord-light button.danger3.55
nord-light button.danger:hover4.23

ADR-0087 held them in an enumerated KNOWN_FAILURES list rather than fixing them, because the fix repaints the most-used control in the toolkit and a change that adds a badge should not also be that. This is that change.

Two things stand out in the table, and both are the shape of the fix rather than the size of it.

Every ramp’s darkest step already passed. button.danger:active is 5.11:1 on light, button.primary:active is 5.06:1. Nothing needed a new colour system — the ramps needed sliding, and the value that was :active is roughly where rest belongs.

The worst pair in the toolkit was a hover state, and it was worse than the rest state it was one step from. nord-dark button.danger:hover at 2.95:1 is below button.danger at 3.55. That is not a colour that was picked slightly wrong; it is a rule applied where it does not hold. The dark theme lightens on hover — correctly, for a surface moving one step toward the light. A danger button is not a surface. It is a saturated fill carrying --nord6, and lightening moved it toward its own text.

Decision

A filled control’s hover moves the fill away from its own text colour, not in the theme’s usual direction. Stated that way it is one rule, and it already described three of the four filled variants:

varianttexthovercorrect?
dark button.primary--nord0 (dark)lightens✓ 6.24 → 7.00
dark button.danger--nord6 (light)lightened✗ 3.55 → 2.95
light button.primary--nord6 (light)darkens✓
light button.danger--nord6 (light)darkens✓

So button.danger on the dark theme now darkens on hover, against that theme’s usual direction, and it is the only place in the toolkit that does.

The ramps slide down to sit inside the legible band. --gb-button-danger-bg and --gb-button-primary-bg stop aliasing --nord11 and --nord10 and take two new tokens, --gb-danger-fill and --gb-accent-fill — ADR-0087’s split between what a hue is and what you may put words on. The danger ramp is now identical on both themes, because the hue is and the text on it is.

--gb-accent-bg-hover / --gb-accent-bg-active are split out of the button’s ramp. --gb-checkbox-bg-checked-hover and its radio and toggle counterparts aliased --gb-button-primary-bg-hover, on the argument that a checked control and a primary button share the accent ramp. They did — until now. A button’s fill is chosen so its label clears 4.5:1; a checked glyph carries a mark, which §1.2 asks 3:1 of, and darkening every checkbox, radio and switch in the toolkit for a rule that does not apply to them would have been the fix escaping its scope. The accent ramp keeps the values the button’s ramp used to hold, and every checkbox, radio and toggle golden is byte-identical.

KNOWN_FAILURES is now empty, and stays. An empty list asserted equal to the measured failures says nothing is exempt, and a second test asserts the emptiness by name — so re-exempting a pair fails a test that says what happened rather than turning a green run into a differently-green run.

Alternatives considered

Keep the fills and darken the text instead. --nord11 needs something near black to carry 4.5:1, and the palette’s darkest entry is --nord0, which gives 3.05. It would mean a non-Nord foreground on a Nord fill — the derivation has to happen somewhere, and doing it on the fill keeps the text token a palette entry.

Keep the dark theme’s “hover lightens” rule and start the danger ramp low enough that even the lightest step passes. This works arithmetically: rest at 44% lightness, hover at 46%, active at 38% all clear the floor. Rejected because it preserves a rule that is wrong for this case and leaves the ramp with almost no headroom — hover would sit at 5.09:1 with the next step up failing, so the next person to nudge it breaks §1.2 again and the code says nothing about why it is tight. The rule that a fill moves away from its text is the thing worth writing down.

Let the checked-control ramps follow the button’s down. One fewer token pair, and it is what the alias already said. Rejected on the numbers: a checked checkbox carries a mark and not text, so it is under §1.2’s 3:1 non-text rule with room to spare, and moving it would repaint three controls across both themes to satisfy a rule they are not subject to.

Recolour by instancing the palette through a solver at theme load. Considered and rejected in ADR-0087 for determinism; nothing here changes that argument.

Leave them, and document the failures. They were documented, in KNOWN_FAILURES and in TODO.md, which is what made this change a five-token edit instead of an investigation. But button.danger is the control a user reaches for when something is about to be destroyed, and at 2.95:1 its hover state is the least readable thing in the toolkit.

Consequences

Every text-on-fill pair the toolkit ships now meets §1.2, on both themes, enforced. The exemption list is empty and asserted empty.

button.primary on light and button.danger on both themes look different. Deeper and more saturated; button-variants-dark.png and button-variants-light.png are the two goldens that moved, and they are the only two — the checkbox, radio, toggle, slider, progress and spinner images are byte-identical, which is what says the ramp split landed where it was aimed.

One control now hovers against its theme’s direction, and that is a thing a future reader will find surprising. It is commented at the token and the rule is here; the alternative was a ramp that is correct by arithmetic and unexplained.

Two more derived hex values exist — --gb-accent-fill on light joins ADR-0087’s --gb-danger-fill — and a future Nord revision has to re-derive them. Each is commented with the palette entry it came from, its lightness and its measured ratio.

A theme is a larger surface again. An application supplying its own theme now also supplies --gb-accent-fill, --gb-danger-fill, --gb-accent-bg-hover and --gb-accent-bg-active. As in ADR-0087, nothing validates a third-party theme.

Non-text contrast is still unchecked. ContrastTest measures text against its fill. A checked checkbox’s mark, a slider’s thumb against its groove, and the focus ring against the surface behind it are all under §1.2’s 3:1 non-text rule and nothing measures them — the argument that the accent ramp did not need to move rests on a number nobody is enforcing. Recorded in TODO.md.

ButtonTest.fadesRatherThanRemaps no longer pins a hex. It asserted the disabled danger background equalled 0xFFBF616A, so it also asserted which red — and failed here for a reason it was not about. It now compares the disabled button against the enabled one, which is the claim it was making.

ADR-0089: A knob’s gesture is a rate

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/ARCHITECTURE.md §7.1, docs/design-system.md §3 §3.1, docs/core-widgets.md §3

Context

knob is the tenth control and the last of §3’s rotary/linear family. On the face of it a slider bent into a circle: same min/max/step, same keyboard map, same bind and change. Every piece of machinery it needs looked like something ADR-0079 and ADR-0080 had already built.

None of it was.

A slider’s value is a position. The pointer is somewhere along a track, the fraction it sits at is the value, and it is read fresh on every event with no history at all. That is why Slider keeps no state and why the router only ever had to report where a gesture started.

A knob has no track. design-system.md §3 gives it a rate instead — “value drag 200px per full range” — so the value is where it started + how far you have dragged. And “where it started” is exactly what nothing could answer. A widget is an immutable value rebuilt from the model (ADR-0004), so by the second frame of a drag the value at the press has been overwritten by the value the drag itself asked for. The widget that sees the move is a different object from the one that saw the press.

Three more gaps turned up behind it:

  • Box.Mark’s arc was a constant. ARC existed for spinner and hardcoded three quarters of a circle from twelve o’clock. §3 wants 270° with a sweep that is the value.
  • Pointer events carried no modifiers, anywhere — not in PointerEvent, not in the backend SPI, not from SDL. §3 asks for “×0.1 with fine modifier”.
  • Nothing had ever handled Kind.WHEEL. The route had been live and tested since ADR-0061 and no widget consumed it.

Decision

The router remembers a third thing about a gesture, and it is not a point. Handles.gestureAnchor() is asked once, on the press — deepest-first along the chain, so a press that lands on a part is anchored by the control that will handle it — and handed back on every event of that gesture as PointerEvent.anchor(). NaN by default and NaN outside a gesture, which is dragX()’s convention and is load-bearing: a widget that read “no gesture” as an anchor of zero would snap a knob to its minimum on every hover.

This is ADR-0075’s argument one step further. The router’s implicit capture already spans exactly one gesture (ADR-0058), so it is both the only thing that can know and the thing whose lifetime already matches. It is a double and not an Object, because the router must not start holding application values it cannot reason about, and every gesture that has wanted one has wanted a number.

The fine modifier is a property of the gesture, not of the event. PointerEvent.gestureModifiers() is what was held when the button went down. Reading the live modifier instead would rescale travel already covered: press Shift 100 px into a drag and the value jumps from half a range below where it started to a twentieth of one, without the pointer moving. Drawn perfectly, reported nowhere, and it reads as the knob slipping.

Modifiers are read from the platform, not latched from the last key event. SDL_GetModState joins the export list — the first new symbol since ADR-0086 — and is polled inside the same pump that produced the event, because SDL’s mouse events carry no mod field where its keyboard events do. int modifiers is threaded through all four BackendEvent pointer records, both backends, Window and PointerRouter.

Box.Mark gains start and sweep, so ARC is the one mark whose geometry is not fixed by its kind — because it is the one that has to show a number. Arc.addTo was already fully general and already fed by ADR-0064’s cubics, so no native symbol was added for the drawing; a zero sweep draws nothing, which is what a knob at its minimum wants and needed no test for it.

Detents are magnetic; step is a grid. step puts every value the control reports onto a grid, from the keyboard and the pointer alike. Detents leave the knob continuous and pull a drag onto a nearby position. §3 pins the count’s meaning nowhere, so the pull is derived: a detent owns the middle half of the gap to its neighbour, which leaves the outer half reachable. A pull of a whole half would make detents a grid and delete the distinction.

Four nodes, nested rather than stacked — knob, knob-track, knob-arc, knob-dial. §8’s subset has no position: absolute and stack is M3’s, but a child at width: 100%; height: 100% with no padding is exactly its parent’s box, which is stacking for as long as nothing has to overlap in two directions at once.

Alternatives considered

Make Knob the toolkit’s first Widget.Stateful and keep the anchor in its State. No SPI change at all, and the state has exactly the gesture’s lifetime. Rejected because it puts gesture state back on the widget after ADR-0052, ADR-0058 and ADR-0075 spent three records moving it off: the press is the router’s, the drag origin is the router’s, and “the value at the press” is the same kind of fact. It would also have made knob structurally unlike every other control in the catalog for a reason a reader would have to reconstruct.

Track the drag incrementally — apply the delta since the previous move. Needs no anchor, only the previous pointer position. Which the widget also cannot hold, so it moves the same problem one field along; and it accumulates floating-point error over a long drag, so a knob dragged to the top and back does not come home.

Latch the modifiers from the last key event. No new native symbol, and the router already sees keyPressed(key, modifiers, repeat). Rejected: a window that loses focus while Shift is held never sees the key release, so the flag stays down until the next time Shift is pressed and let go — a control that is silently in fine mode, with nothing on screen to say so.

Scope the fine modifier out and ship the knob without it. It is one line of §3’s metrics row, and core-widgets.md §3 lists it among the gestures. It is also the thing that makes a knob usable for a value that matters, which is what knobs are for.

Give Box a list of marks instead of nesting two arc nodes. One node, no parts, no 100% trick. Rejected because the two rings need two colours and a Box carries one ComputedStyle — the argument every part in this catalog rests on (ADR-0065) — and because knob-track and knob-arc are then not selectable, which §11 requires.

Build the circular drag too. §3 offers it as “circular-drag optional”. It has to decide what happens when the pointer crosses the 90° gap at the bottom, and every answer is either a jump or a wrap that depends on which way round the user went — which needs the accumulated angle, a second piece of gesture state, for a gesture that is nobody’s first choice.

Consequences

Gesture state has a general home now. A splitter, a scrollbar thumb, a text-selection drag and a canvas pan all want “what was it when this started”, and none of them will look like a rotary control. GestureAnchorTest is written against a bare widget in :core for that reason.

Every pointer event is four bytes larger and one SDL call more expensive. SDL_GetModState is polled per pointer event, which on a 120 Hz trackpad is a few thousand calls a second into a statically linked function that reads a global. Not measured, and named here so it can be if a profile ever points at it.

The export list grew for the first time since ADR-0086, which means a CI run across four targets is what says this change works — the machinery that has now caught the same class of bug three times.

One control hovers with a rule the others do not have. knob:hover changes knob-dial rather than knob, because the control’s own box paints nothing.

The first drawing was wrong and only the golden said so. The dial was knob’s own background and both rings were stroked on the same box, so the track ran across the body — --gb-border on --gb-knob-bg is about 1.2:1, and the 270° of travel a user is meant to read was invisible. Every value assertion passed. KnobDial exists because of it, and the knob is in controls-on-surface-* rather than exempt from it.

A gentle touchpad scroll used to do nothing on a stepped knob. A touchpad reports fractions of a line; a stepped knob snaps every value it reports; and because each wheel event computes from the current value rather than accumulating, a third of a step rounded straight back — every time. A stepped knob now moves at least one step for any scroll at all. A continuous one still passes the fraction through, which was always right.

§3’s “modifier for fine adjustment” is scoped to the drag, because design-system.md §3 attaches the ×0.1 to the value drag specifically and core-widgets.md §3 lists it in one breath with the wheel and the arrows. The two documents are not quite saying the same thing; the precise one wins, and this sentence is the record that it was a reading rather than an oversight.

Scale is not wired to knob. §3 gives the dB mapping to fader. The range is there and Scale is already a sealed interface of records, so it is an argument away — deliberately not added because a sibling has one.

ADR-0090: A ring is a track and a dial is a grab

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/design-system.md §3, docs/core-widgets.md §3, extends ADR-0089

Context

ADR-0089 built knob to the letter of §3: 270° arc indicator, vertical drag at 200px per range, wheel, keyboard, detents. Put in front of someone, two things were wrong with it that no assertion could have said.

It read as a gauge, not a knob. §3 asks for an “arc indicator” and core-widgets.md §3 for a rotary control, and between them they say what the value is and never say which way the thing is pointing. An arc alone tells you how full something is. Nothing on the dial turned, so nothing about it suggested you could turn it.

The ring did nothing. A slider’s track is clickable — press anywhere and the thumb comes to you. A knob’s ring is the same 270° of travel drawn round a circle, and clicking it was inert: the only way to reach a value was to grab and drag.

Decision

The dial carries a pointer. Box.Mark gains a POINTER kind — a radial line at the mark’s start angle, running 0.35 → 0.78 of the box’s radius. A proportion rather than a length, for the reason the tick and the dash are proportions: the same drawing has to be right on §3’s 32px knob and its 48px one. It stops short of the centre because a line through the middle of a dial reads as a diameter rather than as a direction, and short of the rim so it does not join the ring outside it — two strokes meeting is a join, and a join says the two are one thing.

It is a mark on knob-dial, not a part of its own, which is the first time that has been the right answer since CheckMark went the other way. A part is a node because two things must be styled or moved independently (ADR-0073); the pointer is neither — one colour, one angle, and the angle is a painter argument rather than a transform.

Clicking the ring positions the value; clicking the dial does not. The ring is a track and behaves like one. The dial is the thing you grab, and a press that jumped before the drag started would move the value out from under the gesture about to set it.

The boundary between them is localPart(), not a constant. The control has to know where the dial ends, and it cannot: a widget has no idea how it was laid out, and the inset is the stylesheet’s (knob-arc { padding: 5px }), so an application that restyled it would move a boundary this file had hard-coded. ADR-0080 already answered exactly this question — the router measures PointerEvent.local() against a named part — so knob names knob-dial and “outside the dial” is hypot(dx, dy) > radius, derived from the geometry that was actually painted. The drag is unaffected: it reads dragY(), which is the window’s.

The jump fires on CLICKED, not on PRESSED, and that is what makes it compose with the drag. A press is the first event of both gestures and cannot know which one it is. The router synthesizes CLICKED only when the press and the release landed on the same node (ADR-0058), and the remaining ambiguity — a drag that ended where it began — is settled by an 8px slop, which is Toggle’s answer to the identical question.

A click in the 90° gap resolves to the nearer end. The gap at the bottom is where a user clicks to mean all the way down or all the way up, and a gap that refused every click would make the bottom of the control dead.

Alternatives considered

Jump on the press, like a slider does. It is the obvious symmetry and it fights the anchor: the router reads gestureAnchor() before dispatching the press (ADR-0089), so a drag that began with a jump would continue from the value the jump replaced — the knob would snap to the click and then snap back as soon as the pointer moved.

Let a press anywhere jump, dial included. One rule instead of two, and it makes the dial unusable as a grab: every drag would start by throwing the value to wherever the finger landed.

Pick the dial boundary as a fraction of the radius — the shipped inset puts it at 11/16 = 0.6875, so 0.7 would work today. Rejected because it is a constant that silently agrees with a stylesheet: an application that changed knob-arc { padding } would get a band that no longer matched its own drawing, and nothing would say so.

Make the pointer a knob-pointer part rotated by a transform. Consistent with how the checkbox’s mark was promoted to a node. Rejected because the rotation is about the dial’s centre and the part would sit at the dial’s top, so transform-origin would have to be expressed as a percentage of the child’s box — arithmetic that has to be redone for every diameter, to make a node that nothing needs to style separately.

Build the circular drag instead. Still the right answer to a different question, and still deferred for ADR-0089’s reason: it needs the accumulated angle, a second piece of gesture state, to decide what crossing the gap means. Click-to-position gets most of the benefit — reaching a distant value without a long drag — for none of that.

Consequences

Box.Mark has a kind that is not a shape but a direction, and sweep is meaningless for it. Documented on the constant rather than enforced; a POINTER built with a sweep is not an error, because a record that validated the irrelevance of one component for one kind would be a worse thing to read.

A click on the ring is subject to detents, where a wheel line is not. The jump goes through the same detented() the drag does, so a knob with detents snaps on a click exactly as it does under a finger. The rule that keeps the two apart is what the gesture is: a click and a drag both say “put it there”, and “there” is what detents adjust; a wheel line says “one more”, and a step that snapped would make some clicks of the wheel do nothing.

knob now names a localPart(), so its local() is the dial’s box and not its own. Nothing else in the control reads local(), but anything added later will get the dial unless it says otherwise — the same trap slider documents.

Three goldens moved and one scene grew a pointer. knob-travel.png is the image that says the pointer and the arc agree: at every value the line points at the end of the arc, and a travel that started at nine o’clock instead of seven-thirty would be wrong by 22.5° everywhere — which is not enough to notice in a picture, and is why KnobTest asserts that nine o’clock is a sixth of the way round rather than a quarter.

ADR-0091: One module, a package per control

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/core-widgets.md, amends ADR-0014

Context

ADR-0014 made two decisions and only one of them has held.

One module — goldberry-widgets rather than goldberry-controls plus goldberry-charts plus the rest — is still right, for the reason it gave: a button and a line-chart are the same kind of dependency to an application, and splitting them makes every consumer’s build file longer for no benefit anyone could name.

One package was the same argument applied one level down, and it does not survive contact with the size of the catalog. io.github.digitalsmile.goldberry.widgets holds thirty types today — ten controls and twenty of their parts — with form, panel, nav, overlay and collection still to come, and docs/core-widgets.md has specified packages for all of them since v0.1:

All of these live in the single goldberry-core Gradle module — separated by package, not by artifact.

So the document and the code disagreed, and the document was the one that had thought about where date-picker goes.

There was also a concrete cost. Every part in the catalog is package-private on purpose (ADR-0065) — a slider-thumb is CSS-selectable and deliberately not constructible. With one package, “package-private” meant “visible to the entire catalog”, so nothing stopped Checkbox from reaching into SliderThumb. The encapsulation the parts rely on was a convention rather than a boundary.

Decision

One module, packages by group — core-widgets.md’s table, verbatim — and one package per control inside each group. …widgets.controls.slider holds Slider and its nine parts; …controls.checkbox, .radio, .toggle, .knob, .button, .badge, .progressbar and .spinner are its siblings. form, panel, nav, overlay and collection follow as they are built.

The second level is the one that does the work. Stopping at controls would have grouped the catalog without changing what “package-private” means — thirty types sharing one namespace is a smaller version of the same problem. Splitting per control makes a part invisible to every control but its own, which is the strongest form of the rule ADR-0065 states and the only one a compiler enforces.

…controls itself keeps exactly what its members share: Scale, which slider uses and a future fader will.

Controls, Actions, Icons and Density stay at the root, because they are not widgets. They are the module’s furniture: the KDL registry, the stylesheets, and the three lookups a document resolves names against. An application touches exactly one of them to wire a window up and then never again, and burying that behind .controls would put the entry point inside one of the things it assembles.

nav is added to the document’s nine. breadcrumbs, steps and wizard all answer “where am I in a sequence”, which is neither a surface nor a control that reports a value; folding them into panel or controls would have made that package’s name a lie. Principle 3: extend deliberately.

Alternatives considered

Keep one package. It is one fewer thing to decide when adding a widget, and that is genuinely worth something. Rejected on the arithmetic: thirty types now, and the specified catalog is roughly triple that. A package whose contents nobody can hold in their head is not organised, it is merely flat.

Stop at controls, one package for the whole group. Half the directories and one import line per consumer instead of two. Rejected on the encapsulation argument above: it groups the catalog without making the grouping mean anything to the compiler, and the parts are the reason the split is worth doing at all.

A package per widget with no group level — widgets.slider rather than widgets.controls.slider. Shorter, and rejected because the group level is what core-widgets.md specifies and what tells a reader where date-picker goes when it arrives. A flat list of forty packages is not a structure.

Split into modules after all, one per group. Rejected for ADR-0014’s original reason, which has not changed: JPMS module boundaries are a distribution decision, and nobody wants to depend on five sixths of a widget toolkit.

Consequences

Parts are properly encapsulated for the first time. Package-private now means “inside this control”, so Checkbox cannot reach SliderThumb — which it could have all along, and which nothing but discipline was preventing. The move found one place where that discipline had already slipped: ProgressFill had been widened to public during the restructure, and is package-private again.

A cross-package javadoc link to a part is impossible, by construction. Nine [SomePart] links became code spans, because a package-private type in another package cannot be linked and a link that does not resolve is worse than a name. Links to public widgets are fully qualified instead — the form the codebase already used for [io.github.digitalsmile.goldberry.icon.Icon]. Verbose, and the verbosity is load-bearing: it is visible in the source that the reference crosses a boundary.

Every import of a control changed, in the showcase and in the tests. Cheap once, and it is the last time it will be this cheap: doing it at ten controls is a mechanical rename, and doing it at thirty is a merge conflict with everything in flight.

Two javadoc links in Controls became code spans. Its controlTypes() comment names the parts it deliberately excludes, and those names are no longer resolvable from the root package. A link that cannot resolve is worse than a name, so they are names.

module-info exports eleven packages and will export many more. One line per control, each exporting exactly one public widget (two for radio) and hiding its parts — which is the property that made JPMS worth the trouble in the first place (ADR-0007): a package that is not exported is not API, and that is now a per-control statement rather than an all-or-nothing one. The cost is a descriptor that grows with the catalog, and an exports line is the cheapest possible place to notice a new public type.

Tests mirror the structure, so a control’s tests sit in its package and can reach its parts. Two cross-cutting suites stay at …controls — ControlShrinkTest and MotionGoldenTest, which are about every control at once — and three stay at the root with the furniture they exercise. TestFont widened three members to public to be reachable from the packages it serves, which is the ordinary cost of a shared test harness crossing a boundary it did not used to cross.

docs/core-widgets.md is the authority again, which is the state it was supposed to be in. The package table there is now a description rather than an aspiration, and a widget that does not fit one of its packages is a signal to extend the table — as nav did here — rather than to drop the type somewhere convenient.

ADR-0092: A primitive is a widget like any other

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/core-widgets.md, extends ADR-0091

Context

:core shipped five widgets: text, row, column, panel and spacer, as nested records inside a Widgets class in io.github.digitalsmile.goldberry.widget.

They were there for a good reason that stopped applying. The widget tree, the element tree, the cascade and the painter all had to be provable before there was a catalog to prove them with — WidgetParityTest needed something whose KDL node, CSS type and Java record could be checked against each other, and StyleCacheTest needed a node with a type and classes to be wrong about. Five primitives were the smallest set that made the invariant testable, and the class comment said exactly that.

Then :widgets arrived, reached thirty types, and got a package per control (ADR-0091). At which point:

  • :core was a widget toolkit’s engine that also shipped five widgets. No code in :core’s main sources referenced any of them — the survey found zero uses. Only five of its tests did.
  • core-widgets.md had specified their packages since v0.1 — row, column and spacer under core, text under text, panel under panel — and the code had them nested inside one class in a different module.
  • They were second-class in their own catalog. Every other widget is a top-level record in a package named after it; these five were Widgets.Row, Widgets.Panel, reachable only through a holder class whose name means “all of them”.

Decision

The five move to :widgets, as ordinary top-level records in the packages core-widgets.md gives them: …widgets.core.Row, .Column, .Spacer, …widgets.text.Text, …widgets.panel.Panel. The Widgets holder class is deleted. :core now has no widgets at all.

Attributes stays in :core, promoted to a top-level type. It is not a widget — it is part of the widget contract: Styled asks for an id and classes and the cascade matches on the answers, Widget.key() is what the reconciler pairs two builds by, and Attributes.of(KdlNode) parses them off a markup node the inflater owns. A widget in an application’s own module implements the same three methods and should not have to depend on the catalog to hold them in a value. It was the only member of Widgets that belonged where it was.

The registry follows the widgets, as …widgets.core.Primitives, and Controls.inflater(…) composes it exactly as it used to compose Widgets.inflater(…). Keeping it separate from Controls keeps that class’s sentence true — the catalog is what :widgets adds to the structural widgets — and lets an application that wants a layout and no controls register just these.

Four of the five affected tests moved with them; two split. WidgetParityTest and FrameBenchmark are about the catalog and went to …widgets.core whole. StyleCacheTest and BindingTest did not, because they reach into Element‘s package-private internals — update, cachedStyle, WidgetRenderer.resolver — which is exactly right for a test of the element tree and impossible from another module. They stay in :core and use local test widgets, the pattern DragOriginTest and GestureAnchorTest already established. BindingTest split along a seam that turned out to be real: reading bind= off markup is :widgets’, and what an element does with a binding once it holds one is :core’s BindingLifecycleTest.

Alternatives considered

Leave them in :core. They work, and moving them touched 39 files. Rejected because the reason they were there had expired: they existed to make the engines testable before a catalog existed, the catalog exists, and a module that is explicitly not the widget toolkit should not be the thing that ships panel. The documentation had said so since v0.1 and the code had never caught up.

Move Attributes too, so :core has nothing widget-shaped left. Tempting for symmetry, and wrong: Styled and the reconciler are core contracts, and an application widget that wanted an id would have had to depend on the catalog to get one. Symmetry is not a reason.

Keep a Widgets facade in :widgets re-exporting the five, so Widgets.Row keeps compiling. Rejected as exactly the duplication this change exists to remove: two names for one type is how the two stop agreeing, and there is no external consumer to break.

Give :core its own test-only widget set in testFixtures. It would have let all five tests stay put. Rejected because two of them do not want widgets at all — they want a node with a type and a binding, which is four lines of local record — and the other three genuinely test the catalog and belong beside it. A fixture module would have been a third place for widgets to live.

Fold the primitives’ registry into Controls. One fewer class, and it makes Controls — the class named for one group — the thing that registers row and text. A name that lies, and the codebase keeps rejecting those.

Consequences

:core no longer depends on anything widget-shaped to test itself. Its element-tree and cascade tests use local records, so they say what they are about: nothing in StyleCacheTest is a fact about panel, and it used to look as though it might be.

:core‘s test count fell by 25 and :widgets’ rose by 25. Nothing was lost — the same 1,641 tests run — but coverage moved modules, and :core’s suite is now smaller than the engine it covers might suggest. That is the honest shape: a test that needs a widget is a test of the catalog.

Three more exported packages — …widgets.core, .text, .panel — on top of ADR-0091’s eleven. The descriptor grows with the catalog, which is the property that makes it useful.

docs/core-widgets.md’s preamble was wrong and is now right. It claimed :core held the primitives; it holds none. The package table is a description of :widgets and nothing else.

Every Widgets.Attributes in the tree became Attributes — 139 of them — plus 111 uses of the five types. Mechanical, done once, and the last time it is cheap: the same argument ADR-0091 made about doing a rename at ten controls rather than thirty.

text is alone in its package and will be until span and link are built. That is the package core-widgets.md §2 specifies, and a package with one type in it is a cheaper thing to look at than a type in the wrong package.

The five are now stylable, keyed and documented on exactly the same terms as button. They were already, in principle — the parity invariant held for them from the start. What changed is that a reader looking for Panel now finds it where core-widgets.md says it is, instead of inside a class called Widgets in a module called core.

ADR-0093: An application is a root widget

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/ARCHITECTURE.md §11, extends ADR-0092

Context

The showcase’s main was 190 lines, and none of them were about the showcase.

It opened a window, opened a font book, built an element tree, built a render tree, built a pointer router, held three one-element arrays to remember the renderer and the theme and the density across frames, wrote the paint callback — flush the tree, rebuild the renderer if the theme moved, update the render tree, compute damage, choose between a partial and a full repaint, hand the damage back, capture the hit-test snapshot, ask for another frame if anything is animating — and then took it all down again in an order that matters: the tree, then the render tree, then the icons, then the fonts, then the window.

Every line of it is the same in every application. Two of them are subtly wrong if reordered: a render object holds a Yoga measure callback that closes over a paragraph that closes over a font, so closing the fonts first reads unmapped memory; and Goldberry.shutdown() at the end is not tidiness but the difference between a clean Wayland disconnect and a compositor unwinding a client that never said goodbye (ADR-0085).

Three smaller things were wrong with the same file for related reasons:

  • The window’s CSS was a Java text block. The toolkit reads its own theme and control sheets from resources; an application had no supported way to do it and wrote CSS inside quotes, where no editor will highlight it and no designer will open it.
  • Nothing in the showcase exercised KDL. §9’s inflater had test coverage and no window coverage.
  • Building a tree read like a data structure, not a tree. new Row(List.of(a, b, c), id("bar")) — a List.of between every parent and its children, and a static helper turning a string into an Attributes because a widget had no way to be given one after construction. Configuring a slider with tick marks meant an eleven-argument constructor with four nulls in it.

Decision

An application implements [Application] and calls Goldberry.launch. One required method, root(), returning the widget at the top of the window; defaults for the title, the size, the stylesheets, and a start/stop pair for the native objects only an application knows it owns. The launcher owns everything else and is not public — what an application gets back is a [Host] with repaint, restyle, title, shortcut, fonts and a named escape hatch to the Window.

restyle() is separate from repaint(), and that is the one piece of state the launcher keeps on the application’s behalf. Re-reading stylesheets() every frame would rebuild the renderer every frame, and the renderer is what caches the resolved styles; re-reading it never would make a theme switch impossible. So the application says when, which makes a theme switch two lines and costs nothing the rest of the time.

Every widget is chainable, through two interfaces rather than fifty pairs of methods. [Attributed] gives id, styled and keyed; [Bindable] gives bound. Both are self-typed — Attributed<Badge> — so a chain keeps its type and new Badge("3").styled("danger") is still a Badge. A widget supplies the one thing only it can, withAttributes, which rebuilds its own record.

Containers take children as varargs, so a tree reads as a tree. List.of is gone from the showcase entirely.

Stylesheet.resource and KdlParser.resource load an application’s CSS and markup from files beside its class, the way the toolkit loads its own.

Alternatives considered

An abstract Application class with the loop inside it. An application would extend it and override root(). Rejected because it spends the one superclass slot Java gives, and because it puts the frame loop in the type an application subclasses — where anything protected becomes API and any override is a way to break the shutdown order this exists to protect.

Pass a Consumer<Frame> and keep main. The smallest possible change, and it fixes nothing: the callback is not the hard part, the six objects it closes over and the order they are released in are.

Call stylesheets() every frame and compare the result. No restyle() to remember. Rejected on the arithmetic: Controls.stylesheets(theme, density) builds a new list each call, so equality would be structural over every rule in every sheet, every frame — to answer a question that is false almost always.

Three methods per widget instead of Attributed. id, styled and keyed written out fifty times. Rejected for the reason [Attributes] itself exists: it is the kind of repetition where one copy eventually forgets to preserve the key, and the failure — a widget that silently stops matching its element across a rebuild — costs a focus ring rather than an exception.

Builders. Badge.builder().text("3").styled("danger").build(). Rejected because a widget is a record and records are already values: the constructor takes what matters and the chain names what usually does not, with no second object and no build().

A bare opens in the application’s module. JPMS encapsulates resources, so the toolkit cannot read an application’s showcase.css unless the package is open — exports governs types, not bytes. The showcase opens it to the core module only, because an unqualified open hands the package’s private types to everything on the path as well.

Consequences

The showcase’s main is one line, and the file is 190 lines shorter with no behaviour lost — it opens, paints, switches themes, and shuts down exactly as it did, which the headless three-frame run proves.

A new application is one class and one method. That is the claim this record is making, and the showcase is the only evidence for it so far: nothing else has been written against Application yet, so whether the defaults are the right defaults is unproven.

Host has an escape hatch and it is already used. The showcase reaches host.window() for onResize, onScaleChange and onCloseRequest. Each is a candidate for a method on Host, and each was left off because one caller is not a pattern. The hatch being named an escape hatch is the mechanism for noticing when that changes.

A missing resource now explains itself, including the JPMS case: the error checks whether the owning package is open and, when it is not, says which opens line is missing. That message exists because the first headless run hit exactly that and the original message blamed the file.

Two more interfaces in :core’s widget package. Attributed and Bindable are contracts a widget in an application’s own module should implement too, which is why they are in :core beside Attributes and not in the catalog.

Slider and Knob grew configuration withers — ticks, format, scale, detents — which are a fourth way to build a widget after the constructor, the markup and the attribute chain. They earn it by replacing an eleven-argument call with four nulls, and the rule they follow is the one the chain follows: the constructor takes what matters and a named step takes what usually does not.

The launcher reads two command-line flags, --frames=N and --size=WxH, from the array an application chooses to hand it. A toolkit that parsed argv would be overstepping; these are read from launch(app, args) and an application that calls launch(app) passes none. --frames is what lets CI prove a window opened with no human to close it, and it was showcase-private machinery until now.

ADR-0094: Name the overload, not the allocation

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/ARCHITECTURE.md §11, extends ADR-0093

Context

Two complaints about the same file, and they turned out to have different answers.

The showcase was one 770-line class doing four unrelated jobs: the application lifecycle, the view model, the widget tree, and the layout of three panes. Every screen was a private method on one state object, and the state object held the model.

And building a tree read badly — new Row(new Text(…), new Spacer()), nested four deep. The suggestion was Column.of(…) instead of new Column(…) throughout.

The second is worth taking apart, because the obvious fix is the wrong one.

Decision

new stays the way to build a widget. Static factories are added only where a constructor cannot carry the meaning — which was already this catalog’s rule (Progress.sweeping(), Badge.of(fallback, source), Scale.decibels()) and is now applied consistently.

Three arguments decided it:

A public record’s canonical constructor cannot be hidden. The JLS requires it to be at least as accessible as the record. So new Column(…) is public forever and of() could only ever be additive — two permanent public doors, with no way to steer anyone to one and no compiler help keeping them in step. Every other argument is downstream of that.

The noise is depth, not the keyword. Row.of(Text.of("a")) saves four characters against new Row(new Text("a")). What actually made the showcase hard to read was a 100-line build(), and decomposition fixed it — the file is 175 lines now and the deepest nesting in it is two.

Performance is not a reason either way, and was measured rather than assumed. 20 million allocations, best-of-15, on the JDK 25 toolchain:

new  45.23 ms   of  45.09 ms
new  45.44 ms   of  45.86 ms
new  45.09 ms   of  45.34 ms

Identical within noise, ~2.3 ns each. -XX:+PrintInlining says why: Box::of (10 bytes) inline (hot) — the factory is inlined and the machine code is the same. Memory is identical too: the same object, the same allocation, nothing extra. The only costs are startup-side — one extra bytecode frame before the JIT warms up, and ~45 more methods of metadata across the catalog — and both are negligible. A factory only changes memory behaviour if it caches, which would be a behaviour change and is unsafe for keyed widgets.

(The first attempt at that benchmark reported 87 ms against 46 ms and was entirely wrong: it had a String.equals branch inside the loop. Recorded because it is exactly how a microbenchmark lies, and because the wrong number pointed the “right” way.)

What does get named is the ambiguous overload. Slider had two five-argument constructors differing only in whether the fourth parameter is a double or an Observable; Knob and Toggle had the same shape. A reader cannot tell those apart at a call site and the compiler will pick one for a null. Those four are now Slider.of, Knob.of, Toggle.of and Progress.of — of because the catalog already used it for exactly this meaning, the bound variant.

The showcase is five classes and two documents. Showcase is the [Application] — lifecycle, stylesheets, registries, accelerators. ShowcaseModel is the view model: properties, the methods that change them, and the two registries a document resolves names against. ui.Screen is the layout, ui.Panes loads the documents, ui.Content is the one pane that must be Java.

titlebar.kdl and sidebar.kdl carry everything declarative, which is the first time §9’s markup path runs in a window with all three registries live: bind=, change=, press= and icon= all resolve against what ShowcaseModel and Showcase register, and all three registries are strict, so a typo fails at inflation with a line and column.

Alternatives considered

of() on every widget. The user’s original suggestion, and the honest reason to want it: it reads lighter. Rejected on the canonical-constructor constraint above — it cannot replace new, only join it.

of() on the containers only — Row, Column, Panel, the ones that actually nest. Half the churn for most of the visual gain, and it leaves the catalog inconsistent about which widgets have it, which is the worst of the three outcomes: a reader has to remember rather than know.

Keep the panes as private methods and just shorten them. The smallest change. Rejected because the four jobs in that file have four different lifetimes — the model outlives the screen, the screen outlives a pane, and the documents outlive the process — and a private method cannot express that.

Put the whole window in KDL. Tempting, and it fails on Content: its Undo and Reset buttons are disabled when the click count is zero, and §8’s markup has no expressions. A document that could evaluate clicks == 0 would be code in a data file with no stack trace when it went wrong (ADR-0062). The boundary is instructive and it is where the split was drawn.

Keep the model on the element. ADR-0052’s State is for what the UI remembers — a scroll offset, a caret — and it is right that those die with their widget. The click count and the gain are what the application is about; a second screen showing the same gain should read the model, not a copy.

Consequences

The showcase is a shape an application can copy. 175 lines of application, 209 of model, 188 of UI across three classes, 100 of markup. It was 770 lines in one file.

Markup has window coverage for the first time. sidebar.kdl builds every control the catalog ships, and ShowcaseDocumentsTest asserts the shape rather than trusting that a window opened — an empty document inflates to an empty column and paints a blank panel, and the three-frame headless run would pass. The test also asserts the bindings reach the model’s own properties, which a shape assertion misses entirely: a bind= resolving to nothing still renders a control, and the control renders perfectly and never moves.

Four constructors became factories, and the tests had to be repointed. Which is the argument for the change: the compiler could not tell those overloads apart either, and a regex that assumed new Toggle(label, x, y) meant the bound one mis-rewrote seven boolean call sites before the compiler caught it.

The catalog now has four ways to build a widget — constructor, markup, attribute chain, and named factory. That is one more than ADR-0093 left, and the ceiling: the rule is that a factory exists only where a constructor is ambiguous, and ChainingTest plus the parity tests hold the other three in step.

A second opens was needed, for …example.ui. JPMS works at package granularity, so an application that keeps documents beside more than one class opens more than one package. The improved error message from ADR-0093 named the missing line exactly, which is the first time that message earned its keep.

ShowcaseModel carries two Runnables — onChanged and onRestyle — which is a small observer wiring an application has to do by hand. A Property the launcher watched would remove it, and would also mean the toolkit deciding what counts as a restyle. Left as it is, and named here as the seam to revisit if a second application writes the same two lines.

ADR-0095: A shortcut is built from enums

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/design-system.md §2.3, docs/ARCHITECTURE.md §7.2

Context

An accelerator had one way in: Shortcut.of("Ctrl+S"). The string was parsed at runtime against a tolerant table of spellings, and a typo — "Crtl+S", "Ctrl-S", "Ctrl+Save" — threw when the line ran, which for a shortcut bound at start-up means at start-up and for one bound lazily means whenever.

Modifiers had the matching problem from the other direction: four positional booleans. new Modifiers(true, false, false, false) is four chances to get the order wrong and nothing to catch it, and 23 call sites wrote them out.

The request was Mod.CTRL | Key.A.

Decision

A Mod enum with a real bitmask, composed with and.

Mod.CTRL.and(Key.S)                    // Ctrl+S
Mod.CTRL.and(Mod.SHIFT).and(Key.Z)     // Ctrl+Shift+Z
Shortcut.of(Key.F5)                    // F5

| is not available and the alternative that is would be worse. | is defined for the integral types and boolean and Java does not allow overloading it. The spelling that would compile — Mod.CTRL.bit() | Mod.SHIFT.bit() passed to a method taking an int — is a mask with nothing checking it, and Key.A.ordinal() | Mod.CTRL.bit() would compile and mean nothing. So the mask is real and private to the arithmetic: bit() exists for the SDL boundary and for tests, and an application composes with and, which can only produce Modifiers or a Shortcut.

Modifiers is one int instead of four booleans, with has(Mod), only(Mod) and set() on top and the four boolean accessors kept so no call site changed. The four-boolean constructor stays as a secondary one — it reads fine where all four are literals and is a trap where they are computed, which is exactly the distinction between a secondary constructor and a canonical one.

Shortcut.of(String) stays. An accelerator has to be printed beside a menu item anyway, and a configuration file has nothing but text. What changed is that it is no longer the only way in.

Host.shortcut and PointerRouter.shortcut take both forms, and the showcase uses the enum one.

Alternatives considered

An int mask parameter — the literal reading of the request. Rejected above: it accepts any int, including ones that mean nothing.

EnumSet<Mod>. Type-safe and idiomatic, and heavier than the thing it describes: a Shortcut is a map key on the keyboard path, and an EnumSet allocation per comparison for four possible bits is a poor trade. set() returns one for callers that want it.

Keep the four booleans and only add Mod for shortcuts. Half the change, and it leaves new Modifiers(true, false, false, false) in 23 places — the exact thing the enum was asked for.

Translate Cmd to Ctrl on macOS. Unchanged from before and still refused: a toolkit that silently remapped them would make Ctrl+C mean two different things depending on where it ran. docs/ARCHITECTURE.md §17.1 still records that design-system.md §2.3 wants the opposite, and this ADR does not settle it.

Consequences

A shortcut built in Java cannot be misspelled. A parsed one still can, and that is the price of keeping the string form for menus and config.

Modifiers is a different record shape, so anything pattern-matching its four components would break. Nothing did — checked before the change.

One mask layout is now load-bearing in two places: Mod.bit() and Modifiers.fromSdl. They are in the same file as each other, and the constructor rejects bits no Mod owns, so a third layout cannot appear quietly.

Key is untouched. It is already an enum and already the type Shortcut holds; only the modifier half needed the work.

ADR-0096: A registry is generated, not reflected

  • Status: Superseded by ADR-0125 (the @Bind half) and ADR-0126 (the @Action half). The rule this record argued for — names are wired explicitly, not looked up reflectively — still holds; what changed is that the wiring is written into the model’s own bytecode rather than into a generated source file beside it.
  • Date: 2026-08-18
  • Relates to: docs/ARCHITECTURE.md §9, extends ADR-0062

Context

Wiring a model to markup was two hand-written methods:

public Bindings bindings() {
    return Bindings.strict()
            .bind("app.clicks", clicks)
            .bind("app.gain", gain)
            …                                  // one line per property
}

public Actions actions() {
    return Actions.strict()
            .bind("app.click", this::click)
            .bind("app.set-gain", value -> setGain(Double.parseDouble(value)))
            …                                  // one line per handler, plus the parse
}

Fifteen lines of pure copying in the showcase, and the failure mode is the worst kind: a property that exists and is never registered inflates to a control that renders perfectly and never moves. Nothing points at it.

The obvious fix is to scan the object reflectively. §9 forbids exactly that — “action names bound against a controller object explicitly … no reflective #handler magic” — and it is right to: a runtime scanner needs the application’s package opens, costs start-up, and turns a typo into the same silent non-moving control.

Decision

An annotation processor writes the explicit calls. @Bind on a Property field, @Action on a method, @Registry on the class; the processor generates ShowcaseModelRegistry.bindings(model) and .actions(model) containing exactly the code a person would have written, parse and all.

That satisfies §9 rather than bending it. The generated file is ordinary Java: you can open it, step into it, and get a stack trace out of it. The annotations move the copying, not the explicitness. And there is nothing on the runtime path — the processor is build-time only, like :assets.

The refusals are the point. Every rule is checked at compile time with the member named:

  • a private field or method the generated code cannot see, with the fix in the message;
  • @Bind on something that is not a Property;
  • two members claiming one path — which Bindings refuses at run time and this refuses before there is a run time;
  • an @Action taking more than one argument, or one the toolkit cannot parse from the String a valued action crosses as;
  • an annotated member on a class that is not @Registry, which is the mistake with no other symptom at all.

@Registry is explicit rather than inferred from the presence of a @Bind, so the processor never writes a file nobody asked for and the generated type has a name someone chose to create.

Alternatives considered

Runtime reflection over the model. The version everyone writes first. Rejected by §9, and independently by the cost: an opens per model package, class-scanning at start-up on a toolkit that tracks “starts in milliseconds”, and a typo that still fails silently.

MethodHandles.Lookup passed in by the application — Bindings.of(lookup(), model). Authorised reflection, no opens, and the application opts in. Rejected because it is still a lookup by name resolved at run time: the failure moves from “silent” to “an exception when that path is first used”, which is better and still not compile time.

A builder DSL — Bindings.forModel(m).bind("app.gain", m::gain). No new module and no processor. Rejected because it is the same fifteen lines with a different shape; the copying is the problem, not its syntax.

Generate from the KDL instead — read bind= out of the documents and demand the model supply them. Backwards: it would make the markup the source of truth for the model’s shape, and a document is the thing most likely to be edited by someone who cannot compile.

Consequences

A typo in a path is a compile error naming the field. That is the whole change, and it is the one the hand-written registry could never give.

A new build-time module, :processor. It never ships and has no module-info, because an annotation processor is loaded by the compiler rather than the module system. :assets set that precedent (ADR-0033).

Annotated members cannot be private. Generated code sits in the same package and cannot see one, so a model’s fields become package-private. That is where they belonged — the accessors are the API — but it is a real constraint and the error message says so rather than leaving it to be discovered.

Generated actions() needs :widgets on the classpath, because Actions lives there. An application using only @Bind does not: actions() is generated only when there is an @Action to put in it. Found by the processor’s own test suite, which compiles its output.

The annotations are SOURCE-retained and vanish from the class file, so nothing at run time can be tempted to read them and re-introduce the thing §9 rules out.

Two ways to build a registry now exist — by hand and generated — and they must not drift. They cannot: the generated one is the hand-written one, emitted by a program, and ShowcaseDocumentsTest asserts the generated registry resolves what the documents name, including that a valued action parses the value it is handed.

ADR-0097: A selection that travels needs a geometry

  • Status: Accepted. The drawing half is superseded by ADR-0217, which builds §3’s joined bar once per-corner radii exist (ADR-0216); the deferral of the travelling indicator was already superseded by ADR-0099. The model, the axis and “a segment is a widget” stand.
  • Date: 2026-08-18
  • Relates to: docs/design-system.md §3 and §3.1, docs/core-widgets.md §3, docs/ARCHITECTURE.md §8, extends ADR-0078, reuses ADR-0080’s finding about what a widget can know

Context

segmented is the last control in docs/core-widgets.md §3 that does not wait on M3’s popups, and it looked like the cheapest one left. §3 says it outright: “sharing radio-group’s model and invariant exactly — one Tab stop, arrows rove, exactly one selected, change reports the value. It is radio-group with a different drawing.”

The model transferred without a change. The drawing did not, and both halves of what design-system.md asks for turned out to describe something the toolkit cannot express — for two unrelated reasons, neither of which is a gap to be filled in later.

§3’s row: “radius 8 outer, 0 between; 1px divider in --gb-border”. That is the joined-buttons drawing: segments that meet, square where they touch, rounded at the two ends of the bar. It needs a per-corner radius, and ARCHITECTURE §8 is explicit that ComputedStyle resolves “border-radius / border / outline (one radius, not per-side)”. Nothing clips either — there is no overflow: hidden in the subset and no clip in Box — so the usual escape of drawing square-cornered fills inside a rounded, clipping parent is not available. A square fill inside the bar paints over the bar’s curve, and the result is not subtly wrong: the selected end of the control looks like a corner that lost its radius.

§3.1’s row: “selection indicator translate + width between segments, base — tabs’ effect, same controller”. Two things are wrong with it and only one is about segmented. width is not on §1.7’s whitelist and is not going to be: “layout properties never transition — animating width/height would run Yoga per frame”, which is the same sentence that sends the sanctioned movement effects through transform. And a translate would have to name a distance — how far it is from the segment the selection is leaving to the one it is arriving at. That distance is a fact about two boxes’ laid-out geometry. A stylesheet cannot write it, because segments are as wide as their labels; and a widget cannot compute it, because ADR-0080 is the record of where geometry is available — the router, after a paint — and build/render run before Yoga.

Decision

The bar carries the radius and the segment is inset inside it

segmented        height 32, radius 8, 1px --gb-border, padding 2
└── option       radius 4, padding-x 12, grow 1, no fill until :checked

Both numbers are derived rather than picked (Principle 3). The 2 is what fits a 28-high segment in a 32-high bar, which is exactly the arithmetic toggle’s padding comes from; the 4 is §1.5’s small-control radius, already the radius of every focus ring in the catalog. The bar keeps --gb-border as its own 1px edge, which is what tells a toolbar where the control ends.

The divider goes with the joined drawing. A divider separates segments that meet; segments inset on every side are already separated, and a rule that drew one anyway would draw it through the gap the inset made.

The selection is a fill, and it does not travel

option:checked takes --gb-segmented-selected-bg and the foreground that fill carries (ADR-0087), and both transition on --gb-motion-fast — §1.7’s own duration for “selection”. That is what list row selection already does, and it is the whole of this control’s motion.

The travelling indicator waits for tabs, which is where §3.1 says the effect comes from (“tabs’ effect, same controller”) and which is M3’s. It will need something that does not exist yet: a way for a widget to be told where its own children were laid out last frame. That is a real feature with real costs — a second read-back path, and a widget that is no longer a pure function of its model — and it should be designed for the control whose specification actually requires it, not smuggled in under the one that can do without it. This is ADR-0081’s argument in the same shape: the machinery belongs to the milestone whose problem it solves.

The axis is the widget’s, and that is why this is not radio-group.segmented

Segmented.focusScope() is HORIZONTAL where RadioGroup’s is BOTH, and in Java it is the only line that differs between the two controls. A group has no axis because its direction is its stylesheet’s — .inline flips it. A bar has one: it is a row, no class turns it into a column, and Up/Down are therefore not its keys to take. That is ADR-0078’s rule applied for the first time outside a menu, and it is the machine-checkable form of §3’s “the two are not substitutable in a layout”.

A segment is option, and it is a widget

docs/core-widgets.md §3 writes option for this control’s children and for select’s, so option is the node and the CSS type. It is a widget rather than a part (ADR-0065): a document writes it, it takes the focus, and it means something on its own — which is exactly the test a part fails.

Its content is boxes on its own node rather than child widgets, which is Button’s shape and not Radio’s. A radio needs a child element because its glyph carries a second background; a segment has one background, one radius and one colour across the whole cell.

flex-grow: 1, where radio-group chose align-items: flex-start. The same question, answered opposite ways, for a reason that is about what the two things are: a group’s options are separate controls that happen to be listed together, so stretching one drags its focus ring and its hit target across empty space. A bar is one object and its segments divide it — given a column, the plate fills the width and the segments split it rather than huddling at the left of it.

Consequences

design-system.md §3 and §3.1 are amended rather than left describing something that does not exist. Both rows now say what ships, and the ADR is cited from both. A specification that a stylesheet demonstrably cannot express is not a backlog item; leaving it in place would mean every later reader has to rediscover why the code disagrees with it.

Per-corner radii stay unbuilt, and this is now the second control that would have used one. button.square was the first — §3 gives it radius 0 “where buttons butt against each other”, which is the same joined drawing seen from the other side. If a third arrives, the subset is probably wrong; SegmentedTest pins both radii so that the day it changes, this decision is revisited rather than quietly outlived.

A selected segment’s hover and press need two pseudo-classes on one compound — option:checked:hover — which the selector engine already supported and nothing had exercised. It is what keeps a selected segment selected-coloured under the pointer: option:hover and option:checked have equal specificity, so without it the fill would go grey and take a label drawn for the accent with it. checkbox and toggle solve the identical problem with a descendant selector because their fill is on a part; here the fill and the hover are on one node, so specificity settles it and no extra selector is needed.

The focus ring lands exactly on the bar’s edge. §2.2’s ring is 2px at a 2px offset and the inset is 2, so a focused segment’s ring sits on the plate’s own border rather than inside it. It is legible — segmented-focus.png is the evidence — and it is a coincidence of two numbers that were each derived separately, so it is recorded here rather than relied on.

option is a public widget in …controls.segmented and select will want it. Moving it to a shared package now would be guessing at the second consumer’s needs — select’s options carry a model, may be tree nodes, and are rendered inside a popup — so it stays where its only caller is until there are two, which is ADR-0092’s rule about a reason that has not expired yet.

ADR-0098: A private member is reached by a handle

  • Status: Superseded by ADR-0126. The problem this record solved — generated code in the same package cannot see a private member — stops existing once the wiring is written inside the model’s own class, where private is not a barrier. No handle is looked up at all now.
  • Date: 2026-08-18
  • Relates to: docs/ARCHITECTURE.md §9, revises one consequence of ADR-0096

Context

ADR-0096 moved registry wiring to an annotation processor and listed a cost:

Annotated members cannot be private. Generated code sits in the same package and cannot see one, so a model’s fields become package-private. That is where they belonged — the accessors are the API — but it is a real constraint and the error message says so rather than leaving it to be discovered.

The parenthesis was doing a lot of work. “That is where they belonged” is a claim about some models — and the argument runs the wrong way round regardless: the toolkit was deciding a model’s encapsulation as a side effect of how it reads it. An @Action that only markup ever calls has no business being part of a model’s API, and a Property field is exactly the kind of thing an author wants private, with a read-only accessor beside it. The showcase’s model had six fields and five handlers widened for no reason other than this.

ADR-0096 also considered and rejected MethodHandles.Lookup:

MethodHandles.Lookup passed in by the application — Bindings.of(lookup(), model). Authorised reflection, no opens, and the application opts in. Rejected because it is still a lookup by name resolved at run time: the failure moves from “silent” to “an exception when that path is first used”, which is better and still not compile time.

That rejection stands, and it is not what this record proposes. The difference is which end the name comes from.

Decision

A private member is reached by a handle the processor writes; an accessible one is still read directly.

For each private @Bind field or @Action method, the generated registry declares a constant and fills it in one static initializer:

private static final java.lang.invoke.VarHandle BIND_GAIN;
private static final java.lang.invoke.MethodHandle ACTION_SET_GAIN;

static {
    try {
        var lookup = java.lang.invoke.MethodHandles.privateLookupIn(
                ShowcaseModel.class, java.lang.invoke.MethodHandles.lookup());
        BIND_GAIN = lookup.findVarHandle(ShowcaseModel.class, "gain", Property.class);
        ACTION_SET_GAIN = lookup.findVirtual(ShowcaseModel.class, "setGain",
                MethodType.methodType(void.class, double.class));
    } catch (ReflectiveOperationException e) {
        throw new ExceptionInInitializerError(e);
    }
}

and the registration reads the same as before with the access swapped:

.bind("app.gain", (Property<?>) BIND_GAIN.get(target))
.bind("app.set-gain", value -> call(ACTION_SET_GAIN, target, Double.parseDouble(value)))

The name is still resolved at compile time

This is the whole of why it is not the alternative ADR-0096 rejected. The processor has already proved, against the element model, that the member exists, that a @Bind is a Property, that an @Action takes at most one parameter of a type it can parse, and that no two members claim one path. It then writes the descriptor it verified. Nothing scans and no name is typed by a user.

What the handle changes is access, not discovery. A misspelled path is still a compile error naming the field; the lookup can only fail if the registry and its target were compiled apart and drifted, which is the same skew that turns a direct field reference into a NoSuchFieldError — arriving here as an ExceptionInInitializerError with the member named instead.

No opens, no setAccessible

privateLookupIn requires the target’s module to open the target’s package to the caller’s module. The generated class is in the target’s own package and therefore its own module, and a module always opens its packages to itself — so an application adds nothing to its module-info. This is exactly the property that a runtime scanner over the application’s objects would not have had, and it is why ADR-0096’s cost of “an opens per model package” does not apply.

An accessible member gets nothing

target.gain and target::click stay as they were. A handle constant, a static initializer entry and a call(...) wrapper are three things a reader of the generated file would otherwise have to understand for a member that never needed them — and ADR-0096’s argument is that the generated file is ordinary Java you can open and step into. Mixed models get a mixed file, which is honest: the handles are precisely the members that could not be reached any other way.

One helper for the call

A private @Action goes through handle.invokeWithArguments(...) in a generated call helper rather than invokeExact at each site. The shapes differ per action and the conversion invokeWithArguments performs — unboxing the parsed value into the parameter’s primitive — is what the lambda would otherwise spell out per type. An action runs on a user gesture, so its cost is not on a path that matters.

The helper rethrows RuntimeException and Error as themselves: an action that throws must reach the application looking like what it threw, not like a wrapper. A checked exception — which no @Action can declare without the model itself failing to compile against the Runnable/Consumer it is bound as — becomes an IllegalStateException.

Alternatives considered

Leave it refused. The status quo, and it costs nothing to keep. Rejected because the constraint is arbitrary from the author’s side: nothing about binding a value to markup implies the field must be visible to the rest of its package, and the toolkit should not be the reason it is.

Generate an accessor into the model. An annotation processor cannot modify the class it reads, so this needs a different mechanism entirely — a -Xplugin, a bytecode step, or Lombok-style tree surgery. All three are worse than a handle by a wide margin.

Always use handles, for every member. Uniform, and one code path in the generator instead of two. Rejected because it makes every generated registry harder to read and moves every member’s failure from link time to class-init time, to buy consistency nobody benefits from.

setAccessible(true) over getDeclaredField. Needs the module to be open, which is the cost ADR-0096 rejected reflection for in the first place.

Consequences

A model can encapsulate its state again. ShowcaseModel’s six Property fields and its five markup-only handlers are private now, and the accessors that remain — gain(), themeName(), status() — are the API because someone chose them, not because a processor demanded them.

Two failure modes are now class-init rather than compile or link time, for private members only: a target recompiled without the member, and a target whose member changed type. Both are already impossible within one compilation, which is how a registry and its model are always built.

The generated file grew for models that use the feature. A registry over six private fields and five private actions carries eleven constants, a static block and a helper — about forty lines that were not there. The test suite compiles and runs generated output now rather than only checking that it compiles, because “it compiles” was no longer the interesting half of the claim.

A static @Action is still unsupported, and now for a second reason: the non-private path generates target::method, which does not compile for a static method, and the private path writes findVirtual. Nothing refuses one explicitly; it was broken before this record and remains so, recorded here rather than fixed because no model has ever wanted one.

ADR-0099: An indicator travels on a grid

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/design-system.md §3 and §3.1, docs/ARCHITECTURE.md §8, supersedes the deferral in ADR-0097 (the rest of that record stands), extends ADR-0068

Context

segmented shipped with the switch animated the only way it could be: each segment’s fill cross-fading on --gb-motion-fast. §3.1 asks for something else — “selection indicator translate + width between segments, base” — and ADR-0097 deferred it with this argument:

a translate would have to name a distance — how far it is from the segment the selection is leaving to the one it is arriving at. That distance is a fact about two boxes’ laid-out geometry. A stylesheet cannot write it, because segments are as wide as their labels; and a widget cannot compute it, because ADR-0080 is the record of where geometry is available — the router, after a paint — and build/render run before Yoga.

Every clause of that is true and the conclusion is wrong, because of the premise buried in the middle: “segments are as wide as their labels”. That was a choice, not a fact. If every segment is the same width, the distance to segment k is k times one segment — and “one segment” is a proportion, not a length, so nothing has to measure anything.

The second half turned out to be a real gap, but a different one. A widget can compute the proportion easily; what it could not do is get that value somewhere the animation would see it. A value written in render arrives after WidgetRenderer has already observed the node’s style and started whatever transitions it declared, so it snaps. That is why every Java-computed geometry in the toolkit so far — a knob’s arc, a slider’s fill ratio — is documented as not animating.

Decision

The segments are a grid, and the grid is the widget’s

Every segment is exactly 1/n of the track. SegmentedTrack sizes them, because n is the one metric of this control no selector can express — a stylesheet cannot count the segments.

flex-grow: 1 alone does not do it: with a content basis each cell is its label plus an equal share of what is left, so three labels of different lengths give three different widths. flex-basis: 0 does give equal cells and was tried; Yoga then computes the track’s content size as zero, so a bar that nothing gives a width to collapses to its padding. Explicit percentage widths are the form that works in both directions.

The indicator is one box that moves

segmented                     radius 8, 1px --gb-border, padding 2
`-- segmented-track           the grid: no padding of its own
    |-- segmented-indicator   absolute, 1/n wide, translate(k × 100%)
    `-- option ...            each exactly 1/n

The translation is a percentage of the indicator’s own border box, which ADR-0068 already established is resolved by the painter after Yoga has run. One number — k × 100% — is right for every bar at every size and every segment count, and it needs no measurement at all. The width half of §3.1’s row therefore never animates and does not need to: on a grid every cell is the same size, so there is nothing for a width to move.

The track exists because two percentage bases disagree

Yoga resolves an in-flow child’s percentage width against its parent’s content box and an absolute child’s against its parent’s padding box — CSS’s rule, and 4px of disagreement on a bar whose padding is 2. A pill sized against the padded bar is wider than the segment it covers, and the error compounds per cell.

A track with no padding of its own makes the two bases the same box. The bar keeps the padding, the border and the radius; the track keeps the grid. slider grew a track for the same kind of reason (ADR-0080): two boxes were doing one job under one name.

Styled.restyle — §8’s inline layer, typed

default ComputedStyle restyle(ComputedStyle resolved) { return resolved; }

The widget’s last word on its own style, applied by WidgetRenderer after the cascade, after the style cache, and before the animation observes the result. That ordering is the whole of the mechanism:

  • after the cascade, because it is the most specific statement about one element — which is exactly what §8’s fourth layer is for;
  • after the cache, because the cached value is the cascade’s answer and a widget’s own number changes when the widget does, not when a selector stops matching;
  • before the observation, because that is what makes it animatable rather than merely correct.

The style it returns is the node’s, so children inherit it — CSS’s rule for an inline style, and the reason it returns a whole style rather than a patch.

The rule that keeps it honest: a widget may write here only what a stylesheet could not have written. The toolkit’s own use is two values, both derived from a count no selector can express.

Alternatives considered

One rule per index in controls.css — segmented-indicator.at-3 { transform: translate(300%) }, with the widget contributing a class. Goes through the cascade untouched, so it animates for free. Rejected because it needs a cap: a bar with more segments than there are rules would put its pill in the wrong place, silently. A control that is correct up to seven and quietly wrong at eight is worse than one that refuses.

Keep the cross-fade. It is what shipped, it animates, and it costs nothing. Rejected because it is not what §3.1 describes, and the difference is exactly the thing a segmented control is for: the eye follows one moving object and reads it as the same selection changing place. Two fills dimming and brightening in place read as two things.

Measure the labels in Java and size the cells to the widest. Keeps content-sized segments and still gives a regular grid, which is what grid-auto-columns: 1fr does in CSS. Rejected because it means reimplementing intrinsic sizing — the widget would have to add up its children’s paragraphs, padding and gaps and get the same answer Yoga would — and the day the arithmetic diverges the pill is wrong by a few pixels with nothing to point at.

Animate left instead of transform. The obvious spelling, and a layout property: it would run Yoga every frame of every switch, which is what §1.7’s whitelist exists to prevent.

Consequences

A segmented control has no width of its own. Its cells are proportions, so its content size is indefinite: it takes the width it is given and fills its parent when nothing gives it one. In a column — a sidebar, a form — that is what it did before. In a toolbar beside other widgets it will take the whole row unless it is given a width, and that is a real regression in convenience for a real gain in what the control can do. §3’s row says so now.

A label longer than its cell overflows it, because the cells are equal and nothing in this toolkit clips. The same limit the indeterminate progress sweep documents, reached here by a different road.

position and inset came off §8’s unimplemented list, flex-basis did not. It was implemented in the course of this change, found not to be needed, and taken back out rather than left in as a property nothing uses — “each arrives with the thing that paints it”.

A segment’s hover and press are a translucent wash rather than a surface step. An opaque fill on the selected segment would paint over the pill, since the segments are drawn after it — and worse, clicking a new segment would paint the destination fill instantly and beat the animation to it. The --gb-overlay-* tokens button.ghost uses work on both backgrounds, which also took four tokens out of each theme. They are translucent, so they leave ContrastTest’s sweep on button.ghost’s terms.

A selected label’s color moves on --gb-motion-base, not fast. §3.1 wrote that rule for toggle — “thumb translate base; track colour base (same clock — they arrive together)” — and it matters more here, because the label and the pill are different boxes: a label that darkened in fast would be dark on the plate for 60 ms while the pill was still on its way.

Styled.restyle is a new escape hatch, and the honest risk is that it becomes the place things go when a stylesheet is inconvenient. What is written there is unthemeable and unoverridable, which is right for a number nobody else can compute and wrong for anything else. It has one caller in the toolkit and the rule above; if it acquires a second that is not a count, that is the signal to look again.

ADR-0100: A window has a layer above its application

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/core-widgets.md §7, docs/ARCHITECTURE.md §4 and §11, extends ADR-0093, applies ADR-0062 to the toolkit’s own state

Context

docs/core-widgets.md §7 opens by naming two places an overlay can be drawn: “all overlays render in the in-window overlay layer or backend popup windows as appropriate”. The second half is M3’s — a menu that escapes the window needs a platform window, and Backend says outright that popups are absent “not dropped” until something needs one (ADR-0019).

The first half had nothing behind it at all, and three of §7’s five widgets want it rather than a popup: a toast stacks in a corner of the window, a dialog’s scrim covers the window, and hud — the frame-rate readout this record’s sibling (ADR-0101) adds — lies in a corner of it. None of them wants a second platform window, and on Wayland none of them could reliably have one anyway.

Nothing in the tree can float itself. Yoga places an absolute box against its own parent, which means the furthest a widget can pin itself is the panel it happens to be in. A toast raised from a form’s submit handler would appear in the corner of the form. The thing being pinned to is the window, and only something that sits above the application’s root can name it.

And there is exactly one place that sits there: ADR-0093’s launcher, which owns the window, the three trees and the frame loop, and hands an application a Host instead of any of them.

Decision

Every window’s element tree is rooted at a WindowRoot, always, and an overlay is one of its children.

tree = new ElementTree(new WindowRoot(application.root(), overlays));

WindowRoot renders one box: the application’s root in flow with flex-grow: 1, so it fills the window, and every overlay after it as an absolute box inset to a Corner. Three consequences follow from that one shape, and each is the point of it:

  • An overlay takes no space. An absolute box takes no part in its parent’s flex layout, so the application’s box is the same box it was. Adding a HUD cannot move a pixel of what is under it.
  • An overlay is painted last. A box tree has no z-order beyond document order (ADR-0053), so “on top” is “listed after” and needs no new concept.
  • Two of the four insets are UNDEFINED, not zero. An inset of zero on all four edges pins a box to all four and stretches it across the window — which is a scrim, and a perfectly legal box. Corner.insets sets the two edges its corner touches and leaves the others undefined, so the overlay keeps its own size.

The root node is there from the first frame

Whether or not anything is floating. A layer that appeared with the first overlay would re-parent the entire application to show a toast, and re-parenting is precisely what throws away element state, focus and every animation in flight (ADR-0052). The cost of the node when nothing is floating is one box in the tree and one Yoga node; the cost of adding it later is the application’s state.

The layer arrives as a binding, not as a constructor argument

Host.overlay(...) is called at any time — from Application#start, from a handler, from a key. An ElementTree’s root widget cannot be swapped, so the list cannot be a value the root was built with.

It is a Property<List<Overlay>> the launcher owns and WindowRoot watches, through the binding() every widget already has. The root element subscribes for as long as it lives, and a change marks it for rebuild — the same route an application’s model takes to the screen (ADR-0062). No setState, no second invalidation path, and no mutable list read behind the framework’s back.

The list is replaced rather than mutated, which is not a style preference: the subscription is to the value, and a list changed in place is the same value.

Overlay is a handle with identity

var hud = host.overlay(new Hud(), Corner.BOTTOM_END);
hud.remove();

Adding is one call and removing is the same object. Two identical HUDs in two corners are equal values and two different things on screen, so removal is by identity — which is why Overlay is a class and not a record. remove() is idempotent, because removing twice is what shutdown looks like when two things both think they own it.

window-root is selectable and not constructible

A stated exception to §11’s parity invariant, on the grounds a part is one (ADR-0065): a document cannot write the node it is the document of. It is CSS-selectable because it is the element :root matches and the one place a stylesheet could put the window’s own background.

Nothing visible changed by its arrival, and that is checkable rather than hoped for: the :root blocks the two themes ship declare custom properties and nothing else, so moving which element they match moves nothing that is drawn.

Alternatives considered

  • A stack widget the application wraps its own root in. docs/core-widgets.md §1 specifies stack and calls it “the basis for badges-over-things and custom overlays”, so this is not a wrong tool — but it puts the layer in the application’s tree, which means an application that forgot to add one has no overlay layer, and a library widget that raises a toast cannot know whether there is one above it. stack is still owed; it is a layout widget and this is a window facility, and building one does not build the other.
  • Overlay.of(context) reached through BuildContext, Flutter’s shape: any descendant finds the layer and pushes an entry into it. That is what toast and tooltip will want, because the thing raising them is deep in the tree. It is not built here, for ADR-0019’s reason: there is one consumer, it is the application itself, and an interface designed against one caller is designed twice. Host.overlay is the half that is certainly needed either way — a BuildContext-reachable form would be implemented in terms of it.
  • An overlay window per overlay, the popup path, used for everything. Wrong for the three widgets that want this: a toast is inside the window by specification, and a scrim over the window is of the window. It is also the more expensive answer everywhere and the less portable one — see §4 on Wayland.
  • Placement in CSS rather than in Java. There is no position in §8’s subset, deliberately: it is the same reason affix is a widget rather than position: sticky. A corner and a margin are Java’s, and --gb-window-margin is the name the number will take when a floating button needs it in a rule.

Consequences

  • Host grows two methods — overlay(...) and frames() — and an application that uses neither is unchanged. §7’s toast, tooltip, dialog and popover all now have somewhere to be drawn that does not wait on the backend.
  • The application’s root is one level deeper, which is visible in a test that walks from tree.root(). Selectors are unaffected: nothing in the toolkit’s stylesheets is anchored to the root, and :root matches custom-property blocks only.
  • Nothing hit-tests an overlay yet. The pointer router tests against the painted frame (ADR-0054), and an overlay is in that frame, so a button in one is reachable today by accident of the ordering rather than by a rule anyone wrote. A modal scrim needs the rule written — “the topmost overlay takes the pointer first, and a modal one takes it exclusively” — and that belongs with dialog.
  • An overlay is not a focus scope. §7 says each overlay “wraps a focus-scope and restores focus on close”, which is true of the ones that take focus and is not true of a HUD. The wrapping belongs to those widgets rather than to the layer, and focus-scope exists already (ADR-0078).
  • Overlays do not animate in or out. A toast that appears and disappears wants §1.7’s overlay curve, and the machinery is transitions on a node that is there — so it is the widget’s, not the layer’s, and it is the same problem collapse has with a body that is unmounted while closed.

ADR-0101: A diagnostic must not be the thing it measures

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/core-widgets.md §7, docs/design-system.md §1.7, needs ADR-0100, extends ADR-0028 and ADR-0053

Context

The toolkit measures its own frames already and has since ADR-0028: Window.paint takes five nanoTime readings and logs where a frame went — buffer, begin, draw, end, present. Every one of them is behind LOG.isTraceEnabled(), which is the right gate for a log line and the wrong one for a number somebody wants to watch: switching TRACE on to see a frame rate means measuring a loop that is now also writing a line per frame to a file.

What was wanted is a hud — §7’s frame-rate readout, floating over the window it is reporting on, and the first widget in the catalog that is about the toolkit rather than about the application.

Two things had to be decided, and the second is the one worth a record.

Decision

The statistics are a live object on the window, read down the render context

FrameStats is an interface; FrameRing is the fixed-size ring of the last 60 frames behind it, written once per painted frame by Window.paint from two ungated nanoTime readings. Two calls against a frame is not a cost worth an if, and a rate that exists only at TRACE is not a rate anyone can use.

It reaches a widget on Paints.Context, beside the frame clock and the reduced-motion flag, because it is the same kind of fact: something true of the frame being rendered rather than of the node rendering. Paints.Context was described as an interface “precisely so that it can grow” (ADR-0053) and this is the third thing to grow onto it. The alternative — a widget holding the stats it was built with — makes hud unwritable from a document, because the inflater has no window to ask.

The interface is what makes it testable: FrameStats.of(60, 16.7, 2.1, 4200) is a rate somebody chose, so a golden image of a HUD is a golden image and not a race against the machine that ran it. This is Clock.virtual()’s argument (ADR-0067) applied to a second kind of time.

The HUD reports the loop and never drives it

A hud does not call repaint. It draws the numbers as of the frame it is being drawn in, and when the loop goes quiet they stop moving with it.

The alternative is not a small convenience. docs/design-system.md §1.7 ends with “the frame loop is fully idle when no animation is active”, and the toolkit holds itself to it: the launcher asks for another frame only while something is animating. A HUD that asked for a frame so it could show a fresh number would make that sentence false for every window with one in the corner — and would report a steady 60 fps whatever the application was doing, because the frames being counted would be the ones the HUD requested. It would be measuring itself.

So a HUD tells you about frames you were already getting. During a resize, a drag or a transition — which is when there is anything to watch — the loop is running and the numbers move. On an idle window they freeze, and that is the honest answer to “what rate is a loop drawing nothing achieving”.

It is self-correcting rather than stale: the window is a frame count, so an idle second sits inside it, and the first frame after that second carries it into the mean. The rate falls the moment there is anything to fall in front of.

Nothing measured reads as dashes, not as zero

— fps, not 0 fps, when there is no frame loop over the tree — in a unit test, or a render into a Layer. A zero is a measurement. A loop that genuinely stopped dead does read 0 fps, and being able to tell those two apart at a glance is the whole reason for the distinction.

The readings are parts, and the numbers are formatted in the root locale

Each reading is a hud-reading part (ADR-0065) with its own class, so a stylesheet can dim the units or colour a paint time that has run out of budget; one paragraph with separators in it could carry none of that.

Locale.ROOT, always. docs/core-widgets.md §5 says a locale-formatted number produced inside the toolkit makes a golden image that cannot be reproduced on another machine — a rule written for statistic, whose numbers are the application’s and which therefore takes a string. A HUD’s numbers are the toolkit’s own, so it formats them itself, the one way that is the same everywhere. The test that pins this sets the default locale to Germany and asserts 16.7 ms, because 16,7 ms is what a CI runner in Berlin would otherwise have produced.

A bare hud shows two numbers, not three

60 fps and paint 2.1 ms. The frame interval is the third reading and is off by default because it is 1000 / fps: a HUD showing both would spend a third of its width restating its first number. hud readings="fps frame paint" asks for it, for whoever is thinking in budgets rather than in rates.

The pair that is shown is the pair that answers different questions. The interval is the display’s and says nothing about headroom; the paint time is the toolkit’s half of it. 2 ms inside a 16.7 ms frame is idle hardware, and 15 ms inside the same frame is one resize away from dropping every other one.

Alternatives considered

  • A rate since start-up. Trivial to compute and useless to read: it tells you about the resize you finished a minute ago, and it stops moving, so a HUD showing it looks broken.
  • A time window rather than a frame window — “the frames of the last second”. It has to be pruned, which means the answer changes when nobody asked it anything, and a HUD reading it twice in one frame could get two numbers. A fixed count is a mean over a fixed sample and is computed on demand from data nothing but record touches.
  • Sampling on a timer at 2 Hz so the digits do not flicker. That is a second clock, it needs a frame to display its result, and needing a frame is the thing this record refuses. The ring’s 60-frame mean is already the smoothing.
  • Reading the statistics through BuildContext.findAncestor(WindowRoot.class) rather than off the render context. It works, and it makes the window’s own node a carrier for anything a subtree might want to know — which is a service locator growing in the widget tree. The frame clock settled this shape already: facts about the frame travel with the frame.
  • A HUD outside the catalog, as a debug flag the launcher honours (-Dgoldberry.hud=true). Tempting, and wrong in the module graph: the launcher is :core and the catalog is :widgets, so :core would have to ship the one widget it deliberately stopped shipping (ADR-0092). An application writes host.overlay(new Hud(), Corner.BOTTOM_END), or hud in a document, and both are one line.

Consequences

  • Window.paint takes two nanoTime readings on every frame, where before it took none unless TRACE was on. The other five are still gated.
  • The showcase toggles a HUD on Ctrl+F, off by default — which is also what keeps it out of the golden images, since a frame rate is exactly the kind of machine-dependent number §14’s image corpus must not contain.
  • --gb-hud-bg is the only background token that does not invert with the theme. A HUD lies over colours the toolkit does not know, so it carries its own contrast: a dark plate with a hairline edge on both themes. Without the edge it disappears into a dark window, which is what the first pass did.
  • Nothing yet reports a dropped frame. The ring records what was painted, so a frame the platform refused after it was painted is in the mean and a frame the loop never got to is not. A HUD that said “3 late” would need the pacer’s view as well as the painter’s, and the pacer is the sdl3 backend’s.
  • paintMillis is the painter’s wall time, which on a multi-threaded Blend2D context includes waiting for its workers at end and excludes the platform’s own upload in present. That is the same split the TRACE line has always reported, and the same caveat applies to reading it.

ADR-0102: A popup is a window the platform may refuse

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/ARCHITECTURE.md §4, docs/core-widgets.md §3, §7 and §8, completes the other half of ADR-0100, settles a deferral in ADR-0019

Context

ADR-0019 left four things out of the backend SPI — popups, a tray icon, a clipboard and a GPU surface — with a rule for putting them back: “each needs a consumer before its shape can be decided, and an interface designed against nothing is an interface that gets designed twice.”

Popups now have four, and they are not speculative. select is the one control in docs/core-widgets.md §3 that M2 did not build, and the reason recorded at the time was exactly this: “closed control + popup list (backend popup window, so it escapes window bounds)”. §7’s popover is “the primitive under menus, dropdowns, date-picker, color-picker and autocomplete”, §7’s tooltip is attached by attribute to any widget, and §8’s menus are “rendered in backend popup windows so menus escape window bounds”.

ADR-0100 built the other place an overlay can go and drew the line precisely: the in-window layer floats things over the window, and cannot put anything outside it. A dropdown near the bottom of a window is routinely taller than the space below its button. Clipped to the window, a nine-item list shows four.

Decision

Backend.createPopup(owner, spec) returns Optional<BackendPopup>, and empty is a normal answer.

default Optional<BackendPopup> createPopup(BackendWindow owner, PopupSpec spec) {
    return Optional.empty();
}

Optional, because popup support is a property of the driver

Not of the request. SDL_CreatePopupWindow fails with SDL_Unsupported unless the video driver declares VIDEO_DEVICE_CAPS_HAS_POPUP_WINDOW_SUPPORT. The four drivers Goldberry ships against — x11, wayland, cocoa and the Windows one — all declare it; SDL’s dummy driver, which is what every headless test in this repository runs under, does not.

So the refusal is a branch that runs in this repository’s own CI on every platform, not a hypothetical. A caller has to have an answer for it, and there is one: the in-window overlay layer, at the cost of being clipped to the window.

SDL_Unsupported is told apart from a caller’s mistake by SDL’s own message. A null parent or two conflicting kind flags is a bug and is thrown; “not supported” is the platform and is a value.

BackendPopup extends BackendWindow, and Sdl3Popup extends Sdl3Window

A popup acquires a frame, is painted into, presents, paces and closes exactly as a window does, and its events arrive through the same pump under their own window id. Modelling it as a different thing would mean a second present path, a second pacing path and a second event lookup, all identical.

What it adds is three things that only a popup has: an owner, a kind, and a position that means something relative to that owner. Sdl3Window became sealed … permits Sdl3Popup rather than gaining a boolean, so a popup lands in the backend’s window map and its events find their way home by the code that was already there.

Popups are in windows(). A caller enumerating windows to shut them down must not leave one open because it was the wrong shape.

Exactly one kind, and a tooltip is not focusable by being a tooltip

PopupKind is MENU or TOOLTIP because SDL refuses a popup that claims to be both, and because every window manager treats the two differently — animation, shadow, whether it appears in the window list, when it is dismissed.

The trap is that SDL_WINDOW_TOOLTIP alone does not stop a popup taking focus: SDL_WINDOW_NOT_FOCUSABLE is a separate flag and SDL checks it separately. §7 says a tooltip is “never focusable itself” and shows “on hover and on keyboard focus” — which only works if showing it does not move the focus that summoned it. So TOOLTIP sets both flags, and that is the whole of the difference in the backend.

SDL_WINDOW_NOT_FOCUSABLE is 0x80000000, which turned out to be the first constant in the toolkit with the top bit set. The layout probe read every constant into a signed int and refused negative values — right for a size, wrong for a bit pattern — so a constant row’s value is now read unsigned, and the four new flags are checked against the compiled SDL headers like every other.

Position is in the owner’s coordinates, and is what was asked for

A popup’s position is logical pixels from its owner’s top-left — the same space a hit test reports in, so anchoring a menu under the button that opened it needs no conversion.

It is remembered rather than read back: SDL_GetWindowPosition reports the display’s coordinates on some drivers and the parent’s on others, and the request is the one answer that is the same everywhere.

A resize is a request, and the fake makes you believe it

On X11 and Wayland the window manager decides when a resize happens. size() keeps reporting the old size until it has — one event pump later, in practice — and a BackendEvent.Resized is what says otherwise. Measured straight after the call, a popup that was just resized reports the size it had before, which is what Sdl3PopupTest found on the first run.

HeadlessPopup therefore defers its resize the same way: the size is applied when the event is delivered, not when resize is called. A fake that applied it instantly would be the one place a caller measuring too early passes its tests, and the desktop would be where it failed.

Placement policy is not in the SPI

Nothing here decides where a menu near a screen edge should flip to. That needs the display’s work area, the anchor’s rectangle and a preference order, and it belongs with the widget that has all three — popover, which §7 calls the primitive under the rest. PopupSpec is the platform request such a policy ends in.

Alternatives considered

  • Do everything in the in-window overlay layer. Cheaper, portable, and wrong for the four consumers: they are the ones whose content routinely does not fit in the window. It remains the fallback when createPopup is empty, which is a real configuration and not a theoretical one.
  • Throwing rather than an Optional. It makes “this platform has no popups” an exception, and the caller writes a catch block to do what an if would have. The SPI already draws this line: acquireFrame returns empty for a backend with no buffer to lend.
  • A separate PopupWindow type not extending BackendWindow. It would keep a popup out of windows() — which sounds tidy until shutdown misses one — and duplicate the entire present and pacing path for no difference in behaviour.
  • Binding SDL_CreateWindowWithProperties instead and setting the popup properties by hand. It is what SDL_CreatePopupWindow does internally, and it trades one symbol for six property names typed as strings — the exact failure mode the layout probe exists to prevent, with no probe to catch it.
  • Deferring popups until select is built. ADR-0019’s rule is that an interface needs a consumer, not that it must be written in the same commit as one. Four are specified, and the SPI is the part that has to exist before any of them can start.

Consequences

  • select, menu, tooltip and popover are unblocked at the platform layer, and blocked at the widget layer on three things this record does not build: rendering a widget subtree into a second window’s frame, routing input to it, and light-dismiss.
  • Nothing paints into a popup yet. The launcher owns one window, one element tree and one render tree (ADR-0093), and a popup needs a second render tree over a subtree of the same element tree. That is the next piece of work and it is a widget-layer one.
  • Nothing dismisses a popup yet. Light-dismiss — outside click, Esc, focus loss — is policy over events that now arrive, and belongs with popover.
  • Three new SDL symbols (SDL_CreatePopupWindow, SDL_SetWindowPosition, SDL_SetWindowSize) and four new window flags, all exported from libgoldberry and all checked against the compiled headers.
  • The layout probe now reads a constant’s value unsigned. A struct’s or a scalar’s size column still refuses a negative, because a negative there still means the table is being read wrongly.
  • macOS is fine, and it was worth checking. VIDEO_DEVICE_CAPS_HAS_POPUP_WINDOW_SUPPORT is declared by the cocoa driver as well as x11, wayland and the Windows one — so a menu is a real popup window on all three platforms, and the fallback path is for the dummy driver and for whatever a future embedded backend cannot do.

ADR-0103: A popup is a second tree in a second window

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/core-widgets.md §7, docs/ARCHITECTURE.md §4 and §7, builds on ADR-0102, applies ADR-0080’s finding about where geometry lives

Context

ADR-0102 got a platform window open and left three things undone, and said so: nothing painted into one, nothing routed input to one, and nothing dismissed one.

All three are the same question — what does a popup contain? — and the launcher’s shape made it a real one. It owns one window, one element tree, one render tree and one pointer router (ADR-0093), and every one of those is per window.

Decision

A popup is a widget tree of its own, in a window of its own, painted by the renderer of the window it belongs to.

host.popup(menu(), LogicalPoint.of(24, 120), LogicalSize.of(180, 132))
    .ifPresent(open -> this.menu = open);

Popup holds an ElementTree, a RenderTree and a PointerRouter, and a Window over the BackendPopup — which is the whole trick: everything above the SPI is identical for a popup and a window, so Window.over(backendWindow) registers it with the runtime and the existing frame loop paints it, the existing dispatch delivers its events, and its pointer goes through its own router.

What is shared is the renderer, and that is deliberate

The stylesheets, the font book and the frame clock. A popup is themed by the same cascade as the window that opened it, restyles with it, and animates on the same tick — because it reads () -> renderer, the launcher’s current one, rather than a copy taken when it opened.

What is not shared is the tree. A popup’s contents are a root, not a descendant of the widget that opened them, so they inherit nothing from it: no color, no font-size, and no descendant selector reaches into them. For a menu that is correct — its items are a list, not part of a button’s subtree — and the showcase’s #menu says so by carrying its own surface and its own edge, because nothing above it will.

For a tooltip that wants the styling of the thing it describes it is a limitation, and the answer when that widget is built is to pass the anchor’s resolved style in, not to reparent the tree.

Light dismissal needs input the router will not deliver

§7 gives popover “light-dismiss on outside click/Esc”. Neither reaches a widget: an outside click usually lands on nothing, and Escape belongs to no control in particular. The pointer router dispatches to the element under the pointer and correctly does nothing when there is none — which is exactly the case a menu must close on.

So Window gained one package-private hook, InputWatcher, called before routing with “a press happened” and “a key went down”. The launcher watches the owner window; the popup watches its own, for Escape alone — once a menu has taken focus the key goes to it and the owner never sees it. A press inside a popup is deliberately not watched: that is someone choosing an item.

It is package-private because “see every press before anything else does” is not something an application should be handed.

A popup is anchored to a rectangle from the last frame

Host.anchor(id) returns the painted rectangle of a node, and a menu opens under the button that opened it by asking for it. Where a button is is a fact about the last frame — geometry exists after a paint and it is the router that has it (ADR-0080) — so the launcher keeps the same HitTest capture it hands the router and answers from that.

By id rather than by element, for two reasons that point the same way: §7’s tour “names a target by id”, and an application holds ids rather than elements. A popover anchoring to itself will want the element form and will be built with it.

Empty before the first frame and for a node that was not painted. The showcase falls back to a corner rather than refusing to open, because a menu that does not appear is a worse answer than one in the wrong place.

Closing a window closes its popups, and that is not tidiness

The event loop runs until backend.windows() is empty. SDL destroys a window’s popups with it, leaving this side holding dangling handles and — worse — entries in the window map, so an orphaned popup is a process that never exits. Both backends now close a window’s popups first, and headless does it for the same reason rather than for symmetry: it is where that bug would otherwise pass.

Alternatives considered

  • One element tree, with the popup’s contents as a subtree of it. What a browser does, and what makes a tooltip inherit its anchor’s styling. It needs the render tree to be splittable — one subtree laid out and painted against a different surface at a different origin — which is real machinery in RenderTree and HitTest for a benefit only tooltip has asked for. Recorded as the thing to revisit when it does.
  • Painting the popup from the owner’s paint callback. One frame loop, two surfaces. It couples the two windows’ repaint rates — a menu that animates would drive the whole window’s loop, and a window that idles would freeze the menu — and the SPI already gives each window its own requestFrame.
  • Light dismissal as a widget — a full-window transparent scrim under the popup that swallows the click. It is what a web toolkit does because it has no choice, it makes every popup cost a full-window overlay, and it cannot see Escape at all.
  • Host.popup returning a Popup and throwing when the platform has none. The SPI’s Optional is deliberate (ADR-0102) and hiding it at this layer would put the decision “what do I do without popups?” somewhere the application cannot reach it.

Consequences

  • select, menu, tooltip and popover are unblocked at the widget layer too. What each still needs is its own: placement policy, item semantics, keyboard traversal into and out of the popup.
  • A popup does not size itself to its content. The caller gives a size, and the showcase’s menu is 180×132 because someone measured it. Auto-sizing needs a measure pass without a surface to measure against, which Yoga can do and RenderTree.update currently cannot be asked for.
  • Placement is not policy yet. Nothing flips a menu that would open off the bottom of the screen, which needs the display’s work area — a call the SPI does not have.
  • Focus does not travel into a popup. Its router has its own focus root, so Tab inside a menu works; what does not is opening a menu from the keyboard and landing in it, or returning focus to the button on close. §7’s “each wraps a focus-scope and restores focus on close” is the widgets’ to keep, and focus-scope exists (ADR-0078).
  • Two popups do not know about each other. A submenu chain — where opening one closes its siblings but not its parent — is menu’s to arrange; the launcher’s light dismissal closes all of them at once, which is right for one and wrong for a chain.

ADR-0104: A popup is measured, then placed

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/core-widgets.md §7, docs/ARCHITECTURE.md §4 and §7.2, completes ADR-0103, uses ADR-0080’s finding about where geometry lives

Context

ADR-0103 put a widget tree in a popup window and listed what was still missing. Three of those items were the same shape — the caller has to know something it cannot know — and they are what docs/core-widgets.md §7’s popover is made of:

popover — anchored floating panel (the select popup generalized): placement with flip/shift when near edges, light-dismiss on outside click/Esc; the primitive under menus, dropdowns, date-picker, color-picker and autocomplete.

  • A size. host.popup(content, at, size) made the caller supply one, and the showcase’s menu was 180×132 because somebody measured it by hand. Add an item and the number is wrong.
  • A position. The caller could anchor to a rectangle (ADR-0103’s Host.anchor) but nothing stopped the result opening off the bottom of the screen, or underneath a taskbar.
  • The keyboard. A menu with no focus in it answers Down by doing nothing.

Decision

host.popup(content, anchor, placement) measures, places, and opens.

host.popup(new Popover(items), "menu-button", Placement.BELOW)
    .ifPresent(open -> this.menu = open);

Measuring: two passes, and the second one is the whole record

RenderTree.measure(box, scale, availableWidth, availableHeight) lays a tree out with no surface and reports the size it wants. Two floats rather than a LogicalSize, because a size refuses NaN and “undefined” is exactly what has to be expressible.

The trap is that Yoga lays a root out at exactly the available size when that size is definite. There is no parent for the root to be “at most” of, so a bound and a target are the same number. Measuring a menu against the window therefore returns the window — which happened twice, once per axis, and both times it looked like a placement bug:

  • First against window.size(): the menu came out 960×640.
  • Then against (windowWidth, NaN): 960×108 — the height was right and the width was still the window’s.

So the measurement is: nothing definite, which gives the content’s natural size; and only if that is wider than the window, a second pass with the width pinned, where a definite width is now what is wanted and a paragraph wraps at it rather than running off the side. A menu is a few dozen Yoga nodes and this happens once, when it opens.

The same trap bites a widget: Popover.render originally returned a growing box so it would fill its window, and a growing root fills a definite available size. It is content-sized now, and filling the window is not something it has to ask for — the window was created at its measured size.

Placing: three rules, no state

Placement is a record of a preferred side, a cross-axis alignment and a gap, with one pure function on it: anchor rectangle, size, and the rectangle it must stay inside, in; a point and the side it ended up on, out. It opens no window, reads no display and knows nothing about popups, which is why every case of it is a test rather than a screenshot.

  1. Preferred side, gap away, aligned by align.
  2. Flip to the opposite side only if it does not fit on the preferred one and does fit on the opposite one. Not “if there is more room the other way”: a menu that changed sides on the strength of a comparison is a menu nobody can predict.
  3. Shift along the cross axis until it is inside — which keeps the popup attached to its anchor’s side while sliding it along. Flipping the cross axis would move it somewhere else entirely.

Still too big — a menu taller than the screen — and it is clamped to the near edge, so the top of it is what survives. Making it scroll is scroll’s job and scroll does not exist.

The rectangle it must stay inside is the display’s work area

Not the display’s bounds. SDL_GetDisplayUsableBounds excludes whatever the desktop has reserved, and the difference between the two rectangles is exactly the taskbar a menu would otherwise open underneath. BackendWindow gained workArea() and position(); the launcher translates the first by the second, so a placement policy works entirely in the window’s own coordinates — the same space an anchor and a hit test are already in.

Both return Optional, because some drivers will not say and a headless backend has no desktop. When either is absent the window’s own bounds stand in: a popup kept inside its owner is always on the screen, which is a worse answer and not a wrong one.

HeadlessBackend has a pretend desktop of 1920×1040 — 40 logical pixels reserved at the bottom, so a test that confuses the work area with the display’s size fails. The window can be moved about on it, because a placement policy is only interesting near an edge and “near an edge” needs an edge.

The keyboard belongs to the open popup

A popup’s router focuses its first focusable node after its first frame — a menu whose first item is not focused answers Down by focusing the first item, one keystroke later than every menu anywhere else.

And keys the owner window receives are forwarded to the topmost open popup before its own router sees them. Window.InputWatcher.keyPressed returns a boolean now: true takes the key. This is not belt-and-braces — whether a popup has the platform’s keyboard focus is per-driver (SDL gives a POPUP_MENU window focus on some and not on others, and a tooltip must never have it), so without forwarding an arrow key would move the selection in the window underneath the menu on half the platforms.

Escape is taken by the watcher itself and closes the popup, which is the one key that belongs to no widget.

popover is the panel, not the opening

The widget in :widgets is the surface: background, border, radius, padding, and a class="menu" shape for items that fill the width. Where it goes and when it goes away is Host.popup, and that machinery serves tooltip, select and menu equally — none of which is a popover, so it does not live inside one.

Being a widget is what makes it themeable, density-aware and writable from a document; being only the panel is what stops three other widgets from having to be popovers to get placement.

Alternatives considered

  • A maxWidth on the box, measured once. The clean version of the two-pass measure, and it needs max-width in §8’s subset, which does not have it. Adding a CSS property to avoid a second Yoga pass over forty nodes is the wrong trade.
  • Placement inside Popup. It would make Popup need the work area, the window position and the anchor, which is three things it otherwise never touches — and would make the arithmetic untestable without opening a window.
  • Flip by choosing the side with more room. Predictable-looking and unpredictable in use: a dropdown near the middle of a tall screen would open upwards or downwards depending on pixels nobody is looking at.
  • Let the platform place it. Windows and X11 both have menu-positioning conventions; SDL exposes none of them, and the three platforms disagree about flip behaviour. A toolkit that inherited that would have three menus.
  • Move focus to the popup window and let the platform route keys. It is what the flags ask for, and it is not reliable: it depends on the driver, it is wrong for a tooltip by specification, and it makes “did my menu get the keys?” a question about the window manager.

Consequences

  • select, menu and tooltip are ordinary widget work now. Each still owns its own model, its item semantics and its keyboard map, and none of them has to solve size, position or dismissal.
  • A popup still cannot scroll. A menu taller than the work area is clamped and loses its bottom. scroll is docs/core-widgets.md §1’s and unbuilt, and it is the one thing between here and a select over a realistic option list.
  • BackendWindow grew two calls, both Optional, and libgoldberry exports two more SDL symbols.
  • Nothing re-places an open popup. Move the window with a menu open and the menu stays where it was put — Popup.move exists, and nothing calls it. A popover that follows a scrolling anchor is the case that will need it.
  • Two popups still do not know about each other, so a submenu chain is menu’s to arrange: the launcher’s light dismissal closes all of them at once.

ADR-0105: A tooltip is an attribute, not a widget

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/core-widgets.md §7, builds on ADR-0104, extends ADR-0092’s rule about what :core may ship

Context

docs/core-widgets.md §7 describes tooltip in one sentence, and every clause of it decides something:

tooltip — attached by attribute (tooltip="…") to any widget; shows on hover and on keyboard focus after delay; never focusable itself; plain text v1.

“Any widget” is the awkward part. Everything else in the catalog is a node somebody writes; this is a property of every node that exists, including the ones in an application’s own module. And “after delay” needs something the toolkit did not have at all: a timer.

Decision

The text lives on Attributes

Attributes already carries what every widget has and no widget decides — id, class, the reconciler’s key. A tooltip is the fourth of those, and putting it anywhere else means every widget in the catalog carrying a field it never reads, with thirty chances to forget one.

The record gained a component and kept its three-argument constructor, because new Attributes(id, classes, key) appears in every widget and most of their tests; a fourth positional null in four hundred places would be a worse change than the one it avoids.

Every wither had to be revisited, and that was not obvious: id(), classes() and key() all rebuilt the record and would have silently dropped the new component. new Target(...).tooltip("Save").id("target") lost its tooltip, and the symptom was a tooltip that never appeared — no error, nothing in a log. The test that found it was already written and failing for what looked like a timing reason.

The loop grew a timer

EventLoop.after(delay, action) runs something on the UI thread later, and shortens the next pump so the loop wakes for it. It is the loop’s because the loop is the thing that is asleep: a delay implemented by sleeping elsewhere would fire on time and then wait up to a second for the pump to come back and notice.

Two consumers are named in the specification — a tooltip’s delay and a submenu’s hover intent — and a toast’s timeout is the third.

The router says when, the launcher says what

The router knows what is hovered and what is focused, and opens nothing: it has no window and no notion of one. The launcher owns the window and can open popups but does not see input. So the router gained one hook, onPointingChanged, and the launcher does the rest — cancel the pending delay, start a new one, and on firing open a tooltip popup anchored to the element’s painted rectangle.

One listener, not a list. A second would be a second thing deciding what a hover means.

The target is found by walking upwards from the hovered element, because a tooltip on a button has to survive the pointer being over the button’s label — which is a different element, and the one a hit test reports.

Hover wins over focus when both have one: reaching for the mouse is a more recent statement of intent than the last thing tabbed to.

The plate is a :core widget, and that is an exception with a reason

TooltipPanel is in :core, which ADR-0092 emptied of widgets. The exception is WindowRoot’s: nothing asks for this one. There is no call site an application could pass a widget to — the toolkit decides when a tooltip appears — and the thing deciding is the launcher, which cannot see the catalog.

So tooltip is CSS-selectable and not KDL-constructible, like window-root and like a part. Its appearance lives in controls.css with everything else that has one, because where a type is declared and where it is styled are different questions.

It is never light-dismissed

A tooltip closes when the pointer leaves or focus moves, and not on a press. A press that closed it would fire in the same gesture as the click on the thing it is describing — closing it a moment before it was going to close anyway, and taking the next tooltip’s timer with it.

It also cannot end up under the pointer, by construction: it is placed outside the anchor’s rectangle and the pointer is inside it, whether it opens above or flips below.

Alternatives considered

  • A Tooltipped interface a widget implements. Type-safe, and it makes “attached to any widget” false: every widget in the catalog would have to implement it, and a widget in an application’s own module would have to know to.
  • A tooltip widget wrapping its target, as some toolkits do. It puts an element between a node and its parent, so panel > button stops matching a button with a tooltip — the same argument that keeps bind on the widget rather than on a wrapper (ADR-0062).
  • The application opening its own tooltips, with the toolkit supplying only the popup. It is a line of wiring per widget, and it puts the delay, the cancellation, the anchor and the dismissal in every application.
  • Sleeping on a virtual thread instead of adding a timer to the loop. It works and it is one line; it also fires into a loop that may be parked for another second, which turns a 500 ms delay into anything up to 1.5 s.
  • A tooltip in the in-window overlay layer rather than a popup window. Free of the platform, and clipped to the window — so a tooltip on a control near the bottom edge is cut in half, which is where tooltips most often are.

Consequences

  • Attributes has four components, and any code constructing one positionally with four arguments now has to mean it. The three-argument form still compiles and means “no tooltip”.
  • The event loop has a timer, which menu needs next for hover intent and toast will need for its timeout.
  • PointerRouter has one listener slot. If a second consumer appears, this is where it will need a real listener list — and a decision about what it means for two things to react to one hover.
  • A tooltip does not follow the pointer, does not have a maximum width beyond the window’s, and is plain text — all three as specified for v1, and all three things docs/core-widgets.md §7 will want revisited when rich content arrives.
  • The delay is 500 ms and is not configurable. §7 says “after delay” and does not say how long; a --gb-tooltip-delay token is a design-system question rather than an implementation one.

ADR-0106: A menu is a widget, and opening one is not

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/core-widgets.md §8, builds on ADR-0104 and ADR-0105, applies ADR-0073’s pattern to a tree that spans windows

Context

docs/core-widgets.md §8 gives a menu item four things — “label, optional icon, accelerator (displayed right-aligned and auto-registered in the window’s shortcut map), checkable items, disabled state, nested submenus (hover-intent timing)” — and one of them cannot be a widget’s own business.

A submenu is a second platform window. Opening it needs a Host: something has to measure the panel, place it beside the row that owns it, ask the platform, and close it again. A widget has no Host and must not have one — it is a value, described afresh every frame, and one holding the window it is drawn in would be describing its own surroundings.

The same is true of the outer menu, and of what happens when a command is chosen: choosing “Save” from a submenu of a submenu has to leave nothing on screen, which is a fact about a stack of windows that no item in one of them can see.

Decision

menu, item and separator are widgets. Menus.open(host, anchor, menu) is a call.

Menus.open(host, "file-button", new Menu(
        new Item("Open…", this::open).accelerator("Ctrl+O"),
        new Separator(),
        new Item("Recent").submenu(
                new Item("notes.txt", () -> open("notes.txt")))));

The widgets are ordinary: a document writes them, a stylesheet styles them, and they draw in whatever they are put in. The opener is where the Host is.

The opener rebuilds the tree, which is how composites work here already

Menus.open walks the menu’s children and hands each item the thing only an opener knows — a way to open its own submenu, and a command wrapped in “and close the stack”. That is exactly what radio-group does to its radio children (ADR-0073): the composite supplies what the child cannot know, and the child stays a value.

Items get a generated id if they have none, because a submenu is anchored to its row and an anchor is looked up by id. An author writing a menu should not have to name every row that happens to lead somewhere.

A submenu is anchored inside a popup, which needed a second anchor

Host.anchor answers from the main window’s geometry and knows nothing about what is in a popup. So Popup gained one of its own, returning the rectangle in the owner window’s coordinates — translated by the popup’s own offset, because that is the space Host.popup places in. Without the translation a submenu opens at the right place relative to the wrong origin, which looks like a placement bug and is a coordinate-space one.

Every command closes the whole stack; a submenu closes its siblings

Two rules, and each is what a menu does everywhere. Menus keeps the stack of popups it opened: a command closes all of them, and opening a submenu first closes everything opened after its parent — so travelling down a menu past three rows with submenus leaves one open rather than three.

Hover intent is 150 ms, from the loop’s timer (ADR-0105), and one pending timer is shared: there is one pointer, so two menus cannot both be being hovered, and a per-menu timer would let a submenu open after the pointer had moved to a different menu entirely.

The keyboard: vertical scope, and Right is not traversal

A menu is a vertical focus scope. Up and Down move between items; Left and Right are deliberately not traversal, because in a menu they mean “open that submenu” and “close this one”, which is the item’s business (ADR-0078 is the record of a scope having an axis, and this is the second widget to need the narrow one).

Escape belongs to the popup, not to any item, and already worked (ADR-0104).

The tick column is always there

item-check is a part, built checked or not. A column that appeared with the first tick would shift every label in the menu sideways the moment one row became checkable — and a node that only exists while something is on cannot transition, which is radio-dot’s reason (ADR-0065).

The accelerator is displayed and not registered

§8 asks for both: “displayed right-aligned and auto-registered in the window’s shortcut map”. This is the display half.

Registration needs a lifetime a menu does not have. A menu is built when it opens and thrown away when it closes; a shortcut has to work when the menu is shut, which is the whole point of one. Something would have to own the menu for longer than one opening — a menu model the window keeps — and that is a design §8 does not describe. Recorded rather than guessed at.

Alternatives considered

  • A Menu widget that opens itself, holding a Host or reaching a global. It makes a widget stateful about its surroundings, and two menus built from one description would fight over which window they are in.
  • A menu drawn in the in-window overlay layer. No popup, no placement, no second window — and clipped to the window, so a menu near the bottom edge shows half its items. That is the distinction ADR-0100 and ADR-0102 exist to draw.
  • Submenu items as children of the item’s element. They are in a different window with a different render tree; making them children would mean a tree spanning two surfaces, which RenderTree has no notion of and should not.
  • A submenu node in KDL. Nesting item inside item is the syntax, so there is no second node to forget and no way to write a submenu that is not one.
  • Opening a submenu instantly on hover. What the first version did, and it is wrong in the ordinary case: travelling down a menu opens every submenu on the way past.

Consequences

  • menubar is not built. §8’s in-window bar with Alt activation is the remaining widget in the group, and it is the one that needs a menu to exist for longer than one opening — the same thing accelerator registration needs.
  • Context menus are not built. §8 gives any widget context-menu="menuId", which needs a registry of menus by id and a right-click path into it. The attribute would ride on Attributes exactly as a tooltip’s text does.
  • A keyboard Right waits 150 ms, because it goes through the same hover-intent path. Wrong, and one line to fix once Item can tell a hover from a keypress.
  • Host grew after(delay, action), which an application can use for anything and which is what the tooltip already used privately.
  • GoldberryTestAccess moved to test fixtures, so :widgets tests can drive the real launcher against the headless backend — which is what the four tests behind this record do, with real posted clicks rather than direct calls.

ADR-0107: A tab strip is a model, a header and a panel

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/core-widgets.md §5, applies ADR-0063 and ADR-0073 to a set whose membership changes, confirms ADR-0097’s finding about §8’s border properties

Context

docs/core-widgets.md §5 asks for “top placement v1; keyboard (arrows between tabs, Tab into content); closable tabs optional; lazy content instantiation; selected tab is retained state”.

A tab strip is the fourth exactly-one set in this catalog — after radio-group, segmented and select — and the first where the set itself changes: tabs are closed and opened while the control is on screen. That turns out to need no new mechanism at all, and the reason is worth writing down.

Decision

Adding and removing need no API, because the list is the application’s

tabs reads which tab is selected through bind and reports what the user asked for through change, exactly as radio-group and segmented do (ADR-0063). Extending that to membership is the same sentence twice more:

  • close asks for a tab to go. The strip removes nothing.
  • new asks for one to arrive. The strip adds nothing.

A strip whose close handler does nothing keeps its tab, which is the visible form of “the model did not change” and is where the bug is when a tab will not close. There is no addTab, no removeTab and no internal list, because a control that owned its own tabs would be a control an application has to keep in step with the thing the tabs are of.

new is not in §5 — it asks for “closable tabs optional” and says nothing about adding — but a strip that can lose tabs and never gain them is half a control, and the alternative is every application drawing its own + and lining it up with the row by hand.

A colour is a value, and it is the first one in the catalog

tab colour="#bf616a" is the one place a widget here takes a colour as data rather than from a stylesheet. The justification is that a stylesheet cannot know it: a tab coloured after the project it belongs to is application data, like a label, and there is no selector for “the tab whose project is red”.

It is written through restyle, which is the seam for exactly this — a value only the widget can know, applied where a transition can still see it (ADR-0099) — so a stylesheet still decides what the colour means: controls.css puts it on the label and on the underline, and a tab given none is styled entirely by the theme. The syntax is CSS’s, parsed by the CSS engine’s own colour parser, because an author who knows how to write #bf616a in a stylesheet should write it the same way here.

Lazy content is lazy by omission

tab-panel holds the selected tab’s content and nothing else: an unselected tab’s widgets are never built into elements at all. Nine background tabs cost nine headers.

The consequence is the one to know, and it is collapse’s: a tab’s content is rebuilt when it is selected again, so anything that must not be lost — a scroll position, a caret, a half-typed form — belongs in the application’s model rather than in the subtree. Cheap to rebuild is what the widget tree is for (ADR-0004).

The underline is a box, and the golden image is what said so

The first version wrote border-bottom: 2px solid transparent on a tab and border-bottom-color: currentColor on the selected one. Both were silently dropped: §8’s subset has one border property covering all four edges and no currentColor at all — which is ADR-0097’s finding about per-corner radii, in a second corner of the same subset.

Every number in the layout was correct and the underline was simply not there. The golden image is what found it, which is now the fifth occasion.

So the underline is tab-indicator, a 2px box pinned across the bottom of the header and out of flow, and the rule under the row is tab-rule, the same shape across the list — segmented-indicator’s anatomy for the same reason. The indicator is always built, selected or not, so that it can transition rather than appear (ADR-0065), and it is listed before the label so the label is painted over it.

The rule belongs to tab-list and not to tabs, because it has to stop where the headers stop: on the outer box it would run under the panel as well.

One Tab stop, and the × is not in it

HORIZONTAL, because a top-placed strip is a row and Up/Down belong to whatever is above it (ADR-0078).

tab-close is deliberately not focusable. A focusable × would make a strip two stops per tab — nine tabs would be nineteen stops between the strip and the content. The keyboard’s way to close a tab is Delete on the tab itself. The + is focusable, and the difference is that adding a tab is a destination the roving selection should reach, where closing one belongs to the tab it is on.

Two new marks rather than two icons

CROSS and PLUS join CHECK, DASH, DOT and ARC in the painter. At eight to ten logical pixels inside another control, an icon’s metrics and lookup buy nothing: what a close × has to do is line up with the glyph beside it and take the colour of the thing it closes, and a mark does both for free.

Alternatives considered

  • A strip that owns its tabs, with addTab/removeTab. It is what most toolkits do, and it puts the list in two places — the control’s and the application’s — with no rule about which wins when they disagree.
  • closable on the strip rather than on the tab. A file that cannot be closed because it is unsaved sits next to nine that can; §5 says “closable tabs optional” and the option is per tab.
  • A colour as a class — tab class="danger". Right for a meaning and wrong for an identity: there is no fixed set of project colours, and a stylesheet cannot enumerate one.
  • Keeping every tab’s content in the tree and hiding the unselected ones. It makes reselecting a tab free and makes nine tabs cost nine subtrees, their subscriptions and their images. §5 asked for the other trade by name.
  • border-bottom. Not available, as above — and adding per-edge borders to §8’s subset to draw one underline would be a change to the CSS engine for a widget that can express it with a box.

Consequences

  • tabs is panel’s first widget, and card, group-box, split-pane, collapse, carousel, statistic and skeleton are still unbuilt.
  • A tab strip does not scroll. Enough tabs and the row overflows its window, because §1’s scroll does not exist — the same sentence that ends the popup work.
  • Nothing reorders tabs. §5 does not ask for drag-to-reorder, and the model shape here would take it without a change: the strip draws the list it is given.
  • CssColor.parse(String) is new, and is the door for every other value that arrives as text rather than as a parsed declaration.
  • A tab’s content is rebuilt on reselection, which is the cost of the laziness §5 asked for and is stated on tab-panel where somebody will read it.

ADR-0108: A context menu is a name on a widget

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/core-widgets.md §8, completes ADR-0106, follows ADR-0105’s shape exactly

Context

docs/core-widgets.md §8, one line:

Context menus — any widget takes context-menu="menuId"; opened by right-click or the keyboard menu key at the focused widget.

“Any widget” is ADR-0105’s problem again, and the answer is the same: it rides on Attributes, beside id, class, the key and the tooltip’s text.

What is not the same is who can act on it. A tooltip’s plate is a :core widget, because nothing else can draw it. A context menu’s is a menu — a catalog widget — and opening one means wrapping every item so that choosing it closes the stack, which is Menus’ (ADR-0106).

So the two halves of one feature sit on opposite sides of the module boundary. :core is the only thing that can notice the right-click: it has the router, which knows what is under the pointer, and the window, which is where a popup goes. :widgets is the only thing that can turn a name into a menu.

Decision

The toolkit finds the name; the catalog says what it means.

Host.onContextMenu(handler) is the seam, one sentence wide: the launcher walks up from what is under the pointer on a secondary press, and hands over the name it found and the point the click landed at. Menus.contextMenus(host, menus) is the line an application writes:

Menus.contextMenus(host, Map.of("row", rowMenu(), "canvas", canvasMenu()));

The press is taken

InputWatcher.pressed returns a boolean now, and a context menu opening returns true — so the press does not also travel to whatever it landed on. Right-clicking a button should open its menu, not press it.

The same watcher is what light dismissal uses, which is why it learned the button and the position: light dismissal only needs to know that a press happened, and this needs to know which button and where.

Anchored to the pointer, not to the widget

The rectangle handed over has no size. Two right-clicks in one list open two menus in two places, which is what every desktop does — anchoring to the widget would put both menus at the top-left corner of a list a thousand rows long.

Walked upwards

A right-click on a button’s label is a right-click on the button. The same walk a tooltip does, for the same reason: the element a hit test reports is the deepest one, and the attribute is usually on something above it.

An unknown name is logged and ignored

Unlike press=, which is a compile-time-ish failure at inflation (ADR-0051’s strict registry). The difference is when it is discovered: a press= typo is found when the document loads, and a context-menu= typo is found on a right-click, where throwing takes the window down. A menu that does not appear is a smaller failure than an application that stops.

Alternatives considered

  • A registry of menus in :core, so the launcher opens them itself. It cannot: a menu is a :widgets widget and opening one is Menus.open, which wraps every item. :core would have to depend on the catalog it deliberately does not ship (ADR-0092).
  • A context-menu widget wrapping its target, which some toolkits do. It puts an element between a node and its parent, so panel > button stops matching — the argument that keeps bind and tooltip on the widget rather than on a wrapper.
  • The widget holding the Menu itself rather than a name. A widget is a value rebuilt every frame; holding a menu means rebuilding a menu every frame for the 99.9% of frames where nobody right-clicks anything.
  • Throwing on an unknown name. See above: the discovery point is a right-click, not a load.

Consequences

  • The keyboard menu key does not open one. §8 asks for “right-click or the keyboard menu key at the focused widget”; the key half needs Key.MENU in the key map and an anchor from the focused element’s rectangle — which Host.anchor’s element-wise form would give, and which does not exist yet.
  • A right-click still does not select what it is over. Every file manager selects the row you right-click before opening the menu; that is the application’s to do in its handler today, because the toolkit has no notion of what “select” means for an arbitrary widget.
  • Attributes has five components. Every wither has to preserve all of them, which the tooltip’s arrival is the reason anyone now checks (ADR-0105).
  • menubar remains unbuilt, and it is now the only part of §8 that is: it needs a menu that outlives one opening, which is the same thing accelerator registration needs (ADR-0106).

ADR-0109: A tab arrives and departs on the frame clock

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/design-system.md §1.7, docs/core-widgets.md §5, extends ADR-0107, applies ADR-0081’s shape to something that is not a loop

Context

Three things were wrong with tabs as it shipped, and each turned out to be a different lesson.

  1. A tab added or closed did not appear until the window was resized.
  2. The + was 28 wide and 20 tall, so the mark drawn to fill it had a long arm and a short one.
  3. Nothing animated, and §1.7’s enter/exit lifecycle had been a specification with no subject since it was written.

Decision

A structural change needs something to subscribe to

The first was not a tab bug. ShowcaseModel held its tabs in a plain List, and a plain list is not something a widget can watch: the toolkit rebuilds a subtree when something it subscribes to changes, and nothing subscribed. Everything else in that window is a value reaching a bound widget, which needs no rebuild — and this is the first thing in it that changes the shape of the tree.

So the list is a Property<List<String>> and the pane that builds the strip subscribes to it, which is exactly what showProse and clicks already did for the other two structural changes in the showcase. Replaced rather than mutated, because a subscriber is subscribed to the value and a list changed in place is the same value.

The resize was a red herring twice over: it made the tabs appear because a resize re-lays out from a tree that was rebuilt for another reason, and it made the bug look like a layout problem.

A mark fills its box, so a box for a mark is square

The + and the × are painter marks rather than icons (ADR-0107), and a mark is drawn to the box it is in. A 28×20 box is a cross with unequal arms. The two numbers are one number now.

The margin that would have spaced it from the last tab is not in §8’s subset — the third property this widget has reached for and not found, after border-bottom and currentColor. The list’s own gap does it instead.

An arrival is a function of the clock, not a transition

Everything else that moves in this catalog moves between two styles the cascade resolved (ADR-0067). Neither half of this can be:

  • A tab arriving has no two styles. Its element did not exist last frame, and the first frame of a newly built element starts nothing (ADR-0065).
  • A tab leaving is worse. The application has already dropped it from its list — that is what close asking rather than doing means (ADR-0107) — so without something holding on there is nothing left to animate.

So it is spinner’s shape (ADR-0081): a function of the frame clock, read in render, which is the only place a widget is given one. What a spinner does not need and this does is a beginning, and the first read of the clock is what stamps it.

Tabs therefore has state, and it is one thing: which tabs are arriving, which are leaving, and — for the leaving ones — what they last looked like, because the application no longer has a description to give.

Opacity and a translation, and nothing else

§1.7’s whitelist is the compositor-cheap set. A tab that animated its own width would run Yoga on every frame of every arrival and reflow the row beside it. So a tab appears in its final place and fades up into it, which also makes a departure the same animation backwards.

Under reduced motion there is no animation at all — §1.7 asks for movement to be removed rather than shortened.

The phase is passed as functions, because the record is public

Tab is exported and TabPhase is not, and a public record cannot have a component of a type nobody outside the module can name — the compiler says so, which is the module system doing its job. So a tab is handed a BooleanSupplier and a DoubleUnaryOperator: are you animating, and how visible are you at this time. Reading the second is what starts an arrival and what finishes a departure.

The model node and the styled node are two nodes

Making Tabs stateful and leaving it Styled put two tabs nodes in the cascade, one inside the other, so every rule in controls.css applied twice — a doubled padding waiting to happen. Tabs is a composition node now: it holds the model, and the TabStrip it builds holds the appearance, the CSS type, the attributes and the focus scope.

Alternatives considered

  • Animating with a transition anyway, by building the tab one frame before showing it. It is a frame of latency on every arrival, and it does nothing at all for departures.
  • Keeping closed tabs in the application’s list until the animation ends. It makes every application implement the lifecycle, and makes “which tabs are there” a question with two answers.
  • A Clock on the state instead of reading the frame clock in render. The state would then be on a different clock from the renderer, so a golden image of a half-finished arrival — which is how the four tests here work — would be impossible.
  • Animating height or width, which is what a strip that slides tabs open would do. Off §1.7’s whitelist for the reason the whitelist exists.

Consequences

  • The frame after an arrival finishes is still painted. Whether a node animates is read before it is drawn, and drawing is what advances the phase — so the frame that completes an arrival still reports itself as animating and the one after it does not. One frame, once, and the test says so rather than hiding it.
  • A departing tab answers nothing. It is not in the application’s list any more, so it has no select and no close: picking it would report a value that does not exist.
  • This is the toolkit’s first enter/exit animation, and toast, dialog and popover want the same thing. What is here is deliberately a tab’s own — a shared TabPhase promoted to an overlay lifecycle is the next step and should wait for its second consumer.
  • Reordering is still not animated, and would be a different animation: a tab that moves has two positions and no geometry to interpolate between them, which is ADR-0097’s problem again.

ADR-0110: The showcase is a gallery of screens

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/core-widgets.md’s cross-cutting notes, docs/ARCHITECTURE.md §14, uses ADR-0107 and ADR-0093

Context

docs/core-widgets.md asks for a gallery in one sentence, and the sentence has a rule in it:

Gallery app exercises every widget in every state in both themes; golden-image CI runs the gallery matrix. A widget isn’t done until it’s in the gallery.

What existed was a sidebar — one document holding every control the catalog had — beside a pane of prose. That worked at four controls and was failing at eleven: the file had become a list rather than a demonstration, sidebar.kdl was the place a new widget went because there was nowhere else, and nothing in the window was about anything.

Decision

A window is a title bar and a gallery: one tab strip, and a screen behind each tab.

ScreenWhat it is aboutWhere it lives
Controls§3’s controls whose value is a statecontrols.kdl
Values§3’s controls whose value is a numbervalues.kdl
Text§2’s wrapped paragraph, and buttons that act on the modelContent.java
Overlays§7’s two places something can floatoverlays.kdl
Tabs§5’s strip, gaining and losing tabsTabsDemo.java

A screen is a file

Adding a screen is a file and a line. That is the whole of the structural argument: a document that is about one thing can say what that thing is, and the gallery’s own Screen names five of them and knows nothing about what is in any.

Three documents, two Java panes, and which is which is the point

The split is not about appearance — every screen looks the same kind of thing — it is about what §8’s markup cannot say:

  • Text is Java because Undo and Reset are disabled when the click count is zero, and markup has no expressions. disabled=#true is a constant, and a document that could evaluate clicks == 0 would be code in a data file with no stack trace.
  • Tabs is Java because its list changes: tabs are added and closed while the window is open, and KDL is data. It can write three tabs, not “however many the model has”.

Everything else is a document, because everything else is bind= and change=.

The gallery’s selection is an ordinary bound value

tabs reads which screen is showing through bind and reports change, like every other control (ADR-0107). So Ctrl+1…Ctrl+5, the strip itself, and anything else that wants to are three ways to set one property rather than three copies of a selection.

The gallery’s strip is deliberately fixed — five screens, none closable, no + — and the Tabs screen is where a strip that gains and loses tabs is demonstrated. A gallery whose own chrome could be closed would be a gallery you can break.

Only the selected screen exists

§5’s lazy content (ADR-0107) means four of the five screens are not in the element tree at all. That is what keeps a five-screen window as cheap as the one-pane one it replaced, and it is why switching screens costs a rebuild of one screen rather than of the window.

The cost is the documented one: a screen is rebuilt when it is selected again, so anything that must survive belongs in ShowcaseModel — which is where all of it already was.

GalleryGoldenTest paints one image per screen, plus one on the light theme. ShowcaseDocumentsTest asserts the documents’ shape — every control present, every bind= reaching the model — and could not tell you whether a screen renders at all; these can.

Two things had to be true for that to work, and neither was:

  • :example’s test JVM did not know where the native library was, so every golden test skipped. A green build that checked nothing, which is the failure mode a skip always has.
  • The Values screen has a spinner on it, whose rotation is a function of the frame clock rather than of a transition (ADR-0081). Against the system clock the image failed by 113 pixels with a channel delta of 144 — a spinner caught a few degrees round. The renderer takes Clock.virtual(), which is the frame every machine gets.

Alternatives considered

  • Keeping the sidebar and adding tabs beside it. Two navigation structures in one window, and the sidebar would still be the place a widget goes because there is nowhere else.
  • One screen per widget. Thirty tabs is a list again, with a scroll bar the toolkit does not have. The five groups are core-widgets.md’s own §-boundaries, which is the grouping a reader already has.
  • A screen per document with no Java screens at all, by teaching markup an expression or two. That is the change §8 exists to refuse, and the two Java screens are worth more as the demonstration of why it refuses.
  • Building all five screens and hiding four. It makes switching instant and makes a five-screen window cost five subtrees, their subscriptions and their animations — including a spinner that would keep the frame loop awake from a screen nobody is looking at.

Consequences

  • sidebar.kdl is gone, and with it the last place a widget could be added without deciding what it is about.
  • The gallery has no scroll, so a screen taller than the window loses its bottom — §1’s scroll, again, which is now the missing widget behind the popup work, the menu work and this.
  • Six golden images of the example, which is the first visual coverage the showcase has ever had; before this, a screen that rendered blank passed every test it had.
  • A widget is not done until it is on a screen, and there is now a screen for it to be on: select belongs on Controls, text-input on a Forms screen that does not exist yet, dialog and toast on Overlays.

ADR-0111: A text box is painted inside its padding

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/ARCHITECTURE.md §8, docs/core-widgets.md §7, corrects ADR-0036’s painting site, follows ADR-0105

Context

Five things were reported against one window, and they turned out to be five different faults rather than one:

  1. Warning spam: dropping "transition": color 100ms ease-out, and ignoring unsupported property "align-self".
  2. A menu popup with black corners where its radius cut them.
  3. A tooltip whose text was not vertically aligned, and which “looks poor”.
  4. The cursor changing from a hand to an arrow the moment a tooltip appeared.
  5. The + in a tab strip not aligned with the tabs beside it.

Decision

1. The vocabulary was wrong, not the engine

ease-out is not one of §1.7’s easings — they are ease-enter and ease-exit, named for what they do rather than for a curve — and background is not a transitionable property, background-color is. Two rules in controls.css got both wrong and the engine said so, once per node per frame.

align-self is genuinely not in §8’s subset, and adding it is a 20-component record change to ComputedStyle and Box for one +. The row says align-items: center instead, which every header wanted anyway. The gap is recorded rather than filled.

2. A popup is transparent, and its corners are nothing

A popup’s panel has a radius; what is outside it is nothing, and nothing was being presented as an opaque buffer that had never been cleared — black.

Popups are created with SDL_WINDOW_TRANSPARENT and their frame is cleared to 0x00000000 before painting. The surface format was checked rather than assumed: X11 hands back SDL_PIXELFORMAT_ARGB8888 for these windows, so the alpha survives the blit. It also fixes a second thing nobody had noticed: a popup that shrinks no longer leaves the previous frame in the margin.

3. A text box was painted outside its padding

The real defect, and the oldest. BoxPainter drew a paragraph at the box’s own origin and wrapped it at the box’s full width:

box.text().paragraph().paint(frame, x, y, width, box.text().argb());

Yoga sizes a measured leaf as its measured content plus its padding. So a box with padding: 6px 10px around text is 20px wider and 12px taller than its text — and every one of those pixels ended up on the right and the bottom, with the text hanging off the top-left corner. Magnified 3×, the tooltip’s glyphs were visibly outside their own plate.

Nothing had hit it before because every other widget in the catalog puts text in a child box rather than on its own box — button, option, badge and the rest, for the reason Option gives: a box with text is a measured leaf, and Yoga never lays out a measured node’s children, so a control that held its own text could not also hold an icon. TooltipPanel is the first widget with padding and text on one node.

The painter resolves the padding and paints inside it. Every other golden image in the corpus is byte-identical afterwards, which is the evidence that this touched exactly the case that was broken.

4. X11 says the pointer left a window when a window is mapped over it

The cursor changing was not the router: headlessly the same sequence keeps both the hover and the pointer cursor, which is what pinned it on the platform. Opening a tooltip maps a window near the pointer, X11 reports a leave for the window underneath, and delivering it clears the hover and the cursor of the very widget the tooltip is describing.

Window.InputWatcher gained exited(), and the launcher swallows an exit that arrives within 250 ms of opening a tooltip. Bounded rather than a flag that waits for the next exit: on a driver that sends no spurious exit at all, a flag would swallow the user’s real one whenever it finally came.

5. The + sat at the top because nothing centred it

align-self: center was ignored (see 1), and align-items: stretch leaves a child with a definite height at the start of the cross axis. The row centres its children now.

Alternatives considered

  • Adding align-self to §8’s subset. A legitimate flexbox property and a real gap — and a 20-component record change in two records where every wither must be revisited, which is exactly the change that silently dropped a component two records ago (ADR-0105). Not for centring one +.
  • Giving TooltipPanel a child text box instead of fixing the painter. It is the pattern every other widget uses and would have worked — and would have left a painter that silently mis-draws any text box an application puts padding on.
  • Filling a popup’s frame with its panel’s colour rather than making the window transparent. Always works, on any driver, and gives square corners: the radius would be drawn and then filled in behind. Kept in reserve for a platform where transparency is refused.
  • Swallowing every exit while a tooltip is open. Simpler, and it strands the hover when the pointer genuinely leaves — the tooltip would stay up until something else moved.

Consequences

  • padding works on a text box now, which is a thing an application’s stylesheet could always write and never got.
  • A popup needs a compositor for its corners. Without one the flag is ignored and the corners are whatever the platform puts there — no worse than before, and not verified on Windows or macOS.
  • The 250 ms window is a heuristic, and the honest kind: it is bounded, it is named, and it is wrong only if a driver reports a real exit within a quarter of a second of a tooltip opening — which is a pointer that left while resting still enough to summon one.
  • tooltip has golden images — three, plus one magnified 3× — where it had none. “Looks poor” was a claim about twenty-two pixels, and the magnification is what turned it into a bug report.

ADR-0112: A menu follows the pointer, and lights for the keyboard

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/core-widgets.md §7 and §8, docs/design-system.md §2.2, corrects ADR-0106

Context

Two faults in the same menu, reported together, and they are opposite halves of one question — what does the pointer being somewhere mean?

  1. A submenu did not close when the pointer left the row that opened it. It closed when another row with a submenu opened one, and never otherwise, so moving down a menu left a submenu hanging over the rows below it.
  2. The first row always looked hovered, from the moment the menu opened.

And one cosmetic: the tooltip was too small to read comfortably.

Decision

Every row reports the pointer arriving, not only the ones with children

ADR-0106 gave an item an onOpenSubmenu and handed it only to rows that had one. That is the wrong half of the relationship: a submenu is closed by the pointer moving to a sibling, and most siblings have no submenu of their own.

So every row is handed onHovered, and the menu decides what arriving means:

  • a row with a submenu opens it,
  • a row without one collapses whatever this menu had open.

Both go through the one hover-intent timer, because they are one gesture: travelling down a menu past three rows with submenus opens none of them, and arriving on a plain row puts away what the row above had opened.

The renaming is the point. onOpenSubmenu described what the caller wanted; onHovered describes what the item knows. An item cannot know whether the pointer arriving should open something, close something or do nothing — that is a fact about the menu it is in.

Focus and the highlight are two things

Popup focuses a menu’s first row as it opens, so that an arrow key has somewhere to start. It did so through moveFocus(1), which reports the move as the keyboard’s — and controls.css lit item:focus. So every menu opened with a pointer had its first row picked out, which reads as a menu that has already chosen.

Two changes, and each is wrong without the other:

  • PointerRouter.moveFocus(direction, fromKeyboard) — the one caller that moves focus with nobody pressing anything says so.
  • item:focus-visible rather than item:focus — the highlight is the keyboard’s affordance, which is what :focus-visible has meant since §2.2 defined it.

The result is what every desktop menu does: open it with the mouse and nothing is picked out; press Down and the row it lands on lights up.

A tooltip is read at arm’s length

caption is §1.4’s size for secondary text under a control, where the reader has the control itself for context. A tooltip is the only text on screen at the moment it is read, and 11px of it is a squint. It takes body now, with 8px and 12px of padding rather than 6 and 10.

Alternatives considered

  • Closing a submenu on the item’s own pointer-exit. The obvious fix, and it flickers: the pointer leaves the row on its way into the submenu, which is a window of its own and reports nothing to the row it came from. Every menu that does this has a “safe triangle” heuristic to compensate; arriving on a sibling needs none.
  • Closing it immediately, without the intent delay. Travelling down a menu would open and close each submenu in turn, which is the flicker the delay exists to prevent — in the other direction.
  • Not focusing the first row at all until an arrow is pressed. Then the first Down has to mean “focus the first row” rather than “move to the next”, and the row it lands on depends on which of the two it was.
  • Keeping item:focus and having the launcher clear focus on a pointer move. It makes focus follow the pointer, so Enter would run whatever the mouse last passed over.

Consequences

  • A keyboard Right into a submenu still waits 150 ms, because it goes through the same intent path as a hover. Recorded when the timer was written and still true.
  • Left does not close a submenu. The arrow that opens one has no opposite yet: it needs a callback from the item to the popup it is in, which is one more thing Menus would have to wire.
  • A tooltip is bigger than a menu row’s text, which is deliberate and worth watching: the two are read in different circumstances, and if it starts to look heavy the answer is --gb-font-tooltip rather than going back to caption.

ADR-0113: A submenu is placed beside its menu, and a tick column is a menu’s

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/core-widgets.md §8, corrects ADR-0106’s two remaining geometry mistakes

Context

Two things about a menu’s geometry, reported together, and both are the same kind of mistake: a decision made per row that belongs to the menu.

  1. A submenu opened on top of the right-hand border of the menu it came from.
  2. Every row in every menu was indented by a tick column, whether or not anything in that menu could be ticked — and, once that was fixed, a row with an icon was still indented further than the rows above it, because the icon was drawn after the column rather than in it.

Decision

A submenu is placed beside the menu, level with the row

ADR-0106 anchored a submenu to its item, which is right for one axis and wrong for the other. An item’s right edge is a few pixels inside the menu’s — the panel’s padding, and its border — so Placement.AFTER put the submenu’s left edge inside the parent’s frame, overlapping the border it should have cleared.

The anchor is two rectangles now: x and width from the popup, y and height from the row. Popup.bounds() is the popup’s own rectangle in the owner window’s coordinates, beside Popup.anchor(id) which is a node inside it.

The gap is 2px: far enough that the two panels do not share an edge — which reads as one panel with a seam — and near enough that a pointer crossing it does not leave both menus and put the submenu away.

One leading column, holding a tick or an icon

The indent was reported twice, and the second time was the interesting one.

The first fault: the column was built for every row, always, on the argument that a column appearing with the first tick would shift every label sideways. That argument is right within a menu and was being applied to every menu in the toolkit — so a File menu with nothing checkable in it indented every label for a tick none of its rows could have.

The second, still visible after that was fixed: a row’s icon was drawn after the tick column rather than in it, so a row with an icon sat further in than the rows above it. The showcase’s own menu has both an icon row and a checkable row, which is why it still read as an unexplained gutter with a ragged left edge.

So there is one leading part, item-lead, with one width and three possible contents — a tick, an icon, or nothing. A menu reserves it when anything in it has something to put there, and then every row has one. That is what every desktop menu does, and what keeps the labels in a line.

A tick column is a menu’s decision, so checked has three states

The column was built for every row, always, on the argument that a column appearing with the first tick would shift every label sideways. That argument is right within a menu and was applied to every menu in the toolkit — so a File menu with nothing checkable in it indented every label by fourteen pixels and eight of gap, for a tick none of its rows could ever have.

The scope was wrong, not the rule. A menu reserves a column when anything in it is checkable, and then every row in that menu has one.

Which needs a third state, because “unchecked” and “not a checkbox” had been the same value. Item.checked is a Boolean: TRUE and FALSE are a checkable row that is on and off, null is a row that is not checkable at all. In markup, checked=#true and checked=#false are the first two and no attribute is the third.

A row that leads somewhere says so

While looking at the images: a submenu row was drawn exactly like a command. The chevron is a painter mark — CHEVRON_END, beside CROSS and PLUS — rather than Lucide’s chevron-right, for the reason those two are marks and one more: an icon owns native memory that must be closed exactly once (ADR-0043), and a menu is built and thrown away every time it opens.

Alternatives considered

  • Anchoring the submenu to the item and adding a bigger gap. It works until the panel’s padding changes, at which point the gap is wrong and nobody knows why — the number would be compensating for a different number in a stylesheet.
  • Overlapping the parent deliberately, which Windows does by a few pixels. It needs the parent’s border drawn over the child to look intentional, which two platform windows cannot do.
  • A checkable flag beside checked. Two booleans with three legal combinations, and the fourth silently meaningless.
  • An icon column beside the tick column. What the code did by accident, and it is what a row with both would need — except that no menu anywhere shows a tick and an icon on one row, because the tick is the row’s state and the icon is its identity, and a row has one leading thing.
  • Reserving the column only on rows that are checkable. The labels step in and out down the list, which is the thing the always-on column was avoiding.

Consequences

  • Item.checked() returns Boolean. An author reading it has to know that null means “not a checkbox”, which is the price of the three states being distinguishable at all.
  • Popup.bounds() is public, and is what any widget anchoring to a popup rather than to something in one will want — a submenu today, a tour stop later.
  • The menu has golden images, five of them, where it had none: a menu is drawn in a window of its own and appears in no other picture in the corpus. Both faults here were visible the moment there was one.
  • A submenu still has no keyboard Left to close it, and a chevron does not yet rotate or highlight when its submenu is open — the row is :focus-visible when the keyboard is on it and nothing marks “this one is showing”.

ADR-0114: A clip is a rectangle the painter carries

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/ARCHITECTURE.md §8 (overflow in the layout subset), §11 (“hit-testing … respects clips and transforms”), docs/core-widgets.md §1’s scroll; follows the reasoning of ADR-0068

Context

scroll is the single missing widget behind three separate pieces of work — a gallery screen taller than its window, a tab strip with more tabs than fit, and a select over a realistic option list — and every one of them is blocked on the same thing underneath: nothing clips.

The absence had already been reported three times from three different directions. An indeterminate progress bar reverses at the ends rather than sweeping off one and back in at the other, because the sweep needs overflow: hidden. A segment’s label overflows its cell when it is longer than 1/n of the bar, because nothing clips. A window narrower than its content overflows silently, because flex-shrink: 0 stops a control being squashed and does not stop it being cut off.

overflow is not a new property. It has been in §8’s layout subset since the subset was written, and Yoga’s binding for it — Overflow, YogaNode.setOverflow — has been sitting in :natives unused. What was missing is the other half: Yoga decides sizing from overflow and clips nothing, and there was no clip anywhere above it.

What the native side offers, and what it does not

Blend2D exports bl_context_clip_to_rect_d and bl_context_restore_clipping, both already on the export list — they are how a partial repaint confines itself to the damage rectangle (ADR-0072). Two properties of that pair decide this record:

  • clipTo intersects with the clip in force. It can narrow and cannot widen.
  • restoreClipping goes back to the whole surface — not to whatever clip was in force before.

bl_context_save / bl_context_restore are not on the export list, so there is no clip stack down there to push onto. A nested scroll view therefore cannot be expressed as “clip, recurse, unclip”: the inner viewport ending would take the outer one’s clip off with it, and the rest of the outer scroller would paint over everything beside it. Worse, the damage clip is the outermost one — so an unclip anywhere in the tree would silently widen a partial repaint into a full-frame scribble, which is the bug that only shows up on the frames nobody is looking at.

Decision

The clip stack lives in Java, and every change assigns the whole rectangle

Clip is a rectangle in the frame’s logical coordinates, held as four edges because every operation on it is an intersection. The painter accumulates it on the way down the tree, and the painter’s Painting state tracks what the context is currently set to — exactly as it already tracks the current transform matrix.

Each change is resetClip() followed by one clipTo of the accumulated rectangle. Never a bare clipTo to narrow and never a bare resetClip to widen, because neither of those composes.

This is ADR-0068’s conclusion reached from the other end. There, the stack was kept in Java so hit testing could invert the matrix it painted with. Here it is kept in Java because the native side offers no way to undo one clip without undoing all of them. The two now travel together down the same walk, which is the argument for them having the same shape.

Clip.NONE is infinite rather than “the frame”. An intersection with an infinite rectangle is the identity, so a tree with no clipping box in it needs no null checks and changes no context state at all.

The base of the stack is the damage, not “no clip”

RenderTree.paint(frame, damage) computes the damage rectangle and passes it in as the base. Every restore goes back to that rather than to the whole surface, so a scroll view inside a damaged region narrows the damage clip and widens back to it — never past it.

The clip is the padding box, and it applies to the children

A box is painted under its parent’s clip; its children are painted under its own. That is what CSS means by overflow: hidden — it clips the content of a box, not the box — so a viewport still paints its own background, its own border and its own focus ring, and only what is inside it is cut off. The rectangle is the padding box rather than the border box, which is CSS’s rule and is what keeps a viewport’s 1px edge crisp while rows slide past underneath it.

A transform maps the clip; a rotation bounds it

The clip rectangle is mapped through the accumulated matrix before it is intersected, so a translated viewport clips where it is drawn rather than where it was laid out. The four corners are mapped and their bounding box taken: exact for a translation or a scale, conservative for a rotation. A viewport rotated 45° clips to the square around its diamond and lets a little content show at the corners.

That is the honest cost of a rectangular clip. Blend2D’s is a rectangle; the alternative is clipping to a path, which is a different native call with a different cost, and nothing in the catalog has asked for a rotated scroll view.

An empty clip stops the walk

A subtree scrolled entirely out of its viewport cannot produce a pixel, so the recursion stops rather than visiting every box for Blend2D to clip away. This is the one place a clip saves the traversal as well as the rasterization — ADR-0072 noted that the damage clip does not, because the damaged node is somewhere inside a tree that still has to be walked to find it, and a scrolled-away subtree is different: it is contiguous, and it is known to be entirely outside.

The clip reaches hit testing, in the frame’s coordinates

HitTest.Region carries the clip and tests it first, before the transform inverse and before the rectangle.

This is not an optimization; it is the only correct answer. A row scrolled out of its viewport is still laid out exactly where it always was — Yoga never saw the scroll, which is a transform on the content — so its own rectangle happily contains a pointer that is nowhere near it on screen. Without the clip, a scroll view would be a list where invisible rows above the viewport still take clicks meant for the visible ones.

ARCHITECTURE §11 has promised since it was written that hit-testing “respects clips and transforms”. The transform half was true. This half had nothing to be true about until now.

The two walks — the painter’s and forEachPlacedBox’s — share one clipFor method rather than each deriving the rectangle. Two walks that have to agree exactly is how a pointer starts landing where the ink is not, and that failure is silent: the control looks right and simply does not respond where it looks like it should. The same argument HitTest already makes about the transform’s inverse.

auto resolves to scroll

CSS has four keywords and Yoga has three. auto sizes exactly as scroll does; the difference in CSS is whether bars appear only when they are needed. The design system routes that question elsewhere — §2.4 makes overlay auto-hiding bars the default for both, and gives the always-visible gutter to an application setting rather than to a keyword — so auto maps onto Overflow.SCROLL and nothing downstream carries a distinction no rule in the canon can act on.

A promoted layer is clipped at the blit

A layer is rasterized whole, in its own coordinates, and composited back with one blit. The clip in force outside is applied to the blit, not inside the raster: so the clip is set before the promoted branch is taken, not after it. A clip within the promoted subtree still applies, and lands in the layer’s own coordinates for free, because every rectangle below is derived from the shifted origin the layer walk is given.

Consequences

A clipping box costs two native calls, and a transform reset. Blend2D applies a clip in the context’s current user space, so the transform goes to identity before the clip is assigned — a clip set while a translated subtree’s matrix was in force would land at the translation twice over. The matrix the next box needs is re-assigned on its way through, which costs nothing it was not already paying. Only boxes that actually clip pay any of this.

A scroll view’s content must not shrink. Yoga’s default flex-shrink: 1 squashes a 200-tall child into a 50-tall parent, so a viewport built without flex-shrink: 0 on its content lays out perfectly, clips nothing, and has nothing to scroll. This was found by writing the test rather than by reasoning about it, and it is the same trap ADR-0076 documented from the other direction: there, shrink made every fixed metric in §3 negotiable; here, its absence is what creates the overflow in the first place.

A rotated viewport clips loosely. Documented above and asserted in the tests, so the day someone needs it exact the test fails rather than the picture being subtly wrong.

Nothing else in the catalog clips yet. The three faults that reported this absence — the progress sweep, the segment label, the silently overflowing window — are all now fixable and none is fixed here. Each is a change to that widget’s stylesheet or its box, and each belongs with the widget rather than with this.

overflow is not inherited and not animatable, which is CSS’s rule for it. Nothing had to be done to achieve that; it is recorded because the transition whitelist is a closed set and this is not on it.

115. A wheel reports a fraction and a detent

Date: 2026-08-18

Status

Accepted. Closes the second of the five disagreements in docs/ARCHITECTURE.md §17.1. Follows ADR-0056 (the wheel is lines, and the sign is ours) and ADR-0089 (a knob’s wheel is a rate).

Context

docs/design-system.md §2.4 asks for “pixel-precise wheel/trackpad deltas with line fallback”. What ships is lines. That has been recorded as a disagreement since the input layer landed and left open ever since, on the grounds that SDL exposes no pixel axis: Wayland’s wl_pointer.axis and macOS’s scrollingDeltaY both carry one, and SDL_MouseWheelEvent does not surface it. Reaching the design system’s wording means going around SDL to the platform.

scroll is the first widget that has to care, so the question is due. And reading the header again makes the shape of the answer clearer than “SDL has no pixel axis” suggested:

typedef struct SDL_MouseWheelEvent {
    ...
    float x, y;                 /* fractional */
    SDL_MouseWheelDirection direction;
    float mouse_x, mouse_y;
    Sint32 integer_x, integer_y; /* accumulated whole clicks (3.2.12+) */
} SDL_MouseWheelEvent;

There are two numbers per axis, and the toolkit has been reading one of them. x and y are fractional — a precision touchpad reports parts of a detent — and integer_x/integer_y are SDL keeping the running fraction itself and emitting a whole click when it crosses one.

Which of the two a consumer wants is not a matter of taste, and neither is derivable from the other:

  • A scroll view wants the fraction. Round it and a trackpad scrolls in jerks; that is the entire content of “pixel-precise” as a user experiences it.
  • A select stepping options, or anything else that moves in discrete steps, wants the click. And it cannot get one by truncating the fraction: a trackpad dragged slowly reports a long run of values that each truncate to zero, so a control that truncates per event never moves at all. The accumulation has to be kept across events, and SDL is already keeping it.

Decision

The delta stays in lines, and the SPI carries the detents beside it.

PointerWheel and PointerEvent gain ticksX/ticksY alongside deltaX/deltaY. The float is the fraction, normalized as before — positive down and right, un-flipped where the platform inverted it. The int is SDL’s accumulator, negated on the same axis and for the same reason, and passed through rather than derived. A consumer picks the one that matches what it means: a distance reads the float, a step reads the int.

Every path that cannot know better truncates — a synthesized event, a headless scrollPointer, a test poking the router — so the pair is always populated and the honest value is available wherever a real accumulator exists.

Lines are not becoming pixels. What a line is worth in pixels is the scrolling widget’s to decide, and it is decided where that widget is rather than in the event. It is not a token today, and that is a gap rather than a choice: nothing lets a widget read a resolved custom property, so a --gb-scroll-line would be a number an author could set and no widget could see. ADR-0116 carries the constant and book/src/TODO.md carries the gap.

Why not go to the platform for a real pixel axis

Because ADR-0056 already decided this, and the reasons have not weakened. Goldberry ships one native library and no hand-written platform backends; a pixel axis means Wayland, X11, macOS and Windows code paths, four places for “natural scrolling” to be got wrong again, and a second event route that the SDL one would have to be reconciled with. That is a large permanent cost for a property the fraction already delivers.

So §2.4 is satisfied in effect and not in mechanism, and this ADR is where the difference is written down rather than left as an open row in a table. What the design system wanted from “pixel-precise” is scrolling that does not quantize; that is what a fractional line delivers, and the only thing lost is that a line’s worth in pixels is the toolkit’s decision rather than the compositor’s. If a platform backend ever exists for another reason, the float becomes a pixel count, the token becomes 1, and no consumer changes.

Consequences

The wheel path carries two numbers per axis, and each one has exactly one right consumer. Reading the wrong one is a bug the documentation now names in both directions: truncating deltaY gives a control that never steps, and rounding it gives a scroll view that jerks.

Knob keeps reading the fraction. §3 calls its wheel a rate and ADR-0089 built it as one — a fast scroll moves it further — so it is not a detent consumer, and the Math.max(1, …) that makes a stepped knob move at least one step for any scroll at all stays what it was. The detent reader is select’s, when it lands.

SdlEventBuffer.writeWheel gains an overload that states the detents rather than truncating them, because the case worth testing — fractions that truncate to nothing and a click arriving part-way through — cannot be produced by any function of one event’s floats.

The integer_* pair is SDL 3.2.12 and later. The build pins 3.4.14 and the layout probe already declares both fields, so nothing new is being assumed about the struct; what changes is that the offsets are now read.

116. A scroll view is a clip, an offset and two extents

Date: 2026-08-18

Status

Accepted. Builds on ADR-0114 (the clip), ADR-0115 (the wheel) and ADR-0068 (the transform stack). Answers the geometry question ADR-0080 and ADR-0097 both stopped at.

Context

scroll is docs/core-widgets.md §1, and book/src/TODO.md named it three times in three unrelated entries: a menu taller than the work area is clamped and loses its bottom, a tab strip wider than its window overflows it, and select over a realistic option list cannot be written at all. One missing widget behind three pieces of blocked work is the definition of what to build next.

It is also the widget that finally forces a question the toolkit has walked around twice. A scroll view is arithmetic on two rectangles — how much taller its content is than its viewport — and a widget cannot measure either one. build and render both run before Yoga, which is ADR-0080’s finding; ADR-0097 hit the same wall from the other side, wanting the distance between two segments and finding that “a stylesheet cannot write it, because segments are as wide as their labels; and a widget cannot compute it, because build/render run before Yoga”.

Both of those ADRs found a way to avoid needing the number. scroll cannot: the clamp is the widget.

Decision

Three nodes, each of them one idea

scroll               the viewport. Clips, takes the wheel and the keys
└── scroll-content   the moving box. Translated by the offset
    └── …            whatever was written inside

scroll itself is a composition node: stateful, styling nothing, holding the offset. scroll as a CSS type is the viewport it builds, for exactly ADR-0109’s reason — a stateful widget that was also styled would put two scroll nodes in the cascade, one inside the other, and every rule would apply to both.

The offset is a transform, not a layout

The content is moved with transform: translate, which costs no layout. §1.7 already refuses to transition width and height because “animating width/height would run Yoga per frame”; an offset expressed as top or as a margin would run Yoga over the whole subtree on every wheel notch to move a box that did not change size.

It also makes hit testing come out right for free. The painter carries the accumulated matrix and the router inverts it (ADR-0068), so a row scrolled up by 200px is clicked where it looks, with nothing in the scroll view arranging that. And it makes the clip correct without a second mechanism: ADR-0114’s clip is intersected in the same walk, so content translated out of the viewport is cut at the viewport’s edge rather than painted over its neighbours.

flex-shrink: 0 on the content is the whole difference between a scroll view and a squashed one. Yoga’s default shrinks a child that does not fit, so content in a too-short viewport would be compressed to fit it and there would be no overflow left to scroll — negotiated away before it was ever measured.

The two extents arrive on the event

This is the part that is new machinery rather than assembly.

PointerEvent and KeyEvent each gained bounds() and part(), both Extents — a width and a height, resolved by the router out of the hit-test snapshot the last paint left behind. bounds() is the handling widget’s own box; part() is the box named by the existing Handles.localPart(). A scroll view names scroll-content, so it is handed its viewport and its content in the same event and the clamp is a subtraction.

Three things make this the right shape rather than a special case:

  1. It reuses a vocabulary that already exists. localPart() was built for a slider measuring a value along its track (ADR-0080). Nothing new is being named; what was missing was the other rectangle — a widget that pointed local() at a part had no way back to itself.
  2. It works for the keyboard, which is why it is on KeyEvent too. PageDown carries no position and needs both extents exactly as the wheel does. Nothing had ever put a size on a key event, and a scroll view that only worked for people with a mouse would fail §1’s “keyboard (PgUp/PgDn/Home/End/arrows when focused)” outright.
  3. It stays one frame behind, and that is honest. These are measurements of the last paint, not predictions. A viewport resized this frame clamps against last frame’s height for one frame — invisible in practice, and the alternative is a widget that computes layout, which is the thing three ADRs have now declined to build.

At the edge it lets go

A wheel or a key is consumed only when something actually moved. At the top of a list a further scroll up is left unconsumed and bubbles, which is §2.4’s “inner scroller consumes until its edge, then chains to the ancestor” — obtained from the router’s ordinary bubble path rather than from anything here knowing an ancestor exists. PointerRouter.pointerWheel now reports whether anything consumed the event, which keyPressed already did.

What a line is worth is a constant, and that is a gap

The wheel reports lines (ADR-0115) and a viewport moves pixels, so something has to convert. That number is ScrollViewport.LINE, a constant of 20 — three of which is the conventional notch, which is what the rest of the desktop does.

It should be a token. §3 says metrics ship as component-token defaults that an application may override, and this is plainly one. It is not, because nothing lets a widget read a resolved custom property: StyleResolver computes them for var() substitution and ComputedStyle does not carry them, so a --gb-scroll-line would be a number an author could set and no widget could see. Shipping a token with no reader is worse than shipping the constant, because the token would silently do nothing. The gap is in book/src/TODO.md; closing it is the same change that would let any widget honour any metric.

Consequences

Three blocked pieces of work are unblocked: a popup can hold a scroll view, a tab strip can scroll its overflow, and select over a realistic option list is now ordinary widget work. None of them is done here — this is the viewport they were waiting on, not their integration.

What is not built, and each is in book/src/TODO.md with what it waits on: scrollbars of any kind (§2.4’s overlay thumb, its hover-widening, its idle fade, its drag and its track-click paging), scrollIntoView, the “always show scroll bars” 12px gutter, and affix. The scrollbars are the largest and want a second commit rather than a bigger one; the rest each want a decision first.

Every widget in the catalog now sees bounds() on the events it handles, and most should ignore it. The temptation it creates is real — a control that starts laying itself out from last frame’s measurements is a control that lags its own content — and the rule that keeps it honest is the one ADR-0080 already wrote: read geometry to interpret an input, never to decide a size.

Nested same-axis scrollers are banned in the canon (§2.4) and nothing enforces the ban. Chaining works, so a nested pair behaves reasonably rather than badly; what is missing is the diagnostic that would tell an author they wrote something the design system rules out.

117. A widget may be told what it measured

Date: 2026-08-18

Status

Accepted. Completes ADR-0116, and opens from the other side the door ADR-0080 and ADR-0097 both stopped at.

Context

ADR-0116 gave a scroll view the two extents it needs to clamp, on the event that asks it to move. That was enough because a clamp is only ever wanted in response to input: nothing needs to know how far a viewport could scroll until something tries to scroll it.

A scrollbar breaks that. §2.4 asks for a thumb whose length says what proportion of the document is on screen, and it has to be right before anyone touches anything — a widget whose entire job is to say “there is more below” cannot wait until you have found out. The extents have to reach build, and build runs before layout.

This is the third time the same wall has been hit. ADR-0080 wanted a slider’s track and got the router to answer for it; ADR-0097 wanted the distance between two segments and redesigned the drawing rather than acquire it; ADR-0116 wanted two rectangles and put them on the event. The first two avoided needing geometry outside input. This one cannot.

Decision

A widget may implement Measured and be told, once per frame, what the last frame laid it out as.

public interface Measured extends Widget {
    void measured(Extent bounds, Extent part);
}

The same pair a PointerEvent carries — the node’s own box, and the part it names through Handles.localPart() — so a widget that reads geometry reads one shape however it arrived.

It is delivered from PointerRouter.updateRegions, which is the one place that holds the painted rectangles and the one call every window already makes once per frame. That is localFor’s argument again: the widget cannot see its own elements, so the thing that can is the thing that tells it.

Three rules, and the third is the one that matters

  1. It is last frame’s. A measurement, not a prediction. A widget acting on it is one frame behind.
  2. It fires only on a change, comparing both extents. A still window notifies nothing, so §1.7’s idle frame loop stays idle. Both halves, because a viewport whose content grew while it did not is exactly the case a scrollbar must redraw for.
  3. What it triggers must not change what it reports. This is the whole safety argument, and it is why the interface is opt-in rather than a hook on every widget.

Rule 3 needs care, because the obvious implementation loops: a measurement causes a rebuild, which causes a frame, which produces a measurement. The scroll view terminates because the bars are absolutely positioned — they take no space from the content and none from the viewport, so the second frame measures exactly what the first did, the router sees no change, and it stops. One extra frame when a window resizes, and none after it. The tests assert the convergence rather than trusting the argument.

A first attempt did not call setState at all, reasoning that a rebuild would be a loop. That was wrong in the other direction: without one the extents never reach a build, and the thumb never appears. The rebuild is necessary; what makes it safe is the absolute positioning, not its absence.

The scrollbar itself

§2.4’s numbers, taken as written: a 6px thumb widening to 10 with a visible track on hover, full radius, the accent colour while dragging, and a fade 800ms after the last movement.

  • The length is the widget’s; everything else is the stylesheet’s. How long a thumb is says what proportion of the document is visible, which is the one thing no selector can know — so it is written through Styled.restyle, which ADR-0099 opened for exactly this. The colour, the radius and the width are untouched.
  • It travels by translate, like the content it mirrors, for §1.7’s reason: a thumb that moved by changing a margin would re-run Yoga on every wheel notch to shift a 6px rectangle.
  • The thumb has a floor of 24px. A hundred-screen document gives a 1.5px thumb — proportionally honest and impossible to grab. Every scrollbar ever written makes this trade.
  • The bar has no padding, and that is load-bearing rather than taste. The arithmetic runs against the viewport’s length because that is the only measurement the widget is given; a bar that inset its own track would have a track shorter than the number used, and the thumb would overrun the bottom by exactly the padding. The thumb is centred across the bar instead.
  • The widening is a jump, not a slide. §1.7 keeps layout properties off the transition whitelist and ADR-0067 refuses transition: width outright. Only the colour transitions.
  • Hover is read from the viewport, not the thumb. A 6px target cannot be pointed at until it has grown, so scroll:hover is what widens it — which is also what §2.4’s “visible track on hover” describes.

The fade is a clock, not a transition

“800ms after the last movement” is not a style and no selector can express when, so it cannot be a transition (ADR-0067). It is spinner’s shape and TabPhase’s: a function of the frame clock, read in render, which is the only place a widget is handed one (ADR-0081).

The wake and the clock arrive at different moments — a wheel event knows something happened and has no time; render has a time and does not know what happened — so ScrollFade holds a pending flag that the next frame stamps. Exactly how TabPhase records the beginning of an arrival.

The bars start invisible. A window that opens on a scrollable document shows nothing until the user scrolls or points at it. §2.4 calls these overlay scrollbars, and an overlay that greets you is a reserved gutter with extra steps.

Consequences

scroll is now what §2.4 describes, less the reserved-gutter mode. Dragging the thumb, clicking the track to page, hover-widening and the idle fade all work.

Every widget in the catalog can now ask for geometry, and almost none should. The temptation is real and the failure mode is specific: a widget that sizes itself from last frame’s measurement lags its own content, and one that does so in a way that changes the measurement never settles. The rule that keeps this honest is ADR-0080’s, restated: read geometry to interpret an input or to draw something that cannot affect layout, never to decide a size.

scrollIntoView is still not built, and this does not build it. It needs a descendant’s position within the viewport — “where is that box inside me” rather than “how big are these two boxes” — which is a different question that nothing asks yet. affix needs the same one.

The reserved 12px gutter for “always show scroll bars” is unbuilt, and waits on a settings mechanism rather than on anything here. It is genuinely different from the overlay bar — §2.4 says “layout, not overlay” — so it is a second drawing rather than a flag.

118. A popup that does not fit scrolls, and so does everything else

Date: 2026-08-18

Status

Accepted. Puts ADR-0116’s viewport to work in the three places that were waiting for it. Amends ADR-0104 (a popup taller than the work area) and ADR-0107 (a strip wider than its window).

Context

scroll was written because three unrelated entries in book/src/TODO.md named the same missing widget. Building it did not close any of them: a viewport that nothing uses unblocks work rather than doing it. This is the work.

Decision

docs/core-widgets.md’s cross-cutting notes ask for a gallery that “exercises every widget in every state”, and a screen taller than the window lost its bottom. Every screen is wrapped, including the short ones, for two reasons: a viewport over content that fits draws no thumb and takes no input, so it costs an element; and a screen that is short at one window size is tall at another, which is exactly what a per-screen decision gets wrong.

A menu longer than the screen becomes a menu of the screen’s height

ADR-0104 clamped a too-tall popup to the near edge, which keeps the top visible and silently drops the rest. That was the honest thing to do with no viewport, and a menu that loses its last three commands without saying so is the worst kind of wrong.

The cap is applied by Menus, not by the popup facility, and that placement is the decision rather than an implementation detail:

  • :core has no widgets to wrap anything in (ADR-0092), and Scroll lives in :widgets.
  • More importantly, whether long content should scroll is a fact about the content. A menu should. A tooltip should not — a tooltip you have to scroll is a tooltip that should have been a dialog. A facility that wrapped everything it placed would be wrong for one of its three callers.

So Host gains placeableArea(), which it already computed for Placement, and the caller that wants to keep its own content inside it can ask. Scroll gains an explicit height(double) for the same reason: §8’s subset has no max-height, and no stylesheet knows how tall the display is.

The menu’s own cap is an estimate — rows times an assumed height — because Menus cannot lay anything out and the thing that can does not know what a menu row costs. It rounds up, so it errs towards wrapping a menu that would have fitted rather than clamping one that does not. A viewport over content that fits is invisible; a clamped menu is missing commands.

A tab strip scrolls its headers and not its rule

The viewport goes around the headers only. The rule is pinned across the bottom of the whole strip, and inside the viewport it would scroll out of the left edge — leaving the underline of a scrolled strip somewhere it does not belong.

This is where a bug in the viewport surfaced. ScrollViewport laid its content out as a column regardless of axis. A horizontal viewport doing that stretches its content to the viewport’s width, so the content’s measured width equals the viewport’s, the overflow computes to zero, and nothing ever scrolls — while the tabs inside spill out of a box that claims to fit them. Both the viewport and the content now lay out along their axis. It took a real horizontal consumer to find; the vertical tests could not have.

Consequences

Three TODO entries are closed by doing rather than by deferring.

The tab strip’s tree gained two levels, and four tests navigated it by index. That churn is the cost of a structural change and is worth naming: tab-list’s children are now the rule and a viewport, and a header is two elements further down. The box tree gains one level and the element tree two, because Scroll is a composition node.

Placement still clamps. Nothing about ADR-0104 changed — a popup taller than the work area is still clamped to the near edge — and what changed is that menus no longer ask to be that tall. A caller that opens an oversized popup without capping it gets the old behaviour, which is the right default for a facility that cannot know what its content means.

The gallery goldens cannot see typography, which this found by accident while looking at something else. GalleryGoldenTest builds its renderer with the single-font constructor — whose own documentation says it ignores font-family, font-size and font-weight entirely — so every screenshot draws prose, headings and button labels at one size. A screen with no hierarchy looks exactly like a screen with one. That is how a screen title and the paragraph under it came to be the same 13px with nothing catching it: the tokens were right, the cascade applied them correctly, and the only test that looks at the gallery is blind to the difference. ShowcaseTypographyTest asserts the sizes through the cascade instead, which is where they are decided.

119. A widget may be told where it is

Date: 2026-08-19

Status

Accepted. The third and last of the geometry facilities, after ADR-0116 (extents on an event) and ADR-0117 (extents once a frame). Builds on ADR-0114, whose clip turns out to be the other half of the answer.

Context

affix is docs/core-widgets.md §1: a child pinned to an edge of the nearest scroll “once the child would have scrolled past it”, leaving a same-sized hole behind so nothing below it jumps.

Every geometry facility so far carries a size. Extent on an event answers “how big are these two boxes” when input asks; Measured answers “how big did I turn out” once a frame. A scrollbar needed nothing else — a thumb’s length is a ratio of two heights and its position is a ratio of two offsets, both of which the scroll view already knows.

affix needs a position, and one that no widget can compute. “Has this header scrolled above the top of the viewport” is a comparison between where the header was painted and where the viewport’s top edge is. The first is a fact about the layout and every transform above it; the second is a fact about a node the header cannot see.

Decision

One more interface, answering the other question

public interface Located extends Widget {
    void located(LogicalRect self, LogicalRect clip);
}

self is this widget’s border box as painted — for a node inside a scroll view, where it has been scrolled to rather than where it was laid out, which is the whole point. clip is what the nearest overflow above it confines it to.

Delivered once a frame and only on a change, exactly as Measured is, from the one place that holds the painted rectangles.

The clip is why this is small rather than large. The obvious implementation of “the nearest scroll view’s rectangle” is an ancestor walk with a cast, which couples :core’s router to a widget in :widgets and answers nothing for a node inside two of them. ADR-0114’s clip is already exactly that rectangle, already computed, already carried on every region for hit testing — so the question was answered before it was asked, and the answer composes with nesting for free.

Nothing clips, and the router answers the window’s rectangle rather than null. An affix outside any scroll view is then pinned to the window, which is what a toolbar at the top of a page means, and it costs a branch nobody has to write.

The rule, and the shape that enforces it

A widget told where it is must not move itself: it would be told a new position, move again, and oscillate at the frame rate forever. That is Measured’s rule 3, and it bites much harder here, because moving is precisely what affix wants to do.

The way out is structural rather than a rule anyone has to remember. affix is two nodes:

affix            the hole. Measured, and never moves
└── affix-content  the child. Translated by however far it has lifted

The outer node’s position is a function of the layout alone, so the inner one sliding under it changes nothing that is reported. The second frame reports what the first did and the router notifies nobody.

This is the same shape the hole needs anyway — §1 asks for a same-sized gap left behind — so the constraint and the requirement turn out to be one thing. That is the argument for it being the right shape rather than a workaround.

It is also why located is handed the rect including this widget’s own transform rather than excluding it. Excluding it would make the contract safe for a widget that breaks the rule, which is worse: it would work, one frame late, and be impossible to reason about.

:affixed is a pseudo-class

§1 asks for one and is right to. A stylesheet cannot express “this node is currently over another one”, and the moment a header lifts is exactly when it should gain a shadow. Mirrored onto the element from Styled.isAffixed() the way :checked and :disabled are, so a stylesheet, a hit test and the widget agree without three of them asking separately.

It fires when the widget changes, not only when the rectangle does

The first version compared rectangles alone, and the showcase found the hole in it within a frame: a section header asked to scroll itself into view is in exactly the position it was in, so nothing had changed and it was never told anything. The request was made and never heard.

So the cache holds the widget as well as the two rectangles, compared by identity. A node that has just been rebuilt hears again even if it has not moved, because a rebuilt node may want something different from the same numbers. And §1.7’s idle guarantee survives intact: an element that was not rebuilt holds the same widget instance, so a still window still notifies nobody.

ADR-0117’s Measured had the same latent bug and now has the same fix. Nothing had hit it, because its one consumer is a scrollbar whose state is stable — which is exactly the kind of gap a second consumer finds.

Consequences

There are now three ways for a widget to learn geometry, which is two more than a toolkit should need and exactly as many as have been earned: each arrived with a consumer that could not be built without it, and each answers a question the others do not. The table in Located’s documentation is the map, and the rule they share is the one ADR-0116 wrote: read geometry to interpret an input or to draw an indicator, never to decide a size.

affix pins on one axis. §1’s edge= takes all four and all four work, but an affix pinned to two edges at once is not expressible — nobody has asked, and the widget would need two shifts and a rule about which wins.

A pinned child does not stop at its section’s end. A sticky header conventionally gets pushed out by the next header, and this one stays pinned until its own subtree scrolls away entirely. Doing better needs the affix to know about its sibling, which is a relationship nothing in the widget tree expresses today.

Located is walked per frame over the regions, alongside Measured’s walk. Two passes over the same list rather than one, because almost nothing wants both answers and merging them would cost every node the union of two checks to save one iteration of a list that is already being built.

120. A widget scrolls itself into view

Date: 2026-08-19

Status

Accepted. Completes scroll alongside ADR-0116, ADR-0117, ADR-0118 and ADR-0119.

Context

docs/core-widgets.md §1 gives scroll a scrollIntoView(widget) API. Three things in the catalog want it and none can be finished without it: selecting a tab the strip has scrolled past currently selects something the user cannot see, affix needed the same geometry from the other side, and §5’s tour “scrolls the target into view and waits for the frame” before it places a popover.

The obvious reading is a method on the viewport, and it is the expensive one. For a viewport to scroll an arbitrary descendant into view it must find it: a way to name the target, a lookup from that name to a rectangle, and an answer for what happens when the target is not built yet. None of that machinery exists, and all of it would exist only for this.

Decision

The child asks, and the viewport moves by a distance

ADR-0119 already tells a widget its own rectangle and the rectangle that clips it, which for anything inside a scroll is that viewport. The difference between those two rectangles is the answer. So the direction is inverted: the thing that wants to be seen — the one node that certainly knows where it is — computes the distance, and the viewport is asked to move by it.

ScrollController is the handle:

public void scrollBy(double dx, double dy);
public void reveal(LogicalRect self, LogicalRect clip);

reveal is scrollBy with the subtraction done, because every caller has the same two rectangles and doing the arithmetic at each call site is how two of them end up disagreeing about what “in view” means.

It scrolls the least it can. A row below the fold comes up to the bottom edge, not to the middle: §1 asks for the target to be in view, and a reveal that centred it would throw away everything the user was already looking at. When the target is larger than the viewport the near edge wins, which is what every browser does — the alternative shows a heading’s bottom and hides the heading.

A controller and not a wrapper — which is the second attempt

The first attempt was a Reveal widget wrapping whatever wanted to be seen. It reads better than a controller, it needed no new API on Scroll, and it was wrong: a wrapper is a box, and a box in a flex row changes how that row is sized. Putting one around each tab header broke two tab goldens and two motion tests immediately. The widget did exactly what it promised; the layout underneath it was no longer the same layout.

That is not a bug to be fixed in the wrapper. There is no box that is guaranteed transparent to flexbox — display: contents is the CSS answer and §8’s subset has no display — so any widget that inserts a node to observe geometry can change the geometry it was inserted to observe.

§1 words this as an API rather than as markup, and that turns out to be the load-bearing part of the wording. An API adds no node. A widget that wants to be revealed implements Located itself, which it can do without gaining a parent, and calls the controller from there. Tab does exactly this: it was already a Handles node, and it is now a Located one too.

The controller is created above the viewport

Not by the viewport’s own state, which is the tempting shortcut. Whoever needs to scroll a viewport is by definition somewhere else, and a controller created by ScrollState would have to be reachable downwards — which is the direction findAncestorState cannot look, since the scroll view a tab strip owns is the strip’s descendant rather than its ancestor.

So TabsState creates one, holds it for its lifetime, and hands it down through TabStrip and TabList into the Scroll. An application does the same with a field. A controller with no viewport attached is inert rather than an error, because a controller existing for a frame before the Scroll that answers to it is the normal order of construction and throwing there would make that order load-bearing.

BuildContext.findAncestorState is added and used by nothing in the end — the downward case is what the catalog needed — but it stays, because it is how an application-level scrollIntoView from inside a scroll view reaches the viewport, and that is the case §1’s wording is actually about.

A reveal is a request, not a constraint

TabsState holds one pending value, set when the selection changes and cleared the moment it has been acted on. A strip that pulled the selected tab into view on every frame would take the scrollbar away from the user for as long as anything was selected, which is always. Exactly one header per build carries the callback, so the router asks one node where it is rather than all of them.

Consequences

The three-way geometry story is closed: extents on an event for the clamp, extents once a frame for the thumb, a position once a frame for affix and this. Each arrived with a consumer that could not be built without it and each answers a question the others do not.

A reveal costs two frames — one to be measured, one to act — and lands without animation. §3.1 gives scroll “scrollIntoView / programmatic: overlay duration”, so a revealed row should glide rather than jump; the offset is state and nothing interpolates it. That is a transition on a value the cascade cannot see, which is the same shape TabPhase solved for one widget and the second consumer that would justify promoting it.

Nothing reveals horizontally and vertically with different urgency. A wide table asked to reveal a cell scrolls both axes at once, which is right, and scrolls the minimum on each independently, which occasionally moves a view further than a person would have.

The wrong turn is worth keeping in mind beyond this ADR: any widget that adds a node to observe layout can change the layout it observes. affix avoids it by being two nodes deliberately, with the outer one load-bearing for the hole anyway. A future widget that wants geometry without a node has to implement Located on something already there, which is a real constraint on what such a widget can be.

121. A tour is a veil and a sequence

Date: 2026-08-19

Status

Accepted. The last of the scroll group, after ADR-0119 and ADR-0120. Reuses ADR-0100’s overlay layer and ADR-0108’s “a target is a name”.

Context

docs/core-widgets.md §5’s tour: a guided sequence of popovers over real widgets, each stop naming a target by id with a title, a body and Back/Next/Skip. The window dims outside the target with a veil cut to its rect. It scrolls the target into view before positioning. Esc skips the whole tour, and a target that is not in the tree is skipped with a warning rather than throwing.

Two of those had no mechanism. A veil “cut to a rect” wants a mask, and §8’s subset has no path, no mask and no clip-path. And an overlay in the layer is pinned to a corner and sized to its content, which is right for a hud and exactly wrong for something that has to cover everything.

Decision

The veil is four rectangles

Above the target, below it, and the two beside it spanning the gap between those two. They tile the window exactly and leave the target uncovered. No mask, no path, no new property in the subset — four absolutely positioned boxes, which is what the subset already does well.

The workaround is better than the thing it replaced. Nothing is drawn over the target, so it stays live: it takes the pointer and the keyboard normally, and a stop that says “click Save to continue” can be obeyed. A single masked rectangle would have had to arrange an exception to itself to allow that, and the exception would have had to be described in terms of hit testing rather than in terms of paint.

The bands consume the pointer. That is what makes a tour modal without anything declaring it so: everything the veil covers is unreachable because the veil is over it, and the one thing it does not cover is the thing the stop is about.

They tile without overlapping, which matters because they are translucent — a doubled band would be visibly darker, and the seam would trace a rectangle around nothing.

Host.fill is one flag, not a second placement path

An overlay’s insets already decide where it goes: two sides is a corner, and four is a fill. So Overlay.filling sets a flag and WindowRoot chooses Insets.all(0) instead of the corner’s. No new placement code, no new concept in the layer.

The card is not a popover

§5 calls a tour “a guided sequence of popovers”, and the word is doing less work than it looks. popover is the panel half of an anchored floating thing; its other half — measure, flip, shift, open a platform window, light-dismiss — is precisely what a tour must not do (ADR-0104). A tour’s card lives inside the window, over a veil that is also inside it, and dismisses on its own buttons rather than on an outside click.

Placement is flip-and-shift done in six lines rather than reused, because Placement positions a window against a display’s work area and this positions a box inside another box. Same idea, different coordinate space, and sharing it would mean teaching it about both.

The target is resolved every frame

A stop names a widget and the anchor is read from the painted frame on every build, not once when the stop opens. A window that resizes, a list that scrolls, a panel that reflows — all of them move the thing being described, and a veil cut where the widget used to be is worse than no veil at all.

That also makes “skipped with a warning” fall out rather than being handled: a target that is not on screen is simply one anchor does not answer, and the build walks on to the next stop. A tour is documentation, and documentation going stale must not take the window down.

Starting one takes a Host

Tours.start(host, stops), exactly as Menus.open(host, …) does and for the same reason (ADR-0106): resolving an id against the painted frame and putting something on the window are both things a widget tree cannot do to itself.

It needed a picture, and the picture found the bug

TourTest drives fifteen cases against a stub host and every one of them passed while the card was drawn down the entire left edge of the window, one pixel from the top, stretched to the full height.

Insets is in CSS order — top, right, bottom, left — and the placement passed left and top. Anchoring a box by its top and its bottom stretches it; anchoring it by nothing horizontal puts it at the origin. Every assertion about what the tree contained was true throughout, because the defect was entirely in two numbers’ positions in an argument list.

So TourGoldenTest exists, and it is the right kind of test for this widget rather than a belt-and-braces one: a veil is a fact about pixels, and which region is dimmed and which is not is not a question the widget tree can be asked.

Consequences

tour is the first widget to use the in-window overlay layer for something that is not a corner badge, which is what Host.fill exists for and the only reason it does.

A tour cannot find the viewport its target is in. §5 asks it to scroll the target into view, and Stop takes an optional ScrollController for the application to supply. Discovering it automatically means walking from an element to its nearest scrolling ancestor, which is a :core-to-:widgets dependency the toolkit does not have — the same wall ADR-0120 turned around to avoid, and here there is nothing to turn around because the tour is not the thing being revealed.

The card’s height is estimated when deciding whether it fits below its target. Measuring it would need the measure-then-place machinery ADR-0104 built for popup windows, which works on windows rather than on boxes. Being wrong puts a card above its target when it would have fitted below, which is a placement nobody will notice and not a defect anybody can see.

tour-band’s opacity is a fixed 0.55 rather than a token. §1.2 has a scrim and this is one; a --gb-scrim would be the right name and there is one consumer, which is ADR-0019’s argument for waiting.

122. A setState asks for a frame

Date: 2026-08-19

Status

Accepted. Fixes a gap left by ADR-0052 and ADR-0067 between them.

Context

Reported as “the scroll starts working on the second or third turn of the wheel”. It is not a scroll bug and it is not about the wheel.

Two rules met and left a hole between them:

  • §1.7, through ADR-0067: the frame loop is idle when nothing is animating. A window paints when something asks it to, and not otherwise.
  • ADR-0052: setState defers. It marks its element dirty and returns; the tree flushes once per frame.

Nothing connected them. Window.handlePointerWheel did not repaint at all, and every other input handler called repaintIfRestyled, which asks the router whether a pseudo-class changed — a question about :hover and :focus, and not about widget state. So a widget that changed its own state in a handler sat there until some unrelated event caused a frame, and then showed the change one interaction late. A scroll view made it obvious because scrolling produces a stream of events: each wheel appeared to do nothing and the next appeared to do the previous one’s work.

Every stateful widget in the catalog had this. It went unnoticed because most of them change a pseudo-class in the same gesture — a button that is pressed is also :active, so the restyle repaint covered for the state change. A scroll view changes no pseudo-class at all.

Decision

ElementTree tells its window when it goes from clean to dirty, and the window paints.

tree.onDirty(window::repaint);

On the transition into dirty rather than on every mark, so a handler calling setState ten times asks for one frame — the same coalescing flush already does one level down.

A single listener rather than a list, because there is exactly one thing that can paint a tree, and a second would mean two windows drawing one element tree. Popup windows get the same wiring against their own window, because a setState in a menu item is as much a reason for a frame as one in the application.

Why not repaint from setState itself

Because State has an element and an element has a tree, but a tree does not have a window — and giving it one would put the whole windowing layer inside the widget layer to deliver one call. The tree is the last thing that knows about the change and the first thing the window already owns, so the listener sits exactly where the two meet.

Why not simply repaint after every input event

It was the tempting one-line fix, and it is wrong in the direction that matters: it repaints on every mouse move over a window where nothing changed, which is the idle frame loop §1.7 spends effort to have. Asking on a state change is both narrower and more correct — it also catches the changes that come from a timer, a subscription or a completed future, none of which is an input event at all.

Consequences

The idle guarantee is intact and now means something stronger: the loop is idle when nothing has changed, rather than when nothing is animating and nobody has touched anything.

repaintIfRestyled stays. A pseudo-class change is a repaint reason that involves no rebuild — hovering a button restyles it without any widget’s state moving — so the two are genuinely separate conditions and neither implies the other.

The bug was invisible to every test in the suite, because a test drives frames itself: tree.flush(); render.update(...) in a loop is a frame loop that never asks whether anyone wanted one. That is the right way to test a widget and it cannot catch this, which is an argument for the showcase being run rather than only rendered.

123. A pinned box paints after its siblings

Date: 2026-08-19

Status

Accepted. Amends ADR-0053’s “no z-order” in one narrow place, for ADR-0119’s affix.

Context

affix pinned correctly and was unreadable. The header stopped at the viewport’s edge exactly as §1 asks and the rows of its own section slid straight over the top of it.

AffixTest passed throughout. Every assertion it makes is about a position — where the hole is, where the content is, whether :affixed is on — and every one of them was true the whole time. What was wrong was paint order: a box tree has none beyond document order (ADR-0053), the affix sits at index N of its section, and the rows at N+1 are drawn afterwards.

A background does not fix it. The rows are painted later, so they paint over whatever the header has.

This is not an exotic case. It is how position: sticky works in every browser, and the reason it works is a rule this toolkit does not have: a positioned element paints above its non-positioned siblings. CSS puts sticky headers in the positioned layer, and that is the entire mechanism.

Decision

Box gains one boolean:

public Box elevated(boolean value)

A box that carries it is painted after its siblings. AffixSlot sets it while, and only while, it is pinned.

It is not a z-order, and ADR-0053 still holds

There is no stacking context, no z-index, and no ordering among elevated siblings — they keep document order relative to each other. It is one bit meaning “draw me last”, which is the whole of what a pinned header needs and substantially less than a layer.

Two passes over the child list rather than a sort: the flag is rare, the lists are short, and a comparator would define an ordering between elevated siblings that this deliberately leaves undefined.

Layout is untouched

Yoga sees the children in the order it was given. Only the painter reorders. That is what makes the flag safe to toggle every frame — a header lifting must not relayout the list it is in, which would be the one thing worse than being painted under it.

The hit test reorders with the painter, or not at all

RenderTree’s two walks — the paint and the placed-box walk the hit test is built from — both do the same two passes. They have to: a box drawn on top that was not also clicked first would be a header you can see and point straight through, which is a worse bug than the one this fixes because it is invisible until someone tries.

Consequences

overflow clips an elevated box exactly as it clips any other, because the clip is accumulated on the way down and paint order does not change the walk’s shape. A pinned header is still confined to its viewport.

The flag is set by a widget and not by the cascade. No declaration says elevated, and none should: it is a fact about what a widget is currently doing rather than about how it looks, and a stylesheet that could set it would be able to reorder painting from a hover rule.

AffixGoldenTest exists because of this. AffixTest’s eleven cases were all true while the widget was unusable — the difference between “the header is at the top of the viewport” and “you can read the header” is not visible to any assertion about the tree, and an image is the only thing that can tell them apart.

124. A pinned affix is revealed by its hole

Date: 2026-08-19

Status

Accepted. Repairs the meeting point of ADR-0119 (affix) and ADR-0120 (scrollIntoView), both of which were correct alone.

Context

Reported as “the buttons for affix stop working after a few clicks”. They were never working; the first press happened to be a scroll forwards, which any measurement would have got right.

The showcase’s jump buttons ask a section to scroll into view. ADR-0120’s rule is that the thing wanting to be seen measures itself, so the section’s header implemented Located, was handed its own rectangle and the viewport’s, and passed both to ScrollController.reveal.

That header is inside an affix. Which means that the moment its section starts scrolling away, the header is pinned to the viewport’s edge — it is sitting at the top of the visible area, by design, permanently. A reveal measured against it therefore concludes the section is already in view and scrolls nowhere, no matter how far away the section actually is.

Two rules, each right, composing into something wrong:

  • affix: your content stops at the viewport’s edge.
  • scrollIntoView: the thing that wants to be seen measures itself.

Follow both and a sticky header can never ask to be scrolled to, because by its own account it has already arrived.

Decision

An affix hands out its hole, and the hole is what a reveal measures.

Affix.revealedBy(listener) gives a caller the outer node’s rectangle and the rectangle that clips it, once a frame. The outer node is the hole §1 already requires — the same-sized gap left behind so nothing jumps — and it travels with the document precisely because it never moves itself. It is the only rectangle in the widget that still means “where this section is”.

The listener is a door, not a policy. Affix does no scrolling and holds no controller: it forwards two rectangles, and the caller decides whether the section wants showing and what to do about it. That keeps the widget generic and puts the decision where the reason lives, which is the same split Tab uses for the identical job.

Null by default, so exactly one affix per build is measured. A list of forty sections has forty affixes and at most one of them is being revealed.

Why not fix it inside scrollIntoView

Because there is nothing there to fix. The controller is handed two rectangles and does arithmetic on them; both were accurate. The mistake was upstream, in choosing which node to measure, and that choice can only be made by something that knows the widget is an affix — which the controller deliberately does not.

Why not have affix reveal itself

It would need a ScrollController, and then a rule for what “wants to be revealed” means, and then a way for a caller to say when. All of that is the caller’s already: the showcase holds one flag and clears it when the reveal lands. A widget that owned the policy would own a worse version of it.

Consequences

ADR-0120’s rule stands with a caveat worth stating plainly: the thing that wants to be seen measures itself, unless something is deliberately holding it somewhere. affix is the only widget in the catalog that does that today. Any future one — a docked panel, a frozen table column — will have the same problem and now has the same answer.

The showcase’s SectionHeader went back to being a plain node, which is the small proof that the door is in the right place: the widget that had to know about geometry no longer does.

None of the eleven AffixTest cases could have caught this, and neither could the four in ScrollingScreenTest as first written: the failing sequence is scroll away, then ask to come back, and every test asked to go somewhere new. The test that finds it presses two buttons alternately, four times — which is what the person who reported it did.

125. A raw field is woven into a binding

Date: 2026-08-19

Status

Accepted. Supersedes ADR-0096 (the annotation processor) and ADR-0098 (private members reached by VarHandle). Both were solving problems that this removes rather than answers.

Context

§9’s binding half asked for “a small built-in Property<T> type; no framework dependency”, and that is what was built. Five years of JavaFX say what happens next, and it had already started happening here:

private final Property<Integer> clicks = Property.of(0);

@Action("app.click")
public void click() {
    changed(() -> clicks.set(clicks.get() + 1));
}

clicks.set(clicks.get() + 1) is clicks++ with three extra tokens and an object. The model cannot touch its own state without going through an accessor nobody chose to write, and every value costs a heap object whose only job is to hold a reference to another heap object. The showcase’s model had eight of them.

Worse, the shape is contagious. Because the field is a Property, every read inside the model is get(), every write is set(), and a method that wants to compare two values writes Objects.equals(a.get(), b.get()). None of that is about binding. It is about the container the binding needed.

The obvious fix — intercept the field — does not work in Java. getfield and putfield are not virtual, so a subclass cannot hook them; a proxy cannot either. The class that declares the field is the only place the write can be seen.

Decision

The build rewrites the model’s own bytecode, with the JDK’s class-file API (JEP 484).

An author writes plain Java:

@Model
public final class Settings {
    @Bind("app.gain") private int gain = 40;

    @Action("app.louder") private void louder() { gain++; }
}

and Settings.class comes out of the build with:

  1. implements BoundModel, and a lazily created FieldListeners;
  2. a synthesised goldberry$set$gain(int) — compare, store, notify;
  3. every putfield gain in that class rewritten into a call to it;
  4. bindings() and actions(), built from the annotations.

Step 3 is a one-for-one instruction swap. putfield pops objectref, value, and so does an instance call taking one argument — the stack either side is identical and nothing around it moves. That is the whole trick, and the reason this is a small transform rather than a compiler.

Reads are left alone

Deliberately. getfield is already the fastest thing that could happen and there is nothing to observe about a read, so a model pays for a binding only where it writes. The cost lands on the other side: reading through the binding goes via boundValue’s switch and, for a primitive, a box — measurably slower than Property.get, and the right trade, because a model writes on every event and the tree reads once per rebuild.

Nothing notifies on a write that changed nothing

Property.set compared with Objects.equals, and the woven setter reaches the same answer the cheap way for each type: if_icmpne for the small integrals, lcmp for a long, and Float.compare/Double.compare for the floating types — not ==, so that NaN and -0.0 answer the way a boxed comparison would. The rule that makes two mirrored values terminate instead of recursing is the same rule, and it had to be.

Property does not go away

A @Bind field that already is one is bound directly and not rewired. That is what lets a model publish a value somebody else owns beside its own, and it is why Bindings now holds Observable<?> rather than Property<?> — the registry hands out the read-only half either way and has no reason to know which it was given.

Why a build step and not an agent

An agent means -javaagent on every launch, a second thing to configure, and no image (ADR-0127). Weaving the compiled class in the build needs none of that, and what ships is an ordinary class file that javap explains.

Why the annotations moved to CLASS retention

The weaver reads them out of the class file and nothing reads them afterwards. An image that carried @Bind would be carrying metadata for nobody. @Model stays RUNTIME, for one purpose: so Models can tell an author that their class was annotated and never woven, instead of handing back a binding that silently notifies nobody.

Consequences

The showcase’s model lost every Property, every .get() and every .set(). isProseShown() went from Boolean.TRUE.equals(showProse.get()) to showProse. That is the change this was for.

Measured (./gradlew :example:benchmark --tests '*BindingBenchmark*', one machine, medians, per operation):

Propertywoven field
write, no listeners9.5 ns2.5 ns3.9× faster
write, one listener19.0 ns12.9 ns1.5× faster
write, value unchanged0.28 ns0.22 ns1.3× faster
read through binding, int1.2 ns2.3 ns1.9× slower
read through binding, reference1.1 ns1.6 ns1.4× slower
construct the model23.8 ns2.8 ns8.6× faster
build both registries1.15 µs1.15 µsthe same

The read row is the cost, stated plainly. It is boxing: Property<Integer> holds a box already and hands it back, where a woven int makes one per read. For a reference-typed field the gap is the switch alone.

A field written from outside its declaring class is not observed. An inner class assigning to its outer’s @Bind field compiles to a putfield in a different class, which this transform never sees. Nested classes of the model are the realistic case, and the failure is silent. Lambdas are fine — javac compiles them into synthetic methods of the same class, and there is a test that proves the rewrite reaches them.

A @Model may not extend a @Model, and that is a build error rather than another silent gap. Both classes would get a goldberry$listeners field, the subclass’s would shadow the superclass’s, and the inherited @Bind fields would then notify a store nothing subscribes through — half a model working, which is the worst thing this design could produce. Catching it needs the whole tree, because neither class can see the problem alone, so the weaver takes two passes: one to learn which classes are models, one to weave them. A model extending an ordinary class is untouched by the rule.

An array cannot be bound. Only the assignment is observed, so values[0] = x would notify nobody; the weaver refuses one rather than letting that be discovered later. Hold a List and assign a new one — the same rule ADR-0109 already stated for Property.

A model is now a build-time contract. A module that keeps one applies goldberry.weave, and forgetting to is a loud failure at the first Models.bindings(...) rather than a quiet one at the first click. That is worse than an annotation processor, which needed only a dependency; it is the price of touching bytecode, and the error message names the missing step.

Every rule ADR-0096’s processor enforced is enforced by the weaver instead, and each has a test, because a rule with no test is a rule that quietly stopped applying. What was a compile error is now a build error one phase later — the same feedback, from a different tool.

126. Actions are bound by LambdaMetafactory

Date: 2026-08-19

Status

Accepted. The action half of ADR-0125; together they supersede ADR-0096.

Context

ADR-0096 generated Java source. @Action("app.click") void click() became a line in a ShowcaseModelRegistry.java that a person could open:

return Actions.strict()
        .bind("app.click", target::click)
        .bind("app.set-gain", value -> call(ACTION_SET_GAIN, target, Double.parseDouble(value)));

That worked, and the readable-output argument was a real one. But it bought the readability with two things that were not free.

The first is that a private method could not be named. Generated code sits in the same package and cannot see one, so ADR-0098 reached it through a MethodHandle looked up in a static initializer via MethodHandles.privateLookupIn. Explicit, yes — and a reflective lookup on the startup path, running for every action of every model, in a scheme whose stated selling point was that it did no reflection.

The second is that the generated file is a second artefact for one class. It had to be named, placed in a package, kept from colliding, and regenerated whenever the model moved. ShowcaseModelRegistry is not a thing anybody wanted; it is a thing the mechanism needed.

Once ADR-0125 was already rewriting the model’s bytecode for the @Bind half, the @Action half had somewhere better to go.

Decision

The weaver writes one invokedynamic per action, into the model’s own class, bootstrapped by LambdaMetafactory.metafactory.

It is byte for byte the call site javac emits for model::click: the same bootstrap, the same three static arguments, the model captured as the single dynamic argument. This is not a new mechanism. It is the mechanism a method reference has always used, written by something other than javac.

Because the call site is inside the model’s own class, the lookup that bootstraps it is the model’s own lookup — which has private access to the model. So a private @Action is reached the way any code in a class reaches its own private method, with no handle, no privateLookupIn, and nothing on the startup path. ADR-0098’s problem does not get a better answer; it stops existing.

Every action goes through a synthesised bridge

private void goldberry$action$click(), calling click(). Uniform, even for the no-argument case that could have referenced the method directly, because:

  • it is where the parse lives. A valued action crosses as the String the document wrote down, and goldberry$action$setGain(String v) calls setGain(Double.parseDouble(v)) — one place, visible in javap, rather than a coercion the registry performs on the way past.
  • it makes every call site in the class the same shape: one bridge, private, returning void, reached by invokespecial. An action that returns something is called for its effect and the value dropped, which is what a Runnable wrapping it would do anyway.

Why not keep generating source

Because the readable artefact was answering a question nobody was asking. The thing a person wants to read is which name is bound to which method, and that is in the model, next to the method, as @Action("app.click"). The generated file restated it in a second place that could only ever agree.

What is genuinely lost is steppability: you can no longer put a breakpoint in the registry. In exchange the stack trace goes straight from Actions.resolve to the model’s own method, with one synthetic frame between — shorter than it was.

Consequences

Dispatch is exactly as fast as before, and that is the finding. Measured against a method that does nothing, so the number is the call site and not the model behind it, the two are indistinguishable — both loops optimise away entirely. They should: both are a LambdaMetafactory call site, and the JIT has been inlining through those since Java 8. The action half of ADR-0096 was never slow, and nothing here claims to have made it faster.

The first version of that benchmark measured app.click on both models and reported the woven form 5.8× faster. That was the write path from ADR-0125 showing up under an “action dispatch” label. The measurement was replaced with one against a no-op, and the honest answer is “no difference”.

The valued path is likewise unchanged: at ~35 ns per call both schemes are dominated by Double.parseDouble, which is the same parse either way and costs more than everything around it.

ShowcaseModelRegistry is gone, and so is the :processor module. One fewer build-time tool, one fewer generated source root, and one fewer thing an IDE has to be told about.

Every bootstrap in a woven class is LambdaMetafactory.metafactory, and a test asserts it — which is what ADR-0127 needs, and the reason that assertion is worth making mechanically rather than by inspection.

127. The binding schema fits a closed world

Date: 2026-08-19

Status

Accepted. The constraint that decided ADR-0125’s when and ADR-0126’s how.

Context

The brief for the binding redo asked for three things that do not obviously fit together: the class-file API to wrap raw fields, LambdaMetafactory instead of an annotation processor, and a result that builds as a GraalVM native image.

Taken literally, the first two contradict the third. Both name mechanisms whose natural home is runtime:

  • ClassFile.of().build(...) plus Lookup::defineHiddenClass generates a class while the program runs. A native image has no class loading and no class definition; the world is closed when the image is built.
  • LambdaMetafactory.metafactory(...) called as a method spins a class per call site, at the moment it is called. Same problem.

A design that did either at runtime would satisfy points 1 and 2 and fail point 3 outright — not degrade, fail: the image would not build, or would build and throw on the first model.

There is also a fourth mechanism the previous scheme used and which is a milder version of the same problem. ADR-0098’s generated registry ran MethodHandles.privateLookupIn, findVarHandle and findVirtual in a static initializer. Native image supports those, but only with reachability metadata naming every field and method reached — a JSON file, per model, that has to be kept in step with the code by hand or by an agent trace.

Decision

Everything generative happens at build time; nothing generative happens at runtime.

The same class-file API does the same work — it just runs between compileJava and jar instead of during main. And LambdaMetafactory is used in the form a closed world can resolve: as an invokedynamic bootstrap, which the image builder links when it builds the image, exactly as it does for every method reference javac ever emitted.

So the two “contradictory” requirements are met in full, and the contradiction was only ever in the word when.

What a woven model needs at runtime is then: its own fields, invokevirtual, invokestatic, and a handful of ordinary classes (FieldListeners, BoundField, Bindings, Actions). No lookup, no handle, no proxy, no metadata file.

The claim is a test, not a sentence

NativeImageComplianceTest parses the woven bytecode and asserts:

  • no call to Class.forName, getDeclaredField, setAccessible, privateLookupIn, findVarHandle, findVirtual, defineClass, defineHiddenClass, Method.invoke, or LambdaMetafactory.metafactory;
  • every invokedynamic bootstrap is LambdaMetafactory.metafactory;
  • one call site per action and no more, so the image carries one generated lambda class per handler rather than two;
  • the runtime classes the woven code calls are themselves clean;
  • @Bind and @Action are gone from the class at runtime, and @Model is the only one that stays.

LambdaMetafactory.metafactory being forbidden as a call and required as a bootstrap is the whole decision in two assertions.

“We did not use reflection” is exactly the kind of claim that stops being true one commit after somebody writes it down, which is why it is checked by machine.

Consequences

No native image has been built. There is no GraalVM in this repository’s toolchain and none in CI, so what is verified is the structural property above and not an image that starts. That is a real limit and worth stating rather than implying otherwise: this ADR says the binding schema does not stand in the way of an image, not that the toolkit produces one.

The rest of the toolkit is a separate and much larger question. :natives is FFM downcalls into SDL3, Blend2D and HarfBuzz; an image of the showcase needs those libraries handled, and JEP 472’s --enable-native-access has an image equivalent that nobody here has exercised. The binding layer is no longer the reason it cannot be tried, which is the most this change can honestly claim.

The reachability metadata ADR-0098 would have needed was never written, because the scheme that needed it was replaced before an image was attempted. If one is ever needed for the toolkit’s other halves, the bind package will not be in it.

A runtime-weaving mode was considered and dropped. It would have been genuinely nice for development — edit a model, reload, no build step — and it is what a literal reading of the brief describes. It was dropped because two code paths that generate the same bytecode at two different times is two things to keep in step, and the one that could not go in an image would have been the one everybody developed against. One path, always the build.

128. A change is its own frame request

Date: 2026-08-19

Status

Accepted. Finishes ADR-0125: the model stopped holding containers, and this is it giving up the last thing it was doing on the toolkit’s behalf.

Context

Every action in the showcase ended the same way:

@Action("app.click")
public void click() {
    clicks++;
    changed();          // <- ask the window to repaint
}

Nine methods, nine changed() calls, and a Runnable onChanged field plus a setter to install it. The line has no meaning of its own. It is never wrong — it is only ever missing, and when it is missing the symptom is a value that changed and a window that did not repaint until something else happened to move. That is among the worst bugs a toolkit can hand somebody, because the code that is wrong looks complete.

It was also imprecise in the other direction. reset() called changed() whether or not clicks was already zero, so a button that did nothing still asked for a frame.

Decision

A @Bind field changing is the frame request.

FieldListeners gained a second kind of listener — one that wants to know that something moved, without caring what — and Models.onChange(model, runnable) is where a window subscribes:

Models.onChange(model, host::repaint);

An action is now an assignment and nothing else.

Fired once per change and not once per write, which is the same rule everything else in the binding layer follows: a model that assigns the value already there notifies nobody and asks for no frame. So reset() on a zero counter is now genuinely free.

Fired after the per-field listeners for that change, so a subscriber watching one path has already run by the time the frame is asked for. That ordering is what let the showcase’s other callback go too: onRestyle was a second hook the model had to remember to call, and a stylesheet depends on exactly two values, so it became two subscriptions:

Models.observable(model, "app.theme").subscribe(value -> restyle.run());
Models.observable(model, "app.density").subscribe(value -> restyle.run());

density was an unbound field and is now bound, which is what made that possible. Nothing displays it — but “nothing displays it” was never the same question as “does anything depend on it”.

Consequences

ShowcaseModel lost onChanged, onRestyle, both setters and nine changed() calls. It now contains fields, methods that assign to them, and four derived getters. There is nothing in it about frames.

An action that moves three fields asks for three frames. That is not a regression: the frame scheduler already coalesces requests within a frame, for the same reason it coalesces three setState calls (ADR-0122). It is worth stating because the old code asked exactly once per action by construction, and this asks per change.

A model that changes something the UI depends on but does not bind still needs telling. Nothing enforces that a value the view reads is a @Bind field — ShowcaseModel.added is deliberately not one. The rule is now “if the UI depends on it, bind it”, which is a better rule than “remember to call changed()”, but it is still a rule.

The listener runs on the write. A model that assigns a field in a tight loop calls repaint once per iteration. Nothing in the toolkit did that, and the frame scheduler makes it cheap, but a model doing real batch work should assign its result once rather than accumulate in a bound field — which was already true of Property.

129. A value is named one way

Date: 2026-08-19

Status

Accepted. Removes the second half of what ADR-0125 left behind.

Context

After the weaver landed, ShowcaseModel still carried nine methods like this:

public Observable<String> tab() {
    return observable("app.tab");
}

plus the two private helpers behind them and a cached Bindings field. Twenty lines of a model whose entire point was that it had stopped being plumbing.

They existed because a widget built in Java had no other way in. A document says bind="app.tab" and resolves it against the registry; a widget built in Java had to ask the model, and the model had to offer a method per path. So every bound value was named twice — once in the annotation, once in an accessor that said the same thing in Java — and the two could disagree.

Decision

One lookup, and it is the one markup uses.

Models.observable(model, "app.tab")

bind="app.tab" in KDL and Models.observable(model, "app.tab") in Java resolve the same path against the same registry. There is no second vocabulary.

To make that affordable, the weaver now caches the Bindings it builds in a synthetic field. A path lookup is a map get on a registry built once, so calling it while building a widget costs what reading a field costs — it happens on every frame, for every widget, and rebuilding a registry each time would have been the kind of cost nobody goes looking for.

actions() is deliberately not cached, and the asymmetry is the point: an application routinely extends the action registry — the showcase adds app.open-menu and app.toggle-hud, which are the window’s actions and not the model’s — and handing out a shared one would make the second caller fail with “already bound” for doing exactly what the first did. Bindings are the model’s values and nothing adds to them.

Why not keep the accessors and generate them

Because generating them would put back the thing ADR-0126 deleted: a second artefact restating what the annotation already said. And a generated Observable<String> tab() cannot be more type-safe than the field it reads, which is the only argument the hand-written ones had.

Why not pass Bindings to every widget instead

It is the same lookup by a different route, and it means threading a registry through every constructor of every application widget. The model is already there; asking it is shorter and reads the same as the markup does.

Consequences

Java call sites are stringly typed, and the type is inferred rather than checked. Models.observable(model, path) returns Observable<T> with T taken from where the result is used. That is a real loss against Observable<String> tab(), and it is the price of having one name for a value instead of two. Two things soften it: the registry is strict, so a typo throws at construction naming every path that is bound, and Models.observable(model, path, Class<T>) checks the value where the answer matters more than the brevity.

Derived getters stay. theme(), density(), isProseShown() and hasClicks() are still on the model, because they are not plumbing — they are the application deciding what its own values mean. isProseShown() is now return showProse;, which is what it always meant.

The cached registry is shared. Models.bindings(model) returns the same object every time, so an application that calls rebind on it changes what every document resolves. That is defensible — it is the model’s registry — but it is a change from the previous fresh-per-call behaviour and would surprise somebody who expected a copy.

130. A widget inflates itself

Date: 2026-08-19

Status

Accepted. Relates to docs/ARCHITECTURE.md §9’s inflater and its parity invariant.

Context

Controls.inflater was 300 lines of one shape:

inflater.register("button", (node, children) -> new Button(
        node.argument().map(v -> v.asString()).orElse(""),
        icons.resolve(node.stringProperty("icon")),
        actions.resolve(node.stringProperty("press")),
        node.booleanProperty("disabled"),
        Attributes.of(node)));

nineteen times, in one file, none of which was near the widget it built.

Two costs. The first is that a widget’s markup contract lived somewhere else than the widget: Button.java documents what a button is, and what button means in KDL was three hundred lines away in a file about the catalog. §9’s parity invariant says every built-in must be constructible in all three forms — Java, KDL, CSS — and two of the three were in one file and the third in another.

The second is that the file was mostly repetition. node.argument().map(v -> v.asString()).orElse("") appeared eight times. change == null ? null : value -> change.accept(String.valueOf(value)) appeared three. Neither is a decision; they are the same sentence written out again, and they made the parts that did differ hard to see.

Decision

Each widget gets a static Widget inflate(KdlNode, List<Widget>, Wiring), and Controls becomes a list of names.

catalog.add("button", Button::inflate);
catalog.add("checkbox", Checkbox::inflate);
…

Wiring is the three registries §9 asks for — [Actions], [Icons], [Bindings] — travelling together, because a factory generally needs more than one of them and threading three parameters through nineteen registrations was three chances to pass the wrong one. It also carries the readings that were repeated: Wiring.label(node), wiring.bound(node), wiring.icon(node), wiring.numeric(node, "change").

Inflatable.Catalog binds one Wiring to an inflater so the table is names and factories and nothing else. It is a class and not a Map, because the order names are registered in is the order an unknown node is reported against — and that list is the most useful thing an error message about a typo can say.

Primitives uses the same catalog, which is §9’s “built-ins and application widgets register identically” made literally true: there is no privileged path, only a first caller.

Consequences

Controls.java went from 443 lines to 204, and Primitives from 82 to 77. The lines did not vanish — they moved next to the records they build, where each one sits under a javadoc paragraph explaining the attribute it reads.

Adding a widget is now a method and one line, instead of a fifteen-line lambda in a file about something else.

The helpers moved with the bodies. requiredValue and colour are on Wiring because two widgets each need them; readings moved into Hud, which was the only caller and where Reading already lived.

A widget class now names Wiring, and therefore Icons and Actions. That is not new coupling — every one of these already took an Observable or a Runnable in its constructor — but it does mean the widget package depends on the catalog package, where before the arrow pointed one way. The alternative was a registry of factories somewhere in between, which is a third place for a widget’s markup contract to live and was the problem to begin with.

inflate is a static method, matched by convention rather than by a type. Java cannot require a static method on an interface, so nothing stops a new widget from omitting one — except the parity test, which already fails when a widget is not constructible from KDL, and which is why that test was worth having.

131. A widget package announces itself

Date: 2026-08-19

Status

Accepted. Replaces the hand-written catalog ADR-0130 left in Controls.

Context

ADR-0130 moved each widget’s markup contract next to the widget and left Controls.inflater as a table of nineteen lines:

catalog.add("button", Button::inflate);
catalog.add("checkbox", Checkbox::inflate);
…

Better than three hundred lines of construction, and still wrong for the question actually being asked, which was: there will be dozens of widget packages — how does an application wire them all?

Under the table, it does it by hand. Controls.inflater knows exactly the widgets :widgets happens to ship. A second module — charts, an editor, a platform-specific control set — has its own inflater, and an application wanting both merges two registries and keeps the merge in step with both. That is the same copying ADR-0096 removed from binding, in a different place.

Primitives already showed the shape of the problem inside one module: two inflaters, and every caller wanting column and button had to know that Controls.inflater happened to fold Primitives.inflater in.

Decision

A widget declares its node name; the build collects them; ServiceLoader finds them.

@Markup("button")
public record Button(…) implements Widget.Leaf {

    public static Widget inflate(KdlNode node, List<Widget> children, Wiring wiring) { … }
}

That is the whole registration. The build then produces, per module:

  1. a GoldberryCatalog implementing WidgetCatalog, whose register calls into.add("button", Button::inflate) for every annotated class — each one an invokedynamic bootstrapped by LambdaMetafactory, the same call site an @Action gets (ADR-0126);
  2. a provides io.…widgets.WidgetCatalog with …GoldberryCatalog patched into the module’s own module-info.class;
  3. a META-INF/services entry, for the same jar on the class path.

and an application writes:

var inflater = Widgets.inflater(icons, model);

which gathers every catalog on the path. A second widget module is found by an application that never names it.

Why the descriptor is patched rather than written

A named module publishes services through its descriptor; META-INF/services is ignored for one. So the provides has to be there — and it cannot be written in source, because it would name a class that does not exist until after javac has run. Patching module-info.class is what the class-file API is for, and it is the same “rewrite the compiled artefact” move the rest of the weaver makes. Both declarations are emitted because a jar has to work in both worlds.

Why ServiceLoader and not a scan

Because it is the only discovery mechanism GraalVM already resolves at image build time. A classpath scan would be a runtime scan, which ADR-0127 spent the whole redesign avoiding. ServiceLoader is the Java answer to this exact question and the closed world already understands it.

Why the interface is not a generated Map

So that a module needing to register conditionally — a widget behind a feature flag, a platform-specific control — can hand-write a WidgetCatalog instead. That is the escape hatch, not the road; nobody writes one today.

Consequences

Controls went from 204 lines to 136 and no longer has an inflater at all — it is the stylesheets and the controlTypes() list, which is what it always should have been. Primitives.inflater is gone entirely: the structural widgets carry @Markup like everything else, so §9’s “built-ins and application widgets register identically” is now literally true rather than nearly.

Widgets.inflater(Object...) is a footgun and needed guarding. During the migration, Widgets.inflater(actions, icons, bindings) bound to the varargs model-taking overload, compiled, and failed at run time reading Actions as a model — eight tests caught it. An exact (Actions, Icons, Bindings) overload now exists so the call means what it looks like. An overload that compiles and means something else is worse than no overload.

The parity test changed from equality to containment. It asserted that the inflater’s names equalled Primitives.builtInTypes(), which was true when Primitives had its own registry. One merged catalog carries tabs, menu, popover and hud too, so it now asserts every declared name is registered, and reports which one is missing when it is not.

The module-info patch is not exercised by :widgets’ own tests, because that source set runs on the class path, where the services file is what gets read. It is checked structurally in CatalogWeaverTest against a descriptor built for the purpose — that the provides is added, that requires and exports survive it, and that patching twice is a no-op — and for real when the showcase runs, which it does modularly. The class-path test asserts it is on the class path, so that this note cannot go stale silently.

A @Markup class must have the right inflate. Java cannot say that in an annotation, so the build checks it: a @Markup class without a public static Widget inflate(KdlNode, List<Widget>, Wiring) is a build failure naming the class, rather than a node that fails the first time a document uses it. Two classes claiming one node name is refused for the same reason.

132. A model wires itself

Date: 2026-08-19

Status

Accepted. Finishes what ADR-0129 started.

Context

After ADR-0129 the application still said this:

Widgets.inflater(
        Showcase.actions(model, this::toggleMenu, this::toggleHud),
        icons,
        Models.bindings(model));

Three registries handed to a call that could have worked out two of them. A model already declares its @Bind paths and its @Action names — fetching both and passing them back is ceremony around a fact the object carries.

Showcase.actions(model, openMenu, toggleHud) is the other half of the problem. Two of the window’s actions are not the model’s: “open the menu” needs a Host, which a view model must not have. So the application wrote a static that took the model’s registry and added two handlers to it, and a test that wanted the same document had to call the same static or pass while the application refused to start.

Decision

Hand over the models. The toolkit reads them.

Widgets.inflater(icons, model, this);

Wiring.of(icons, models…) merges what each model publishes. Icons stay explicit, and only icons: an Icon owns native memory and has to be closed, so markup may name one and must never be able to build one — that registry is a decision the application makes and the toolkit cannot.

More than one model, which is what makes the window’s own actions ordinary. Showcase is itself a @Model now, with @Action("app.open-menu") on the method that opens the menu. The list is List.of(model, this), and a document writes press="app.open-menu" beside press="app.click" without knowing they came from two objects.

The same list is [Application#models()], so the toolkit also uses it to wire repainting (ADR-0128) and restyling (ADR-0133). One declaration answers three questions.

Names may not collide across models

Wiring.of refuses two models claiming one path. Two features quietly sharing one name is a bug that presents as a value changing by itself, and it is no less a bug for the two being in different classes — the check that already existed within a model now spans them.

Consequences

Showcase.start lost the three-registry call, the Models.onChange line, the two restyle subscriptions and the Showcase.actions static. What is left is one Widgets.inflater(icons, model, this) and a models() returning two objects.

Models.bindings and Models.actions still exist, and are still public. They are what Wiring.of calls, and what an application building its registries some other way needs. They are no longer on the path an ordinary application walks, which was the ask; deleting them would have left the escape hatch nailed shut.

The merge has to tell the two halves of the action registry apart. Actions keeps plain and valued handlers in separate maps for a reason — adapting a valued one down to a Runnable would call it with a value it was never given — so the merge inspects each and re-binds it into the right half. There is a test for it, because the failure would be a slider that reports nothing.

A test that wants the real document now constructs the real Showcase. That is better than the static it replaced: the names come from the object the application uses, so a test cannot pass against a list the application does not have.

133. A restyle is declared

Date: 2026-08-19

Status

Accepted. The other half of ADR-0128, which handled repainting and left this behind.

Context

ADR-0128 made a change its own frame request, and the showcase’s onRestyle callback went with it — replaced by two subscriptions:

Runnable restyle = () -> host.restyle();
Models.observable(model, "app.theme").subscribe(value -> restyle.run());
Models.observable(model, "app.density").subscribe(value -> restyle.run());

Which is three lines saying what one word could. Worse, it is three lines with exactly the property ADR-0128 was written to remove: they are never wrong, only ever missing, and when they are missing the symptom is a theme that changes and a window that keeps painting the old one. Moving changed() out of nine methods and into two subscriptions is moving the bug, not fixing it.

A restyle is genuinely not a repaint — every resolved style is thrown away, which is why Host.restyle is a separate call and the common case is a change that moves no rule at all. So it cannot simply be folded into the frame request.

Decision

The field says so.

@Bind(value = "app.theme",   restyle = true) private String theme = "dark";
@Bind(value = "app.density", restyle = true) private Density density = Density.REGULAR;

The weaver emits an extra call in that field’s setter, and whatever installed the model subscribes. An application declares which values a rule depends on and says nothing else.

Before the frame request, deliberately: the setter calls restyled() and then fire(), so a window has dropped its resolved styles by the time it is asked for the frame that will use them. A window that repainted first would paint one frame with the old theme.

Only on a real change, like everything else here: assigning the theme it already has restyles nothing.

Why a flag on @Bind and not a second annotation

Because it is a property of the binding, not a separate declaration. @Restyle on a field would be a second thing to put next to the first, and one that means nothing without it.

Why not a method-level @OnChange("app.theme")

More general, and generality is the wrong instinct here. The toolkit knows what a restyle is; an application saying “call this when that path moves” is back to writing the subscription by hand with a shorter syntax. If an application needs an arbitrary reaction it still has Models.observable(model, path).subscribe(…), which is the honest way to spell an arbitrary reaction.

Consequences

Density became a bound field to get this, having been an ordinary one. Nothing displays it — but “nothing displays it” was never the same question as “does anything depend on it”, and a stylesheet does.

A Property field cannot ask for a restyle, and the weaver refuses one. It rewires no writes to a Property, so there is nowhere to put the call; the error says to hold the value as a plain field or call Host.restyle() directly. That is a real asymmetry between the two kinds of @Bind field, and the only one.

The toolkit now subscribes to every model an application names, in Launcher, after start. A model with no restyling field costs one empty listener list.

@Model(repaint = false) turns the frame request off for a model the UI does not show — one driving a background job — where every write would otherwise wake a window with nothing new to draw. Restyle has no equivalent switch: a field that asked for one asked for it.

134. A write is rewritten wherever it is

Date: 2026-08-19

Status

Accepted. Removes the known gap ADR-0125 shipped with, and is what makes ADR-0136 possible.

Context

ADR-0125 rewrote putfield inside the declaring class only, and listed the consequence honestly:

A field written from outside its declaring class is not observed. An inner class assigning to its outer’s @Bind field compiles to a putfield in a different class, which this transform never sees. […] the failure is silent.

That was tolerable while a model was one class holding both its values and the methods that change them. It stops being tolerable the moment anybody wants the two apart — which is the ordinary request, and the one that arrived. A ShowcaseActions assigning model.clicks++ compiled, ran, changed the field, and notified nobody.

Every way around it was worse. Public setters on the model are the get/set this whole redesign deleted. Making the actions a nested class works, because nestmates share private access — but then “separate class” means “same file”, and the model is not distilled at all.

Decision

Rewrite a write to a woven @Bind field wherever it appears in the same compilation.

The weaver already made two passes — one to learn which classes are models, one to weave them — so pass one now also records each model’s rewired fields, and pass two rewrites putfield against any of them, in any class. A class that is not a model and writes to no model is still not touched at all.

Two things follow:

  • The synthesised setter is package-private, not private. A sibling class has to be able to call it.
  • A write from another package is refused, at build time, naming both classes. The setter is package-private, so the call would not verify; an IllegalAccessError at the first click is not an acceptable way to find that out.

Constructors keep their exemption, but only for the class’s own fields: nothing can have subscribed to a model still being constructed, while a write to somebody else’s model from inside a constructor is an ordinary write to an object that was built long ago.

Consequences

A model’s fields go from private to package-private the moment its actions move out. That is a real loss — ADR-0125 was pleased that a model kept its fields private — and it is the direct price of the split. It is not public: nothing outside the application’s own package can reach a field, and the alternative was accessors, which are worse and reach further.

An application that keeps its values and methods in one class loses nothing and should carry on doing so. The split is available, not required.

The rewrite is per compilation, not per program. Pass one sees one tree of classes — one module — so a class in module B writing to a model in module A is not rewritten. In practice that is the same rule as the package check, since JPMS forbids split packages, and it is worth stating rather than discovering.

The bug that found the implementation was mine. Composing two transformingMethodBodies with complementary predicates — one for constructors, one for everything else — silently drops every rewrite: the second pass no longer sees the code elements the first handed on. Every notification test failed at once, which was the good outcome; the fix is a single transform that picks per method.

135. A frame is asked for by the value that moved

Date: 2026-08-19

Status

Accepted. Refines ADR-0128, and supersedes the @Model(repaint = false) it introduced.

Context

ADR-0128 made a change its own frame request and put the opt-out on the class:

@Model(repaint = false)
public final class BackgroundJob { … }

The granularity is wrong, and obviously so once a real model is written. One model routinely holds both the gain a slider shows and the byte counter nothing shows. A switch on the class has to be wrong about one of them, and the way out of being wrong is to split the model along a line that has nothing to do with what the model is about — which is the tail wagging the dog.

The showcase hit it immediately: added, the counter behind “Untitled 3”, is genuinely part of what the model knows and is displayed by nothing.

Decision

The value says whether showing it needs a frame.

@Bind("app.gain")                              Number gain = 40;    // asks
@Bind(value = "app.tabs-added", repaint = false) int added;         // does not

@Model(repaint = false) is gone. There is one place to look, and it is next to the value the question is about.

Decided in the build. The weaver emits the repainted() call in the setters of fields that ask and emits nothing in the others — so a value declared repaint = false costs an instruction that is not there rather than a branch that is. The runtime has no notion of which fields are quiet, and needs none.

It is not “do not observe”

A field declared repaint = false still notifies everything bound to it. Off means do not wake the window; something else may perfectly well be watching a value nothing on screen shows. There is a test for exactly that, because the two are easy to conflate and the conflation would be silent.

Consequences

The three signals a change can raise are now all per value and all declared in one place:

DeclaredFires
the binding@Bind("a.b")always, on a real change
a frameby default; off with repaint = falseafter the binding’s listeners
a restylerestyle = truebefore the binding’s listeners

Which reads as one idea rather than three, and is a better answer than the two places it took before.

Models.repaints(model) is gone, and with it the only thing that read @Model’s members at run time. @Model is now a bare marker again — still RUNTIME-retained, for the single purpose of telling an author their class was never woven.

A model with every field quiet still gets a repainted() listener list, empty and never fired. One null field on one object per model; not worth a switch.

136. An application is values, actions, views

Date: 2026-08-19

Status

Accepted. Names the shape the previous eleven records arrived at, and writes it down as a guide rather than leaving it to be inferred from the showcase.

Context

Nine records between ADR-0125 and ADR-0135 changed how an application is written, each for a local reason: a model stopped holding Property, then stopped holding accessors, then stopped asking for frames, then stopped merging registries. Each step was an improvement and none of them said what the result was.

That gap shows up as questions no record answers. Where does a scroll offset go? Is a derived getter logic or a value? What may a widget know? Somebody reading the log gets eleven local decisions and has to infer the shape, which is exactly the thing a decision log is bad at.

The showcase demonstrated it and did not explain it — and until this record, did not even follow it: ShowcaseModel was 248 lines of values and the methods that changed them.

Decision

Four kinds of class, and the arrows only point one way.

Values ────────► Views ────────► Actions ────────► Values
   (read)          (report)         (assign)         (notify)
  • Values — a @Model class of plain fields. Knows nothing: no widget, no window, no toolkit type beyond @Bind.
  • Actions — a @Model record wrapping the values. One method per thing a control can ask for. Knows the values, and nothing else.
  • Views — Widget records, or a .kdl document. Knows the values it reads and the actions it calls, and cannot write, because what it is handed is an Observable with no set on it.
  • Application — one implements Application. The only class that knows a window exists.

models() is the whole of the wiring: from that one list the toolkit resolves a document’s names, repaints when a value asks, and restyles when a value declared restyle = true moves.

Values are a class; actions are a record

Not a style preference — each is the only shape that works. A record’s components are final, and a bound field has to be assignable, so the values cannot be a record. The actions hold one thing immutably and have no state of their own, so they are the half a record fits exactly. “Wanting a mutable field on the actions” is the signal that the thing is state and belongs with the values; the showcase’s added counter went that way.

Splitting is available, not required

An application that keeps its values and its methods in one class loses nothing. The split costs private on the fields — a class that assigns to another’s field has to see it, and the package is the smallest visibility that allows it (ADR-0134). Worth it when the values are worth reading on their own; not worth it for a model with three fields.

Derived reads stay with the values

theme() turning "light" into Theme.NORD_LIGHT is a question about the fields with exactly one answer. Putting it in a class named for writes would be worse than leaving it. “No logic in the model” means no transitions, not no projections.

Consequences

ShowcaseModel went from 248 lines to 125, and is now fields and four projections. ShowcaseActions is 157 lines of one-line methods. Neither is shorter than the sum was; what changed is that one of them can be read in isolation and answers “what does this application know?”.

The showcase now has three models — values, actions, and the window itself — which is the shape the guide describes and a useful proof that multi-model wiring is not a special case.

Views gained a constructor parameter. Content(model, plus) became Content(model, actions, plus), and so on. That is the arrow made explicit: a view that reads and reports now says so in its signature, where before it took one object that did both.

book/src/applications.md is the deliverable, and this record exists mainly to date it and say why it was needed. A shape that lives only in a showcase is a shape every reader re-derives.

The risk is drift. The guide describes what the showcase does, and nothing enforces the agreement. A test could — “no @Action on a class with @Bind fields”, say — and deliberately does not: the split is a recommendation, and mechanically enforcing a recommendation turns it into a rule nobody agreed to.

137. A model keeps its fields

Date: 2026-08-19

Status

Accepted. Repairs the cost ADR-0134 accepted and ADR-0136 wrote down as unavoidable.

Context

ADR-0134 let a separate class assign to a model’s @Bind fields, and charged for it:

A model’s fields go from private to package-private the moment its actions move out. That is a real loss […] and it is the direct price of the split.

It was presented as arithmetic — javac cannot see a private field from another top-level class, therefore the fields open up — and the arithmetic is right for the case it considered. It considered the wrong case. Two top-level classes in one package is not the only way to have two classes.

Nestmates (JEP 181, Java 11) share private access in both directions. A nested Actions reads values.clicks with an ordinary getfield and calls a private method with an ordinary invokevirtual; javac emits no accessor and the verifier is satisfied. So the whole cost was avoidable, and was paid for a commit.

Decision

Nest the actions, and nothing opens up.

@Model
public final class Settings {

    @Bind("app.gain") private Number gain = 40;

    @Model
    public record Actions(Settings values) {
        @Action("app.louder") public void louder() { values.gain = … }
    }
}

Fields stay private. The weaver’s synthesised setters stay private too, and that is the second half of this record: the setter’s visibility is now derived rather than fixed.

The build already walks every class to find writes. It now also asks, for each model, whether any writer is outside the model’s nest — comparing NestHost attributes, which is exactly the question the JVM will ask later. Setters are emitted private unless the answer is yes.

That matters because a package-private goldberry$set$gain is a real hole: any class in the package could call it and set a private field. Small, obscure, and synthetic — and still a hole that existed only because the weaver could not be bothered to work out whether it was needed. Now it is only there when it is.

Sibling top-level classes still work

They are refused nothing. A Settings and a SettingsActions side by side get package-private fields (javac’s requirement, not the weaver’s) and package-private setters (now the weaver’s, following javac’s). What changed is that this is the fallback rather than the only shape.

Nesting is scoping, not coupling

The obvious objection is that the values class now “knows about” its actions. It does not: Settings holds no reference to Actions, names it in no signature, and compiles with it deleted. A nested type is a name inside a namespace. The arrow still points one way.

Consequences

ShowcaseModel is one file again — 272 lines, of which the first 120 are values and the rest a nested record. That is the trade this makes: one file, or open fields. ADR-0136’s guide recommends the nested form and says why; an application that would rather have two files can still have them.

A model written to from two nests gets package-private setters for all its fields, not just the ones written from outside. The analysis is per model, not per field, because a finer one would buy nothing — a model’s actions are one class in practice, and the whole question is whether that class is inside or outside.

The nest check needs the whole compilation, like every other cross-class rule here. A writer in another module is not seen, and is already refused by the package check.

138. A window’s actions are a model of their own

Date: 2026-08-19

Status

Accepted. Removes a leak ADR-0132 introduced.

Context

ADR-0132 made the window’s own actions ordinary by annotating the application:

@Model
public final class Showcase implements Application {

    @Action("app.open-menu") private void toggleMenu() { … }
}

It solved the real problem — “open the menu” needs a Host and has no business on a view model — and it solved it by putting two unrelated roles on one class. An Application is the thing that owns the window, the lifecycle and the native resources. A @Model is a thing markup resolves names against. Stacking the annotation on the implements made that visible in the worst way: the first two lines of the application’s class declaration are now about two different abstractions.

It was also the only place in the guide where the four kinds of class did not hold — the application was quietly a fifth thing.

Decision

A small @Model record for the window’s actions, holding what they do rather than what does them.

@Model
public record WindowActions(Runnable openMenu, Runnable toggleHud) {

    @Action("app.open-menu")  public void open() { openMenu.run(); }
    @Action("app.toggle-hud") public void hud()  { toggleHud.run(); }
}

built in the application as new WindowActions(this::toggleMenu, this::toggleHud) and added to models(). Showcase carries no annotation and keeps its methods private.

Two Runnables rather than a reference to the window, so this type knows what the actions are called and nothing about who performs them. It is testable without a Host and reads as what it is: the window’s half of the name table.

Consequences

The application is an Application again, and the guide’s four kinds of class hold everywhere including the showcase.

It is one more object for two names, which is the cost. A window with a dozen actions would find this obviously worthwhile; a window with two finds it a wash, and the reason to do it anyway is that the alternative was a leak that got worse with every action added.

models() is now three entries — values, actions, window actions — which is a better demonstration of multi-model wiring than the two it replaced, since the third one genuinely comes from somewhere else.

139. Actions are annotated as actions

Date: 2026-08-19

Status

Accepted. Finishes ADR-0138, which moved the window’s actions off the Application and left them still calling themselves a model.

Context

@Model marked two different things. A class of @Bind values is a model. A class of @Action methods that operates on somebody else’s values is not — it holds nothing, publishes no paths, and is the opposite half of the pair. Marking both @Model said otherwise on every one of them:

@Model                                  // holds no values
public record WindowActions(Runnable openMenu, Runnable toggleHud) { … }

ADR-0138 fixed the worst instance — @Model sitting on top of implements Application — by extracting exactly this record, and in doing so made the mislabelling more obvious rather than less: a type whose entire content is actions, called a model.

Decision

@Actions marks a class of @Action methods. @Model keeps the values.

@Model
public final class Settings {

    @Bind("app.gain") private Number gain = 40;

    @Actions
    public record Commands(Settings values) {
        @Action("app.louder") public void louder() { values.gain = … }
    }
}

Both markers produce the same woven shape — BoundModel, a listener store, and the two registries — because a class with no @Bind field simply has an empty half. What the second annotation buys is that the declaration says what the class is.

Three rules, all build failures:

  • @Actions with a @Bind field is refused: a class that holds values is a model, and the message says to annotate it @Model or move the field.
  • @Actions with no @Action method is refused, the same way an empty @Model already was.
  • Both markers on one class is refused. A class holds values or it does not.

A @Model may still carry @Action methods. That is the right shape for a model small enough that splitting it would be ceremony (ADR-0136), and taking it away would have made a second annotation a tax rather than a clarification.

The registries were renamed to make room

io.github.digitalsmile.goldberry.bind.Actions was already taken — by the registry of name-to-handler that Wiring holds. So Bindings and Actions became BindingRegistry and ActionRegistry.

That is a rename made to free a name, which is a bad reason on its own. It turns out to be an improvement for a better one: there were four near-identical names in one package — Bind, Bindings, Action, Actions — where two were annotations on members and two were runtime registries. Now each family reads distinctly: @Bind/@Action on members, @Model/@Actions on types, BindingRegistry/ActionRegistry as the things they resolve against.

Both registries are plumbing an application no longer touches: Wiring.of builds them and Models.observable reads them.

Consequences

A type named Actions cannot use the simple annotation name. Inside a class that declares a nested Actions, @Actions resolves to that record rather than to the annotation, and the fully-qualified name is required:

@io.github.digitalsmile.goldberry.bind.Actions
public record Actions(ShowcaseModel values) { … }

That is a real wart and it is in the showcase, which uses exactly that name. The way out is to name the type for its domain — Commands, Editing, Playback — which reads better anyway; Actions describes the annotation’s job, not the class’s. The guide recommends it and the showcase deliberately does not, so that one worked example of the wart exists to look at.

Renaming across markdown was a mistake, briefly. The first pass rewrote Actions in prose as well as in code, producing “GitHub ActionRegistry matrix” and “Bindings are hand-written” in a dozen ADRs. Reverted: the decision log records what the names were when the decision was made, and this record is where the rename belongs.

140. A widget may reach its window

Date: 2026-08-19

Status

Accepted. Answers the gap ADR-0100 left open — “an overlay cannot be raised from inside the tree” — and narrows ADR-0106 rather than reversing it.

Context

ADR-0106 is right and stays right: a menu is a widget and opening one is a call, because opening needs a Host — something to measure the panel, ask the platform for a window and close it again — and a widget is a value described afresh every frame. One holding the window it is drawn in would be describing its own surroundings.

select breaks the analogy at the one place it matters. Opening a menu is something an application does: a menu bar, a toolbar button, a right-click handler. Opening a dropdown is something the control does, and nobody else can — a user clicking a select has not asked the application anything, and a control that needed Selects.open(host, …) wired up per instance would be a control that does not work when a document writes it:

select bind="app.theme" change="app.pick-theme" { option value="dark" "Dark" }

There is no application code on that line to hold a host with.

Two shapes were already in the tree and neither fits. Tour takes a Host as a record component, handed in by Tours.start(host, …) — fine for a widget only Java builds, and impossible for one a document writes. And Wiring could have carried a host to the inflater; that puts a window in the thing whose job is resolving names, and would still leave a Java-built Select with no way to get one.

BuildContext is where the answer was, and ADR-0100 said so: “what it wants is Flutter’s Overlay.of(context)”. It was not built then because there was one consumer, and an interface designed against one caller is designed twice (ADR-0019). There are two now.

Decision

BuildContext.host() returns the window an element is being built into, as an Optional.

@Override
public Widget build(BuildContext context) {
    host = context.host().orElse(null);          // captured, not read
    return new SelectField(…, this::toggle, …);  // which uses it on a click
}

The window is held on the ElementTree and not on each element, because it is the same answer for every node in one tree and a different answer in a popup’s: new ElementTree(root, host). The launcher passes itself when it builds the application’s tree and when it builds a popup’s, so a select inside a menu opens against the window that owns the menu.

For acting, not for reading. BuildContext is documented as deliberately narrow — a build must be a pure function of its widget, its state and the context, so that anything reachable through it is something the framework can invalidate. A host does not fit that rule if a build reads from it: Host.anchor answers from the last painted frame, and nothing invalidates a build that depended on it. What a build may do is capture the host for a handler that runs later, which is outside the build entirely. The javadoc says so; nothing enforces it, for ADR-0136’s reason — mechanically enforcing a recommendation turns it into a rule nobody agreed to.

Empty is a normal answer, and the reason this is an Optional rather than a nullable getter. A widget test builds new ElementTree(widget) with no window behind it, and so does every golden image. A control that threw there could not be drawn at all, and one that silently did nothing would be indistinguishable from a bug — so the contract is stated: no window, no popup, and the control draws its closed form.

Consequences

A widget can now open a platform window, and must close it. A popup is not a value and is not collected with the tree, so a state that opens one closes it in dispose() — otherwise an element that goes away leaves a window parented to nothing. That is the first resource a widget in this toolkit owns, and it is why SelectState has a dispose at all.

A press that dismisses a popup no longer also activates what it hit. This fell out of the first control to open its own popup and is a general rule the toolkit was missing: with a list open, the press on the field that dismisses it must not also be read as “open it”, or the control toggles twice and never closes. The launcher already took the press for the secondary button (ADR-0108); it now takes any press that actually closed something, which is what every desktop does — the click that puts a menu away does not also press the button underneath it.

Popup.focusOn(String) exists, because a control that has already chosen opens on the row it chose. See ADR-0141.

Menus does not change. Opening a menu is still an application’s call and still takes a host explicitly, because that is what a menu bar and a context menu actually are. What has changed is that a widget which must open something for itself now can, and toast, dialog, date-picker and autocomplete all want exactly this door.

A widget that reaches for host() when it wants an ancestor is doing it wrong. findAncestorState is still how a descendant asks the thing it is inside for something (ADR-0120); this is for the case where the answer is not in the tree at all, because a second platform window is not in anybody’s tree.

141. A select is a closed control and a list

Date: 2026-08-19

Status

Accepted. Closes M2’s catalog: select was the last control in docs/core-widgets.md §3 with a specification, a metrics row and no code.

Context

§3 asks for “closed control + popup list (backend popup window, so it escapes window bounds); typeahead; keyboard open (Space/Alt+Down), arrows, Enter/Esc. Option model or inline KDL option children.”

Everything that sentence needs now exists and did not before. scroll unblocked a list longer than the screen (ADR-0116), host.popup(content, anchor, placement) measures and places a panel against a rectangle (ADR-0104), and ADR-0140 gives the control a way to ask for one.

The value model needed nothing new at all: it is segmented’s, which is radio-group’s, which §3 says outright.

Decision

option moved, because it now has two callers

§3 gives segmented and select the same child node. Option lived in …controls.segmented under ADR-0092’s rule about not generalising from one caller, and book/src/TODO.md recorded exactly what would move it. It is …controls.option now — one record, one CSS type, one markup name, two controls.

Option.within(…) went from package-private to public with it, and the visibility was not protecting what its comment claimed. Both controls rewrite every option on every build, so a selected an application sets is discarded before it is drawn. What keeps a set from having two selected options is that “exactly one” is computed in one place from the bound value and stored nowhere.

The drawing is not shared and does not need to be. A segment is a cell in a bar and a choice in a dropdown is a row: segmented option against select-list option. That is a descendant selector telling one widget’s two surroundings apart, which is not the improvisation ADR-0065 warns about — that one uses an ancestor to tell two different widgets apart. The difference is whether the selector describes where a thing is or what it is.

Two keyboards, and the option carries which one it is in

This is the one place the shared node genuinely diverges, and §3 says so in as many words. A radio-group — and therefore a segmented — has “arrow keys move selection (roving focus)”: the arrow is the choice. A select has “arrows, Enter/Esc”: the arrow moves and Enter chooses.

Option.inAList() is that difference, one flag rather than two because the two halves are one decision: a set where the keyboard chooses has no use for a separate commit, and a set with a commit must not choose before it. It also unlocks Enter, which every other control in this catalog refuses on the grounds that it belongs to a dialog’s default action — a list is in a popup over everything, and there is no default action behind it to take.

This was found by a test and not by reading. The first cut left the roving behaviour on, so the first Down in an open list selected a row, and selecting closes the list — leaving the second and third arrows with nothing to move. arrowsMoveTheHighlight is that failure, kept.

What it is made of

select                 stateful, styles nothing, holds whether the list is open
└── select-field       CSS type `select`: focusable, takes the click and the keys
    ├── select-value   the chosen label, or the placeholder — `.placeholder` marks which
    └── select-chevron the mark saying there is a list under this

select-list            in a popup window of its own, when open
└── option × n         the same widget a `segmented` puts in a bar, `.inAList()`

Stateful and unstyled for ADR-0116’s reason: a stateful widget that also carried the CSS type would put two select nodes in the cascade, one inside the other, and every rule would apply twice. Parity is checked against what the widget describes, which the parity test already knew how to do.

select-list is a sibling of menu rather than a use of it. The two are the same drawing and different meanings — §3’s list is a set of values, §8’s is a set of commands — and neither has an ancestor to be told apart by, because each is the root of its own tree (ADR-0103).

It anchors to itself, by rectangle and not by id

SelectField implements Located, so it is told where the last frame painted it and hands that to the state, which opens the popup against it. Anchoring by id was the alternative and is worse here: a select a document gave no id would have to be given a generated one to be able to open itself, and two of them in one window would then depend on that generation being unique.

The rule Located carries — a widget told where it is must not move itself — holds trivially: nothing here does anything with the rectangle until something is clicked.

The list opens on the row that is already chosen

Popup.focusOn(String id) is new. A popup focuses its first focusable node after its first frame so that an arrow has somewhere to start (ADR-0112); for a select showing its third option, that makes Down mean “the second option” whatever the value was, which is a control that loses the user’s place every time it opens.

Focused not “from the keyboard”, which matters more here than it does for a menu: a row focused from the keyboard would be chosen on the spot in a control whose options rove, so opening the list would report a change nobody asked for.

The field is a field

§3 files this row with text-input rather than with the buttons — “height 32 (28); padding-x 8; radius 4” — so it carries a border and a 4px radius where button carries 8 and none. Its fill is --gb-surface-2 rather than --gb-surface, because a field whose fill was the panel behind it is a control held together by one pixel of border. That is the defect controls-on-surface-* exists to catch, which is why select is in that scene rather than exempt from it.

Consequences

M2’s catalog is complete. Every §3 control is built.

Typeahead works on the closed control and not in the open list. A TextEvent goes to the focused node, which in an open list is an option, and there is no text capture phase for the list to intercept it in — onKeyCapture exists and onTextCapture does not. Recorded in book/src/TODO.md with what it needs.

A select is as wide as its current value. Nothing sizes it to its widest option, because no selector can measure options — the same wall segmented hit, where the answer was that the control writes the width itself (ADR-0099). An application gives it a width today, which is what a form does anyway. Also in TODO.

multiple, autocomplete and tree are not built, and two of the three are waiting on widgets rather than on decisions: autocomplete=#true makes the closed control an editable text-input, and tree=#true takes a tree’s model. Neither exists. multiple=#true renders the selection as badge chips, which do exist, and is deferred as scope rather than as a blocker.

The list is not clamped to the screen and does not scroll. Menus caps its own content by estimating a row height (ADR-0118); select does not, so a list longer than the display is clamped by Placement as every other popup is. It is the same gap, in one more place, and it will be fixed in one place when the popup facility can report what it measured.

142. A style handed down keeps its identity

Date: 2026-08-19

Status

Accepted. Repairs ADR-0070’s cache, which had a hole in it from the day ADR-0099 added the inline seam.

Context

The frame was costing 10–15 ms in a window where nothing had changed, and worse with a tour or a menu on screen. The obvious suspects — the popup’s second window, the veil, damage tracking — were all innocent. Measured on the showcase’s own tree, one render of a settled screen was 10 069 µs for 77 elements, and counting cache lookups said why: 56 of 72 styled elements missed on every frame, and every one of them missed for the same reason.

ADR-0070’s cache is keyed on two things by identity: the resolver, and the style this node’s parent handed down. The second half is what makes inheritance invalidate itself — an unchanged parent hands down the same instance, so its children keep their entries, and a parent that really changed hands down a different one and its children re-resolve without anything having to tell them.

The style a parent hands down is not the one it cached. [Styled#restyle] runs after the cache — deliberately, because a widget-computed value must not be cached (ADR-0099) — and it returns a whole ComputedStyle. Every widget that writes an inline value therefore allocates a new one on every frame, whether or not anything in it moved:

// ScrollContent
public ComputedStyle restyle(ComputedStyle resolved) {
    var style = resolved.flexShrink(0);      // a new instance. Every frame. Always.
    …
}

So every node under a scroll, a tab or a segmented re-resolved on every frame. In the showcase every screen is inside a scroll, which is to say: the whole window.

Decision

A node hands its children the same ComputedStyle instance for as long as that style keeps its value.

self = element.stableStyle(styled.restyle(self));

Element.stableStyle holds the last style this node handed down and returns it again when the candidate is equal to it. ComputedStyle is a record of records, enums and primitives, so that is a flat value comparison — against a re-resolve that costs two orders of magnitude more.

The stored instance is deliberately not cleared by invalidateStyle. It is not a cache of this node’s answer; it is the identity its children are keyed on, and dropping it would make every descendant re-resolve after an invalidation that changed nothing they can see. A stale one that no longer matches is simply replaced.

Measured, one render of a settled screen:

screenbeforeafter
Controls10 069 µs294 µs
Values8 125 µs126 µs
Text2 607 µs50 µs
Overlays2 923 µs17 µs
Tabs5 036 µs22 µs

Consequences

The cache now works where it was written to work. ADR-0070 claimed style resolution was the largest term in a frame and cached it; the claim was right and the cache was reachable only by a tree with no inline value anywhere in it, which the showcase stopped being the day scroll shipped.

A widget’s restyle no longer has to be careful. It may allocate freely and return a fresh style every time — which is the natural way to write one, and what all seven of them do. The identity discipline the cache needs lives in one place rather than in every widget that writes a value.

A style that really moves still moves. Scrolling changes the transform, so the instance changes and the subtree re-resolves — the same conservative behaviour as before, and the case where it costs something. Narrowing that to “only the inherited properties changed” would fix scrolling too, and is not done here: it needs a notion of which properties inherit, which the cascade has and ComputedStyle does not, and inventing one for a case nobody has reported would be guessing at the next problem while this one is measured.

The test asserts the mechanism, not a duration. StyleIdentityTest builds a widget whose restyle allocates — the exact shape of ScrollContent — and asserts its child is handed the same instance on the second frame, and a different one when the value really changes. A timing test would pass on a fast machine with the bug still in it.

143. A strip keeps its height, and an icon its centre

Date: 2026-08-19

Status

Accepted. Two drawing defects with one shape, both reported by looking at the running window rather than at a test.

Context

A tab strip took its height from the tallest thing in it. That is a tab while there are tabs, and the + button when the last one is closed — 24 rather than 32 — so closing every tab shrank the header row and left the + sitting at the top of a row that was no longer as tall as it. The same rule was quietly wrong with tabs in it: the showcase’s strip measured 30 where its tabs are 32, which is a row two pixels shorter than its own contents.

A menu icon was drawn at the corner of its column. The leading slot is 16 square, because it has to be one width whether it holds a tick or an icon (ADR-0113) — and an Icon is rasterized at whatever size the application built it, which cannot be changed after the fact (ADR-0043). The painter drew it at the box’s origin, with a comment arguing that a stylesheet which resized the box should not make the icon “drift to a centre nobody asked for”. The showcase builds its menu icon at 20, so the glyph hung four pixels above and left of the tick it lines up with, and that row read as the odd one out.

Decision

tab-list has a height of its own — var(--gb-control-height) — rather than taking one from its content. A header row is a row of headers: it is one control tall by definition, and the number of tabs in it is not what decides that.

An icon is centred in its box. In the common case the box is the icon, because Box.icon sizes it — so the offset is zero and nothing changes. Where a stylesheet said otherwise, centring is what a slot means. The old comment had it backwards: a glyph parked in the corner of a slot is not “staying put”, it is the report “the row with the icon looks wrong”.

Consequences

The gallery’s controls goldens moved by two pixels, and that is the tab strip being the right height rather than a regression. Everything below the header row shifted down with it.

A menu icon larger than its column still overflows it, symmetrically now rather than into the label. The toolkit cannot resize an Icon, so an application that wants its menu icons to fit the column builds them at 16 — which is what §3 sizes a glyph at, and what the showcase should have been doing. Centring is what makes the wrong size look merely large instead of misaligned.

Both are pinned by pictures, because both are facts about where something is drawn and neither changes a number an assertion can reach. menu-icon-oversized.png is built at 20 on purpose: the other menu images build 16, which is exactly why they never showed this.

144. A popup goes away when the application does

Date: 2026-08-19

Status

Accepted. Adds the focus half of the window SPI, which ADR-0103’s light dismissal had been living without.

Context

Open the showcase’s menu, click on another application, and the menu is still there — floating over the window you switched to, because a popup is always-on-top by kind. Every desktop puts a menu away when its application stops being the active one, and this one had no way to know: there was no focus event at all, neither in BackendEvent nor in the SDL translation.

Light dismissal covered a press inside the owner window and Escape. Neither of those happens when the user clicks somewhere else entirely.

Decision

BackendEvent.FocusChanged(window, focused), translated from SDL_EVENT_WINDOW_FOCUS_GAINED and SDL_EVENT_WINDOW_FOCUS_LOST — two more constants verified against the compiled C like every other one, which is the check that caught them being unregistered on the first build.

Reported per window, because that is what every platform reports and the honest thing for an SPI to carry. “The application lost focus” is a conclusion drawn from the whole set, and only one thing needs to draw it, so only one thing does: the launcher watches every window’s focus through the runtime, and closes every light-dismissed popup when none of them has it.

The check is deferred by 60 ms, and that is the whole mechanism rather than a fudge. Opening a popup is a focus-lost for the window under it, immediately followed by a focus-gained for the popup — so a menu that acted on the first of those would close as it opened. One turn of the event loop later, both have arrived and the question has its real answer.

Consequences

A menu no longer outlives the window that owns it. The reported symptom, and the worst kind of it: an always-on-top rectangle over somebody else’s application.

A tooltip is unaffected, because it opted out of light dismissal and this goes through the same door. A tooltip is dismissed by the pointer leaving, which is a different fact.

60 ms is a number, and it is stated rather than tuned. Short enough that nobody sees the menu over the other application, long enough to cover a focus pair the compositor delivers in two batches. If a driver is found that takes longer, this is where it is written down.

The headless backend never sends these events, which is what makes them testable: PopupLifecycleTest posts the pair a compositor would and asserts both outcomes — the popup closes when focus left the application, and stays when it merely moved to the popup itself. The second test is the one that would have caught the naive implementation.

145. A dropdown is as wide as what it drops from

Date: 2026-08-19

Status

Accepted. Adds one parameter to ADR-0104’s measure-then-place, for the one caller that needs it.

Context

select opens a list measured from its content. A field stretched across a form therefore opened a panel as wide as the word “Dark”, hanging off its left-hand end — which reads as a mistake rather than as a menu. Every dropdown on every desktop is at least as wide as the control it drops from.

No measurement of the content can produce that number: it is a fact about the anchor, and the anchor is the caller’s.

Decision

Host.popup(content, anchor, placement, minimumWidth) — a floor, not a width. The content still decides the rest, so an option longer than the field widens the list past it, which is the other half of the same rule.

The floor is applied inside the existing two-pass measurement rather than beside it. Pass one is the content’s natural size, as before; pass two lays it out with a definite width — which is what both the window-width cap and this floor need, and which is what makes the content fill the floor rather than merely be placed in a wider window. Content with a width of its own, which will not stretch, still gets the window the floor asked for: the floor is about where the popup’s edges are, not about what is drawn inside it.

Opt-in per call, and not a property of Placement. It is false for the other two callers: a menu is as wide as its commands and a tooltip as wide as its text, and neither has any business being as wide as the thing it points at. Placement is about position; this is about size.

Consequences

select passes its own painted width, which it already knows because SelectField is Located (ADR-0141). No new plumbing, and no id to anchor by.

The floor is bounded by the window’s width, like the cap it shares a pass with: a popup wider than the window it belongs to is not what any anchor meant.

Host grew a method rather than a default, so every implementation says what it does with the floor. There are two stubs in the test suite and they were the only cost.

146. A HUD shows where the frame went

Date: 2026-08-19

Status

Accepted. Extends ADR-0101’s hud with the four stages a frame is made of.

Context

The showcase spent a month painting at 10–15 ms with nothing on screen moving. The HUD said paint 12.4 ms, which was true and useless: a total says a frame is slow and says nothing about which part of it is. Finding the answer took a purpose-built probe, a counter compiled into the renderer and two rounds of guessing — and the answer, when it came, was that the style cache had stopped hitting the day scroll shipped (ADR-0142).

A number on screen would have said so on the first frame.

Decision

Four more readings — build, style, layout, raster — and one word for the set of them.

hud readings="stages"
host.overlay(Hud.stages(), Corner.BOTTOM_END);

The launcher’s painter takes five nanoTime readings and hands the four intervals to the frame ring, which keeps them in the same 60-slot window as the paint time. Ungated by log level, for the reason ADR-0101 gives about the rate itself: a diagnostic you have to reconfigure the process to see is one nobody looks at, and five timestamps against a frame costing hundreds of microseconds is not a cost worth a branch.

The four do not add up to paint, and are not asserted to. The hit-test capture and the frame’s own setup are in the total and in none of the stages — neither large enough to name nor zero. Making them add up would mean either a fifth “other” reading nobody can act on, or moving work around to make a display tidy.

Two decimals for a stage, one for a total. 0.0 ms cannot be told from a stage that is not running, and telling those apart is the whole use of a breakdown. Three ranks in the stylesheet — the rate bright, the total dim, the stages dimmer and one size down — because six equally bright numbers on one plate read as a wall.

Consequences

The showcase turns it on, so the thing that gets looked at is the thing that would have shown the defect.

A raster that jumps when nothing moved is a buffer that stopped retaining, not a scene that got harder — the damage-tracking half of ADR-0072 made visible for the first time.

A build above zero is a widget dirtying itself every frame, which is the one failure this catalog has repeatedly produced and never had a reading for.

FrameStats grew four methods with a default of zero, so a source that does not measure the stages — every one but the frame loop’s own — reports zero rather than lying. A HUD with no loop over it still draws dashes, which is the existing distinction between “not measured” and “measured as nothing” (ADR-0101).

147. A frame has a budget, and the build checks it

Date: 2026-08-19

Status

Accepted. The regression test ADR-0142 should have had before it needed it.

Context

A 34× performance regression lived in this repository for a month with every test passing. FrameBenchmark in :widgets measured the engine on a synthetic 15-node tree, reported 0.6 ms for a whole frame, and was right: the defect only appears in a tree with a scroll in it, which is to say in a real application and not in a benchmark’s fixture.

A benchmark that prints is a benchmark nobody reads on a green build.

Decision

FrameBudgetTest measures the showcase’s own tree at five resolutions, prints the table, and fails the build when a stage is over its ceiling.

  resolution          build    style   layout   raster
  800x600            0.000    0.076    0.010    1.113  (ms, median)
  1920x1080          0.000    0.060    0.005    1.579
  3840x2160 @2x      0.000    0.035    0.005    6.671

Ceilings, not stored comparisons. A test that compared against a recorded number would fail on a slower machine and pass on a faster one that had regressed. Every budget is written beside the measurement it is a multiple of — style measures 0.03–0.08 ms and is allowed 1 ms — which is useless against a 20% drift and exactly right against what actually happens. The bug this exists for was 34×, and it trips this test at 10.1 ms against a 1 ms budget, naming the stage and the resolution.

Two structural claims that hold on any machine, and they are the sharper half:

  • Style and build do not grow with the pixel count. The cascade runs per element and a 4K window has the same elements as an 800×600 one. A style cost that followed the pixels would be a cache keyed on something it has no business being keyed on — one letter from the defect that prompted this.
  • A settled render is two orders of magnitude cheaper than a cold one. Measured at 450–520× with the cache working and 11× with ADR-0142’s defect reintroduced; the threshold is 40, which is a chosen number rather than a picked one.

Zero Blend2D workers, and the first run of this test is why: a threaded context queues its work and blocks at frame.end(), so a timing loop around paint measures submitting a frame — which reported a 4K raster as cheaper than an 800×600 one. FrameBenchmark pins it for the same reason and the note was already there to be read.

A warm-up sweep before the measurements, because without one the first resolution measured costs three times the last whatever order they are in, and every ratio in the class becomes a measurement of C2.

Consequences

It runs in test and not in benchmark. That is the point: a regression has to fail a normal build. The cost is about four seconds and the risk is a slow or loaded runner tripping a ceiling — which is why every failure message says “either something regressed or this machine is slower than the one the budget was written on” and prints the table that answers it.

The budgets are this machine’s, and the first CI run on other hardware is what will say whether the multiples are right. They are deliberately loose enough that the answer should be yes.

FrameBenchmark stays. It measures the engine’s parts against each other — retained against throwaway, damage against whole-frame — which is a different question from “is a real frame still fast”, and neither replaces the other.

raster is the only stage that scales with the frame, and the table now says so on every run: 1.3 ms per megapixel at 800×600 and 0.83 at 4K, because a frame has a fixed cost a small one cannot spread.

148. A menu row does not wrap

Date: 2026-08-19

Status

Accepted. Explains a report — “the menu item after the iconed one is vertically aligned to top” — that four rounds of measuring the rows could not reproduce, because the rows were never wrong.

Context

Every row of the showcase’s menu measures 32 tall, and every label sits 8 from the top of its row, at both densities and at every display scale. The layout was right. What was reported was the text, and the text was right too — until the menu was narrower than its content.

A menu row is a row of measured leaves: Box.text asks the paragraph how tall it is at the width Yoga proposes. Nothing in controls.css stops those boxes shrinking, so a row squeezed narrower than its content does not clip the label — it wraps it. A two-line label measures 32 in a 32-tall row, and align-items: center then puts it at the row’s top edge, against 8 for every row that still fits.

The widest row wraps first, and the widest row is rarely the one with the icon: “Switch density Ctrl+D” is longer than “Switch theme Ctrl+T”. So the symptom presents as the row after the iconed one, which is why it read as something the icon had done.

A popup gets a definite width when it would be wider than the window (ADR-0104) — which is the second measuring pass working exactly as designed, and is where the squeeze comes from.

Decision

A menu row’s label and accelerator do not shrink.

content.add(Box.text(context.paragraph(style, label), style.color()).shrink(0));

The cost is the one option already documents and takes for the same reason: a label longer than the room for it overflows, because nothing in this toolkit clips. A menu one word too wide is legible; a menu of two-line rows is not — and a row that wrapped also breaks Menus.fitted, which caps a long menu by assuming 34 pixels a row (ADR-0118).

Consequences

The test asserts where the paragraph was painted, not where the row was. ItemAlignmentTest sweeps both densities and four display scales for the general claim, and squeezes the menu to 160 logical pixels for the reported one. With the fix reverted it fails naming the row; without the squeeze it passes with the defect in place, which is why the general sweep alone was not enough.

item is still absent from controls.css’s no-shrink list, and that is deliberate: the list is about controls keeping their metrics (ADR-0076), and what this needed was two anonymous boxes inside one widget’s render rather than a rule about the widget. A stylesheet cannot reach those boxes, which is also why this could not have been fixed in CSS.

Clipping would be the better answer and does not exist. §8’s subset has no text-overflow, so an overflowing label is what there is. When clipping arrives this is where it belongs — a menu row that ellipsises is right where one that wraps is wrong.

149. A state invalidates what it can reach

Date: 2026-08-19

Status

Accepted. Narrows ADR-0070’s invalidation, which was conservative by an amount nobody had measured until ADR-0146 put the number on screen.

Context

Reported as “if I just click on empty space the paint and styling keep adding a lot of ms each click”. Measured through the real launcher, on the showcase’s own tree: 74 of 78 elements re-resolved on every click, and style sat at 12 ms a frame.

:hover and :active apply to the whole ancestor chain — .card:hover .title has to work — so a click on empty space marks every node from the deepest one to the root. Each of those called Element.invalidateStyle, which throws away the whole subtree’s cached styles on the grounds that a descendant combinator can make a node’s match depend on an ancestor’s state. For a node near the root, that subtree is the window.

ADR-0070 said so plainly: “Conservative on purpose. Working out which descendants a rule could reach is real machinery, and this walk is pointer-chasing against a cascade pass that costs hundreds of times more.” The walk is cheap; what it triggers is not, and it triggered all of it.

Decision

A state change invalidates the subtree only when some rule can read that state through a descendant combinator, on a node of this type.

StyleResolver walks every selector once, at construction, and records which pseudo-classes appear to the left of a combinator and on what type: checkbox:hover check-indicator puts checkbox under :hover. Then

if (resolver.reachesDescendants(pseudoClass, type())) { … invalidate the subtree }

Two answers are deliberately conservative. An ancestor compound naming no type — .section:affixed > affix-content, in the showcase’s own sheet — cannot be narrowed, so its pseudo-class reaches everything. And a resolver that has not been seen yet reaches everything, which is the first frame.

A node with no CSS type reaches nothing, and getting this wrong is what made the first attempt worthless. The hover chain is full of composition nodes; a compound that could match one names no type either, and every such compound is already in the untyped set. Returning “unknown, be conservative” for them left the whole tree re-resolving with the narrowing in place and the measurement unchanged.

The resolver comes from the tree, not from the node. A node’s own resolver is half of its cache key and is cleared when the entry is thrown away — so the question could not be asked at the moment it matters, which is a press whose release arrives before the next frame. ElementTree carries the resolver the renderer is using; that is a fact about the tree, and invalidating a style does not change it.

Measured, clicking empty space on the showcase’s Controls screen: 74 elements re-resolved per click → 3, and style from 12.4 ms → 2.5 ms.

Consequences

The remaining 2.5 ms is transitions, not the cascade. controls.css puts transition on most controls, and a click that changes :hover and :active genuinely starts and settles them. That is work the frame asked for.

A stylesheet can make this expensive again, and honestly so: writing column:hover x puts column back in the set for :hover. That is the rule being paid for by the rule that needs it, which is the right way round.

Only the state path is narrowed. Element.update — a rebuilt widget with possibly different classes — still invalidates the subtree wholesale, because what changed there is the node’s identity to the cascade rather than one bit of its state.

The test asserts by identity, not by time. StyleIdentityTest marks :hover on a parent no rule reads through and asserts the child was handed the same ComputedStyle instance — the thing the cache is keyed on — and asserts the opposite with poisoner:hover recorder in the sheet.

150. A HUD reads itself against a budget

Date: 2026-08-19

Status

Accepted. Finishes ADR-0146, which put seven numbers on a plate and left the reader to know what they meant.

Context

Three things were wrong with the breakdown as it shipped, and all three are about a number that is correct and unreadable.

  • It said nothing about what the numbers were. Every reading is a mean over the ring’s sixty frames; paint 2.1 ms reads as “this frame” and is not. A reader with the wrong model sees a spike as a plateau on the way in and a plateau as a spike on the way out.
  • There was one total and there are two. paint is the toolkit’s share of an interval; frame is the interval. Four stages under a 2 ms paint inside a 16.7 ms frame is idle hardware, and the same four under a 2 ms paint inside a 40 ms frame is something outside this toolkit — and the plate could not tell them apart.
  • Seven numbers in a row is a wall. A reader scanning for the one that has gone wrong was scanning along a line of text, in the direction a line of text already uses.

Decision

A column, a caption, both totals, and a colour when a number is in trouble.

60 fps
frame 16.7 ms
paint 2.1 ms
build 0.05 ms
style 0.29 ms
layout 0.11 ms
raster 1.34 ms
per frame · mean of last 60

Every reading carries a budget and reports one of three levels — ok, near, over — as a CSS class, at three quarters of the budget and past it. The budgets are shares of a 60 Hz frame: 16.7 for the interval, 8 for the toolkit’s paint, 4 for the raster, 2 each for style and layout, 1 for build. They are judgements, and they live on the reading rather than in a token because a token would invite an application to move the line instead of the number.

Two readings are judged differently, and both would otherwise cry wolf. The rate is a floor, not a ceiling — more is better — and the frame interval is a target to sit at: a vsynced loop is supposed to measure 16.7, and a healthy window reading amber teaches a reader to ignore the colour.

The colour comes from a class, and the class comes from the frame. §10’s whole mechanism is that a colour is a token, so the widget says which of three states it is in and controls.css says what that looks like — --gb-warning and --gb-danger, which §1.2 admits “only with semantic meaning” and this is one.

That needed a hook: Styled.classes(FrameStats). The cascade reads a node’s classes before that node’s render runs, and the frame statistics only arrive in render; a widget is a value described once and drawn many times, so it cannot hold the answer in between either. It is the same shape as the pseudo-classes the renderer already mirrors from isChecked() and isDisabled() — a fact the widget knows and the element has to carry — with the frame added, because that is what this fact is about.

Consequences

A widget can now class itself by the frame, and almost none should. Anything derivable from the widget belongs in classes(), which costs nothing per frame. This is for a value that is genuinely a property of the loop, and there is one.

A changed frame class invalidates that node’s style and no other. A rule reading it through a descendant combinator would be a stylesheet colouring one node by another node’s frame timings, which is not something anybody should be able to write (ADR-0149 is the machinery that makes “this node only” cheap).

The HUD is a column for two readings as well as for seven, and the default plate is taller than it was. A diagnostic that changed shape with its contents would be one whose position in the corner moved as the numbers arrived.

The budgets are one machine’s opinion of a 60 Hz display. A 120 Hz window has half the interval and every one of these is wrong by a factor of two — which is in book/src/TODO.md, because reading the display’s refresh rate is a backend question and not this widget’s.

151. A frame can say what it did

Date: 2026-08-19

Status

Accepted. The counters that two performance investigations each had to invent from scratch, kept this time.

Context

hud’s stage breakdown (ADR-0146) says which part of a frame is expensive. Twice now the answer to why has been a count rather than a duration — the style cache missing on every element (ADR-0142), and a click invalidating the whole tree (ADR-0149) — and both times finding it meant compiling a counter into the renderer, running a purpose-built probe, and taking it out again.

Decision

-Dgoldberry.trace.frames=true logs one line per frame that did something.

frame 268 | build 0.010 style 8.636 layout 1.115 raster 1.304 ms
  | elements 125, built 0, resolved 3, invalidated 1
  | cascade 5.133 identity 0.956 motion 0.176 boxes 1.467
  | text 17 cached, 6 shaped | damage 3

The counts are what the timings cannot say. resolved 3 beside style 8.6 is a cascade that is slow per element rather than running too often; built 125 is a rebuild; subtree walks: column:ACTIVE -> 61 names the node and the state that threw a subtree away; 6 shaped is text being re-shaped that a cache should have held.

style is broken down further into the four things the render walk does — cascade, identity (restyle and the value comparison that keeps a style’s instance), motion, and boxes (the widgets’ own render, paragraph measurement included). That split is what separated “the cascade is slow” from “the cascade is running too often”, which are different bugs with the same symptom.

Quiet frames are skipped unless =all, because an idle loop at 60 fps otherwise writes a line a frame saying nothing happened, and the frames worth reading are the ones next to the click.

-Dgoldberry.trace.input=true is separate and loud: one line per node per pseudo-class the pointer or the keyboard sets. It is the other half of the same question — that one says what a frame cost, this says what asked for it.

Consequences

Free when off. Every counter sits behind one static final boolean read from a system property, which the JIT folds away entirely. Nothing allocates on a traced frame either, except the map naming subtree walks — and that is only touched when a subtree is actually thrown away.

A system property and not a log level, for ADR-0101’s reason: an isTraceEnabled() per element per frame would be a diagnostic measuring itself.

The timings inside style are taken with nanoTime per element, so a traced frame is slower than an untraced one by four timestamps a node. That is fine for finding a 10× problem and useless for finding a 10% one, and it is the honest limit of a counter you can leave in the shipping build.

152. The cascade looks at rules that could match

Date: 2026-08-19

Status

Accepted. What ADR-0146’s breakdown and ADR-0151’s counters found once ADR-0149 had stopped the tree re-resolving on every click.

Context

With the invalidation narrowed, a click on empty space re-resolved one or two elements — and style was still 1.5 ms. The trace said why in one line:

style 8.636 ms | elements 125, resolved 3 | cascade 5.133 identity 0.956 motion 0.176 boxes 1.467

5.1 ms of cascade for three elements. One style resolve cost 1.7 ms, and two things were doing it:

  • Every rule in every sheet was matched against every element. The showcase loads controls.css, a theme, a density sheet and its own — some two thousand rules — and SelectorMatcher was asked about all of them for a text node as readily as for a button.
  • Custom properties are collected by walking to the root, and each level ran a full cascade. One node at depth ten was eleven cascades. resolve then ran a twelfth, because it asked for the custom properties and the declarations separately and both cascade the element.

ADR-0070 measured this term and cached its result; it never made the term itself cheaper, and every cache miss paid the full price.

Decision

Three changes, all inside StyleResolver except the last.

Rules are bucketed by the type their rightmost compound names. A selector’s rightmost compound is the one that must match the element being styled, so a rule for button cannot apply to a text. Rules whose subject names no type — .primary, #gain, * — go in an untyped bucket that is always tested, and a rule with several selectors goes in every bucket any of them names, because a rule is a unit and the cascade has to see it whole. Over-collecting is the only safe error here.

Custom properties are cached per element, on the same scheme the computed style already uses one level up (ADR-0070): keyed on the resolver and on the map the parent handed down, both by identity. An unchanged parent therefore keeps every entry below it valid without anything having to tell them. A node that declares none hands down its parent’s own instance, so a chain of nodes that define nothing shares one — which is what keeps the identity stable through the composition nodes that make up most of a tree.

resolve cascades the element once. It computes the declarations and hands them to the custom-property collection, instead of each asking for its own.

Measured on the showcase’s Controls screen: one resolve 1.7 ms → 0.13 ms, the median cascade on a click frame 0.48 ms → 0.26 ms, and a cold render of a whole screen 112 ms → 52 ms — which is what a tab switch pays.

Consequences

StyleElement grew two default methods. They default to “no cache”, so a hand-written element in a test is unaffected and correct. The cache is on the element because that is where the lifetime is: a map keyed by element inside the resolver would outlive the elements it was about.

The buckets are only as good as the stylesheet. A sheet written entirely in classes puts every rule in the untyped bucket and gets nothing. The toolkit’s own sheets are type-first, and this is a reason to keep writing them that way.

Invalidating a style now invalidates the custom properties with it. They are matched by the same rules, so what changes one changes the other.

The remaining term is the HUD measuring itself. Three paragraphs a frame are re-shaped in the showcase, and they are the HUD’s own readings: strings that change every frame cannot be cached by a cache keyed on the string. It cannot be taken out of the measurement without lying about the frame the window painted, so the caption says this hud included instead — which is ADR-0101’s rule kept by being honest rather than by pretending.

153. A rate is counted; a refresh is asked for

Date: 2026-08-19

Status

Accepted. Removes a reading ADR-0150 put a budget on, and answers the question that removing it raised: can the real frame rate come from SDL?

Context

frame was the mean interval between frames, and fps is 1000 / it. Both are counted by the loop, and §1.7 makes that loop idle when nothing asks for a frame — so the gap between two frames is however long the user did not touch the window. A rate over that measures the user, not the toolkit: it collapses the moment they stop clicking and stays low for the next sixty frames, because the ring is sixty frames long.

ADR-0150 then gave both a budget and a colour. So a window sitting still read red, permanently, and the showcase looked broken at rest. A diagnostic that cries wolf while nothing is wrong is worse than no diagnostic, which is ADR-0101’s subject from the other side.

And SDL has no frame rate. SDL_GetCurrentDisplayMode reports what the display does; nothing anywhere reports what a loop achieved, because that is not a thing a platform knows — only the loop can count its own frames.

Decision

frame is gone. refresh takes its place, and it is asked for rather than counted.

60 fps
refresh 60 Hz
paint 2.1 ms
build 0.05 ms
…

SdlVideo.refreshRate was already bound for the frame pacer; it is promoted to BackendWindow.refreshRate() with a default of 0 for “the platform will not say”, which is a headless backend and a mode SDL cannot describe. Window reads it once a frame — it is cached in the backend and changes only when the window moves monitors — and it reaches a hud through FrameStats.displayHertz().

Every budget is now a share of one display frame rather than of a hard-coded 16.7 ms: paint a half, raster a quarter, style and layout an eighth each, build a sixteenth. So a 120 Hz window judges its paint against 4.2 ms, and the same 2 ms paint that is comfortable at 60 Hz is near its budget at 240. When the platform will not say, it falls back to 60 Hz — a stated assumption rather than a hidden one. This closes the gap ADR-0150 left in book/src/TODO.md.

fps stays and is never coloured. It is worth showing — while something is moving, the loop runs continuously and it is exactly the number to watch — but it is context rather than a budget. The readings the toolkit is answerable for are paint and the four stages, and those are the same number whether the loop is busy or idle.

Consequences

The showcase stops reading red at rest, which was the report.

A reading’s name is a CSS class, and the namespace is shared. The first draft called this one display, and .display is already §1.4’s largest type rank — so 60 Hz display rendered at 28px in the golden. Renamed to refresh. Nothing prevents the next collision; the classes a widget invents and the classes a design system defines are one namespace, and that is now written down.

refresh reads dashes on the headless backend, and every golden image of a hud therefore states a rate explicitly. FrameStats.of grew an overload for it rather than a defaulted field, so a test that does not care about the display still compiles unchanged.

A 0 Hz answer is not an error. The pacer already treated it that way — an unknowable refresh rate means an unpaced loop — and this reads it the same way: assume nothing, say so, and budget as if 60.

154. A reading is a range

Date: 2026-08-19

Status

Accepted. Finishes the hud line that ADR-0146 started and ADR-0150 coloured.

Context

Every reading was one number: the mean over the ring’s sixty frames. A mean is the right thing for a budget to judge and the wrong thing to read a frame by, because it hides the shape of the cost — and the shape is usually the question. Two windows both averaging 2 ms of paint are different animals if one never leaves 1.9–2.1 and the other ranges 0.2–14: the second is a spike being averaged away, and nothing on the plate could say so.

Three smaller things were wrong with the surrounding text. per frame did not say what unit the numbers were in. mean of last 60 described the arithmetic rather than the reading. And this hud included, added one commit earlier for honesty, spent a whole line on a caveat that is true of every diagnostic ever drawn into the thing it measures.

Decision

Each duration reading is min / mean / max.

60 fps
refresh 60 Hz
paint 1.3 / 2.1 / 5.0 ms
build 0.00 / 0.05 / 0.15 ms
style 0.15 / 0.29 / 1.16 ms
layout 0.04 / 0.11 / 0.22 ms
raster 0.94 / 1.34 / 2.55 ms
ms/frame · min / mean / max · last 60

FrameStats grows a Span(min, mean, max) and a span per stage. The default is a flat span built from the existing mean, so a source that keeps no window — a test’s fixed numbers — reports its one number three times rather than inventing a spread it never measured. FrameRing computes all three in one pass over at most sixty longs, which is what keeps it safe to ask inside a build that runs every frame.

The mean is in the middle, where the eye lands and where the budget is judged. The colour still comes from the mean: a budget is about what a frame costs habitually, and colouring by the max would paint every window red for one slow frame in sixty.

The caption says the unit and the shape — ms/frame · min / mean / max · last 60 — because the rows say neither. this hud included is gone: it is true, it is true of every such diagnostic, and it is in ADR-0152 where a reader who wants it can find it.

Consequences

value() and text() read the same span, and this is not a tidying. The first draft left the level reading styleMillis() while the row printed style(), and the over-budget golden came out with style 4.80 / 9.60 / 38.40 ms in the quiet colour — nine milliseconds over an eighth of a frame, drawn as though it were fine. A colour that can disagree with the number beside it is worse than no colour.

The plate is wider. Three numbers and a label is about 24 characters, against nine before. A HUD is content-sized and pinned to a corner, so it costs screen rather than layout — and a diagnostic that has to be legible at a glance is worth the corner.

A golden of a HUD now needs a FrameStats with a spread, because a flat one draws the same number three times and proves nothing about the row. The one in HudGoldenTest is written out rather than built from FrameStats.of, which is the honest cost of the default being flat.

min is frequently 0.00, and that is a real reading rather than a rounding artefact: a frame in which no widget rebuilt spends no measurable time building, and a window with sixty of them in its ring has a genuine floor of zero.

155. A jar binds at run time; an image is woven

Date: 2026-08-20

Status

Accepted. Amends ADR-0125 and ADR-0127: both stand, and what changes is who has to run the weaver.

Context

ADR-0125 made a @Bind field observable by rewriting the compiled class, and ADR-0127 explained why that has to happen in the build rather than at run time — a GraalVM native image has no class loading and no class generation, so anything generative has to be finished before the image is.

That reasoning is still right, and it justified making the weaver mandatory. Every module keeping a model applied goldberry.weave, Models threw at the first sight of an unwoven class, and the message named the missing build step. A model that compiled and was not woven was treated as a broken build.

What that overlooked is who pays. The weaving is cheap; the mandatory is not, and it is charged to everybody who is not building an image — which is nearly everybody:

  • Every consumer has to install a build step. Gradle gets a plugin, Maven gets two <execution> blocks of exec-maven-plugin because there is no Mojo, and anything else gets java -jar goldberry-weaver.jar target/classes and a place to put it. A toolkit whose “hello window” needs a class post-processor configured before the first field notifies is a toolkit with a much steeper first hour than it needs.
  • An IDE does not run it. Running a main class from IntelliJ compiles with the IDE’s own compiler into the IDE’s own output directory, and nothing weaves it. The failure was loud and clear and still meant “do not run this from your IDE”, which is where an application author spends the day.
  • It is unnecessary for what a jar can do. Everything a woven class does is reachable reflectively — the annotations are on the members, VarHandle reads the field, MethodHandle calls the action. Everything except the one thing: seeing the write.

That last point is the real question, and it is worth being exact about. A putfield is not virtual; no subclass, proxy or handle can intercept one, and the class that declares the field is the only place the write can be observed. That is ADR-0125’s whole argument and it has not changed. Weaving is not one way of noticing a change — it is the only way of noticing it at the instant it happens.

Decision

A model is bound one of two ways, and the build decides which.

  • Woven — the weaver rewrote the class. What a native image is built from: nothing is reflected, nothing is looked up, a change notifies from inside the assignment that made it, and ADR-0127’s closed-world claim holds unchanged.
  • Bound at run time — the class is as javac left it, and Models builds the same two registries reflectively. What an ordinary jar uses, and the default: ./gradlew run, mvn exec:java, a green Run button and a java -jar all work with no build step at all.

Models picks. Models.bindings(model) returns the woven registry if the class implements BoundModel and a reflective one otherwise, and every method on that class answers the same for both. An application does not branch on it; the only thing that reports which it got is Models.isWoven, which is a diagnostic.

@Bind and @Action become RUNTIME-retained, because a CLASS-retained annotation is exactly the one the reflective binder cannot read.

A change is noticed by a sweep

The one thing reflection cannot do, done the only way it can be: RuntimeBinding keeps what each field held at the end of the last sweep, and a sweep compares, fires the listeners of the fields that moved, and asks for the restyle and the frame each of them declared. Read access is unaffected and exact — an Observable over a VarHandle sees the field itself, so a value is never stale when something asks for it. Only the notification is deferred.

Three places sweep, chosen so that the deferral is invisible in the cases that actually arise:

  1. After every action a registry dispatched — and after it, every other model bound at run time, not only the one that published the action. An @Actions record holds no fields of its own and writes to the model beside it (ADR-0134, ADR-0136), so sweeping only itself would sweep nothing at all.
  2. At the top of every frame, over the models an Application named. A change made from a timer or a finished background job therefore reaches the screen with the next frame the window was going to paint anyway.
  3. Wherever the application says so, with Models.refresh(model) — a no-op returning false for a woven model, so the call is correct in both forms.

What is left is one honest gap: a field written from neither an action nor anything that leads to a frame, and followed by nothing. The showcase has exactly one — a background job’s continuation calling setStatus — and it is one line of Models.refresh with a comment saying why.

The catalog half is not affected

The weaver does two unrelated things, and only one of them moves. Collecting a module’s @Markup widgets into a WidgetCatalog and patching provides into module-info.class (ADR-0131) has no runtime equivalent — finding annotated classes while the program runs means scanning the path, which is the thing a provides exists to avoid. So WeaverMain grows --models and --catalog, the Gradle plugin registers a task per half, and the catalog half stays hung off classes for every build while the model half waits for -Pgoldberry.nativeImage=true.

Alternatives considered

Keep weaving mandatory and improve the error message. The message was already good — it named the class, the annotation and the Gradle plugin. The problem was never that the failure was confusing; it was that there was a failure at all, in a case where the toolkit could simply have worked.

Generate the woven class at run time and load it. ClassFile.of().build(...) plus defineHiddenClass would produce exactly the right bytes, and cannot be used: new Settings() in the application’s own code names the class javac compiled, and no hidden class can take its place. This is also precisely the mechanism ADR-0127 spent its argument avoiding.

Generate the binding at run time, rather than the model. The near miss, and the one worth writing down properly, because it does work. A hidden class defined NESTMATE through the privateLookupIn the reflective binder already holds may read the model’s private fields with a plain getfield — no setAccessible, no handle, no accessor. So a jar could bind reflectively at start-up and, on first use, emit a per-model sweeper and reader that run at woven speed. It cannot intercept the write — nothing can, and the sweep would remain — but it would make the sweep nearly free.

BindingCodegenBenchmark builds it and measures it. Reading one int field and comparing it against the last value seen, which is what a sweep does per field:

ns/op
boxed reflective — what the sweep does today15.7
unboxed reflective — same VarHandle, asked for an int5.2no codegen
generated nestmate — checkcast, getfield, if_icmpne0.60
a plain Java call0.61the floor

Generating one costs 1.9 ms for the first in the process and 124 µs after, paid on first use — a hitch in the first frame rather than a line in the start-up timeline.

Rejected, for four reasons in ascending order of weight.

Half the gap was not reflection — and this was checked rather than asserted. A sweep measured 38 ns per model per press, of which only ~16 ns was the read-and-compare above; the rest was this implementation’s own plumbing. Doing the plain specialization the middle row of that table describes took the sweep to 9 ns per field and 14 ns per model, and the press from 107 ns to 45. That is most of the distance, for one afternoon and no new mechanism.

What is left buys nothing anybody is spending. After that work ten models cost a button press ~140 ns and a frame’s sweep the same, against a 16 ms budget (ADR-0147). The remaining ~4 ns per field that codegen would recover is real, and is not a cost this toolkit has.

It costs :core its innocence with GraalVM. defineHiddenClass and java.lang.classfile would become reachable from the module every image is built from. Models in an image are woven, so the generator would never be called — but reachability analysis cannot prove that, so the image carries the class-file API and a code path that throws if it is ever reached. ADR-0127’s claim is currently one sentence; it would become one sentence and a substitution.

And it makes the wrong half faster. Codegen would make the sweep quick. It would not make it unnecessary: notification stays deferred, Models.refresh stays, and the semantic difference this ADR spends its length on is untouched. The mechanism that removes the sweep entirely already exists, is already tested, and is one flag away. Building a second code generator to make the inferior semantics run at the speed of the better ones is effort pointed away from the problem.

Revisit if a real workload puts the sweep on a frame profile — the hud (ADR-0146) is what would show it — or if an application’s model count reaches the hundreds.

A -javaagent that weaves at class load. It works, it is the JavaFX/Hibernate answer, and it swaps one mandatory build step for one mandatory JVM flag — a worse trade, since a flag is invisible in the failure it causes and a build step at least appears in a build file.

Make the model hold Property fields again. Then nothing needs weaving and nothing needs sweeping. This is the design ADR-0125 replaced, for the reasons it records: clicks.set(clicks.get() + 1), an object per value, and a vocabulary that spreads through every method of the model.

Sweep on a timer. Rejected as the thing that turns an idle window into a busy one. §1.7’s “the frame loop is fully idle when no animation is active” is a property worth more than the last edge case of change detection.

Consequences

The first hour is a dependencies block. An application depends on goldberry-core and goldberry-widgets, writes a @Model, and runs it — from Gradle, from Maven, from an IDE, from a jar. Nothing to install, nothing to configure, and the weaving page is now something read by whoever is building an image rather than by everybody.

A native image is a flag. -Pgoldberry.nativeImage=true and the model half of the weaver runs; the image is built from woven classes and ADR-0127’s test still holds over them. The fast form did not get slower, it got optional.

Two implementations of one contract, and one test that holds them together. RuntimeAgreesWithWovenTest drives the same model class — once raw, once woven — through the same actions and asserts the same paths, names, values, notifications and frame requests. Without it this decision would be two behaviours with one name, which is worse than either.

A model in a named module has to open its package. opens com.example.app to io.github.digitalsmile.goldberry.core;, and the refusal says so in those words. This is the cost the woven form does not have, and it is a real one: it is a line in a file most application authors do not otherwise edit. The toolkit adds its own read edge (Module::addReads), because that half is not the application’s business — but the opens is, and nothing can supply it from outside. A classpath application is unaffected: the unnamed module is open.

The registry order is no longer promised across the two forms. The weaver publishes in class-file order; getDeclaredFields and getDeclaredMethods promise no order at all, so the reflective form sorts by member name rather than leaving it to the JVM. Both are deterministic, and they differ. The order shows up in one place — the Bound: ... list a strict registry prints when it refuses a name — so this is a cosmetic difference in a diagnostic, written down here so it is not found as a surprise.

Notification is deferred, and the deferral is observable. A woven model notifies inside the assignment; a bound one notifies at the next sweep. The three sweep points cover the paths a document takes, and code that writes a field outside all of them and expects an immediate callback will not get one. This is the price of the whole arrangement, it cannot be engineered away, and Models.refresh is the escape hatch.

An image now carries annotation metadata nothing reads. @Bind and @Action are RUNTIME-retained for the jar’s sake, and a woven class in an image consults neither — a few bytes per member against a build step every consumer would otherwise have to install. NativeImageComplianceTest asserts the second half of that sentence: the woven registries are emitted code, and no annotation is read to run them.

Sweeping is O(models × fields) per action, and here is what that is. BindingSchemeBenchmark measures both forms of one model class in one JVM (Corretto 25.0.4, i7-4790K, median of 200 samples of 100 000 presses):

wovenbound at run timefirst cut
a press one widget is watching15 ns45 ns107 ns
per extra model attached, per press—+14 ns+38 ns
per extra bound field on a model, per press—+9 ns+31 ns
a read through a binding1.3 ns10 ns32 ns
rebuilding both registries (a document reload)570 ns630 ns617 ns
binding a class the first time0.58 ms2.8 ms first, 0.22 ms after—

The last column is the reflective binder as first written, and it is in the table because the difference between the two is the point: most of what looked like the cost of reflection was the cost of writing it carelessly. The first cut walked a List with an enhanced for (an iterator allocated per sweep), asked a VarHandle for an Object (a box allocated per field per sweep, thrown away unread), compared the two boxes with Objects.equals, and copied the attached-map values into a fresh List on every action dispatch.

None of that is reflection. Reading the field through the same VarHandle as an int and comparing two longs — one class per primitive kind, which is verbose and entirely mechanical — with arrays instead of lists and a snapshot rebuilt on change instead of copied per press, took a press from 107 ns to 45 and a read from 32 ns to 10, with no new mechanism and no change to a single test.

So a press is ~3× dearer and a read ~8×, from a base small enough that it does not matter: an application with 10 models pays about 140 ns per button press, against a frame budget of 16 ms (ADR-0147). What is still worth watching is the slope, because it is paid by every model in the process rather than by the one that changed — but at 14 ns a model, 40 models cost a press 0.6 µs.

A document reload costs the same either way, which is the number that could most easily have gone wrong: it is the one thing here that happens per frame in a development loop.

What remains is genuinely reflection. A VarHandle read that the JIT cannot constant-fold is ~5 ns where a getfield is ~0.6, and no amount of care around it closes that. An index of which model an action writes to would remove the model axis and cannot be built reflectively either — finding out means reading the method’s bytecode, which is the weaver’s job and the thing this exists to avoid needing.

156. The image’s metadata is traced, not written

Date: 2026-08-20

Status

Accepted. The first thing built on ADR-0127’s promise, and it takes ADR-0155’s weaving flag as its input.

Amended by ADR-0339: the foreign descriptors are generated from the bindings now, not traced. The trace still supplies reflection, services and resources.

An image has now been built and run on linux-x64 against GraalVM CE 25.2.4: 30.6 MiB, ~0.55 s to start, ~6 ms a frame headless, exit 0, and logging. It is still not built in CI.

Context

ADR-0127 said the binding layer would no longer be the reason an image cannot be attempted, and it was right: NativeImageComplianceTest parses the woven bytecode and finds no Class.forName, no setAccessible, no defineHiddenClass. The binding is closed-world clean.

The rest of the application is not, and the reason is :natives. A closed world has to know, at image build time, every foreign function the program will call — GraalVM folds a Linker::downcallHandle whose FunctionDescriptor is a build-time constant, and needs RuntimeForeignAccess.registerForDowncall for every one that is not.

Goldberry’s are not. A binding class takes a SymbolLookup obtained from NativeLibrary at run time and builds its handles in a constructor:

private HarfBuzz(SymbolLookup lookup) {
    this.blobCreate = handle(lookup, "hb_blob_create", DESCRIPTOR);
    …
}

The library is dlopened when the program runs, from a path a system property can override — which is exactly what ADR-0019 wanted and exactly what a closed world cannot see through. libgoldberry exports 184 symbols, and there is an upcall as well: Yoga’s measure callback is a Linker::upcallStub, which needs registering whether or not the descriptor is constant.

There is also everything that is not FFM: the fonts and the 1544 compiled icon paths in :core’s resources, the stylesheets, the KDL documents, and whatever reflection Logback does to read logback.xml.

So something has to enumerate all of it.

Decision

GraalVM’s tracing agent produces the metadata, a human reviews the diff, and the result is checked in beside the code.

Two tasks on :example:

  • nativeImageMetadata runs the showcase under -agentlib:native-image-agent, headless, for 120 frames, and writes what it observed into src/main/resources/META-INF/native-image/io.github.digitalsmile/goldberry-example.
  • nativeImage runs native-image over the woven jar and that metadata.

The metadata lives in src/main/resources and not in build/, because it is source: it is reviewed in a pull request, it changes when the application does, and packaging it into the jar is what lets a downstream image build find it without being told.

nativeImage depends on weaveModels and jar, and goldberry.weave orders the two — an image built from classes the weaver had not touched would be an image that binds by reflection, which is the one thing ADR-0155 says an image never does.

Alternatives considered

Write a Feature that registers the descriptors by hand. The mechanically honest option: a class implementing org.graalvm.nativeimage.hosted.Feature whose beforeAnalysis calls RuntimeForeignAccess.registerForDowncall for each descriptor. It is also 184 registrations that duplicate, in a second place, facts the binding classes already state — and the failure mode of getting one wrong is an image that builds and dies on the call. The binding classes are the source of truth for what Goldberry calls; a hand-written list is a copy that goes stale silently.

Make the descriptors build-time constants so GraalVM can fold them. This would be the best answer and it is not available: it means resolving symbol addresses at image build time, which means linking libgoldberry into the image statically, which contradicts ADR-0019’s “the library is a file the application chooses” and ADR-0041’s four platform artifacts. Worth revisiting only if a statically linked image becomes a goal of its own.

Ship no metadata and let --no-fallback fail. Tempting, because the failure would at least be loud. But the agent exists precisely to answer this, and asking every consumer to derive the same file by hand is the mistake ADR-0155 just finished undoing for the weaver.

Consequences

The metadata is only as complete as the run that traced it. A screen the 120-frame run never reaches contributes nothing, and the symptom is an image that starts and then dies opening a menu. This is the real cost of the decision and it is not a small one: it makes the trace run part of the contract, and it means the showcase’s own coverage — every widget on a screen, §14’s whole argument — is now load-bearing for a second reason. The frame count is high enough to open the menu and the HUD; a screen added later needs the trace re-run and the diff read.

A reviewed diff, not a trusted tool. Checking the output in means a change to it shows up in review, which is the only mechanism that catches the agent recording something surprising — a reflective call nobody meant to add.

The trace found an upcall this ADR did not know about. Its context section names one — Yoga’s measure callback — and the agent recorded two: SdlEventWatch.invoke as well. That is the argument for tracing over hand-writing, made by the mechanism itself on its first run. It also collapsed the 184 exported symbols into 55 distinct downcall descriptors, which is the list a hand-written Feature would have had to get right.

Of the two holes predicted here, neither was what it looked like. NativeLibrary marked --initialize-at-run-time was reasoning rather than evidence, and it works. Logback was predicted to break on reflective config reading and does not — Joran, the XML parse and the reflective instantiation of appenders and encoders all work in the image. What broke was the file: the agent records logback.xml as a classpath resource, because a ClassLoader is what logback asks, and the image runs on the module path where that file is at the root of a named module. Registered without its module it is simply absent, and logback with no configuration ends with no appenders and prints nothing at all — not even its own status.

So there is now a second metadata directory, goldberry-example-manual, holding what a human writes. The traced one is never edited, because the next trace overwrites it. native-image merges every META-INF/native-image/**, so the split costs nothing and keeps the diff honest.

The general lesson is sharper than the fix: the agent records how a lookup was made, not where the file will be. Any resource a module-unaware library fetches through a ClassLoader has the same shape of bug waiting in it.

The linker needs zlib1g-dev, not zlib1g. Not a decision, but the first thing that happens to anyone running these tasks: analysis succeeds, a minute passes, and the link fails with cannot find -lz. Written down on the native-image page beside the commands.

build does not depend on either task, and CI does not run them. A task nobody can run on the machines this project builds on would be a red build for a missing tool. It fails with the download link when asked and says nothing otherwise.

157. A layer is blitted into its own size

Date: 2026-08-20

Status

Accepted. Fixes a bug in ADR-0071’s composite step that was invisible at a 1:1 display scale.

Context

Reported from a Mac: every disabled control was about twice the size it should be. Disabled is the only thing in the toolkit’s stylesheets that sets opacity (ADR-0077), and opacity is what promotes a subtree to a layer — so “disabled controls are huge” was really “promoted subtrees are huge”, and only on a display whose scale is not 1.

Two coordinate spaces meet at the composite, and each is right on its own:

  • A layer is a raster, so Layer.of allocates it in physical pixels — ceil(logical × scale). Allocating at logical size would throw the display’s detail away, which is the whole point of promoting to a raster on a HiDPI screen.
  • A frame is in logical coordinates, because its Blend2D context is scaled once when the frame begins. Every other call on it — a rectangle, a path, a glyph run — is stated in logical units.

Frame.drawLayer used bl_context_blit_image_d, which draws an image at an origin, one image pixel per context unit. A 60-point square on a 2× display is a 120×120 raster drawn across 120 logical units: 240 physical pixels, twice the size it laid out at.

At 1× the two spaces coincide and the arithmetic is correct by accident. Every golden image, every test in LayerTest, and every Linux and Windows run of the showcase is at 1×.

Decision

The blit states the destination size, in the context’s units.

bl_context_blit_scaled_image_d takes a BLRect rather than a BLPoint, so the raster is drawn to a stated size and the two spaces are reconciled in one place. BlendContext gains blitScaled, blit stays for an image that really is one pixel per unit, and its javadoc now says which is which.

The destination size is derived from the raster, not from the bounds the caller laid out:

context.blitScaled(x, y, size.width() / factor, size.height() / factor, image);

Layer.of rounds the physical size up, so at a fractional scale the raster is a fraction of a pixel larger than the box. Dividing that back is what maps it one-for-one onto the device; passing the box’s logical size instead would squash the raster by that fraction and leave a resampling seam along two edges of every promoted subtree.

Alternatives considered

Allocate the layer at logical size. The two spaces would then agree and no new symbol would be needed — at the cost of rasterizing every promoted subtree at 1× and scaling it up, which is exactly the blurring that promoting to a raster on a HiDPI display is meant to avoid.

Neutralise the context transform around the blit. Push scale(1/factor), blit at x × factor, pop. It works and needs no new native symbol, but it makes the composite depend on the transform stack being in a known state at that moment — and the promoted node’s own affine is applied immediately before. A destination rectangle composes with whatever transform is in force and needs to know nothing about it.

Consequences

A 185th exported symbol. The list in exports/goldberry.symbols is explicit and this adds one line to it, which is the intended way to add a call and the reason the list exists.

The goldens did not move. Blend2D’s scaled blit at exactly 1:1 is pixel-identical to the point blit, so every existing golden passes unchanged — which is the evidence that this is a fix and not a rendering change.

Two blits, and the wrong one is still callable. blit remains, because an image rasterized at the context’s own scale genuinely wants it. The distinction is now in the javadoc of both rather than in nobody’s head.

The tests are at 2× and 1.5×, and that is the lasting change. LayerTest grew a Scaled nest, and the fractional case is there deliberately: it is the one a “just divide by two” fix gets wrong. The broader gap is not closed — almost every pixel assertion in this repository is at 1×, and this class of bug is invisible there. A golden corpus at 2× is the obvious next step and is not built.

158. A full repaint is a full upload

Date: 2026-08-20

Status

Accepted. Fixes a hole between ADR-0046’s damage list and ADR-0072’s promise.

Context

Reported from a Mac: dragging a window edge flickered, with black areas.

Two mechanisms were each behaving correctly.

ADR-0072 asks the backend whether the buffer it lends still holds the previous frame. When it does not — a resize reallocates it — canRepaintPartially() returns false and the painter repaints the whole frame. That is right, and it is what the frame loop does.

ADR-0046’s damage list is a separate question: which regions changed since the last frame, so the platform uploads those and not the whole surface. The frame loop reported it on every frame:

if (window.canRepaintPartially()) {
    render.paint(frame, damage);
} else {
    render.paint(frame);          // everything
}
window.damaged(damage);           // ... but upload only what changed

So the frame after a resize was painted in full and uploaded in part. The buffer was entirely correct; the platform’s surface was entirely new; and the region between the two — everything outside the damage rectangles — was whatever the compositor had there. Black. During a live resize the surface is reallocated on frame after frame, so it is black that flickers.

At a steady size the two questions have the same answer, because the surface holds the last frame and “what changed” is exactly “what is not already right”. They come apart only when the surface underneath is new, which is a resize — and every damage test, every golden and every headless run was at a fixed size.

Decision

A frame that could not be repainted in part is uploaded in whole, whatever the painter reported.

window.present(target,
        damage == null || !partialRepaint
                ? List.of(DamageRect.all(frameSize))
                : damage);

In Window and not at the call site, and that is the substance of the decision rather than a detail of it. A painter reporting which regions changed is doing the right thing and has nothing to do differently; the window is the only thing that knows whether the buffer it is about to present had valid contents to begin with. Asking every painter to combine the two questions would put the same three-line conditional into the toolkit’s frame loop, into Popup, and into every application that writes its own onPaint — and the failure mode of forgetting it is a bug nobody sees until they own a HiDPI Mac and drag a window edge.

damaged keeps its meaning: what changed. Its javadoc now says the other half — that on a full repaint it is not consulted.

Alternatives considered

Force the damage list to be whole after a resize, where the resize is handled. It fixes the reported symptom and not the rule. A reallocated buffer is not only a resize: ADR-0072’s own javadoc allows a backend to rotate between two buffers and copy, and canRepaintPartially() already answers for all of those. Fixing the resize case specifically would leave the others.

Have damaged reject a partial list when the frame was full. Louder, and wrong for the caller: the painter’s report is accurate. The clamp belongs where the two questions are combined, not where one of them is asked.

Always upload the whole frame. Deletes the mechanism ADR-0046 measured at about a millisecond a frame at 960×640, to fix a case that arises only when the buffer changed.

Consequences

A resize costs a full upload per frame, deliberately. That is what it always should have cost, and it is the one case where a partial upload was never valid. A steady window is unaffected, which the second of the two new tests pins.

Two tests, and the second matters as much as the first. WindowDamageTest now asserts both halves: a frame after a resize uploads the whole window, and a frame at a steady size uploads only the reported region. The second is what stops this being fixed by uploading everything always.

The reported symptom is not confirmed fixed. This is diagnosed and reproduced headlessly on Linux, where the cause is present and unambiguous. Whether it is the whole of what a Mac shows during a live resize is not something this repository can currently answer — macOS drives a live resize from inside a modal run loop, and there may be a second cause layered on this one. Said here rather than discovered later.

The general gap stays open. Every damage assertion is at a fixed size except these; the same is true of the golden corpus, and ADR-0157 records the same shape of gap for display scale. A frame-loop test that resizes is now possible, and almost none do.

159. A native image carries its own library

Date: 2026-08-20

Status

Accepted. Finishes what ADR-0156 started, and narrows ADR-0019’s “the library is a file the application chooses” for the image case only.

Context

The first working image shipped as two files: the binary, and lib/libgoldberry.so beside it, with a launcher script setting -Dgoldberry.native.library to point one at the other. Run the binary on its own and it failed — which is what a person does, because a native image is supposed to be a program.

So: can the native libraries go inside it?

libgoldberry is already one shared object with Blend2D, AsmJit, Yoga, HarfBuzz and SDL3 linked in statically (BUILD_SHARED_LIBS OFF), exporting exactly the 185 symbols exports/goldberry.symbols lists. There is no “several libraries” problem — only the question of whether that one is linked into the image or loaded beside it.

Decision

The image embeds the classifier jar’s library resource and unpacks it on first use.

:natives already packages libgoldberry at exactly the path NativeLibrary.resourcePath() looks for — that is the released-application path, and an image is an application. So nativeImage puts that jar on the image’s class path (not its module path: nothing requires it, so module resolution would drop it), registers the resource, and NativeLibrary takes the branch it already had.

One file. No launcher script, no lib/ directory, no property to set.

Alternatives considered

Statically link the archives into the image. The obvious answer, tried first, and it does not work — the evidence is worth recording because the failure is not where one would guess.

Linking is fine: -H:NativeLinkerOption=<archive> plus -Wl,-u,<symbol> pulls the code in, verified by the binary growing 2.7 MB for libblend2d.a alone. It also needs -lstdc++ and -lm, which native-image does not pass and Blend2D being C++ requires.

What fails is symbol visibility. Goldberry resolves every native function by name at runtime through SymbolLookup, so the symbols have to be in the executable’s dynamic symbol table for dlsym to find them. They are not: native-image links with its own --version-script, which makes every symbol it does not list local. Three ways round it were tried and all three failed:

  • -Wl,--export-dynamic-symbol=<name> — the version script still wins;
  • a second --version-script, anonymous tag — “anonymous version tag cannot be combined with other version tags”;
  • the same with a named tag — same error, because SVM’s own tag is the anonymous one.

There is no -H: option to add to the exported set. So on Linux with GNU ld, a statically linked archive’s symbols cannot be reached by name from Java in a native image. Revisit if native-image grows a way to extend its export list, or if the bindings ever stop resolving by name.

Ship the library beside the binary, as before. Works, and is what the jlink image does (ADR-0048) — but a jlink image is a directory by nature and a native image is a file. Two files with a launcher between them gives up most of what the format is for.

Consequences

A 9 MB temporary file per run, and a writable temp directory becomes a requirement. The library is unpacked because a shared object must be a real file to be dlopened — it cannot be mapped out of the binary. Measured at ~5 ms on this machine, once, at start-up. A deployment with a read-only or noexec /tmp cannot run the image, and -Dgoldberry.native.library remains the way out.

The image is 41 MB where it was 32. The library is carried rather than compressed away.

Two bugs surfaced that were not about images at all.

A named module cannot see a class-path resource. NativeLibrary lives in io.github.digitalsmile.goldberry.natives, and Class.getResourceAsStream on a class in a named module searches that module and never the class path. So the classifier jar — the mechanism a released application is supposed to use — could never have worked for a module-path deployment. It went unnoticed because every module-path run in this repository points at a locally built library with -Dgoldberry.native.library, which takes the other branch. There is now a ClassLoader.getSystemResourceAsStream fallback.

deleteOnExit runs in reverse. The unpacked library registered itself for deletion before its parent directory, and the queue is drained in reverse registration order — so the directory was attempted first, failed because it was not empty, and every run left one behind, on the JVM as much as in an image. The two registrations are now the other way round.

Neither has a test, and that is the honest position. Both bugs live where this repository does not look: the first only manifests on the module path, and tests run on the class path — which is precisely why it survived. The second needs a process to exit to observe. What caught them was building the artifact and running it with nothing beside it, and the lasting lesson is that the artifact is the test.

:example:nativeImage now depends on :natives:stageHostArtifact. An image built from a source tree needs the local library staged into the artifacts directory before the classifier jar can package it. The ordering is declared from :example rather than by making nativeJar depend on staging, because that dependency is wrong in CI: the Linux legs build inside the manylinux container and download the result, and a cmakeBuild on the runner would link against a different glibc (ADR-0012).

160. A module’s own resources are declared, not traced

Date: 2026-08-20

Status

Accepted. Narrows ADR-0156, which said the metadata is traced. Most of it still is; resources are not.

Amended 2026-09-17: the showcase had applied this rule to its stylesheet and its .kdl documents and not to its Markdown, HTML or the canvas screen’s five sample images, and a Windows image died on the canvas tab with canvas-sample.qoi is not on the classpath beside CanvasScreen. The globs cover src/main/resources/…/example/ui/* now, and DeclaredResourcesTest holds the manual declaration to every file on disk, so the next undeclared resource fails the build rather than the image.

Context

ADR-0156 wrote down the cost of tracing before it had been paid:

The metadata is only as complete as the run that traced it. A screen the 120-frame run never reaches contributes nothing, and the symptom is an image that starts and then dies opening a menu.

It was paid on the first real use of the image. Toggling the theme:

IllegalStateException: the NORD_LIGHT theme is missing from the jar: nord-light.css

The trace recorded nord-dark.css, because that is the theme the showcase starts in. Three more had the same shape:

MissingWhy the trace never saw it
nord-light.cssthe run never toggled the theme
density-compact.cssthe run never switched density
JetBrainsMono.ttfthe run drew no monospace text
OpenMoji-black.ttfthe run drew no emoji

Every one is the other half of a control the user can reach. And the failure is worse than a missing feature: it is a crash, in the frame loop, on a click that worked yesterday in the jar.

Re-running the trace with more interaction would have found these four and nothing about the fifth. A trace can be made longer; it cannot be made complete.

Decision

A module declares its own resources by glob, and ships that declaration itself.

:core, :widgets and :example each carry a META-INF/native-image/io.github.digitalsmile/<artifact>/reachability-metadata.json listing what they ship as globs:

{ "module": "io.github.digitalsmile.goldberry.core",
  "glob": "io/github/digitalsmile/goldberry/css/*.css" }

Globs, because this set is finite and known at build time — unlike a trace, which records only what one run happened to touch. Two themes, four fonts, one icon table, two stylesheets: a directory listing answers it exactly, and a directory listing cannot be one screen short.

Each module ships its own, because native-image reads META-INF/native-image/** from every jar on the path. So an application building an image gets the toolkit’s resources without knowing it needs them — which is the real point. A consumer should not have to discover that Goldberry has two themes by shipping an image that crashes on the theme toggle.

Everything else in ADR-0156 stands. The FFM descriptors, the reflection and the services are still traced, because those genuinely depend on what the code does and no directory listing can enumerate them.

Alternatives considered

Trace harder. Drive the showcase through every screen, every toggle and every control in the metadata run. It would have caught these four, and it makes the trace run a second test suite that has to be kept exhaustive by hand — with the same failure mode, one control further out. It also makes the metadata depend on how thoroughly somebody clicked, which is not a property a build should have.

-H:IncludeResources on the native-image command line. The same globs, in the build file instead of the module. It works for the showcase and does nothing for anyone else’s application: the flag is not shipped with the jar, so every consumer would have to write it out again from knowledge they do not have.

One io/github/digitalsmile/goldberry/** glob per module. Shorter, and it matches every .class file in the module as a resource — carrying the whole module a second time in the image heap. The paths are spelled out instead.

Consequences

The image grew from 41 MiB to 43 MiB, which is the two fonts the trace had been leaving out. That is the honest cost of completeness and it was always owed: an image that fits because it is missing a font is not smaller, it is broken.

The traced file and the written file are still separate, and this adds a third kind of entry to the written one. The rule from ADR-0156 holds — nothing hand-written goes in the traced directory, because the next trace overwrites it.

A resource added to a module needs no thought, and a resource directory does. Dropping nord-dim.css beside the other two is covered by the existing glob. Adding io/github/digitalsmile/goldberry/sounds/ is not, and nothing will say so until an image is built and someone reaches the control that needs it.

This does not fix the general case, and should not be read as doing so. The FFM and reflection metadata remain as complete as the run that traced them, and ADR-0156’s warning applies to them unchanged. What has changed is that the largest and most predictable category — files this repository ships — no longer depends on where somebody clicked.

161. A downcall handle is a constant, or it is not a call

Date: 2026-08-20

Status

Accepted. Changes how every binding written under ADR-0010 holds what it bound, and adds a second thing :natives declares for an image build alongside the resources of ADR-0160.

Context

The showcase, built as a native image and painting the same scene as the JVM does, was reporting this on its hud:

paint    37.5 / 41 / 53 ms
raster   34.7 / 36 / 52 ms

Against a 16.7 ms budget, painting two and a half frames’ worth of work per frame — and the JVM build of the same code, on the same machine, sits at a fraction of it. raster being almost all of paint said where to look: the Blend2D calls, which is to say the Foreign Function & Memory API.

This is a known GraalVM limitation, and an open one. oracle/graal#8113 has “Improve downcall performance (currently always unoptimized)” on its list of unfinished work, and oracle/graal#12219 is somebody else’s SDL application going from 400 fps to 25. A GraalVM engineer answered that one in May 2026: it is hard to fix in general, there are no resources to fix it, and there is a workaround on 25.1 and later — build the downcall handle unbound, and initialise the class holding it at image build time.

So: is that the cause here, and does the workaround work?

Measured, before deciding anything

goldberry_abi_version is the cheapest function libgoldberry exports — it returns a constant — so timing it in a loop times the crossing and nothing else. Five million calls, GraalVM CE 25.2.4 (JDK 25.0.4), linux-x64:

how the handle is heldJVMnative image
bound to its address, built at run time10 ns4560 ns
unbound, built at run time10 ns4500 ns
unbound, built at image build time10 ns10 ns

450x, and both halves of the workaround are needed: the middle row is the one that says so. An unbound handle built at run time is exactly as slow as a bound one.

The reason is the same one behind every MethodHandle performance note. A handle is only a call when the compiler can see which handle it is; otherwise it is an interpreted lambda form. On the JVM the JIT gets there anyway — it watches the field, sees one value, and folds it. A native image has no second chance: whatever the compiler could not prove at build time, it emits the slow path for, once, forever.

And a handle bound to an address can never be proven at build time, because the address does not exist yet — libgoldberry is dlopened by the process that runs, which is the whole point of ADR-0159.

Decision

A binding keeps the address it looked up. The handle is a constant, shared by every symbol with the same signature, and it is linked while the image is being built.

Downcalls is that set of constants — one static final MethodHandle per signature, each linked from a FunctionDescriptor alone:

public static final MethodHandle INT__PTR_PTR_INT = of(INT, PTR, PTR, INT);

An unbound handle takes the function to call as its leading argument, so the class depends on no SymbolLookup and can be initialised in the builder. A binding then holds a MemorySegment where it used to hold a MethodHandle:

this.contextFillRectDRgba32 = Downcalls.symbol(lookup, "bl_context_fill_rect_d_rgba32");
...
check("bl_context_fill_rect_d_rgba32",
        (int) Downcalls.INT__PTR_PTR_INT.invokeExact(contextFillRectDRgba32, context, rect, argb));

134 bindings across Blend2D, Yoga, HarfBuzz, SDL and the shim share 56 signatures, which is what makes this a small file rather than a parallel copy of the bindings.

:natives ships the flag that makes it work, in META-INF/native-image/io.github.digitalsmile/goldberry-natives/native-image.properties:

Args = --initialize-at-build-time=io.github.digitalsmile.goldberry.natives.Downcalls \
       --initialize-at-run-time=io.github.digitalsmile.goldberry.natives.NativeLibrary

Both classes have an opinion about when they are initialised and they are opposite ones, so both belong beside the code that holds the opinion — not in the build file of every application that wants an image. This is ADR-0160’s argument for resources, applied to class initialisation: it travels in the jar, and a consumer building an image gets it without knowing it needs it. --initialize-at-run-time=…NativeLibrary moves here from example/build.gradle, where it had been since ADR-0127.

Naming a signature

<return>__<arguments>, in C’s words rather than Java’s: INT__PTR_PTR_INT is int f(void*, void*, int) and INT__VOID is int f(void). BOOL is C’s _Bool — one byte, not the four JAVA_BOOLEAN suggests — and PTR is any pointer. The declaration reads the same way, because the layout constants are spelled with the same words:

public static final MethodHandle INT__PTR_PTR_INT = of(INT, PTR, PTR, INT);

This started out as JVM descriptor letters — I_PPI, V_PFFI — which is shorter and needs a legend. It was not worth the legend: these names appear at two hundred call sites and are read far more often than they are typed.

The name is not decoration. invokeExact checks the constant’s type against the static types at the call site, so a call site that reads the name correctly and reaches for a constant that does not match its arguments throws WrongMethodTypeException on the first call rather than pushing four bytes where the ABI wanted eight. DowncallsTest checks the other direction — that every name describes the layouts beside it — so the two halves cannot drift.

Alternatives considered

One handle per function, named for it, instead of one per signature. The obvious reading of ADR-0010, and the descriptor would sit beside the symbol again. It is not available, and the reason is the same trap one level down: the constant has to be read by the method that calls it. Measured, same harness — a constant handle passed into a three-line static helper costs 810 ns in an image against 8.9 ns when the helper names it itself. The JVM inlines and folds either way; native-image does not.

The binding classes call through shape-generic helpers — Yoga.call, SdlVideo.callBoolean, Blend2D.invoke — exactly so that a hundred call sites share one try/catch. A handle named for one C function cannot be read inside a helper shared by forty of them, so per-function naming would mean deleting every helper and inlining it at roughly five extra lines a site. Yoga’s eleven length properties would not even benefit: they compose their symbol names at run time (YGNodeStyleSet + property + Percent/Auto), so those thirty-three symbols have no compile-time name to be called after. The signature is what the helpers have in common, so the signature is what the constants are named for. DowncallBenchmark.throughAHelper keeps the number honest.

Overloaded call helpers instead of named constants — Downcalls.callInt(fn, a, b, c), with Java’s overload resolution picking the signature. Shorter at every call site, and rejected: when no overload matches exactly, overload resolution does not fail, it widens. A call site passing an int where only a (void*, long) helper exists compiles silently and corrupts the frame. Named constants turn the same mistake into “cannot find symbol”.

Keep the descriptor beside the symbol, and name the constant at the call site as well. Preserves the ADR-0010 reading of the constructor — symbol, C prototype, descriptor, in one place — at the cost of stating the signature twice, in two files, where the compiler checks neither against the other. The C prototypes stay as comments; the descriptor does not.

Linker.Option.critical(). GraalVM’s FFM documentation offers it as a performance option, and it is the wrong tool here: it removes the thread-state transition, which is a real gain for a trivially short function and a real hazard for bl_context_fill_path_d_rgba32, which is neither short nor a thing that should be holding off a safepoint. It also does nothing about the 4.5 µs of lambda-form interpretation, which is the actual cost. Nothing here is marked critical.

Wait for GraalVM. #8113 is open, unticked, and answered with “we do not currently have the resources”. Waiting means shipping an image that paints at 25 fps.

Consequences

The showcase’s native image went from 42 ms a frame to 1.0 ms. Sixty frames headless (-Dgoldberry.backend.videoDriver=dummy --frames=60), same binary shape, same machine:

build60 framesper frame
before2.533 s42.2 ms
this change, with the flag withheld2.55 s42.5 ms
this change0.061 s1.0 ms

The middle row is a control: the same code, built with the properties file moved aside. It reproduces the original number exactly, which is what makes the last row attributable to the flag rather than to anything else in the change.

The image is now faster than the JVM over a short run — the JVM spends its first frames compiling (83 ms of style resolution on frame 0, 24 ms on frame 1), and an image has nothing to compile. That is what a native image was supposed to be for, and until now the FFM path was taking it back.

The JVM is unaffected. 9.81 ns bound against 9.27 ns unbound, measured by DowncallBenchmark: the address arrives as a value the JIT folds just as it folded the bound handle’s. Nothing was traded away.

Two invocation paths that boxed every argument are gone. SdlVideo and SdlCursors called through invokeWithArguments(Object...), which boxes each argument and decides the shape at run time from what it was handed. Both are now invokeExact against a constant. That is a JVM improvement as well as an image one, and it was not the point — it fell out of having to name a signature.

The FFM half of the traced metadata stopped depending on the run. ADR-0156 warns that a trace is only as good as the run that produced it, and ADR-0160 took resources out of the trace for exactly that reason. Descriptors are now linked in Downcalls’ class initialiser, which runs on any JVM start — so the agent records all 56 whether or not the run reached the screen that uses them. The directUpcalls entries still depend on the run.

A signature that is used once still needs a constant. INT__PTR_INT_INT_INT_PTR_LONG_INT_PTR_PTR exists for bl_image_init_as_from_data alone. That is the cost of sharing by shape rather than by symbol, and it is paid in one line.

This is a workaround, and it is load-bearing. If #8113 is ever finished, Downcalls becomes an ordinary way to write bindings rather than a necessary one, the properties file can lose a line, and the handles can move back beside their symbols. Until then, deleting either half — the unbound handle or the build-time initialisation — silently costs a factor of forty, with nothing failing and no test going red. The --initialize-at-run-time control above is the check; book/src/native.md says how to run it.

162. A golden is checked at every scale

Date: 2026-08-20

Status

Accepted. Closes the gap ADR-0157 left open.

Context

ADR-0157 fixed a layer composited at twice its size on a 2× display and ended with what it had not fixed:

The broader gap is not closed — almost every pixel assertion in this repository is at 1×, and this class of bug is invisible there. A golden corpus at 2× is the obvious next step and is not built.

The numbers behind that sentence: 39 calls to GoldenImage.assertMatches across 21 test classes, producing 106 committed images. 37 of the 39 are at 1.0. One is at 1.5 and one at 3.0, both added deliberately by somebody who had just been bitten.

The class of fault this hides is narrow and entirely mechanical. Two coordinate spaces meet everywhere in the paint path — logical units, which are what a stylesheet and a layout speak, and device pixels, which are what a raster is made of — and the conversion between them is a multiplication by the display scale. At a scale of 1 that multiplication is the identity. So a size converted twice, a size never converted, an origin computed in the wrong space and a stroke width that picked the factor up on the way past all produce exactly the right picture on the machine the golden was generated on, and the wrong one on the reporter’s.

Decision

Every golden that matches is drawn again at 2× and 1.5× its own scale, and asserted to be the same picture. Nothing further is committed.

GoldenImage.assertMatches calls ScaleInvariance.assertScaleInvariant once the image comparison has passed. That renders the same scene into a frame of the same logical size on a larger device — TestFrames is described in physical pixels, so both the buffer and the scale are multiplied and the logical size comes out unchanged — area-resamples the result back down, and compares.

ScaleInvariance.assertSamePictureAtEveryScale is the same check with no golden behind it, for the tests that read pixels back directly. ClipTest, TransformPaintTest and IconPaintTest now use it: those are where the arithmetic actually lives, and none of them had a golden to hang a second scale off.

What the comparison is allowed to demand

Not equality, and not GoldenImage’s two-level tolerance. Two things differ legitimately between one scale and another:

  • Edge placement. Yoga’s point scale factor rounds computed edges onto whole device pixels, so at 2× an edge can land on a half of a logical one. A high-contrast border that moves half a pixel differs by over a hundred levels in the column it moved out of.
  • Antialiasing. A glyph rasterized at 2× and averaged down is a different approximation of the same coverage integral than one rasterized at 1×. Both are right and they are not the same bytes.

Both are sub-pixel; the faults being hunted are not. So a pixel may find its match anywhere in the 3×3 neighbourhood around it — which absorbs half-pixel movement and resampling blur completely — and what is left has to be small:

SettingValueWhy
Neighbourhood radius1 pixelThe differences forgiven are sub-pixel. A radius of 2 would start forgiving a control that moved.
Channel tolerance72 levelsHigh enough that the honest disagreements — always on glyph edges — stay a handful of pixels rather than a region. Single pixels do go past it (121 is the worst measured); what does the work is the share below.
Share allowed past it1.2%The worst honest case measured is 0.332%.
Multipliers2, 1.5A Retina display, and the ordinary fractional Linux case — the one where a raster is rounded up to a whole pixel and has to be divided back.

The search runs in both directions. “Every pixel of the reference appears near where it was” says nothing about something that grew: the reference’s ink is all still present, just with more around it. Ink that appeared where there was none is only visible looking the other way.

The reference is what this run drew at 1×, not the committed PNG. The question is whether the renderer agrees with itself across scales; comparing against the file would fold a stale golden into the answer as well.

Alternatives considered

Commit name@2x.png beside every golden. The obvious answer. It doubles a 106-image corpus, and — the reason it is wrong rather than merely expensive — a committed 2× image asserts only this is what it drew, which a wrong image satisfies exactly as well as a right one, forever. Nobody reviewing a pull request looks at a 2× render of a slider and notices that its thumb is a half-pixel out. The claim worth making is not “this is the 2× picture” but “the picture does not depend on the device”, and that one can be checked against the 1× image already in the repository.

Compare with a per-channel tolerance and no neighbourhood search. Simpler, and it fails on every scene with a border in it: a half-pixel edge shift is a delta of over 100 in a whole column of pixels, so the threshold would have to be loose enough to let a real fault through.

Assert on ink coverage or a bounding box instead of pixels. Robust against antialiasing by construction, and blind to anything that keeps the same total amount of ink — a control drawn at the right size in the wrong place, which is half of this family.

Leave it opt-in, one test at a time. 21 classes would have had to adopt it and the ones nobody remembered would be exactly the ones with the bug. Making it part of what assertMatches means closes all 39 call sites at once and makes a new golden covered on the day it is written.

Consequences

A golden costs about 17 ms more. :widgets:test --tests '*GoldenTest*' — 88 images — goes from 2.16 s to 3.64 s in-JVM, a 68% increase on a suite that is seconds long. It buys three renders where there was one.

Nothing new was found. The whole corpus passed at 2× and 1.5× on the first run, at 2215 tests and no failures. ADR-0157’s bug was the one that was there and it was already fixed; this is the check that says so, and the check that will not let the next one through. Reporting it any other way would be dishonest about what a green run means here.

The thresholds are calibrated to this machine’s rasterizer. Blend2D compiles its pipelines for the CPU it finds (ADR-0030), so another architecture’s antialiasing differs — the same reason GoldenImage has a tolerance at all. The margin is 3.6× between the worst measured honest case and the limit, and -Dgoldberry.golden.scales.report=true prints what every check measured, so a runner that starts pressing against it says so in numbers rather than in a mysterious failure.

A one-pixel translation of the whole scene is invisible to this. That is the neighbourhood search doing its job, and it means the check is not a substitute for the golden: the golden pins position, this pins invariance, and neither subsumes the other.

Turned off with -Dgoldberry.golden.scales=. A golden update run skips it already, because assertMatches returns before the check when it is rewriting.

Verified by breaking it on purpose. ScaleInvarianceTest reconstructs ADR-0157’s bug — a rectangle sized in physical pixels and drawn in logical ones — and asserts the check rejects it, and does the same for a border thickened by the scale, which is the subtle end of the family and only a stroke wide. A harness whose failure path never runs is a harness that reports success.

163. A menu bar owns its menus, and a value already outlives an opening

Date: 2026-08-20

Status

Accepted. Builds menubar and the half of §8’s accelerator that ADR-0106 deferred.

Context

docs/core-widgets.md §8 asks for two things that have been marked not built since ADR-0106, with the same sentence explaining both:

menubar — in-window horizontal bar; Alt-style keyboard activation; arrows navigate. Not built: it is the widget in this group that needs a menu to exist for longer than one opening, which is the same thing accelerator registration needs.

and, of item:

accelerator (displayed right-aligned and auto-registered in the window’s shortcut map) … The accelerator is displayed and not registered: a shortcut has to work while the menu is shut, and a menu is built when it opens and thrown away when it closes — registration needs something that owns menus for longer than one opening.

That reasoning was right about the problem and wrong about what the problem was made of. What is built and thrown away is the popup, not the menu. A Menu is a record — an ordinary value, described by the author, no different from a Button — and Menus.open builds a second tree from it to put in a window. The description was never short-lived. Nothing was ever holding it, which is a different complaint entirely, and the fix for it is a widget that does.

Decision

menubar is a row of items, and holding them is the whole mechanism.

new MenuBar(
        new Item("File").submenu(
                new Item("Open…", this::open).accelerator("Ctrl+O"),
                new Separator(),
                new Item("Quit", this::quit).accelerator("Ctrl+Q")),
        new Item("Edit").submenu(
                new Item("Undo", this::undo).accelerator("Ctrl+Z")));
menubar {
    item "File" {
        item press="app.open" accelerator="Ctrl+O" "Open…"
        separator
        item press="app.quit" accelerator="Ctrl+Q" "Quit"
    }
}

There is no new markup

A bar’s children are items, and an item containing items is a heading that opens a menu — which has been the submenu syntax since ADR-0106 (“a nested item is the submenu syntax and there is no submenu node to forget”). A menubar is a row of the thing a menu was already made of. Nothing about declaring one is new to learn, and no node was added to the catalog beyond the bar itself.

The accelerators are walked from the description, not from a window

Accelerators.in(widgets) walks the item tree and yields every Shortcut with the command behind it. MenuBarState binds them on build and gives them back on dispose. No menu is opened, and none needs to be — which is the claim, and which is why MenuBarTest’s central case fires Ctrl+O with nothing whatsoever on screen.

Three kinds of row are passed over, and each absence is an ordinary thing to write: no accelerator, no command (a row with a submenu leads somewhere rather than doing something), and disabled — because a greyed row that still fires on its key is worse than no accelerator at all.

An accelerator that does not parse is logged and skipped rather than thrown. It is a typo; it is already being drawn beside the row where somebody can see it; and a stylesheet error should not take a window down.

A heading is not a menu row

MenuTitle is a separate widget from Item rather than a flag on it, because the two answer the keyboard differently and that difference is what makes a bar a bar:

KeyIn a menu (Item)In a bar (MenuTitle)
Downmove to the next rowopen this menu
Rightopen this row’s submenumove to the next heading
Leftnothingmove to the previous heading

Left and Right are not the widget’s at all — MenuBarRow is a horizontal focus scope where a Menu is a vertical one (ADR-0078), so traversal comes free and the two arrows left over are the two the bar wants.

A heading also has none of the three things that make a row a row: no tick column, no accelerator on the right, no chevron.

Hovering opens, but only once something is open

A heading opens on a click. Once a menu is showing, every other heading opens on hover — which is what every desktop bar does, and why running along the bar with a menu down does not need a click per menu. Hovering with nothing open does nothing at all: a bar that dropped a menu because the pointer crossed it on the way somewhere else would be unusable.

F10, and why not Alt

§8 asks for “Alt-style keyboard activation”. A bare Alt tap is a modifier released with nothing in between, and a Shortcut here is a key plus modifiers — Key has no ALT to name, deliberately, because Shortcut’s own constructor refuses a shortcut that can never fire. So the binding is F10, which is the companion activator on every platform that has the Alt one, and which opens the first heading rather than merely focusing it: there is no Host.focus, and a bar that took F10 and did nothing visible would read as a broken binding rather than a missing one.

Alternatives considered

A MenuModel the window owns. What ADR-0106’s wording implies: a registry on the Host, menus put into it by name, the bar reading from it. It is a second place a menu can live, with its own lifetime and its own staleness, and every question it answers — what outlives an opening, what can be walked for accelerators — is answered by the value the author already wrote.

Register accelerators from Menus.open. The registration would then exist for exactly as long as the popup, which is the thing that does not work. Registering on open and not unregistering on close would leave a menu’s keys bound after a window forgot the menu existed.

Put the accelerator walk on Menu itself. menu.accelerators() reads well and puts a Host-facing concern on a value. Accelerators is a static utility for Menus’s own reason: binding needs a Host and a widget must not have one (ADR-0106).

A heading or menu-title markup node. More explicit than a nested item, and it would make a bar’s children a different shape from a menu’s for no gain — the nesting already means “opens this”.

Give Item a bar flag. One widget, two keyboard maps chosen by a boolean. Every method on it would then start by asking which one it is.

Consequences

Host grew removeShortcut. The router had it; the window’s front door did not, because until now nothing bound an accelerator it would later want back. The map is keyed by the shortcut and not by who bound it, so removing takes out whatever is bound to that key — including somebody else’s later binding. Two things claiming Ctrl+O is already a conflict the last registration wins; this is the same conflict at the other end, and it is written down rather than defended against, because defending would mean the map remembering owners and a menubar being the only thing that could ever use that.

A collision inside one bar is logged, and the later row wins. That is the map’s behaviour said out loud. Refusing the second would produce a menu whose second Ctrl+O silently does nothing.

Menus.open gained a placement overload. A heading’s menu hangs from the bar with no gap, where a context menu stands 4px off the pointer so as not to open underneath it.

The tests found a third stub Host, and there is now one. SelectTest and TourTest each carried a near-identical hand-written Host, and adding two methods to the interface would have meant editing both plus writing a third. TestHost is the shared one and both now extend it; it records popups, accelerators, overlays and anchors, and — like the real thing on SDL’s dummy driver — opens nothing, which is the branch ADR-0102 says a control has to survive.

Two things §8 asks for are still not built, and neither is a menu-bar problem. A bare Alt tap needs key-release tracking that Shortcut cannot express. And Left/Right do not move between menus while one is down: the open menu is a separate window with its own focus, so the bar cannot see the arrows — which is the same missing item-to-popup callback that has kept Left from closing a submenu since ADR-0112. Both are in TODO.md.

The bar was the first new widget drawn through ADR-0162. Six goldens, each also checked at 2× and 1.5×, on the day they were written rather than after somebody reports a HiDPI bug.

.open is the accent fill, not a stronger overlay. The first cut used --gb-overlay-active over :hover’s --gb-overlay-hover — 16% against 8% — and the golden showed the two are hard to tell apart, which is exactly the comparison a bar puts in front of somebody, since the hovered heading is usually the one next to the open one. Caught by looking at the image, which is what the image is for.

164. Elevation is an edge, and a closed section is absent

Date: 2026-08-20

Status

Accepted. Builds five of §5’s seven remaining containers — card, group-box, statistic, skeleton and collapse. split-pane and carousel are not built.

Context

docs/core-widgets.md §5 lists nine containers. panel and tabs were built (ADR-0107); the other seven were untouched. Most of them are ordinary composition work with no design question in front of them — but three of the five taken here run straight into a limit of §10’s CSS subset or of §1.7’s motion rules, and each needs the answer written down rather than discovered again by the next person.

The supported property list is the whole of it:

align-items background background-color bold border border-color border-radius border-width bottom color cursor flex-direction flex-grow flex-shrink font-family font-size font-weight gap height inset justify-content left line-height opacity outline outline-color outline-offset outline-width overflow padding padding-* position right top transform transform-origin transition width

There is no box-shadow, no display, no per-edge border longhand, and no min-width. Three of §5’s descriptions ask for something in that gap.

Decision

card: elevation is an edge

§5 asks for “elevated surface: shadow tokens”. There are none, and nothing in this toolkit paints outside a box’s own rectangle.

So a card is raised by contrast: --gb-surface-2 where the page is --gb-bg, plus a border. popover reached the same answer first and its stylesheet says so — “the elevation is a border rather than a shadow: §8’s subset has no box-shadow, and a floating panel with no edge at all disappears into a [page]”.

This is not only a workaround. A shadow says “nearer” by faking a light source; a lift in tone and a defined edge say it by contrast, and contrast is what a rasterizer with no shadow pass can actually express. PanelsGoldenTest carries the check that it works, on both themes, because “does this read as raised” is not something an assertion can answer.

class="interactive" is §5’s optional hover-elevation, opt-in because a card that lit up under the pointer would promise it does something and most cards do not.

group-box: the title is above the frame, not through it

A fieldset puts its legend on the border, with the frame broken behind the words. Reproducing that needs either a notch in a border — nothing in the subset expresses one — or the title absolutely positioned over the frame with the page’s own background painted behind it, which is wrong the moment a group-box sits on anything but the page.

So the title sits above a bordered body. That is what a settings cluster looks like in every desktop written this decade, and it is better at small widths besides: a legend through a border must fit on one line or the frame breaks, and a heading above one simply wraps.

The frame is a widget of its own (group-box-body) because a border on the outer box would enclose the title as well — tab-rule’s argument (ADR-0107), one widget later.

collapse: a closed body is absent, not hidden

§5 is explicit and the reason is the whole argument for a widget tree: “a collapsed section that kept a live subtree would keep its subscriptions, its images and its scroll position alive for content nobody can see, and ‘cheap to rebuild’ is what the widget tree is for” (ADR-0004).

So a closed collapse describes one child. Not a child with display: none, which the subset has not got; not a child of zero height, which would still be built, still be subscribed and still be laid out. CollapseTest’s central assertion is therefore an absence — no body node, and the author’s own widgets never constructed either.

The height does not animate, and never will: §1.7’s whitelist is opacity and transform precisely so that a transition can never cost a reflow. The chevron turning is what says the section opened — and it is CHEVRON_END rotated by the stylesheet rather than CHEVRON_DOWN swapped in, because a mark that changed kind would jump where §5 wants it to travel. That forced the chevron to be a real node: a transform is resolved for an element, so a mark drawn inline by the header could never turn.

Left and Right are absolute rather than toggles — Right on an open section leaves it open — which is what §5 asks for and what lets somebody hold Right down a list of sections and open all of them.

skeleton: the one loop in the canon, computed from the clock

§5 makes this the single exception to §1.7 rule 4. A loop is not a transition: a transition runs between two states and a skeleton has one. So the pulse is a pure function of Context.nowMillis(), which is spinner’s arrangement exactly and for the same reason (ADR-0081) — no controller, no start, no stop, every skeleton on screen in step by construction, and one that unmounts leaves nothing behind.

A triangle wave, folding at the halfway point, so the two ends meet and the pulse does not snap once a second. Between 0.45 and 1.0 rather than 0 and 1: a placeholder that fades to nothing is a layout that flickers empty, and one at full strength is indistinguishable from content.

Reduced motion holds it at its dimmest, not its brightest and not the average. A placeholder frozen at full strength reads as content that arrived and was blank.

statistic: the toolkit never formats, and a direction is a sentiment

§5’s reason for taking a string: “a locale-aware number formatted inside the toolkit makes a golden image that cannot be reproduced on another machine”. 12,480 is 12.480 in half of Europe.

direction names the sentiment, not the arithmetic — latency falling is success — so the caller picks it and the widget never infers it from a leading -. Inferring would colour a latency improvement red. The colour itself is a class on the delta (.up, .down) and therefore the stylesheet’s; a widget that looked up --gb-success itself would be the only one in the catalog doing so.

Alternatives considered

Add box-shadow to the subset. It is one property and it would settle card, popover and eventually dialog together. It is also a second rasterization pass per shadowed box — a blurred alpha mask composited under the box — on a CPU rasterizer whose whole frame currently costs about 320 µs. §10’s subset is small on purpose, and this is exactly the kind of addition that is cheap to write and permanent to pay for.

Give card a title. §5 says “group with optional label”, and it reads like a field. It is the accessible name, which arrives with the AccessKit bridge in M5 along with every other widget’s — and a card with a title is a group-box with different tokens, which would leave two widgets doing one job.

Animate a collapse’s height. Every other toolkit does it and it looks good. It is a layout pass per frame for the whole subtree, which is what §1.7’s whitelist exists to prevent.

Make the shimmer a CSS transition between two opacities. No transition loops, and adding a loop to the transition engine would put §1.7 rule 4’s one exception inside the mechanism every ordinary animation goes through — where the next widget to want a loop would find it already built.

Consequences

A record component cannot be called children when children() is overridden. GroupBox described its parts — the heading and the frame — from children(), which is also the record accessor for the author’s widgets, so asking a group box what was in it returned its own chrome. Caught by a test that inflated one node and was told it had two. The component is content now, and the same trap is waiting for any widget that has both.

The skeleton goldens needed a virtual clock, and found out the hard way. A widget that draws from nowMillis() renders differently every run, so its first two goldens could never have matched — ProgressGoldenTest already had the answer (Clock.virtual()), and this is the second widget to need it. The pinned instant is the fold at 500 ms; the first guess was 250, which is a quarter of the way in and not the peak.

The five widgets add eleven CSS-selectable node types. card, group-box, group-box-title, group-box-body, statistic, statistic-label, statistic-value, statistic-unit, statistic-delta, skeleton, skeleton-bar, collapse, collapse-header, collapse-chevron, collapse-body. Most are parts in the ADR-0065 sense: styleable and not constructible.

split-pane and carousel are not built. Both need something the five here did not: a drag with a retained position and a keyboard equivalent for the first, and a timed rotation that pauses on hover, on focus and under reduced motion for the second. Neither has a design question outstanding — they are the remaining work, and they are in TODO.md.

A skeleton keeps the frame loop awake even under reduced motion. Paints.isAnimating() is a property of the description and takes no Context, so it cannot see the preference. spinner has had the same shape since ADR-0081 and pays the same cost: a frame’s worth of paint on a still image. Closing it means giving isAnimating the context, which is an SPI change for two widgets.

165. A divider translates, and a rotation has three brakes

Date: 2026-08-20

Status

Accepted. Finishes docs/core-widgets.md §5 with split-pane and carousel, the two containers ADR-0164 left.

Context

ADR-0164 built five of §5’s seven remaining containers and said why the other two were not among them:

Both need something the five here did not: a drag with a retained position and a keyboard equivalent for the first, and a timed rotation that pauses on hover, on focus and under reduced motion for the second. Neither has a design question outstanding — they are the remaining work.

That was right about the shape of the work and wrong that there was no question in it. Each turned out to have exactly one, and neither is the one you would predict from the description.

Decision

split-pane: the divider translates, and the position is a fraction

The gesture is a translation, not a position. A slider reads its value straight off the pointer, because the value is a position along a track (ADR-0079). A divider cannot: the pointer is somewhere inside a six-point bar, and mapping that to a fraction of the pane would snap the divider so its centre jumped under the finger on every press — by up to three points, which is visible and feels broken.

So it is the knob’s arrangement (ADR-0089): the divider reports its current offset as a gestureAnchor, the router hands that back on every event of the gesture, and the new offset is anchor + dragX. This is the second widget to want an anchor, for a reason that is not the knob’s — a knob needs one because its value has already moved by the second frame; a divider needs one because the pointer’s position inside the grab is not the value. Worth noticing, because it says the mechanism generalises past the case it was built for.

The position is a fraction and the minimums are pixels, deliberately. A divider a third of the way across should stay a third of the way across when the window widens, which a stored pixel offset gets wrong. But “this list needs 160 points or its labels wrap” is a fact about content, and a fractional minimum would let a narrow window squeeze it to nothing. So the fraction is clamped against the pixels on every layout — which needs the measured length, and that arrives through ADR-0117.

The first pane is sized and the second grows. Two flex-grows in proportion is the obvious answer and it does not work: flex-grow distributes the space left over after content, so two panes with anything in them land where their content puts them and the divider’s fraction is ignored. §10’s subset has no flex-basis to say it with instead. So the first pane gets an explicit main-axis size in logical pixels and the second takes the rest.

§5 in one sentence:

Nothing advances on its own unless interval is set, and when it is, the rotation pauses on hover, on focus anywhere inside, and entirely under reduced motion — §1.7 rule 4 says nothing loops except explicit continuous indicators, and a carousel that moves while being read is the canonical violation.

interval defaults to off. When it is on, one one-shot timer is rescheduled after each slide rather than a repeating one, so that “pause” means “do not schedule the next” and needs no second mechanism to suspend. Every reason to stop is checked in one predicate, and checked again when the timer fires — a timer already in flight when the pointer arrives would otherwise advance one slide past the moment it was supposed to stop.

Hover works completely. Reduced motion works completely — reported into the state from render, which is the only place a Paints.Context exists, since a State cannot ask for one.

Focus does not, and that is the honest part. Focus on the strip or on the carousel’s own controls pauses it; focus on a widget inside a slide does not, because the cascade has no :focus-within and nothing tells a widget that focus landed in its subtree. That is a real gap rather than a cosmetic one — somebody who has tabbed into a slide is exactly somebody reading it — and it is in TODO.md with what would close it.

Alternatives considered

Track the pointer’s position for the divider, like a slider. Simpler, no anchor, no Measured. It jumps on every press, which is the thing.

Store the divider’s position in pixels. Then a resize keeps the panes’ sizes and moves the proportion, which is right for a fixed sidebar and wrong for everything else — and an application that wants the fixed-sidebar behaviour can have it by pinning a width on the pane, where the reverse is not available.

Unmount a collapsed pane, as collapse unmounts a closed body. §5 asks for collapse-to-edge, which is a size and not an absence, and a pane that lost its state whenever somebody dragged the divider to the edge would be a surprise nothing in the specification asks for. The two widgets differ on purpose and both say so.

A repeating timer for the carousel. One after, cancelled and rescheduled, is the same amount of code and makes every pause a matter of not scheduling rather than of suspending something.

Add :focus-within to the cascade to close the third brake. It would work, it would serve more than this widget, and it is a change to the selector engine, the matcher and the router’s focus bookkeeping — a :core change of real size in the middle of finishing a widget group. Written down instead.

Build a CHEVRON_START mark for Previous. A second mark kind that has to stay the mirror of the first forever. It is one transform: rotate(180deg) instead, which cannot drift — collapse-chevron made the same trade for the same reason.

Consequences

EventLoop.Timer’s constructor is package-private rather than private, and TestTimers in :core’s test fixtures hands them out. A widget that schedules cannot be tested against a stub Host without something to return, and the assertion that matters is that the timer was cancelled — a timer outliving the tree that scheduled it is one of the two leaks a widget can cause. TestFrames has exactly this arrangement over Frame and for exactly this reason.

The divider’s thickness is written in two places. SplitPaneView.DIVIDER and the split-divider rule in controls.css have to agree, because the first pane’s size is computed against it and a stylesheet that disagreed would put the second pane’s edge that many points out — silently. SplitPaneTest pins them together. The same bargain Menus makes with --gb-menu-item-height (ADR-0117), and the same reason it is not a token: a widget cannot read one.

A build found a wasted wakeup. CarouselState.build calls schedule unconditionally, and at the last slide of a non-looping carousel that scheduled a timer which would fire, move nothing, and stop. The fix was to fold “is there anywhere to go” into the same predicate as the three brakes rather than test it at the reschedule — the alternative was the same condition written twice, and the copy in build was the one that was missing.

A dot is not focusable. Nine slides would be nine tab stops on top of the two buttons, which is tab’s close-button argument (ADR-0107): the keyboard already reaches every slide through the arrows, and the dots are a pointer affordance and a position readout.

§5 is complete, and the showcase’s Panels screen demonstrates all seven — still with no Java behind it, because a split-pane and a carousel that keep their own state need no more wiring than a card does.

166. A raised thing is told apart by its edge, and a group box holds its title

Date: 2026-08-20

Status

Accepted. Corrects two decisions in ADR-0164 and finishes three things §5 asks for that it left out.

Context

Five reports, from looking at the Panels screen:

  1. The top bar with counter is small now, only at Panels tab
  2. In black theme I do not see any visual differences between panel and card
  3. What is the purpose of group box? I thought I should group elements with title and border.
  4. Make carousel animations
  5. Make an option to collapse to show open only one at a time. Add animations

Three of those are defects and two are §5 surface that was deferred. The two defects that matter — 2 and 3 — are both ADR-0164 being wrong rather than incomplete, and both were only findable by looking at the thing.

Decision

panel gets the rule §5 always asked for

§5: “panel — plain surface: --gb-surface, border, radius tokens. The building block; no elevation.”

There was no panel rule in controls.css at all. The widget’s own javadoc said it “sets nothing at all, not even a background”, which contradicted the specification and made every document that wanted a surface invent one. The showcase invented one that looked exactly like a card — which is the whole of report 2. A building block that draws nothing is not a building block.

Elevation is an edge, and --gb-surface-2 was never an elevation

ADR-0164 said “elevation is an edge” and then hedged by also stepping the fill to --gb-surface-2. Both halves were wrong.

--gb-surface-2 over --gb-surface is eight levels on the Nord dark ramp, and eight levels is not an elevation anybody can see — report 2 again. And on the light theme it is a step down: --gb-surface is #ffffff there, so a card built on --gb-surface-2 read as recessed. The token means “the second surface”, and it never promised to be an elevation.

So two tokens that say what is meant:

TokenDarkLight
--gb-surface-raisednord2, a step up#ffffff, because white is the top
--gb-border-strongwhite at 20%black at 16%

The edge is the load-bearing half and it is an alpha over whatever is underneath — the only way to say “lighter than its own surface” in a subset with no colour functions — so it lightens on dark, darkens on light, and stays right on a card sitting on a page, on a panel, or on another card, without either theme stating it twice.

On light the two themes genuinely differ: there is no room above white, so a card there is told apart by its edge alone. That is not a shortfall, it is what a light theme has to offer, and a white card with a defined edge on a near-white page reads as raised.

A group-box’s frame encloses its title

ADR-0164 put the title above the frame, reasoning that a fieldset’s legend through the border needs a notch the subset cannot express. The premise is still true; the conclusion was wrong, and report 3 is the proof: “what is the purpose of group box? I thought I should group elements with title and border.”

A heading floating over a bordered box is a heading and a panel. Nothing about it says the two belong together, and an untitled one was indistinguishable from a card — so the widget had no purpose that two existing widgets did not already serve.

The border now goes round both. The title is a header row inside the frame, tinted and ruled off from the body. That is a titled group with one look and no ambiguity, it needs nothing the subset has not got, and it still wraps at a narrow width where a legend through a border would break the frame.

Phase is shared, and two more things arrive on the frame clock

TabPhase was written for tabs (ADR-0109) and had nothing tab-shaped in it. It is now widgets.core.Phase, and carousel’s slides and collapse’s body arrive through it.

Both are arrival only, and both for reasons that are the widget’s own:

  • A carousel builds only the current slide, so holding the outgoing one alive for the length of a cross-fade would be building a slide that has been moved away from — the one thing “only the current slide is built” says it does not do.
  • A collapse unmounts its body when it closes, and holding a subtree alive for 160 ms after it has been asked to go away is exactly what §5 says it does not do. Closing is instant and opening is not: asymmetric on purpose, because the thing worth animating is content appearing where there was none.

Opacity and a small translation, and nothing else — §1.7’s whitelist is the compositor-cheap set, and neither widget animates its height, which §5 forbids and always will.

accordion=#true inflates to a widget

§5 puts the flag on the containing column, and is right to: “one open at a time” is a rule about siblings, which no section can enforce about the others.

But column is the most-used container in the toolkit and it is a plain record. Making it stateful so one flag can be honoured would give every column in every document a State object it never uses, and statefulness is a property of the type rather than of the instance — it cannot be conditional.

So column accordion=#true inflates to an Accordion, which reports column as its own CSS type and adds an accordion class. A document writes what §5 says, a stylesheet still sees a column, and an ordinary column pays nothing.

The sections become controlled — each re-issued with the open the accordion decides and an onToggle that reports back — which is radio-group’s arrangement exactly (ADR-0073). A section the application already controls is left alone: two things deciding one boolean is a bug, and the application asked first.

Alternatives considered

Add box-shadow to the subset, again. It settles cards, popovers and dialogs together and it is a second rasterization pass per shadowed box on a CPU rasterizer. ADR-0164 refused it; nothing here changes that arithmetic.

Keep the group box’s title above the frame and make it look attached — a tighter gap, a matching background. It is the same two boxes with less space between them, and the question “why is this not just a heading and a panel?” still has no answer.

Make Column stateful. One flag, paid for by every column ever built.

A separate accordion markup node. Cleaner internally and a deviation from §5 for no gain to the author: the flag on the container is the right syntax, and what it inflates to is an implementation detail that the CSS type keeps invisible.

Cross-fade the carousel’s slides. Needs both slides alive at once, which contradicts §5’s “only the current slide is built” — and the reason for that rule (a slide nobody can see should not hold subscriptions) does not stop applying for 160 ms.

Animate a collapse’s closing. Means keeping the body mounted after it has been closed, which is the thing collapse exists not to do.

Consequences

Chrome does not shrink. Report 1 was a title bar half its height on one screen: Yoga’s children shrink by default, so a window whose content asks for more height than there is takes it out of whatever will give, and a title bar with a definite height is the most willing thing in the tree. #bar is flex-shrink: 0 now. If the content does not fit, the content is what scrolls.

Nineteen goldens moved, and every one of them was reviewed by eye rather than accepted. Most are the new panel rule showing up as a backdrop in tests that had been asserting against a transparent one — badge-on-surface, knob-on-surface, segmented-on-surface, both overlay-corners and the Scrolling screen, all of which now show the surface the specification always said a panel had.

Two new tokens, and the reason to accept them is that the alternative was a widget silently choosing a colour. --gb-surface-raised and --gb-border-strong are both semantic — “the surface of something raised”, “the edge of something raised” — and both are answered differently by the two themes because the themes genuinely differ about how much room there is above a surface.

carousel and collapse keep the frame loop awake while something arrives, and only then. A carousel sitting on one slide and a section that has been open a while both ask for nothing, so a window with either in it is as idle as a window without.

A section that started open does not animate. It was there on the first frame; fading it up would be animating the window opening.

accordion is the fourth composite to wire its children. radio-group, tabs, menubar and now this. The shape is stable enough to be a pattern: the container holds one number, and each child is handed its own half of it — which is also why there is no second piece of state that could disagree with the first.

167. A field owns its caret, and the model is told

Date: 2026-08-21

Status

Accepted. Opens docs/core-widgets.md §4, closes the clipboard hole ADR-0019 left open, and adds a per-window platform call nothing had needed before.

Context

§4’s text-input is the first widget in the toolkit whose state is not a value an application can hold. Every control before it — checkbox, slider, select, segmented — has a state that is the model’s: one bit, one number, one key, read down through bind= and reported back through change=, with nothing left over (ADR-0063). A field has a caret, a selection, an undo stack and a scroll offset as well as its text, and none of those is the application’s business.

Three things also turned out not to exist, and each was only discovered by trying to write the widget:

  1. SDL_StartTextInput was not on the export list. SDL3 delivers no SDL_EVENT_TEXT_INPUT to a window that has not asked for one. SdlEventBuffer could read the event, Window.handleTextInput routed it and KeyboardTest exercised it — and on a real SDL window it had never once arrived.
  2. There was no clipboard. ADR-0019 left it out of the backend SPI on the rule that an interface with no consumer gets designed twice, and said it would come back when something wanted it.
  3. A Paragraph could not say where a caret goes. It has the prefix sums — wrapping is built on them — and no way to ask.

Decision

The field holds the text; the model is told

The edit lives in the element, and every change the user makes is reported through change=. A bind= value is the initial text and an override: a value that differs from what the field holds is somebody else’s and takes the field, one that matches is the echo of the user’s own keystroke and is ignored.

That test needs a second half, and it is not obvious. Comparing the incoming value against the field’s text is not enough, because an unbound field’s value= is a constant the widget was built with — so every rebuild would overwrite whatever had been typed. So the state also keeps what the widget last offered, and adopts only a value that has changed since the last build.

It also has to be in build rather than in didUpdateWidget. A bind= value firing does not replace the widget: the property notifies, the element is marked for build, and the widget is the same object it was (ADR-0062). didUpdateWidget would miss the case the mechanism exists for.

The editing model is a value, and it is tested without a widget

TextEdit is (text, anchor, caret) and every operation returns a new one. Three things follow, and the third is the one that mattered:

  • A State holds one and swaps it, which is how every other stateful widget here works.
  • Undo is a stack of states rather than a log of inverse operations, so nothing has to know how to reverse a word delete.
  • Every rule in §4 — what Backspace does to a selection, where Ctrl+Left lands, what a double-click selects — is testable with no font, no frame and no window. Forty-five of this branch’s tests need nothing but the model.

The cost is a string copy per keystroke. For a single-line field that is a few hundred characters of arraycopy, next to nothing beside the shaping the same keystroke causes. A text-area large enough for that to matter wants a rope, and would want one whether or not this were a value.

Two offsets rather than a caret and a length, so a selection dragged right-to-left keeps its direction. No selection is anchor == caret, so there is no separate flag to fall out of step with the offsets.

Everything steps by grapheme, through java.text.BreakIterator’s character instance — the same class Paragraph.offsetAt uses, so the model and the geometry cannot disagree about where a caret may sit.

A run of keystrokes is one Ctrl+Z, and the rule is one comparison

EditHistory folds consecutive changes of the same kind into one entry when the new change starts where the last one ended. Everything else falls out of that one test and is written down nowhere:

  • Moving the caret breaks the run, because the next keystroke then starts from a state the last one did not leave.
  • So does clicking, for the same reason.
  • So does a value arriving from the model.
  • Typing after deleting starts a new entry, because the kinds differ.

Editors that coalesce on a timer have the defect where pausing mid-word splits the undo. This cannot: a long typed run is one undo however long it took. A paste or a cut never folds, because each is one thing somebody did on purpose.

The field names intents; it does not build edits

The seam between the node that takes the keys and the state that holds the text passes intents — move(LEFT, byWord, extend), deleteBefore(byWord) — rather than finished TextEdits. Handing an edit across would have been shorter, and it is wrong for a reason worth stating: the field’s edit is not the field’s text.

A password draws bullets, and the caret and selection it draws are offsets into those. A field applying edit.backspace() to what it was drawing would delete a bullet and leave the password a row of them. The first version did exactly that, and the test that caught it was the one asserting a masked field still holds what was typed.

The intent seam also puts the one rule a masked field has in a single place: a row of bullets has no words in it, so Ctrl+Left in a password goes to the start rather than stepping by an amount that says how long the words are.

A Mask maps display offsets to real ones and back, one bullet per code point, and is the identity for an ordinary field — which pays for none of it.

A spinner draws itself from Context.nowMillis() and answers isAnimating, which asks for a frame every frame (ADR-0081). That is right for something that moves continuously and badly wrong for a caret, which changes twice a second: a field with focus would run the loop at the display’s rate for as long as a form was open, and §1.7’s “the frame loop is fully idle when no animation is active” would be false for every window with a form in it.

So the blink is one one-shot timer, rescheduled — carousel’s arrangement (ADR-0165) — and costs two frames a second instead of a hundred and twenty. It goes solid on every edit and every caret move, because a caret that blinks out mid-word is a caret nobody can find.

The distinction generalises: isAnimating is for motion, and a timer is for a state that changes on a schedule. They look alike and their costs differ by two orders of magnitude.

Three parts, placed by measurement rather than by layout

text-input          the field. Clips, focuses, takes the keys and the pointer
├── text-selection  the highlight, behind the text
├── text-value      the text, or the placeholder
└── text-caret      the insertion point

Each is absolutely positioned by the field from Paragraph.widthBetween, because where they go is a measurement — a caret’s x is the width of the text before it — and no selector and no flexbox can express it. Yoga is told where they are; it is not asked. That is segmented’s indicator travelling on a grid (ADR-0099), applied to something that moves per keystroke rather than per selection.

The highlight is behind the glyphs so selected text keeps its own colour and stays readable, which is why --gb-selection is a translucent background token rather than a pair of them.

An absolutely positioned child here is placed against the border box while the clip is the padding box, so every child’s left carries the field’s padding. Without it the first character of every field is drawn under the padding and clipped away — which is precisely what the Forms screen’s first golden image showed, in all six fields at once, and what no unit test had asked about.

A paragraph answers both directions, and a line at a time

widthBetween(start, end) and offsetAt(lineStart, lineEnd, x), over the prefix sums wrapping already keeps. Both take a line’s range rather than one offset, so a wrapped paragraph works without a second pair for text-area; a caret’s x on a line is widthBetween(line.start(), offset).

There is no caretX(offset), because it would be widthBetween with one argument fixed and a wrapped paragraph has no single left edge to fix it to.

offsetAt walks by grapheme cluster and returns the nearest caret position. That is the whole of the past-the-midpoint rule rather than a second one: the two caret positions bracketing a glyph are its edges, so the midpoint is exactly where the nearer one changes.

The clipboard is text, and SDL_free is bound

Clipboard reads text, writes text and says whether there is any. Images and files are a transfer negotiation rather than a value — the owner advertises formats and serialises on demand — so admitting them means admitting lazy providers, format lists and cancellation, none of which has a consumer. When a widget wants an image on the clipboard, this interface will hear about it from that widget, the way this one heard about text.

A backend with no clipboard reports Clipboard.none() rather than an empty Optional: every caller of a missing clipboard would otherwise write that class, and a copy that quietly did nothing is the honest behaviour of a session with nowhere to put it. The headless backend’s is a real in-memory clipboard, so a copy/paste test tests the widget rather than the stub.

SDL_GetClipboardText returns a string the caller owns, from SDL’s allocator. Handing that to free(3) is undefined wherever SDL was built against a different allocator — which on Windows is the normal case — so SDL_free is on the export list beside it. It is the only allocator call this toolkit binds and it exists to close exactly this loop.

Reads are not cheap: on X11 and Wayland text() is a round trip to the application that owns the selection. hasText() is the cheap question, and nothing polls.

Text input follows focus, not the window

BackendWindow.textInput(boolean), off by default. Asking is what raises an on-screen keyboard on a tablet and what tells an IME where its candidate window goes, so a toolkit that turned it on at window creation would put a keyboard over every phone screen showing a button. A field turns it on when focus arrives and off when it leaves; a read-only field never asks, and a window with nothing editable in it never asks at all.

A filter judges the result, not the keystroke

TextFilter.accepts(String) is asked about the whole value the edit would produce. That is the difference between a filter that works and one that looks like it does: a numeric field testing keystrokes accepts 1-2-3, because every character is legal, and rejects a pasted -5, because the minus arrives with no digits after it. Asking about the result gets both right, and gets pasting right for nothing — a paste is one edit like any other.

A filter rejects; it does not rewrite. One that silently corrected would move the caret out from under somebody mid-word, and would make the field’s contents depend on the order the characters arrived in.

Consequences

  • §4 is open. field, form, validation, text-area, the pickers, code-input and autocomplete all reuse TextEdit and EditHistory, which are the parts with rules in them, and all of them are ordinary widget work now.
  • The clipboard exists for everything else, and Host.clipboard() is how a widget reaches it — the door BuildContext.host() (ADR-0140) opened.
  • Committed text arrives on a real window for the first time, which means every path that was written for it and never exercised now is.
  • IME preedit and RTL editing are still M5. Committed text from an IME works today, because the platform hands over finished characters and a field takes them like any others. What is missing is the underlined in-progress text, which needs a second string the field draws and does not hold, and SDL_SetTextInputArea to tell the IME where to put its candidates.
  • A caret is not in any golden image. It blinks on a timer, so an image with one in it would be an image of whichever half of the blink the test caught. What a caret does is the unit tests’ business and what a field looks like is the golden’s.
  • A field is one line, and the parts know it. text-area wants one text-selection per line rather than a different part, and Paragraph’s two new methods already take a line’s range — so neither is a rewrite.

168. A field is a well, and a drag is a selection

Date: 2026-08-21

Status

Accepted. Corrects four things in ADR-0167, one in ADR-0141, and adds two design tokens.

Context

Six reports, from using the Forms screen:

  1. Move fields to cards
  2. For the first field — if I copy/paste multiple times it stops to paste after third paste and shows only some characters. If there is a limit — write it down.
  3. The placeholder needs different text/style from the regular text
  4. Caret is too large in height
  5. In light theme the background of fields is too pale
  6. I cannot select text if I just click/hold mouse and move it

Four are defects. One (2) is a feature behaving exactly as specified and looking like a fault, which is its own kind of defect. One (1) is the screen.

Every one of them was invisible to the tests that existed. The unit tests asked what the field held; four of these six are about what it looked like or what the pointer did.

Decision

A drag is a selection, and the button is the press’s question

text-input guarded its whole pointer handler with:

if (disabled || event.button() != PointerEvent.Button.PRIMARY) {
    return;
}

PointerRouter.pointerMoved builds its event with a null button, because a motion is not a button event — the button belongs to the press and the release. So the guard threw away every drag before the switch could look at it, and click-and-drag selected nothing, ever.

The button is asked about in the PRESSED arm now, where it means something. Which button started a gesture is the press’s question; what a motion carries is dragX(), which is NaN when nothing is held and is how the router already says “this is not a drag” (ADR-0075).

The general lesson is worth having: a guard at the top of onPointer is a guard on every kind of pointer event, and the kinds do not carry the same fields. Slider gets this right by asking per kind, and it reads as a stylistic choice until this happens.

A caret is as tall as a line, not as tall as a control

The caret and the selection highlight were pinned top and bottom, so both filled the control: an 18-point line of text with a 32-point caret through it, which reads as a terminal cursor rather than an insertion point, and a highlight standing well above and below the glyphs it is meant to be behind.

Both take the font’s line height now and are centred by the field’s own align-items, exactly as the text is. The line height rather than a number in the stylesheet, because it has to follow the text: a field at a larger font-size has a taller line, and a CSS height that disagreed would be wrong at every size but the one it was written for.

A field is a well, and --gb-surface-2 is still not a direction

text-input and select were both filled with --gb-surface-2. On the light theme that is --nord5 on an --nord6 page: one rung, which is what “the fields are too pale” is.

This is the same defect ADR-0166 corrected for card, in a new place and found the same way — by looking at it. --gb-surface-2 means “the second surface” and promises no direction; a card built on it read as recessed on light, and now a field built on it read as absent. The token is not wrong, it is simply not an elevation, and the third consumer to assume it was is the point at which the assumption should stop being made.

So --gb-surface-sunken, --gb-surface-raised’s opposite: content sits in a field and on a card.

It is an alpha over whatever is underneath rather than a rung on the ramp, which is the technique --gb-border-strong already uses. A fixed value has to pick one background to be right against and a field has three — the page, a panel, and a card — and the first attempt proved it: --nord2 on the dark theme is exactly --gb-surface-raised, so a field on a card vanished into it and was held together by its border. Darkening is right on all three, on both themes, with one token instead of three that can each be wrong on their own.

select gets the same fill, because §3 gives the two controls one row and a form where the field you type into and the field you pick from are different colours looks assembled from two toolkits.

A placeholder needs a token of its own, and it was never a matching problem

The rule was there and it applied. text-value.placeholder { color: var(--gb-text-muted) } resolved correctly on both themes — and --gb-text-muted is --nord4 where --gb-text is --nord6. Two rungs. Inside a filled field that is not a difference anybody can see, so an empty field looked like a filled one.

§1.2’s rank for de-emphasised labels is right for labels and too strong for something whose whole job is to look unwritten. --gb-text-placeholder is an alpha over its own surface, for --gb-surface-sunken’s reason.

The alpha is set by §1.2 rather than by taste. At the first value tried it was 2.4:1 on the light theme, which is not a hint, it is a smudge. The shipping values are the lowest that clear 4.5:1 against the worst background a field sits on — a card, where the fill is lightest — and they land a placeholder at roughly half a value’s contrast: unmistakably dimmer and legible.

That measurement cannot go in ContrastTest, and that test says why in its own words: a translucent fill has no contrast ratio, because the answer depends on what it is composited over — the trap that keeps button.ghost out of it. Both new tokens are translucent on purpose. So PlaceholderContrastTest composites them explicitly against each surface a field can sit on, in both themes, and asserts the floor and the gap.

The first version of that test resolved a bare TextInput, which is stateful and styles nothing, and therefore measured a box with no background and no colour: it reported a field whose fill was its own backdrop and a value with less contrast than a placeholder. A test that resolves a widget rather than the node the widget describes is measuring nothing at all — the same distinction WidgetParityTest exists to keep.

A limit that is not visible reads as a bug

The Forms screen’s first field carries max-length=40. Pasting into it a few times stops taking characters and clips the last paste, which is exactly what max-length means, and is indistinguishable from a field that has broken.

Clipping a paste rather than refusing it stands — refusing means a field with a limit silently ignores a paste somebody just made — but the screen now says so on the screen. A showcase demonstrates behaviour, and behaviour a reader cannot account for is a demonstration of a fault.

The fields go in cards

A form is a set of groups rather than a list of lines, and a card is what makes “these belong together” visible without a heading saying it. It is also what shows a field is a well: the card is what it sits on.

Consequences

  • --gb-surface-sunken and --gb-text-placeholder are design-system surface, and both are translucent — so an application overriding either must think about what it composites over, exactly as --gb-border-strong requires.
  • select changed colour. Its five golden images moved and were reviewed rather than regenerated blind: it reads as a well now instead of a raised chip, which is what a control you pick a value from should look like beside a control you type one into.
  • The Forms screen has a light-theme golden, and it earned one by being wrong on light while right on dark. The gallery has had exactly one light image on the grounds that a theme is a stylesheet swap; that is true of a swap and not of a token whose direction differs per theme, which is the whole reason --gb-surface-sunken exists.
  • Three of these six were only findable by looking, and the fourth only by dragging. That is not an argument for more golden images of everything — it is the argument ADR-0167 already made from the other end, when a golden caught the clipped first character that six unit tests had missed.
  • text-area inherits all of this. The caret’s height, the well, the placeholder token and the drag are the field’s, not the single line’s — what changes there is one highlight per line rather than one.

169. A field is silent until you leave it, and a form is found by its fields

Date: 2026-08-21

Status

Accepted. Opens the rest of docs/core-widgets.md §4, adds one pseudo-class the specification asked for, one notification to Handles, and closes an open gap in ADR-0165.

Context

text-input (ADR-0167) is a control. §4’s field and form are the contract around it — the label, the required marker, the message slot, and the gate a submission passes through.

Three things had to be decided before any of that could be built, and one thing did not exist.

When does a field validate? §4 says “on blur and on submit”. Nothing told a container that focus had left its subtree: Handles.onFocusChanged is about the node itself, and a field is not the node that takes the keyboard.

How does a field learn the value? A validator is over a value, and a field contains a control rather than owning one.

How does a form find its fields? They are anywhere in its subtree — inside rows, inside cards, inside a collapse.

Decision

:focus-within, as a notification

Handles.onFocusWithin(within, fromKeyboard). The router walks the chain of the element that lost focus and the one that gained it, and tells only the difference.

That last part is the whole design. Focus moving between two controls inside one field leaves that field’s subtree focused throughout, and a field told “left” and then “entered” would validate on a move that never crossed its boundary. So an ancestor of both is told nothing — which means a field holding one control and a field holding three behave identically, and neither has to filter anything out.

It is a second method rather than a change to onFocusChanged, because the two are different questions and a control that wants both should get both. A focused node is inside its own subtree and is told twice; :focus and :focus-within are both true of a focused node in CSS for exactly that reason.

It had two consumers before it was written, which is what made it a mechanism rather than a guess. The second is carousel: ADR-0165 shipped two of §5’s three brakes and recorded the third as a real gap — “focus on a widget inside a slide does not pause it, because the cascade has no :focus-within and nothing tells a widget that focus landed in its subtree”. That is now one line, and somebody who has tabbed into a slide is exactly somebody reading it.

Silent until blur, live from then on

A field says nothing until the user has finished with it once — however wrong its value is. A form that reddens an empty screen is a form that has been ignored before it was read.

After it has complained once, it re-checks on every change, so the message goes away the instant the value is fixed. That asymmetry is in no specification and every good form has it: a field that validated as you typed would call an email address invalid after the first letter and stay red until the last; a field that waited for a second blur to forgive you is one you have to leave and come back to.

Submitting is the third moment, and the only one that makes an unvisited field speak — otherwise a form submits with an untouched required field empty.

A field reads its control’s binding

A field learns its value from Widget.binding() — the bind= the control inside it already reads. Nothing is written twice, and there is no new channel from a control to its field.

It walks its children one level, deliberately: walking the subtree would find a binding on something incidental — a text in a hint under the control — and validate that instead.

A field with no bound control validates nothing, required included. That reads like a trap and the alternative is worse: failing forever gates a form on a control somebody can type into and never satisfy. It is the same shape as a menu that is only ever opened registering no accelerators, because nothing is holding it (ADR-0163).

The fields find the form

Each field registers with the nearest enclosing form through BuildContext.findAncestorState, which has been on that interface since the element tree was built and this is its first consumer. TabsState looked at it and said it “looks the wrong way” — right for tabs, where a strip has to enumerate its panels, and exactly right here: a field knows one form, a form knows however many fields a document wrote, and looking up needs no subtree walk and no knowledge of what to skip.

A LinkedHashSet, so a field that rebuilds does not register twice and the order still holds — which makes the error summary read top to bottom.

:invalid is a pseudo-class, because the specification asks for one

docs/core-widgets.md §1 lists the states and adds: “plus :invalid for form controls — an addition to the CSS engine’s pseudo set”. So it is one, rather than the .invalid class select.open settled for — that one was a class because §8’s subset had no pseudo-class meaning “expanded” and inventing one for a single widget would be inventing a language. Here the language already says it.

It sits on the field and on the control, and both are wanted: a stylesheet asks for text-input:invalid to redden a border and for field:invalid to reach the message under it, and no selector in §8’s subset walks from a child back up to a parent.

A field is invalid exactly when it has something to say. There is no second flag, so a message and a failure cannot disagree.

A validator returns a message, not a boolean

The reason is the point: a field that goes red without saying why is a field somebody has to guess at. Result.invalid("") throws for the same reason.

and reports the first failure rather than all of them, because a message slot is one line and three complaints about one value is a worse message than the first one. It also short-circuits, which matters as soon as a rule parses a date.

A pattern validator lets an empty value through. “This must look like an email address” and “this must be filled in” are two rules, and a pattern that also refused emptiness would turn every optional field with a format into a required one.

What submit carries, and what it does not

§4 says form.submit() “raises a typed event with bound values”. The event here carries nothing, and this is a deliberate departure.

A bind= reads from the application’s model, so the bound values are already the application’s — an event carrying them would hand an application its own data back. The toolkit could not name them anyway: Widget.binding() is an Observable and not a path, which is precisely what makes bind= a read-only channel (ADR-0063).

A form is submitted through a controller

FormController, the arrangement ScrollController already uses, and here for its reason: what submits a form is by definition somewhere else. A button inside the form could find it the way a field does; a button in a dialog’s action bar or a toolbar could not, and that is where Save usually is.

A detached controller reports isValid() == true rather than false — a form that does not exist has nothing wrong with it, and a Save button that disabled itself waiting for one would be disabled on the first frame of every window.

isValid() is side-effect free, and separate from check() for that reason: a form that reddened every field to work out whether to enable a button would redden them before anyone had typed a character.

Stacked labels are the default; the column is the class

§4 words it the other way — “consistent label column (or stacked labels via class)”. The column is the one that needs a number: every field’s label has to be the same width, and §8’s subset cannot say “as wide as the widest of these”. So a default label column would be a default that is wrong until it is configured. field.horizontal and --gb-field-label-width are what a form that wants one writes.

Consequences

  • carousel has its third brake, and the entry ADR-0165 left open closes.
  • findAncestorState has a consumer, and its shape is confirmed by the one case that fits it rather than by the case that did not.
  • TestHost.after no longer throws. It could not survive text-input: a focused field blinks, so every test of anything containing one would have had to know about carets. It records the timer and hands back one that is never due.
  • Markup cannot hand a controller to a widget, and form is the second widget to want to — scroll was the first. The showcase’s form therefore has no Save button, and demonstrates the half a document can express: the marker, the message, and blur. That absence is a real report and is in TODO.md.
  • A Validator is over a String. What a user typed is text until something parses it, and a validator is exactly the thing that decides whether it can be. A date-picker will want Validator<LocalDate> over its parsed value, which is a second seam and not a change to this one.
  • text-area, the pickers and code-input inherit all of it — a field validates whatever is inside it, and the only thing it asks of a control is a binding.

170. A document names an object, and a label hands focus down

Date: 2026-08-21

Status

Accepted. Closes three gaps ADR-0169 recorded, adds a fourth registry, and fixes a CSS shorthand bug that had been silently deleting ADR-0166’s card edge since it shipped.

Context

ADR-0169 shipped field and form and recorded what they could not do:

Markup cannot hand a controller to a widget, and form is the second to want one. […] Nothing can ask for focus, so field has no click-to-focus.

Both are reported by the showcase’s own screen, which had a form nothing could submit and a label nothing happened when you clicked. §4 asks for both.

Decision

A fourth registry, because the other three each refused the job

Named — what controller= and validator= resolve against.

The first attempt was the binding registry: a @Bind field holding the controller, resolved through the path syntax bind= already uses, on the argument that a value is named one way (ADR-0129). The binding machinery refused it, in as many words:

@Bind field … is final; a value that cannot change is not something to subscribe to, and binding one shows up as a control that never moves.

Which is right, and is the whole answer. A binding is a subscription; a controller is a handle and a validator is a function, and neither ever changes. The check that caught it was written for a different mistake and turns out to describe this one exactly.

So the set is now four, and each is a different kind of thing markup can name and cannot describe:

RegistryWhat …= namesWhat it is
ActionRegistrypress=, change=, submit=a method
BindingRegistrybind=a value that changes
Iconsicon=a resource with a lifetime
Namedcontroller=, validator=an object that does neither

Strict by default, for ActionRegistry’s reason: controller="signip" is a typo, and a form that silently cannot be submitted is the hardest kind of bug to notice. A name registered as the wrong type is refused where it is written rather than resolving to null — a controller= that quietly became nothing would be a form that cannot be submitted and says so nowhere.

This is what keeps §9’s rule intact rather than bending it. validator="app.port-rule" says which rule; a document that could say what the rule is would be code with a different syntax, and hot-reloading it would mean hot-reloading code — which is the sentence Icons and ActionRegistry both already turn on.

A container can hand focus down

Handles.delegatesFocus(). A press focuses the nearest focusable ancestor of whatever it hit, which is what makes clicking a button’s label press the button. A field’s label is not an ancestor of its control — it is its sibling — so that rule cannot reach it, and clicking a label did nothing.

A container that answers true says “the focusable thing here is one of my children”, and the walk turns round and goes down to the first focusable descendant. It is consulted on the way up, so it never overrides a real target: a press on the control finds the control first, and so does a press on a button inside the field. What it catches is the label, the message, and the gap — the places where “the user aimed at this field” is the only sensible reading.

PointerRouter.focusFromPress is now public, because the rule is worth naming: a test that wants to know what a press would focus should be able to ask, rather than paint a frame and synthesize a press to find out.

A form decides the label column, not each field

ADR-0169 shipped field.horizontal. A form is where somebody decides that this form has a label column, and writing the class on every field is the same decision repeated once per row and wrong the moment a row is added. So form.horizontal field carries it, field.horizontal stays for a field standing on its own, and form.horizontal field.vertical is the way back for the one field — a text-area, usually — that wants the width.

A horizontal field’s control has to grow, and that is not decoration: a text-input is as wide as it is told to be, so beside a fixed-width label in a row it takes nothing at all. The controls are enumerated rather than selected as “everything that is not the label”, because §8’s subset has no :not() — the same reason a disabled control is kept from lighting up in the router rather than in a stylesheet (ADR-0064).

A shorthand keeps the spaces inside a function

ComputedStyle’s shorthand splitter broke on any whitespace, so border: 1px solid rgba(255, 255, 255, 0.2) split into seven fragments instead of three. CssColor.parse was handed rgba(255, — which is not a colour — and the whole declaration was dropped.

This was live. --gb-border-strong is rgba(…) by design: an alpha over whatever is underneath is the only way to say “lighter than its own surface” in a subset with no colour functions (ADR-0166). card’s edge is the whole of how a raised thing is told apart from a panel, which is what that ADR is called — and it has had no edge on either theme since the day it shipped.

The warning was there. dropping "border": … is not a valid value was printed on every run of the showcase, and nothing was reading it. It was found by running the application and looking at the log, which is the same way ADR-0166’s own defects were found: by looking.

Consequences

  • A raised card has an edge again, on both themes. Three golden images moved and the change in them is the fix.
  • Any shorthand with a function in it now works — outline, and whatever else split serves. The bug was general and the token that exposed it was the first to have spaces in a function.
  • Wiring has a fourth component. Its old three-argument constructor stays and defaults the new one to Named.none(), so nothing that built one has to change; Widgets.inflater(named, icons, models…) is the new overload.
  • The showcase’s Forms screen is a document with a working form in it — a controller it names, a validator the application wrote, a Save button that refuses, and a label you can click. Which was the point of recording the gaps: the screen said what was missing, and now it demonstrates what replaced it.
  • delegatesFocus has one consumer and is the kind of thing that should have two. group-box and card are candidates and neither has asked; it ships because §4 asks for click-to-focus by name and a <label for> is not a widget this toolkit has.
  • Three to a row. A fourth column on a 900-point screen gives every field about 200 points, which is narrower than what people type into them, and §10’s wrap is not built — so a row that outgrows its width squeezes rather than folding.

171. A column is an x, and a width arrives late

Date: 2026-08-21

Status

Accepted. Builds docs/core-widgets.md §4’s text-area on ADR-0167’s model, and finds two things about the render order that the single-line control could not.

Context

text-input shipped the editing model, the undo history, the clipboard, the caret’s blink and the geometry. text-area is the same control with a second dimension, and the question worth answering before building it was which of those the second dimension actually changes.

The answer is: almost none of them. TextEdit was written without a line in it about how many lines there are, and needed two helpers rather than a rewrite.

Decision

The parts are shared, in a package nothing can see

text-caret, text-selection and text-value are drawn by both controls. A part is styleable and not constructible (ADR-0065), which in this catalog has always meant package-private — because until now one widget owned its parts.

Two widgets own these. So they are public in …widgets.form.parts, which the module does not export: an application cannot construct one, both widgets can, and there is one text-caret rather than two that have to be kept looking alike by hand. The rule was only ever “package-private” because there was no other way to say it; JPMS has one.

A column is an x, and it has to be remembered

Up keeps the column. A column is an x and not an offset — which is why this is the one piece of editing state the second dimension adds, and why it cannot live in TextEdit: the model has no font, no width and no layout, and a character offset is not a column in any of them.

It is captured on the first vertical move of a run and held until something horizontal happens. That is what makes walking down through a short line and out the other side come back to the column you started in, rather than stranding the caret at the short line’s end. Every editor that gets this wrong is immediately noticeable and hard to name.

“Something horizontal” is every other operation, so the column is cleared in the one place every operation goes through, and moveLine sets it back afterwards. NaN means “no run in progress”, which is the arithmetic saying it rather than a second flag — dragX uses the same trick for “this is not a drag”.

A selection is one rectangle per visual line

A run of wrapped text is not a rectangle. This is the whole of what the second dimension costs the selection, and it is why Paragraph’s two measurements take a line’s range rather than an offset: they were written for this in ADR-0167, one widget early.

The count of highlight nodes is maxRows, always — a bound rather than the exact number. How many a selection needs is a question about the layout, and children() is asked before there is one, so the choice was between a mutable field on a value, a count one frame stale, or the bound. The bound is small and correct: a selection can cover at most as many visible lines as the control shows, because the rest are scrolled away and draw nothing.

The height is the widget’s, not the stylesheet’s

§4’s auto-grow between min and max rows is a function of how many lines the text wrapped into, which no selector can ask. A height a stylesheet set would be a control that stopped growing the moment somebody themed it.

Everything else — padding, border, fill, line height — stays the cascade’s, and the control shares text-input’s rules for its parts so the two do not drift.

Two things about the render order, both found by looking

render runs before Yoga, so a box does not know its width. text-input has the same gap and nothing visible depends on it, because one line does not wrap. Here it decides where the text breaks — and the first version reported max(1, measured), so before anything had been measured it wrapped at one point and put every word on a line of its own. The Forms golden showed it immediately.

The fix is that “I do not know” is UNCONSTRAINED and not one point: the text keeps its hard lines for one frame and wraps properly on the next, which is wrong in the direction nobody sees.

And a measurement has to ask for a frame. text-input records its width and requests nothing, because the width only decides how far it has scrolled and the next keystroke redraws anyway. A text-area that did the same would show its first frame’s guess until something unrelated caused another frame — which, for a form nobody has touched, is never.

It converges rather than looping, which is what ADR-0119 warns about: the only frame it asks for is one where the width changed, and the width the next frame measures is the same one. Two frames on mount, one per resize, none after.

Consequences

  • §4’s editing surface is done bar the pickers. code-input and the typed fields of date-picker, time-picker and color-picker are all TextEdit plus a different keyboard map or a popup.
  • There is no visible scrollbar. It scrolls with the wheel and to keep the caret in view; scroll’s bars belong to a viewport rather than to a control, and putting one inside a text-area means either a scroll around the text — which would fight the auto-grow — or a second bar implementation. §4 asks for one and this does not have it.
  • A golden of a text-area is a golden of its first frame. The gallery renders once, and the settled wrap needs the measurement that only a painted frame produces. The image is honest about what one frame shows and is not what the application shows a moment later, which is a limitation of the corpus rather than of the control.
  • Enter is consumed here and nowhere else in §4. A form’s default button cannot have it in a multi-line control, and a read-only one leaves it alone so the form still can.

172. A package is a role, and the module is the fence

Date: 2026-08-23

Status

Accepted. Completes the package move begun for backend → render and layout → paint, and extends it across :core, :natives and :widgets. Relates to docs/ARCHITECTURE.md §2, §3.1 and §15.

Context

:widgets had already learned this lesson. ADR-0091 split it by group, and ADR-0065 gave each control a package of its own so that a slider-thumb is invisible outside …controls.slider — a boundary the compiler keeps rather than a convention a reviewer has to notice. Thirty-nine packages, none of them large.

:core and :natives had not. Four packages carried a third of the toolkit:

packagetypes
…goldberry.css23
…goldberry.backend21
…natives.yoga22
…natives.blend2d20

A package that size is not a boundary, it is a folder. CssTokenizer and ComputedStyle are at opposite ends of a pipeline and could see each other’s package-private members; BlendCompOp, which is a table of C constants, sat beside BlendContext, which owns a native handle. The names said “CSS” and “Blend2D” — the library the code came from — and said nothing about what any of it does.

The move backend → render had already started answering that, and had already found the cost: the first build of this branch was red, because BoxPainter.paintOne and FrameRing had been package-private and their callers had walked out of the package. That is the real question this ADR settles. Every split turns some package-private call into a compile error, and there are only two honest answers to one: the boundary is real and the member becomes public, or the boundary is imaginary and the split was wrong.

Decision

A package is named for the part its contents play, not for the library or the file they came from. The CSS engine is a compiler, so it is css.parse → css.select → css.cascade → css.value. Input is a dispatcher, so it is what arrives (input.event), the vocabulary an accelerator is written in (input.key), the snapshot it is routed against (input.hit) and the interfaces a widget implements to hear any of it (input.handler). A native library is split where the foreign memory stops: the wrappers that hold a handle stay beside the binding class they are the only callers of, and the enums and values, which touch no foreign memory at all, get packages of their own.

When a split makes a package-private call illegal, the member becomes public and says why. Every promotion in this change carries a doc comment naming this ADR. There are eleven of them across three modules, which is the number worth recording: a split that needed thirty would have been the wrong split.

Encapsulation that a package can no longer carry is carried by the module. docs/ARCHITECTURE.md §3.1 says a raw MemorySegment never leaves :natives, and until now that was mostly enforced by Blend2D being package-private. It is now enforced by the module descriptor and by a test that reads it (ExportedSurfaceTest), which is the arrangement ADR-0171 already reached for …form.parts: public in a package nothing can see.

Where the split would leak internals into the public API, there is no split. Two candidates were tried and reverted, and they are in “Alternatives” below, because a refactor that only records its successes is a refactor nobody can argue with.

The result:

modulepackages beforeafterlargest package
:core153510
:natives71512
:widgets383911

Alternatives considered

Split the root …goldberry package into an API half and a runtime half. This is the split the shape of the code suggests: Goldberry, Application and Host are what an application writes against, and Launcher and GoldberryRuntime are what runs it. It was rejected on a count. Launcher and GoldberryRuntime make 21 calls into Window’s package-private surface — handlePointerMoved, handleResize, handleCloseRequest, backendWindow, frameRing, and thirteen more — plus Popup.handleKey, Popup.dismissedByInput and Overlay.attached. Window and Popup are types an application holds, so every one of those would become public API: a toolkit whose Window offers the application a handlePointerMoved has published its own event loop by accident. Eleven promotions across the rest of this change bought four packages; these twenty-one would buy one, and cost the public surface. The ten types left in the root are one role — the running shell — and are documented as such.

Move WidgetRenderer and FrameTrace into widget.render. Attempted, and reverted the same hour. WidgetRenderer reads and writes Element’s style cache through cachedStyle, cacheStyle, stableStyle, isAnimating and animations, all package-private and all deliberately so — the cache protocol is ADR-0152’s and is not something a second implementation is meant to exist for. The renderer is not a neighbouring role; it is the element tree’s own paint pass. It stays beside Element.

Make the raw binding classes public in blend2d.ffm / yoga.ffm, so that every wrapper could move out. This would have allowed blend2d.font, blend2d.image and blend2d.path as separate packages. It was rejected because it inverts the boundary: Blend2D and Yoga expose about two hundred methods taking and returning MemorySegment, and making them public — even in an unexported package — moves the fence from “one class in one package” to “one line in module-info”. The unexported-package trick is the right tool for …form.parts, which is three small widgets; it is the wrong tool for the entire FFM surface. So MeasureCallback, MeasureProbe, SdlWindowHandle, SdlEventBuffer and SdlEventWatch were each moved out and then moved back the moment they turned out to traffic in MemorySegment. Their packages are smaller than they would have been, and the boundary is where §3.1 says it is.

Leave bind alone, because the weaver writes its package name as a string. ModelWeaver builds ten ClassDesc constants from one BIND_PACKAGE prefix and emits them into somebody else’s class file. Splitting bind meant the prefix became three, and nothing in the compiler would have caught getting that wrong — the weave would succeed and a woven native image would fail to start, much later, with a NoClassDefFoundError naming a package that no longer exists. This was not a reason to leave bind alone; it was a hole. It is now WrittenNamesTest, which reflects over the weaver’s own constants and resolves each one.

Consequences

A package name now tells you what its contents do. css.value holds the things a declaration resolves to; render.event holds the loop; input.hit holds the snapshot. The pipeline in §5 of ARCHITECTURE.md can be read off the package list, which was the point.

Eleven members are public that were not. They are: BoxPainter.paintOne, Frame.end, Frame.over (replacing a package-private constructor), FrameRing and its three recorder methods, Transform.parse, Transform.parseOrigin, Selector.PseudoClass.parse, BlendException’s constructor, SdlException’s constructor, and Edge.isPhysicalSide. Each says why in its own doc comment. Frame.end is the one worth watching: it used to be unreachable and is now merely wrong to call, so the frame enforces its own lifetime — ending twice is a no-op, painting afterwards throws, and FrameTest covers both.

Two new architecture tests, and they were needed. ExportedSurfaceTest reads :natives’ own descriptor and its own class files and fails if any reachable member mentions MemorySegment; it was checked against a deliberate break. WrittenNamesTest resolves every class name the weavers write as text. Both are written to discover their subject rather than list it, so a package added next month is checked next month.

An import diff of about nine hundred lines, and a merge conflict for anything in flight. Unavoidable, and the reason this was done in eight commits that each build and test green rather than one. The moves were made by tools/refactor/move_package.py, which is kept: it does the four edits a package move needs, and the fourth — the file left behind that used a type without an import because it shared a package with it — is the one nobody does by hand.

Two things this does not fix. The root …goldberry package still holds ten types, for the reason above; if Window’s event intake is ever separated from Window itself, the split becomes cheap and should be revisited. And blend2d.enums is named after a Java construct rather than a role, which is the one place this ADR does not follow its own rule — the honest description of its contents is “the enums, which map a C constant to a Java name and touch no foreign memory”, and no shorter name says that.

173. A bound function is a holder, and its handle is a constant

Date: 2026-08-23

Status

Accepted. Rebuilds the call layer ADR-0161 designed, keeping its measurement and dropping its shape. Relates to docs/ARCHITECTURE.md §3.1.

Context

ADR-0161 established the thing that matters and cannot be relaxed: a downcall handle is a compile-time constant or it is not a call. Twenty million calls to a trivial int f(void) on GraalVM CE 25.2.4:

how the handle is heldJVMnative image
bound to its address, built at run time10 ns4560 ns
unbound, built at run time10 ns4500 ns
unbound, built at image build time10 ns10 ns

It then drew a conclusion from that which was one step too far: because the constant must be read by the method that calls it — 8.9 ns when the helper names it, 810 ns when the same constant arrives as a parameter — and because the binding classes shared per-shape helpers, the handles were named for their signature and shared across every symbol that had one. Fifty-six constants, INT__PTR_PTR_INT and the like, for a hundred and thirty-four bound functions.

What that cost is visible in every binding. A call site named a shape rather than a function; the function’s own name travelled beside it as a string, for the failure message; the address travelled as a third thing, in a private final MemorySegment field; and each binding class grew its own set of call / invoke / callBoolean / getFloatKeyed helpers so the try/catch sat in one place. Yoga had nine such helpers, SdlVideo fourteen.

private final MemorySegment contextEnd;                      // one
this.contextEnd = Downcalls.symbol(lookup, "bl_context_end"); // two
check("bl_context_end",                                       // three
        (int) Downcalls.INT__PTR.invokeExact(contextEnd, context));

Three things that are one thing, kept apart because of a performance claim about a fourth.

Decision

A bound function is a holder: a small final class holding the address of one C function, with its handle as a private static final MethodHandle FD_<symbol> and a call whose parameters are ordinary Java types.

public static final class ContextEnd {
    private static final MethodHandle FD_bl_context_end =
            Downcalls.link(FunctionDescriptor.of(JAVA_INT, ADDRESS));

    private final MemorySegment address;

    public int call(MemorySegment a1) {
        try {
            return (int) FD_bl_context_end.invokeExact(address, a1);
        } catch (Throwable t) {
            throw Downcalls.failure("bl_context_end", t);
        }
    }
}

The holders are grouped in a record per subject — not per library. A Blend2DCalls of forty-six functions is a list, not a type; ImageCalls, ContextCalls, PathCalls, FontCalls and RuntimeCalls are each the surface of one object, and the binding class that holds one is the surface of one object too. A record is what a binding class keeps instead of forty MemorySegment fields:

check("bl_context_end", calls.contextEnd().call(context));

ADR-0161’s rule is not relaxed by this — it is satisfied more strictly than before. FD_bl_context_end is static final and is read inside the method that invokes it, which is the 8.9 ns case; and because there is now one handle per function rather than one per shape, no call site reaches a constant through a parameter at all. The per-shape helpers that forced the compromise are gone, because the holder’s call is that helper, one per function, naming its own handle.

The holders live in packages that contain nothing else — …natives.calls, …natives.sdl.calls, …natives.yoga.calls, …natives.blend2d.calls, …natives.harfbuzz.calls — and those packages are what --initialize-at-build-time names. That is not tidiness; it is the only form that works, and the measurement is below.

Alternatives considered

record Downcall(MethodHandle handle, MemorySegment address) — one holder type for everything, which is the design anyone reaches for first and the one this ADR started from. It puts the handle in an instance field: a value read from an object, not a constant read from a class. Measured at 4539.53 ns/call in an image, which is ADR-0161’s first row with a new spelling. Rejected on the number.

One holder type per signature — fifty-six records, VOID__PTR and friends, each with the address as its only field and its handle static final on the enclosing Downcalls. This was built, and it works: it keeps ADR-0161’s constant-folding and removes the try/catch from the call sites. It was rejected because it keeps the thing that was actually wrong — a call site still names a shape, the function’s name still travels separately as a string, and Downcalls.INT__PTR_PTR_INT.bind(lookup, "SDL_UpdateWindowSurfaceRects") is not an improvement on what it replaces.

Naming the enclosing class in the build flag. The obvious way to keep the holders nested inside the binding they belong to. It silently does not work:

flagwhere the handle isns/call
--initialize-at-build-time=Outerstatic final on Outer10.55
--initialize-at-build-time=Outerstatic final on Outer$Nested4537.82
--initialize-at-build-time=Outer,Outer$Nestedthe same nested class11.25
--initialize-at-build-time=<package>nested, anywhere in it8.07
anyinstance field of a record4539.53

Measured here, on GraalVM CE 25.2.4, twenty million calls to goldberry_abi_version. The second row is the trap and it is silent — the image builds, runs and paints correctly at a fortieth of the speed, which is exactly the failure ADR-0161 was written about.

The third row works and is unmaintainable: a hundred and thirty-four nested class names in a build flag, each of which has to be remembered when a symbol is added. The fourth row is what shipped. Naming the binding packages instead was rejected too — …natives.sdl holds Sdl and SdlVideo, whose holder idiom dlopens the library, and build-time initialising those would run the dlopen in the builder.

Yoga’s length setters are the one exception, and they are per shape. width: 50% and width: 50px are YGNodeStyleSetWidthPercent and YGNodeStyleSetWidth, and which is called depends on the value — so the function is chosen at run time, and eleven properties × three functions would need eleven record types to group them. SetLength, SetAuto, SetKeyedLength and SetKeyedAuto each serve several symbols and carry the symbol they were bound to, so a failure still names the function rather than the shape. The handle is still a constant read inside call, which is the part that cannot bend.

Consequences

The binding classes lost a quarter to a half of their lines, and all of it was plumbing:

bindingbeforeafter
Yoga658409
Blend2D821561
SdlVideo837653
HarfBuzz393249
Sdl296198

Thirty-six per-shape invocation helpers are gone, along with every MemorySegment field and every function name written as a string argument. A Yoga setter is now one line: styleCalls.styleSetFlexGrow().call(node, value). Blend2D went further and split into five classes of 78 to 248 lines, one per Blend2D object, each holding the one record that is its own surface.

Every call states its parameters. call(a1, a2, a3) is call(context, rect, argb), under a summary, the C prototype it binds, and a @param for each argument. The names were not invented: the wrapper method at each call site already named them — contextFillRect(MemorySegment context, MemorySegment rect, int argb) passes them straight through — so they were read back out of the source and only the twenty-two that were literals had to be written by hand.

A failure names the function it was. Blend2D’s four invoke helpers reported "a Blend2D call" for any of the eighteen symbols that went through them, because a shared helper had no way to know which. A holder does.

Verified end to end, not argued. A native image built from the packaged goldberry-natives jar — so with the shipped native-image.properties and nothing added — calls goldberry_abi_version through its holder at 9.84 ns/call, against 10 ns on the JVM. HolderShapeTest checks the rest by walking the compiled classes: that every holder’s call is exactly its FD_… descriptor with the address dropped, that each keeps one address, and that no handle is anything but static final. It finds the holders rather than listing them, so one added tomorrow is checked tomorrow.

134 handles where there were 56. Each is one MethodHandle linked from a descriptor at image build time; the stubs behind identical descriptors are shared by the linker. Nothing measurable, and it buys the naming.

Roughly 3200 lines of holder code, all generated in shape and none of it interesting. That is the real cost, and it is why DowncallsTest was replaced rather than deleted: what used to be checkable was only that a name matched its layouts, because nothing tied either to a call site. A holder states its signature twice — once in layouts, once in Java types — in one class, so the check is now that the two agree, which is the check that was wanted all along.

A new symbol is more work than it was. It used to be one field, one lookup and a call through an existing constant; it is now a holder class and a record component. In exchange, adding one cannot get the shape wrong without the compiler or HolderShapeTest saying so.

174. What both halves need is its own module

Date: 2026-08-23

Status

Accepted. Answers the question ADR-0028 left open in its last paragraph, and finishes what ADR-0023 started. Relates to docs/ARCHITECTURE.md §15.

Context

Logs and Startup are not native code. Logs exists so that every logger in the toolkit is created after SLF4J’s internal verbosity has been turned down, which is what keeps a “No SLF4J providers were found” warning off the console of an application that deliberately configured no logging (ADR-0023). Startup records what the toolkit did before the first pixel, at trace (ADR-0028). Neither has an opinion about foreign memory.

They lived in io.github.digitalsmile.goldberry.natives.log anyway, and the reason was the module graph and nothing else. :core requires :natives, so :natives is the lower of the two; shared code had to sit in the lower one or in neither. The descriptor said so out loud:

exports ... to io.github.digitalsmile.goldberry.core would say that precisely and does not compile: :core depends on :natives, so :core is not on the module path when this compiles […] So it is a plain export with a docstring that says what it is for.

ADR-0028 saw where that was going:

The package is becoming the place where that compromise accumulates, and is worth watching.

It had accumulated. Seven files in :natives, thirteen in :core, one in :widgets — twenty-one call sites, all reaching into a package named for a layer that none of them is part of. And the ordering guarantee Logs exists for is strongest exactly where it looks worst: NativeLibrary is usually the first thing in the process to want a logger, so the class that quiets SLF4J has to be visible to the native layer whatever else is true of it.

Decision

:common is a new module, below everything. It requires nothing of Goldberry’s; :natives and :core both require it. Logs and Startup move into it as io.github.digitalsmile.goldberry.log, and :natives stops exporting a package it never owned.

:common ← :natives ← :core ← :widgets
   ↖________________________/

:core names it directly rather than taking it through :natives. It would arrive either way — requires transitive on the chain would carry it — but a graph that has to be traced through the native layer to explain why a widget can log is a graph that still says logging is native. :core requires :common, and reading the descriptor is enough.

The bar for putting something here is that both halves need it and neither owns it. That is a narrow bar and it is meant to be: a module below the FFM boundary is a module the boundary cannot protect, so the less in it the better.

Alternatives considered

Move Logs to :core and leave Startup behind. The obvious cheap version, and it splits a package in half. Startup cannot move — NativeLibrary produces the first marks (libgoldberry mapped), and a timeline that begins after the library is loaded is a timeline missing the part that takes longest. So :natives would keep Startup and gain a private logger factory of its own, which means the three lines that set slf4j.internal.verbosity exist twice. The single ordering guarantee that is the entire point of Logs becomes two guarantees that have to agree.

Rename the package but leave the classes in :natives. JPMS does not require a package to match its module, so io.github.digitalsmile.goldberry.log could be exported from :natives today, one line per file and no new artifact. Rejected because it makes the descriptor lie more quietly rather than less: the classes still ship in goldberry-natives, and an application that wants Goldberry’s logger factory still gets it by depending on the FFM bindings. It also sets up a split package the day :core decides it owns …goldberry.log too, which is a hard error rather than a warning.

Move NativePlatform down as well. It was the strongest other candidate — “which OS and architecture am I” reads like something no layer owns. It is not: classifier() and libraryFileName() exist to name a native artifact, and cLongSize() is an ABI fact. :core mentions the class exactly once, in a comment. It stays.

Nothing else in :natives qualified. Every remaining class that touches no foreign memory — BlendVersion, SdlException, HarfBuzzVersion, Insets, NativeConstants — is about a specific native library even when it does not call into one.

Nothing from :core or :widgets qualified either, and the reason is worth writing down because the question will be asked again. :natives references :core in not one file, so nothing above is needed below; a type that moved down would be moving away from its only user. The one real duplication across the boundary is SdlVideo.SdlSize against render.model.PhysicalSize — the same two integers, validated the same way, converted by Sdl3Window at two call sites — and SdlRect against DamageRect beside it. They stay two types. PhysicalSize is not a tuple: it is the backend SPI’s vocabulary, with of, isEmpty and pixelCount on it, and moving the SPI’s own types below the FFM boundary to save four lines of conversion would put them in a module the SPI cannot see. The conversion is also the seam where “SDL’s idea of a size” becomes “the toolkit’s”, which is where a future disagreement between them belongs.

:widgets cannot contribute at all: it is the top of the graph, so nothing in it can be needed by anything below. The only cross-module name collisions are Edge — yoga.style.Edge is nine YGEdge enumerators, widgets…affix.Edge is four sides of a viewport — and FontCalls, one per library. Neither is a duplicate of anything.

Consequences

A sixth published artifact, goldberry-common, holding two classes. That is the cost, and it is the honest one: a module is what the Java platform gives you to say “below both of these”, and there is no lighter way to say it. It has no dependencies but the SLF4J facade, so it adds nothing to a consumer’s graph that was not already there.

:natives exports one package fewer, and the paragraph of apology in its descriptor is gone. What it exports now is wrapper packages and nothing else, which is what docs/ARCHITECTURE.md §3.1 always claimed.

Twenty-one imports changed, and one ADR aged well. ADR-0028’s closing sentence is the reason this was easy to argue: the compromise was written down when it was made, so the case for undoing it did not have to be reconstructed.

A place for the next one to go. The bar above is deliberately hard to clear, but the next thing that clears it now has somewhere to be — which is the second reason to pay for the module once rather than to keep renaming a package inside :natives.

175. A banner says its kind twice

Date: 2026-08-23

Status

Accepted. Builds docs/core-widgets.md §7’s message, the first widget whose whole job is to draw the aurora hues as a glyph and a border on a surface — and the first one to measure whether that was legible.

Context

§7 asks for an inline banner: kind="info|success|warning|danger", an icon, text, optional action links, optional dismiss. It also says, in the same paragraph, why it is not a toast — a toast is transient, floats over the window and is about something that just happened; a message is part of the layout, persists until the condition does, and is about the thing next to it.

The specification’s own sentence about the icon is the one that decided most of this record: “the icon is not decorative — §1.2 forbids colour as the only carrier of meaning, so kind sets an icon and a colour”.

It was picked next because something was already waiting for it. ADR-0169 built §4’s error summary as a register — FormController.errors() — and nothing drew it, because the only thing that should is a banner with a kind, an icon and a dismiss, and there was no such widget.

Decision

The kind is a value, where a badge’s variant is a class

ADR-0087 made badge’s variants classes, and gave a good reason: a variant that is only a skin should not become a second vocabulary that only Java can write.

A message’s kind is not a skin. It picks the glyph, which is §1.2’s requirement rather than a decoration, and it is what M5’s semantics will read to decide status from alert — the difference between a banner that interrupts a screen reader and one that does not. So it is an enum the widget reads, kind= is the attribute §7 names, and the class goes on the node as well so message.danger still selects. skeleton’s shape= is the same arrangement.

Four glyphs, drawn as marks and not as icons

Lucide has exactly these four — info, circle-check, circle-alert, triangle-alert — and a banner cannot use them. ADR-0043 made an Icon a parsed BlendPath that owns native memory and must be closed exactly once, which is why markup can only name one from a registry the application fills. A widget tree is described afresh on every build, so a banner that built its own icon would leak one per frame.

Box.Mark is the answer the catalog already had, and ADR-0107 gave the same argument for tab-close’s ×. So there are four new kinds — CIRCLE_INFO, CIRCLE_CHECK, CIRCLE_ALERT, TRIANGLE_ALERT — named for their shape, like every other mark, and drawn to Lucide’s own geometry so that a banner’s glyph and an application’s icon beside it are one hand rather than two.

They are the first marks made of two drawings: an outline stroked, then a dot filled, with the path reset in between. EnclosedMarkTest measures where the ink lands, because three things can go wrong there and none of them throws — the outline can be skipped, the dot can land in the wrong half, or either can spill outside the slot. It asserts the ring is hollow (a fillPath where a strokePath was meant is a plausible-looking blob at 20px), that an i has its dot above its stem and a ! below, and that the triangle’s corners are empty where a circle’s are not — which is §1.2’s requirement stated as a measurement rather than as an intention.

The hue has a third rank, because the theme’s own claim was untrue

Both theme files document --gb-danger as “what a label, an icon or a border is drawn in”, against --gb-danger-fill for “what you may put words on top of”. This is the first widget that actually draws a hue as a line, so it is the first time anybody measured it:

infosuccesswarningdanger
dark, on --gb-surface3.744.946.442.46
light, on --gb-surface2.211.671.283.36

Five of the eight are below §1.2’s 3:1 floor for anything that is not text. The sentence had been true-looking for months for the same reason ADR-0088’s seven button pairs were: nothing measured it.

So a hue has three ranks now — itself, -fill for words on top of it, and -line for a stroke drawn on the page. The derivation is ADR-0087’s exactly: the palette entry moved in lightness until it clears, per theme, written beside the value it came from. Three alias straight through on dark and one on light, which is the two themes’ opposite problems: a dark theme’s trouble is the dark end of the palette and a light theme’s is the pale end.

ContrastTest gained a second sweep at 3:1 to hold it there, and it resolves through the real cascade like the first one rather than reading the token.

The banner’s own colour is a token per kind, and the tint is 4%

design-system.md §2 gives the metrics — padding 12/16, radius 8, icon 20 with gap 12, “1px border and a 4% tint of its kind colour” — and every number in controls.css is one of those. The tint is a token per kind per theme, written as an eight-digit hex, because §8’s subset has no colour functions; --gb-selection set that convention.

Four percent is faint, and deliberately: the banner is told apart by its glyph and its border, which is what §1.2 asks for. The tint says “this block is one thing”, not “this block is red”.

It is stateful, and it holds nothing but a timestamp

§3 gives message an entrance — “in: opacity + 2px rise, base”. A newly mounted element deliberately starts no transition (ADR-0065): there is no previous style to move from. So an arrival is a function of the frame clock and needs a beginning, which is a Phase — and a beginning has to survive the next build, which is what a State is for.

One difference from collapse and carousel, and it is a correction of them: those hand their part a function of the clock and decide at build time whether there is an animation at all, so isAnimating goes on saying yes until something rebuilds them. A banner is never rebuilt by anything. So this hands the part the Phase itself and asks it, and the frame loop goes quiet on the frame after the arrival ends — which is what §1.7 promises.

The departure runs before the application is told

§3 also asks for “out: opacity fast”, and the first cut of this widget did not have one on an argument that looked airtight: a banner goes away because the application stopped describing it, so by the time anything could animate there is nothing left to draw. Animating an exit, the argument went, needs something that outlives the description — which is what a tab strip does for a closing tab because a strip owns its list, and nothing owns a lone banner.

The argument is a false choice, and the way out is to reverse the order. The × does not tell the application and hope. It starts a LEAVING phase in the widget’s own state, keeps drawing the banner for §1.7’s fast, and calls onDismiss when the fade is over. For the whole of the animation the description is still in the tree, because nobody has asked for it to go yet — so no owner is needed, which is precisely what a lone banner does not have.

That makes Phase carry a duration instead of only the base constant, because §3 gives the two directions different ones: 160ms in, 100ms out. A dismissal that took as long as an arrival reads as the control arguing.

Three consequences worth stating, and the first is the interesting one:

  • An application that wires a dismiss handler and then does not remove the banner keeps a banner that has gone. The widget draws nothing once the phase runs out, rather than springing back — a × that faded something and then restored it reads as a click that failed. What it cannot do is close the gap its container left round it; that is the container’s number.
  • Reduced motion dismisses at once, rather than waiting out a fade that is not happening. The preference is a property of a frame and the code that acts on it runs in a pointer handler, so the value comes back out of render — carousel’s arrangement, for carousel’s reason.
  • With no window there is no timer, so the dismissal is instant. Every widget test that does not ask for a host and every golden image is in that case, and a banner that could not animate its exit must still have one.

The summary is a factory, not something form emits

§4’s “failures register in the form’s error summary” is now drawable: Message.summary(errors) returns one danger banner with a line per failure, or empty when nothing is wrong — a summary of no errors is not an empty banner, it is no banner.

A factory rather than a child form adds, for two reasons. A form does not know where its summary belongs: above the fields is the convention, below them is what a long form wants, and a dialog’s header is what a dialog wants. And a form that drew one would have to rebuild whenever any field’s message changed, which is a notification from Validated to FormAccess that nothing else needs and that FormState.register deliberately does not do.

The dismiss is focusable, where tab-close is not

The two look alike and answer opposite questions. A tab-close sits inside a tab strip, which is one Tab stop with the arrows roving inside it (§7.2), and Delete on the tab is the keyboard’s way to close one. A message is not a focus scope and owns no keyboard map: an unfocusable × would mean a banner a keyboard user cannot dismiss at all. So it is a Tab stop and takes Space and Enter like a button — one extra stop per dismissable banner, which is the honest cost.

dismiss= names an action rather than being a flag, because nothing in a document could take the banner away: what put it there is the application’s own state. The showcase makes that the subject of a screen rather than a footnote — Notifications is Java where its neighbours are documents, and its buttons are the demonstration. Pressing one adds a description and the banner rises into place; pressing its × takes the description away and it is gone on the next frame. A message written in a document can only report a dismissal, and the four resident banners at the top of that screen do exactly that.

The Overlays screen’s first image showed four banners at zero opacity: holding their space, drawing nothing. The gallery painted one frame, and one frame is the frame before every arrival starts.

So GalleryGoldenTest renders twice against its frozen clock and asserts on the second, 200ms in. That is half of the entry ADR-0171 filed under text-area — “the golden painting twice … would make every screen’s image more faithful, not just this one”. The other half, feeding the hit-test regions back between the two frames so a widget that measures itself sees a real width, is still open.

Consequences

  • Two of §7’s five are built — tooltip, popover, tour, hud and now message. What is left in the group is dialog and toast, and both inherit this record’s arrival: a dialog’s scrim and a toast’s slide are the same clock-driven mount.
  • toast inherits the fade-then-tell order, and needs more than it. A banner fades in place and the layout closes over the hole afterwards; a toast stack has to reflow — §3 says “siblings reflow via translate, like toast” — so the entries below a departing one have to travel while it fades. That is a queue’s problem and the queue is the widget that owns one.
  • A third token rank exists and only one widget reads it. --gb-*-line is right for any glyph or border in a semantic hue, and the widgets that already draw one — a field’s :invalid border, a badge’s edge — have not been looked at. ContrastTest will catch them when they are.
  • A message with nothing to say cannot describe itself away. There is no bind= on this widget, deliberately: a bound banner would be present and empty when the value was blank, and §8’s subset has no display, so no widget can take itself out of a layout. The thing that can is whatever describes it — which is why the summary is an Optional, and why the showcase screen that spawns banners had to be Java.
  • A stack of banners is the container’s gap, and the first one had none. A column has no gap of its own — the toolkit’s rule everywhere, because a widget does not decide how far it sits from its neighbours — so the first version’s four banners touched, and four bordered blocks sharing edges read as one control with rules through it. 12px on the ramp, in the showcase’s stylesheet where every other gap on that screen is. Worth writing down because toast will stack them too and will have to decide the same number, in the widget rather than in a document, since nobody writes a toast stack’s container.
  • Every arrival costs one wasted frame. The renderer asks whether a node is animating before it draws it, so the frame that finishes an arrival still asks for one more. That is every clock-driven animation in the toolkit and not something about banners; it is written down here because the golden test had to work around it.
  • The -line derivation is four hand-computed hex values. Each carries its measurement in a comment beside it, which is ADR-0087’s convention, and each is a number that has to be recomputed if the palette moves. A theme is not a program and the subset has no color-mix; this is what that costs.

176. A dialog is a widget, and showing one is not

Date: 2026-08-23

Status

Accepted. Builds docs/core-widgets.md §7’s dialog, and the two mechanisms it needed that did not exist: a focus trap, and a way to focus something by name.

Context

§7: “modal: scrim over the window, focus trap, Esc = cancel-role button, Enter = default-role button; platform button order … applied by the dialog’s action bar automatically. Sizes to content with min/max.”

dialog is the largest name left in M3, and it was blocked rather than merely unbuilt. TODO.md had three separate entries waiting on the same missing thing: “Host.focus still does not exist … Something that wants to focus a control from a handler — a dialog putting the caret in its first field, a form jumping to its first error — still cannot.”

Decision

A dialog is a widget, and showing one is not

ADR-0106’s title, one group later, and the argument is unchanged. A modal needs the window — something has to cover it, dim it and take its pointer — and a widget has no window. So Dialog describes one and Dialogs.show(host, dialog) puts it on a Host, which is Menus.open’s split exactly.

It is not written into an application’s own tree either, and that is the second half of the same point: a dialog written inline would be laid out where it was written, and a modal is not somewhere in a column.

Modality is geometry for the pointer and a declaration for the keyboard

The scrim is a filling Overlay, and that is the whole of the pointer’s modality: a filling overlay takes every press wherever it draws, which tour’s veil discovered (ADR-0121) and which nothing had to be added for.

The keyboard has no position, so geometry cannot answer it. Handles.isModal() is the half that has to be said out loud, and the router reads it:

While something modal is mounted, the focused node is inside it.

That is one sentence and one method. traversalRoot() returns the deepest modal in the tree, so Tab enumerates the dialog instead of the window; and focus() redirects any request that lands outside it to the first thing inside. Enforcing it in focus() rather than at each of the routes that move focus is the decision worth writing down — the routes are Tab, a press, a roving arrow, a control focusing itself and whatever asks next, and a trap that covered four of five would be no trap.

Nothing is registered when a dialog opens, so nothing has to be unregistered. The answer is recomputed from the tree, which means a dialog removed by any route gives the keyboard back — including one removed while it was closing, and one removed by an application that never heard of the trap.

Two modals resolve topmost-first, scanning children in reverse: two dialogs are two overlays on one window and the later one draws on top, so walking forwards would hand the keyboard to the one underneath.

Host.focus(id) resolves a container to the first thing in it

The programmatic door three TODO entries were waiting on, and it takes an id for Host.anchor’s reason: a widget has no element and never will, and an id is the one name a description and a tree agree on. It is also a name a document can write, so it works for a KDL screen.

The rule that makes it useful is the fallback: a node that cannot take focus resolves to the first focusable thing inside it. A dialog’s panel is not focusable — a panel that were a Tab stop would be a stop with nothing to do on it — so without that rule the one caller that most needs this method could not use it. “Focus this dialog” and “focus this form” now mean what a caller intends.

It is refused for anything outside an open modal. A trap that a stray call could step around is a trap with a hole in it.

The roles are values, and the order is the theme’s

§7 asks for two things a plain row of buttons cannot give: Esc and Enter press particular buttons, and the bar orders itself by platform. Both need the dialog to know which button is which, so DialogAction carries a Role — AFFIRMATIVE, DISMISSIVE, NEUTRAL — and the dialog refuses to build with two of either, at construction, where every other document error is refused. Two default buttons is a dialog where Enter is a coin toss.

The class follows from the role rather than from the author: an affirmative is button.primary everywhere in an application, and nobody has to remember.

The order is one CSS declaration. The bar writes its buttons in a canonical order — neutral, dismissive, affirmative — and a theme that wants Windows’ order writes dialog-actions { flex-direction: row-reverse }. That is what §7’s “theme-controlled” has to mean here: children() runs before style resolution and long before anything has asked the platform anything, and a widget that read the operating system to lay itself out would be a widget whose golden images differ per machine.

Both keys are on the bubble phase

Handles.onKeyCapture’s own doc comment says “where a dialog swallows Escape before the thing inside it sees it”, and this dialog does not do that. A control inside a dialog that means something by a key keeps it by consuming it: Enter in a text-area inserts a line, Esc in an open select closes the list. A dialog that took either on capture would break the control it contains, and the control is the reason the dialog is open.

A press on the scrim is the same event as Esc — both mean “the dismissive one” — and a dialog with no dismissive action answers to neither, which is what a question that must be answered wants.

Closing runs before the application is told

design-system.md §1.7: an overlay runs opening → open → closing → removed, “the element stays mounted through closing, input is disabled the instant closing starts (no ghost clicks), removal fires on animation end”.

Every route out — a button, Esc, the scrim — goes through one method that starts the exit, stops taking input, and runs the application’s handler when the animation ends. So a handler that removes the overlay immediately still gets the fade, and an application never writes a line about the animation. ADR-0175 reversed the same order for message on the same day and for the same reason: nothing outside holds a closing overlay, so it has to outlive its own dismissal itself.

Consequences

  • §7 is one widget from done. toast is what is left, and it inherits both halves of this: the fade-then-tell order, and a Phase per entry. What it adds is a queue, which is also what will let it solve the reflow neither this nor message can.
  • min-width and max-width are not in the CSS subset, and §2 asks a dialog for both. Yoga has the setters and Box has no field for them, so this is a gap in the style engine rather than a decision about dialogs. What holds meanwhile: the scrim’s padding is a de-facto maximum, and the minimum is genuinely missing — a dialog with three words in it is three words wide.
  • isModal has one consumer, which is one fewer than a mechanism should have. A wizard step and a sheet are the plausible seconds. It is tested in :core against bare widgets rather than through dialog, so the second consumer finds a mechanism rather than a dialog-shaped hole.
  • Host.focus closes three TODO entries and opens none. A form jumping to its first error is now two lines an application writes; nothing in the toolkit does it yet.
  • Nothing restores focus when a dialog closes. Focus was somewhere before the dialog opened and lands nowhere in particular after — the trap releases and the focused element is simply gone. Every real toolkit puts it back where it was, and doing so means remembering the previously focused element across the dialog’s life, which is a fourth thing the router would hold.
  • A dialog is not announced. §7 asks for “dialog with labelled title” as semantics, which is the AccessKit bridge and M5’s, like every other widget’s.

177. A toast is a queue, and the stack is the widget

Date: 2026-08-23

Status

Accepted. Builds docs/core-widgets.md §7’s toast, which closes the overlay group.

Context

§7: “non-modal notifications: queued, timeout with hover-pause, optional action button, stacking corner configurable; announced via semantics (live region)”.

Every one of those is a behaviour over time, which is what makes a toast a different kind of thing from everything else in §7. A message is a description an author writes where it goes. A dialog is opened, answered and removed by an application that knows exactly when each of those happens. A toast is raised by something that has no idea what else is on the screen, and everything interesting about it happens afterwards without anybody watching.

Decision

The value is not a widget

A Toast is a record — text, an optional action, a timeout — and there is no toast node an author can write. §7 draws the line itself: a message is “part of the layout … about the thing next to it”, so an author writes one where it goes; a toast is “transient, floats over the window … about something that just happened”, so nobody writes one anywhere.

That is also why there is no @Markup("toast"). A document is a description of a screen; a toast is a thing that happened, and a screen that described one would describe it again on every reload.

The stack is the widget, and it owns the queue

One Toaster per window, put in the window’s own corner overlay layer by Toasts.at — the layer hud has occupied since ADR-0100. An application holds a ToastController and raises values through it, which is ScrollController’s and FormController’s arrangement and here for the sharpest version of their reason: whatever raises a toast is by definition somewhere else. A save handler deep in a view model has no widget tree, no Host, and nothing it could reasonably be given — what it has is a field.

Holding the list is what lets the stack do the two things a lone banner could not, and both were filed as gaps against ADR-0175:

  • keep a toast alive past its own dismissal, so it fades out with nothing outside it having to know; and
  • know what its siblings are, which is what §3’s “siblings reflow via translate” needs and what nothing else in the catalog is in a position to do.

The first is built here. The second is not — see the consequences.

“Queued” means a cap, and three is a judgement

§7 says “queued” and does not say how many. Four notifications stacked in a corner is a wall of text nobody reads; one at a time makes a burst take half a minute to get through. Three is the default and the number is on the widget, so an application that disagrees says so once.

A toast on its way out has given up its place, so the next one comes forward while the old one is still fading and the stack briefly holds four. The alternative — waiting for the exit to finish — makes a burst of notifications stutter, and the overlap is 160ms.

Every clock here is the frame clock

Host.after gives a timer and no way to ask how much of it has run. A pause that resumes therefore needs to know what time it is, and the only clock a widget has is the one render is handed — so the stack node reports nowMillis on every frame and the state remembers the last reading. carousel reads the motion preference the same way and for the same reason.

That is what makes §7’s hover-pause a pause rather than a restart. A toast you glanced at for two seconds gets its remaining three back, not another five. Restarting would be one line shorter and is a different promise.

The corner decides three things, because they are one decision

Where the stack sits, which edge a new toast slides in from, and which end of the column is the newest — a stack at the bottom grows upwards and one at the top grows down. All three come off Corner, and the third is a CSS rule: the node describes its children oldest-first and toaster.top-start is column-reverse. A widget that reversed the list would have to reverse it again for the keyboard.

The action button takes the toast with it

Pressing “Undo” runs the handler and starts the exit, which is Menus’ rule for a menu command: choosing what a thing offered is finishing with the thing. An application never writes the dismissal, and a toast that stayed after its one button had been pressed would be waiting for a second answer it has no way to take.

Consequences

  • §7 is done. tooltip, popover, tour, hud, message, dialog and toast — the whole overlay group, and both places an overlay can go.
  • The sibling reflow is still not built, and this is the widget that finally could build it. §3 asks for “siblings reflow via translate, base (explicit controller — the one sanctioned movement effect)”: when a toast in the middle goes, the ones above it should travel to their new places rather than jump. What it needs is the departing toast’s height, which the stack can have — Host.anchor(id) returns the painted rectangle of a node, so the shift is one lookup and a Phase per surviving sibling. It is a whole mechanism rather than a detail, and it is the last thing §3 asks of this group.
  • A toast is not announced. §7’s “live region” is the AccessKit bridge and M5’s, like every other widget’s semantics — and it is the one widget in the catalog where the absence really costs something, because a notification nobody sees is the case a live region exists for.
  • A toast has no kind, which is §7’s omission followed rather than an oversight. A message has four because it has to be told apart from three other things it might be saying; there is only ever one toast saying what just happened. An application that wants a red one has to say so in words.
  • 360 is a width and not a maximum, because the subset has no max-width — the gap ADR-0176 filed. Here it is nearly a virtue: three toasts of one width read as a stack where three sized to their contents read as a pile. It stops being a virtue for a one-word toast.
  • Nothing dismisses a toast by clicking it. §7 gives it an action button and no ×, so a toast with Duration.ZERO and no action can only be removed by clear(). That is the specification’s shape and it is worth knowing before somebody ships a notification nobody can get rid of.

178. A stack closes its own hole

Date: 2026-08-23

Status

Accepted. Builds §3’s sibling reflow, the one thing ADR-0177 left §7 owing, and the last of docs/core-widgets.md §7.

Context

docs/design-system.md §3, the toast row: “in: slide 16px from edge + opacity, overlay · out: opacity base · siblings reflow via translate base (explicit controller — the one sanctioned movement effect, transforms not layout)”.

Everything else in that sentence shipped with the widget. The reflow did not, because it is the one part that is not about a toast at all: it is about what happens to the toasts that stay when one of them goes. Until ADR-0177 there was nothing in the catalog that knew what a notification’s siblings were — a message is written where it goes and has no owner (ADR-0175) — so the effect had no subject. A stack that holds a queue is a subject.

Without it, a toast dismissed from the middle of three is a jump: the column reflows on the frame the element leaves the tree, and everything on one side of it is suddenly somewhere else.

Decision

Only the older toasts move, and the corner decides which way

This is the part that is not obvious, and it falls out of where the column is pinned rather than out of anything the widget does.

A toaster is a corner overlay: content-sized, with two of its four insets undefined, so it is anchored along the edge it is against and grows away from it. controls.css then arranges the children so that the newest toast is the one nearest that corner — column at the bottom corners, column-reverse at the top. Put those together and the column is anchored by its newest member.

So taking one out of the middle leaves everything between it and the corner exactly where it was, and moves everything on the far side — the older half — toward the corner by the height of the hole plus the gap it was keeping. Which direction “toward the corner” is, is the corner’s own, and ToastBox reads it there rather than being handed a signed number; a signed distance would be the one value in the widget that had to be recomputed when a stack changed corner.

A corollary worth writing down: the ordinary case moves nothing. A stack whose toasts all have the same timeout loses its oldest first, and the oldest has nothing older to move. The reflow is what a dismissal from the middle looks like — an action button pressed, a clear(), a burst with uneven timeouts.

The translate runs backwards

Nothing here moves a toast to a new place. The sibling is gone, so Yoga has already put the survivor where it belongs; what the translate does is put it back where it was for one frame and then let go. The offset shrinks to zero rather than growing from it.

This is the same shape as the arrival — (1 - visible) * TRAVEL — and it is worth naming because the two are otherwise easy to confuse: the arrival is about the toast, the reflow is about a hole, and they compose on two axes rather than taking turns. A toast can still be sliding in when the one beside it is dismissed, and either effect being dropped for the length of the other is a gap that closes late.

The height comes from Measured, and the gap from the stylesheet

The distance is two numbers, and neither is one a widget may invent.

  • The height of the hole is the departing toast’s, which nothing can ask for afterwards — by the time it is wanted the toast is gone. So each ToastBox implements Measured and the stack banks what every frame reports. That it is last frame’s is exactly right here: a toast has to have been on screen to be dismissed, so by the time the number is wanted it has been reported. Measured’s third rule holds too — a reflow is a transform, so the box it moves is laid out where it always was, which is why §3 asked for a transform in the first place.
  • The gap is toaster { gap: 8px }, reported up from render beside the frame clock, through the channel ADR-0177 opened for the clock and for the same reason: it is a reading only render can take. Read rather than assumed, because a stylesheet that changed the gap and nothing else would otherwise leave the whole stack reflowing to somewhere it is not.

A toast dismissed before a frame ever painted it has no height, and the stack reads that as no hole rather than as a hole of nothing. The check is on the height and not on the total: the gap alone is a real number, and 8px in a direction nobody asked for is worse than the jump this replaces.

Not an AnimationController

§3 says “explicit controller”, and book/src/TODO.md has had the imperative AnimationController down as a specification without a subject since ADR-0081 took two of its three away — the spinner and indeterminate progress ship as functions of the frame clock with no state at all. Toast reflow was one of the two it had left, on the grounds that it has “a start, an end, and an interruption to reverse from”.

Two of those three are Phase, which already ships and already runs on the frame clock. The third — the interruption — turned out to be arithmetic rather than a mechanism:

var left = entry.reflow == null ? 0
        : entry.reflow.distance() * (1 - entry.reflow.phase().progressAt(now));
return new ToastBox.Reflow(left + distance, new Phase(ENTERING, REFLOW_MILLIS));

Read what is left, add the new distance, start again. That is ADR-0081’s finding one level up: a controller here would be a per-element copy of the time for a consumer that needs three lines of it. The overlay enter/exit sequence is the one subject the specification has left, and it stays on the list.

Phase.Kind.ENTERING for a movement that is neither an arrival nor a departure, because what the kind actually selects is “runs once, then settles” — and settling itself is what stops isAnimating asking for frames forever.

Consequences

  • §7 is complete, and §3’s movement clause has its one implementation. A column of messagees still cannot have it, for ADR-0175’s unchanged reason: a banner has no owner to hold the list.
  • isAnimating had to learn about the second clock, which is ADR-0176’s bug waiting to happen again: a toast that has settled but is still travelling would be a widget nobody repaints, and a widget nobody repaints does not move — it stands still for 160ms and is then somewhere else. A golden would photograph that happily, so the assertion is on isAnimating directly.
  • The golden had to be taken in a real window, which is new for this widget and is the direct consequence of the first decision above. Every other picture in ToastGoldenTest is of a column on its own, and that column is top-anchored — so it photographs the newer toast moving, which is the opposite of what a pinned stack does. Overlay placement is not assertable as a number, which HudGoldenTest found first.
  • Measured has a fifth consumer, and the first whose reason is not its own geometry but a sibling’s. book/src/TODO.md calls it “a door every widget can now open and almost none should”; a stack that must move its survivors by an exact distance is one of the few that should.

179. A popup says what it measured

Date: 2026-08-23

Status

Accepted. Closes two entries in book/src/TODO.md — the menu that capped itself by estimate (ADR-0118) and the select whose list was clamped rather than scrolled (ADR-0141).

Context

Host.popup(content, anchor, placement) has always been three steps, and Host’s own documentation has always said so: measure, place, open, “and each is separately observable” (ADR-0104).

The first of the three was not observable at all. The facility measured the content, handed the number to Placement, and opened; a caller that needed to know how big its content came out had no way to ask. Placement then clamps anything taller than the work area to the near edge — which keeps the top visible and silently drops everything below it.

Two callers needed the number and neither could have it:

  • Menus guessed. It decided whether a menu would be taller than the screen from its row count times an assumed 34px, and wrapped it in a scroll if so. The guess rounded up so that it erred towards wrapping a menu that would have fitted rather than clamping one that would not — an invisible viewport, but a thumb and a wheel handler with no business being there. --gb-menu-item-height is 32, so the number was also kept in two places, one of which is a stylesheet a widget cannot read.
  • select did not try, and a list with more options than the display is tall lost its bottom. The same defect menu had before ADR-0118, still shipping in the control §3 most expects to be long.

One guess and one gap, with one cause.

Decision

The facility reports, between the measure and the place

A new overload takes a Host.Fit: a callback handed what the content measured and the rectangle it has to fit inside, which answers with the content to open.

host.popup(list, field, Placement.BELOW, width, new Fitted("select-viewport"));

Returning the same widget is the ordinary answer and costs nothing. Returning anything else costs a second measurement — the element tree is thrown away and rebuilt, because everything measured against the old content is worthless. That is the right way round: nearly every popup fits, and only the one that did not pays.

The cost is worth stating plainly, because ADR-0104 went out of its way to build the tree once (“a second one would also be a second lot of initState”). It still is, for every popup that fits. A popup that does not fit is being rewritten by its caller, and a tree built from content nobody is going to open is not worth keeping.

The facility asks rather than deciding

Two reasons, and neither is new:

  • Whether content that does not fit should scroll or be clamped is a fact about the content. A menu that lost its last three commands is the worst kind of wrong; a tooltip that scrolled would be absurd, and would rather have been a dialog (ADR-0118).
  • :core could not act on the answer anyway. A viewport is a widget and :core has none (ADR-0092).

So the reporting is in :core and the policy is in :widgets, which is the split the module fence already draws.

One policy, held once

Fitted is the answer both callers give, in widgets.core.scroll beside the Scroll it builds: content taller than the room becomes a viewport of the room’s height, and content that fits comes back untouched.

The 8px margin it keeps at each end came out of Menus and was never anything to do with menus — a panel flush against the top and bottom of the screen looks like one that has been cut off even when it has not. Two viewport classes rather than one (menu-viewport, select-viewport) so that a stylesheet can tell them apart without either inheriting the other’s future; both are flex-grow: 0 today.

Consequences

  • ROW_ESTIMATE is gone, and with it the second copy of --gb-menu-item-height. The end-to-end test measures a twenty-row menu at 667px where the estimate said 696 — close enough that the estimate was never wrong on a full-height display, and 29px of a menu that had to be needlessly wrapped on a short one.
  • A select list longer than the screen scrolls, which is the user-visible defect this was written for.
  • Placement still clamps, and that has not changed: a caller that opens an oversized popup and offers no Fit gets the old behaviour, which is right for a facility that cannot know what its content means. What changed is that the two callers who could know now have the number they need to say so.
  • Host gained a fifth popup overload, which is one more than a surface this wide wants. The alternative was a standalone Host.measure(Widget), and it was refused on cost: it would build a throwaway element tree on every popup — running initState twice for every menu and every dropdown — to serve the rare case where the answer matters. A callback pays only when the answer is acted on.
  • TestHost consults the Fit, driven by a measuring(width, height) knob, because a Fit’s only observable is the widget its caller decided to open and a test without a window has nothing to measure with. Unset, no Fit is consulted and every test that predates this sees what it saw before.

180. The keyboard goes back where it was

Date: 2026-08-23

Status

Accepted. Builds §7’s “restores focus on close” for the modal case, and fixes the bug underneath it. Answers one entry in book/src/TODO.md and corrects two more.

Context

docs/core-widgets.md §7 says each overlay “wraps a focus-scope and restores focus on close”. Nothing did, and three TODO entries said so: a modal, a menu and a select each failed to give the keyboard back.

Looking at the modal case first turned up something the entry did not describe. Element.unmount() tells the element tree — tree.forget(this) — and tells nothing else. The router is not a listener. So when a dialog closed:

after the modal closes:            x
  is that element still mounted?   false

The router went on holding the element that had left the tree. Not “focus was not restored”: focus was pointing at something that no longer existed, receiving key events, and keeping its whole dead subtree reachable. The missing restoration was the visible half of a stale pointer.

Decision

The router never holds an element that is not in the tree

PointerRouter.refocus(), called once a frame from updateRegions — the hook that already runs after every paint and already carries notifyMeasured and notifyLocated. If the focused element has left the tree, the router lets go of it.

This is right well beyond dialogs, and that is the argument for doing it as its own rule rather than as part of the restoration: a tab that switched, a list that shortened and a dialog that closed all strand the same pointer, and none of them has anything to do with modality.

Public rather than private, unlike its two neighbours, because the question is about the element tree rather than about the frame that was painted: a test that closes a dialog without drawing anything still needs the answer, and passing an empty region list to get it would throw the hit-test snapshot away.

Being a frame late costs nothing. Nothing can press a key between a tree flushing and the frame it produces, which is ADR-0117’s argument for Measured in a second place.

One slot, written at one moment

restoreTo is the first state the focus trap has held, and book/src/TODO.md was right to flag that as the cost. Everything else about the trap is a question about the tree asked fresh — deepestModal walks it on every focus change, which is exactly why a dialog opened from inside a dialog gives the first one back for nothing. A remembered element cannot be derived that way: what had focus before a modal opened is a fact about the past, and the tree does not record it.

So it is kept as small as it can be:

  • One slot, not a stack. A nested modal closing keeps the answer rather than spending it, so the outermost answer wins — which is the one the user will still be looking at when everything has closed.
  • Written at exactly one moment: the branch in focus() where the trap takes the keyboard off something outside the modal. Focus moving within a modal never overwrites where it came from.
  • Allowed to go stale on purpose. refocus drops it the moment what it points at leaves the tree, rather than anything having to keep it true. A dialog opened from a row that the dialog’s own action then deletes is the case, and it is ordinary rather than exotic.

The fromKeyboard flag is remembered with it. §7.2 keeps :focus and :focus-visible distinct, so giving the keyboard back has to give back the state it was in: a dialog dismissed with Escape leaves the ring where the user last saw it, and one dismissed with the mouse does not make one appear.

Nothing to go back to means letting go

A dialog opened from a menu command, or on a window’s first frame, had no previously focused element. There is nothing to restore, and the router clears focus rather than holding a corpse — which is what a press on empty space already does, and is legal with a modal up because a null focus is reachable from anywhere.

Consequences

  • The two popup entries were wrong about the cause, and are corrected rather than closed. A probe through the real launcher — a focusable widget that logs every focus change, a menu opened over it and closed — produced no focus loss at all:

    focusing the opener from the keyboard:
      opener GAINED focus (keyboard)
    opening a menu:
    closing it:
    done.
    

    A popup gets its own tree and its own router; nothing in the open or close path touches the owner’s. So the owner keeps its focus throughout, and there is nothing at the router level to restore. What may still be missing is platform keyboard focus — SDL gives a POPUP_MENU window focus on some drivers and not others — and the headless backend cannot show that. The entries stay open, saying that instead of what they said before.

  • refocus is the router’s fourth per-frame job, and the only one that can change focus. That is worth knowing when reading updateRegions: a frame can now move the keyboard, where before only input could.

  • hovered, pressed and captured are not swept. They can go stale the same way and none of them has a demonstrated bug: hovered is recomputed from the regions on the next pointer move, and the other two are cleared on release. Left alone deliberately rather than overlooked — the sweep would be three lines and no test could justify them yet.

181. A box may say how small and how large

Date: 2026-08-23

Status

Accepted. Adds min-width, max-width, min-height and max-height to §8’s CSS subset, and gives dialog the two numbers §2 has always asked it for.

Context

docs/core-widgets.md §2 asks a dialog for “min width 320, max 80% of the window”. Neither was expressible. §8’s subset had no such property, Box had no field for one, and ComputedStyle had nowhere to put one — so controls.css carried a paragraph explaining that the scrim’s 24px of padding was a de-facto maximum and that the minimum was simply missing: a dialog with three words in it was three words wide.

It was never only about dialogs. book/src/TODO.md listed it as “a gap in the style engine rather than a decision about dialogs” and named the consumers waiting. By the time this was built there were five:

  • dialog — §2’s min 320 / max 80%, above.
  • toast — controls.css says outright that 360 “is a width rather than a maximum because the subset has no max-width (see dialog)”.
  • tooltip — “has no maximum width of its own”, which is what makes a long one a single unreadable line.
  • popover — minimumWidth is a Java argument on Host.popup doing what a declaration should do.
  • text-area — max rows.

Three widgets writing a width where they meant a maximum is the shape of a missing property rather than three independent choices.

Decision

One value, not four components

Limits — minWidth, maxWidth, minHeight, maxHeight — beside Insets in natives.yoga, which is where the vocabulary css and layout share already lives (ADR-0172).

Insets’ reason applies: the four are only meaningful together, and Box and ComputedStyle would each have grown four components where they now grow one — across 45 positional reconstructions between them, every one of which is a place to put an argument in the wrong slot.

There is a second reason Insets does not have. These four are the same question asked four ways, and a caller that handled three of them has a bug nobody would find: a dialog with a minimum width and no maximum reads as working right up until somebody writes a long sentence in one. One value makes handling three of four impossible.

Undefined, not zero

“No limit” is StyleLength.UNDEFINED on every edge, which is Yoga’s own default rather than a convention layered over it. It has to be: a minimum of zero is a real declaration that constrains nothing, but a maximum of zero is a box that may not exist. Spelling “no limit” and “a limit of none” the same way would make the second unsayable.

The scrim lost its horizontal padding, and that is the whole trick

§2 wants “80% of the window”, and max-width: 80% resolves against the containing block. A dialog’s containing block is the scrim, which fills the window — so the percentage means what §2 says only if the scrim has no padding across it. With the 24px it had, the maximum would have been 80% of the window less 48px, which in a narrow window squeezes a dialog below the width §2 allows it. Measured, not reasoned about: in the golden’s 424px window the dialog came out at 330 where §2 permits 339.

So dialog-scrim is padding: 24px 0 now. The padding across was the de-facto maximum; there is a real one, and the two would be fighting. Down the page it stays — a tall dialog still has to be kept off the top and bottom edges, and no maximum on the height is doing that job.

This is the argument for putting the number in CSS rather than in Java. “80% of the window” needs nothing to measure a window: a percentage and a containing block that happens to be one.

Consequences

  • The dialog goldens all changed, and the change is §2 being applied. The dialog in them wanted 85% of the window and is now capped at 80%, so its message wraps to two lines. That is what a maximum does, and the picture is the first evidence the property is real.

  • A guard keeps an unlimited box cheap. RenderObject.apply skips all four setters when neither frame had a limit — nearly every node — so a box that never mentions a minimum costs one comparison rather than four foreign calls. A guard that skipped a setter Yoga needed would give a correct first frame and a wrong second one, which is the failure staysIdenticalAcrossFrames exists for; the same question is asked of these, in both directions, because a one-way guard would leave a limit behind after its declaration went.

  • Of the four consumers waiting, only one wanted converting. Looked at one at a time rather than swept:

    • tooltip gets a maximum, and it is the case the property was most needed for. §2’s metrics row gives a tooltip a padding, a radius and two delays and no width at all, so 320 is a judgement of Toaster.DEFAULT_MAXIMUM’s kind: without one, a sentence of help text is a ribbon across the window that is harder to read than no tooltip.
    • toast keeps its width. The note in controls.css gave two reasons and only the first expired — the second is a design argument that still holds: giving every toast the same 360 is what makes a stack of three read as a stack, and a maximum would size each to its own string, which is the ragged pile the note was arguing against.
    • popover’s minimumWidth cannot become a declaration. It is field.size().width() — what the control it drops from turned out to be — and no stylesheet can know a runtime measurement (ADR-0145 says so). It was listed as waiting on this and never was.
    • text-area’s max rows is built, and is a row count rather than a length: it grows between rows and max-rows and scrolls past that. Also never waiting on this.
  • Box and ComputedStyle are 24 and 23 components wide, and the one failure mode that follows is an argument in the wrong slot — a record that compiles, runs, and is wrong in a way no golden obviously shows. RecordWitherTest closes it: every wither is asked to set its component to the value it already holds, and must give back an equal record. That is complete — a wither that writes its argument into the wrong slot, reads the wrong component into a slot, or passes one component twice all fail it — and it needs no value factory and nothing per component, so a component added tomorrow is covered the moment its wither exists. Verified by planting a width/height swap the compiler cannot see: the test named the wither.

    A test rather than a refactor, deliberately. The structural answer is to group components into sub-records until no argument list is long enough to get wrong — which is what Insets and Limits already do for their four apiece — and doing it to the rest would turn box.width() into box.layout().width() across the whole toolkit for a benefit this catches completely and immediately.

182. A select may hold more than one, and a field may suggest

Date: 2026-08-23

Status

Accepted. Builds §3’s select multiple=, §4’s free-text autocomplete, and the way out of a toast that §7’s shape left missing.

Context

Three entries in book/src/TODO.md, and they turned out to share one shape: a control that offers a set of things has to be able to give one back.

  • select multiple= was “deferred as scope” and needed nothing unbuilt.
  • Autocomplete was waiting on text-input, which has since shipped.
  • A toast could not be dismissed by clicking it, so one with Duration.ZERO and no action button was removable only through ToastController.clear() — a notification nobody can get rid of.

Decision

A toast’s plate is its own dismiss affordance

§7 gives a message a dismiss × and gives a toast an action button and nothing else, and that shape was followed exactly. What the omission of a × meant is that a toast does not need a second affordance competing with its action for a 360×40 plate — not that a persistent one should be undismissable.

So the plate itself is the affordance. It costs no vocabulary, no glyph and no room; it makes every toast dispellable rather than only the persistent ones; and the click was already being swallowed, because the plate is hit-testable and a click on it never reached the application underneath and simply did nothing.

The trade-off is real and is worth stating: a click aimed at the action button that misses it dismisses without acting. The button is told first — a click bubbles from the node it hit — so a hit is never lost, and dismissing twice is already ignored.

change is a toggle when a select holds many

The selection is a set, and Select.resolvedAll() reads it the way resolved() reads one value: a bound Collection becomes the strings its elements stringify to, and anything else becomes a single value — so a model that starts as one value and becomes a list is not a different kind of binding. The order is the options’ rather than the model’s, so removing a chip and putting the value back does not move it to the end of the row.

What is reported is the value the user touched, through the Consumer<String> every other valued control already uses. In this mode that is a toggle: the set is the application’s, so asking for a value it already holds can only mean taking it out. One channel rather than two is what keeps a chip’s × and a click on an already-chosen row from being two ways of saying one thing — and what keeps multiple inside the shape §9’s binding already has.

The list stays open while values are picked. The whole point of the mode is picking several, and a list that shut after each one would make three values three round trips through a popup that has to be measured, placed and opened again each time.

A popup’s content may change while it is open

Which the toolkit could not do. A popup is an element tree of its own with its own build schedule (ADR-0103), so a setState in the widget that opened it reaches that widget’s tree and nothing in the window the popup is drawn in — and the only way to show a popup something new was to close it and open another, which flickers and loses the keyboard’s place.

ElementTree.update(Widget) re-describes a tree’s root and reconciles from there, so the elements, their state and their focus survive. Popup.content(Widget) is the door. §4’s autocomplete needs it for the same reason and says so out loud: the popup “stays open and narrows”.

The suggestions are a SelectList, and the rows commit rather than follow

§4: “attaches a popover of suggestions to the field: the widget raises the query, the application supplies the list, and the field’s text is never rewritten without the user choosing.”

All three fall out of the shape rather than being enforced. The field reports what was typed through change and is handed a list back by being rebuilt; choosing a suggestion reports that through the same change. Nothing here sets anything (ADR-0063), so a handler that ignores a suggestion leaves the field exactly as the user typed it.

Option.inAList() is what makes the panel right for this: the arrows move the focus and Enter commits, so a user arrowing through suggestions never has the field rewritten under them. Follow-the-focus is a select’s behaviour and is wrong here for exactly that reason.

Filtering is the application’s, which §3 already argued for the combobox form: a remote-backed autocomplete is then the same widget with a slower model, and nothing in the toolkit has to guess what “matches” means for a street address or a species name.

A field learns where it is, and asks for one frame when it does

A popover is anchored to a rectangle no widget can compute and only the painted frame knows, so TextField implements Located as SelectField already did.

The rebuild it asks for is the part worth recording. Without it, a field focused with suggestions already in hand offered nothing until some unrelated frame rebuilt it: the rectangle arrives after the paint, and §1.7’s idle loop was never going to ask for another one. So located asks for a build — but only when something is waiting to be shown and only when the rectangle changed, which settles in one frame rather than driving the loop. That is what Located’s “a widget told where it is must not move itself” is really asking for: the rebuild describes the same field at the same size, so the next rectangle is equal and nothing more is asked.

Consequences

  • select autocomplete=#true is not built. §3’s combobox form makes the closed control an editable text-input — typing filters, Esc restores the last committed value, and a free-typed value is refused unless free=#true. The suggestion machinery it needs now exists and is proven by the free-text form; what is left is hosting an editable field inside SelectField and the commit/restore rules over it, which is its own decision about where the editing state lives. select tree=#true still waits on tree.
  • Autocomplete is Java-only in v1. §4 says the application supplies the list, and the list arrives by rebuilding the widget in answer to change — a channel a document does not have. A document may write the field; it simply offers nothing under it. Giving markup a named suggestion source is a decision about Wiring, not about this widget.
  • SelectList is public and in the wrong package, which is a wart taken knowingly. Option was moved into a package of its own the day it had two callers, and this now has two; the CSS type it carries is select-list, so moving it means renaming a type in every stylesheet and every golden rather than editing one file. Filed rather than done.
  • A chip is a badge and is not the badge widget. controls.css names both types in one rule so the metrics are stated once. It could not simply be a Badge, which is a leaf with text and no children, where a chip has to hold a × beside its label.
  • A chip’s × is not focusable, which is TabClose’s decision for TabClose’s reason: a select is one Tab stop, and a focusable × per chip would make a five-value select six stops where a document wrote one control. The keyboard’s way to remove a value is to open the list and press Enter on it, which toggles.

183. A combobox is a select you can type in

Date: 2026-08-23

Status

Accepted. Builds §3’s select autocomplete=#true, which ADR-0182 left as the one part of §3’s select line still unbuilt.

Context

§3: “autocomplete=#true makes the closed control an editable text-input: typing filters the options, the popup stays open and narrows, Esc restores the last committed value rather than clearing, and a free-typed value is refused unless free=#true.”

ADR-0182 built the free-text half of autocomplete — a text-input that offers suggestions under itself — and deferred this one, because the combobox form asks a question the free-text form does not: where does the editing state live?

Decision

The editor is a real text-input

§3 says “makes the closed control an editable text-input”, and it is meant literally: SelectField holds a TextInput as a child. Everything an editable field needs — the edit model, the undo history, the clipboard, the caret’s blink, IME — already lives there and has rules in it (ADR-0167). A second editor grown inside select would be a second copy of those rules, and the first one to drift would drift silently.

That the cascade then sees a text-input inside a select is not a wart; it is what §3’s sentence describes, and it means an application’s text-input rules apply to the thing that is one.

One Tab stop, and the plate delegates

The field is not focusable when it holds an editor, and delegatesFocus() is true. Without both, a combobox would be two Tab stops where a document wrote one control; with them, the editor is the stop and a press on the field’s own chrome — its padding, its chevron — hands the keyboard to it. That is field’s mechanism (ADR-0170) reached for its own reason: the thing that takes the press is a sibling of the thing that should end up focused.

Two keys change meaning as a result:

  • Space types a space. §3 lists Space as a way to open a closed control, and a combobox is not one.
  • A click opens rather than toggling, and is not consumed. A click in a combobox is a user putting the caret somewhere; closing the list under them because it happened to be open would take the choices away mid-gesture, and the editor underneath needs the same click to place its caret.

Esc is handled on the bubble phase, so the editor keeps whatever it wanted first — the line every control in this catalog draws.

The offered text is the whole mechanism

SelectState holds one nullable string: what the user has typed, or null while the control is showing the committed value. It is handed to the TextInput as that widget’s value, and TextInputState.follow overwrites the field only when the offered value changes.

That one property does all the work. Typing is never fought, because the offered text is what was typed. Esc restores by setting it back to null, which is a change, so the field is overwritten with the committed label. Choosing an option clears it for the same reason. No new rule was needed anywhere.

Refusing is a blur-time decision

§3’s “a free-typed value is refused unless free=#true” needs a moment to happen at, and the moment is the keyboard leaving: that is when a half-typed value stops being an attempt and starts being an answer. Heard through onFocusWithin rather than onFocusChanged, because the thing that has the keyboard is the editor inside the field — this node never had it to lose.

A free control keeps what was typed and reports it through change. Every other one puts the committed value back, because a combobox is a set of values and text naming none of them is a mistake rather than a new member.

Filtering is still the application’s

The control raises what was typed through query and renders whatever options it is handed back. §3 gives the reason and it is the same one ADR-0182 recorded for the free-text form: a remote-backed autocomplete is then the same widget with a slower model, and nothing in the toolkit has to guess what “matches” means for a street address or a species name.

The popup narrows rather than reopening, on Popup.content — which ADR-0182 built for select multiple and which this is the second consumer of.

Consequences

  • select now has eleven components, and this is the second control to reach the size where ADR-0181’s positional-constructor argument applies. RecordWitherTest covers Box and ComputedStyle and not the widgets; extending it to every record with withers is the obvious next move and was not made here.
  • tree=#true is the last of §3’s select line still unbuilt, and it waits on tree, which does not exist.
  • The editor is not told to select-all on focus. A text-input reached by Tab selects everything, which is right for a field whose value you are replacing and is what a combobox wants too — it comes free, because the editor is a real text-input and that is its rule. Worth stating because it was not designed here; it was inherited, and it happens to be correct.
  • The editor is drawn as the select’s interior, not as a control sitting in one. Left alone it would have brought a text-input’s border, fill, radius and focus ring inside the select’s own — which was checked rather than assumed — so controls.css strips all four and lets it grow into the room the chevron leaves. The focus ring stays the outer control’s, because what the user focused is a select.
  • A guard caught the comment before the code. The base stylesheet “must name no colour of its own” is asserted as contains no #, and a comment quoting KDL’s =#true fails it. Cheap to fix and worth knowing: the check reads the whole file, comments included.

184. A tree is a list that remembers what is open

Date: 2026-08-23

Status

Accepted. Builds docs/core-widgets.md §3’s tree in a first cut, and select tree=#true on top of it — the last of §3’s select line.

Context

select tree=#true “takes a tree’s model instead of a flat option list, so the popup is a tree and a selection is a node”, and it had been waiting on a tree that did not exist.

§3 says a tree “shares list’s item-factory so it inherits the virtualization work when that lands” — and list is not built either. So the model this needs had to be defined here rather than inherited, which is the reason this ADR exists at all: the shape chosen now is the one list will have to agree with.

Decision

The id is the whole model

§3: “expansion state is retained across rebuilds by node id, not by index — a tree that collapsed itself when its model reordered would be the same defect list keys exist to prevent.”

So TreeNode requires an id, TreeState holds a Set<String> of what is open, and TreeRow uses the id as its reconciler key. A model rebuilt with its branches sorted differently, or with a node inserted at the top, leaves every open branch open and every focused row focused. That is one decision paying three times.

A chevron is drawn before anyone knows what is under it

mayHaveChildren() is separate from children() and it is not a convenience. §3 asks for children “fetched when it first expands”, and a node that had to know its children in order to decide whether to draw a chevron would defeat that entirely — a directory tree would stat the whole disk to draw its first row.

So a lazy node draws a chevron on the strength of having a supplier, and loses it on the frame after it opens if the supplier answered with nothing. That is what every file manager does and it is the honest reading of “may have”.

The fetch happens in the toggle rather than in build, which is the rest of “lazy”: a build that fetched would fetch for every node on every frame, and a supplier that reads a disk or a network must run when the user asks. first is a promise too — a branch closed and reopened does not go back to the supplier.

The indent is a box, not padding

§2: “indent 20 per level; chevron 16 in the indent gutter”. The row draws a sized tree-indent before its chevron rather than taking padding, because a stylesheet cannot compute a depth — and because the row’s background has to reach the left edge. An indented padding would start the selection highlight 40px in, which reads as a misaligned row rather than a nested one.

A leaf keeps the chevron’s box and draws nothing in it, so a folder’s label and a file’s label at one level line up. The golden is the argument: dropping the box on leaves steps every leaf half a chevron left, which reads as a level of nesting that is not there.

Left and Right are the row’s, Up and Down are the scope’s

§3 calls the keyboard “the part that has to be right”. Right expands and Left collapses, and both belong to the row because only a row knows whether it is open.

The other half of each is where the flattening pays off. Rows are flattened depth-first, so a parent is immediately followed by its first child — which means Right on an already-open row needs to do nothing: it falls through unconsumed to the vertical focus scope, which moves to the next row, which is the first child. Left on a closed row is the one that needs help, and it asks the host to focus the parent by name, because a row cannot reach another row’s element (ADR-0176).

Horizontal roving is therefore not merely absent, it is forbidden: a scope that took Left and Right would take the widget’s entire keyboard.

Leaf-only is a selection rule, not a checkbox

§3 gives a standalone tree checkable="none|leaf|any|cascade" that “adds a checkbox per node”, and gives select tree= a checkable that decides whether a parent may be chosen — “leaf-only by default, because ‘Europe’ is usually a heading and not an answer”.

Those are two different features wearing one word, and only the second is built. A parent in a leaf-only tree is still a row: navigable, openable, and not an answer. It is .heading to a stylesheet rather than :disabled, because disabled would say it is inert and it is not.

The popup is the same panel with a different child

select tree= opens a select-list holding one Tree rather than a row per option. One panel, so the surface, the edge, the radius and the scrolls-when-it-does-not-fit are one decision instead of two — and it is exactly what §3’s sentence describes.

Consequences

  • This is a first cut and §3 asks for more. Not built: the checkbox per node with cascade propagating down and indeterminate upward — which §3 calls “the one place the tri-state checkbox is not a decoration” — * to expand every sibling, type-to-select across visible rows, multi-selection, and Home/End to the first and last visible rows. Each is additive and none changes what is here; they are filed rather than half-built.
  • §2’s rotate on the chevron is two marks instead. “Expand/collapse: chevron rotate base” is not available, because §8’s subset has no transform on a mark — the wall select’s chevron hit and the reason CHEVRON_DOWN exists beside CHEVRON_END at all (ADR-0141). A closed row draws > and an open one draws v. The cost is the animation, and that is the whole cost.
  • A tree’s model cannot be written in markup, and select tree= therefore takes none from a document. A node carries a Supplier for its children, which is not a thing KDL can say; §3 calls it “a tree’s model” and a model is the application’s. The Choosers screen is Java for this reason as well as the two it already had.
  • Select is twelve components wide. ADR-0181’s positional-constructor argument now applies to it more than to Box, and this change churned four call sites to prove it. RecordWitherTest still covers only Box and ComputedStyle; extending it across the widgets is overdue.
  • list will have to agree with TreeNode. §3 says the two share an item-factory, and defining the model here means list inherits this shape rather than choosing its own — which is the right way round, but it is a commitment made by the widget that happened to be built first.

185. A list that hangs off a field does not take the keyboard

Date: 2026-08-23

Status

Accepted. Fixes three defects found by running the application against work that ADR-0182, ADR-0183 and ADR-0184 had shipped with passing tests.

Context

Three of §3’s newest select forms were broken on screen and green in CI:

  1. A multiple’s chip appeared and the row it came from stayed grey.
  2. An autocomplete took one character and then went dead.
  3. A tree’s branches would not open at all with a mouse.

The tests that should have caught them are the ones this ADR is really about. Every one drove the widget by hand — calling onChange, pressing a key on a row, asserting on what came back — and every one passed, because each defect lives in the seam between the widget and something that only exists in a running window: the application’s rebuild, the platform’s focus, and the pointer.

That is ADR-0176’s lesson arriving a third time. A golden photographed an animation that never ran; here a unit test exercised a control nobody could use.

Decision

A control may not read its own widget between telling the application and being rebuilt

select multiple kept its list open and re-described the rows at the moment of the click, immediately after onChange. At that instant widget() is still the description that was current before the application was told — so the rows were drawn from the selection the list already had, and the tick never appeared. The chip appeared because the chip is drawn by the next build, which does see the new model.

The refresh moved into build, which is by definition the first moment the application’s answer is visible. The general rule is worth stating because it will catch the next one: after reporting upward, a controlled widget knows nothing new until it is rebuilt. Reading widget() there is reading the past.

The one place it is still safe is a value the control owns rather than reports — an autocomplete’s query is this control’s own state and does not travel through the application before the list has to narrow.

A popup that hangs off a field does not take the keyboard

Popup focused its first row on its first frame, always. For a menu that is right — a menu is what you are now operating. For a suggestion list under a field you are typing into it is a bug with a very specific shape: the first keystroke opened the list, the list took the keyboard, and every keystroke after it went to a row instead of the editor.

So Popup.takesFocus(false), used by both autocomplete forms. The arrows still reach the list, because the owner forwards keys to whatever popup is open (ADR-0104) — a mechanism that exists because a popup may or may not have platform focus, and which turns out to be exactly what makes this safe rather than a compromise.

A combobox opens on focus, not on the click

The editor consumes the press to place its caret, so a click reaching the plate underneath could not be relied on. Focus is the honest signal: it is what a click, a Tab and Alt+Down all produce, and a combobox the user is inside with no options showing is a text box that has forgotten what it is.

A tree opens with the pointer

Nothing handled a click. TreeRow selected when the row was selectable, and in a leaf-only tree — §3’s default — a parent is not selectable, so a click on “Europe” did nothing. The chevron had no handler either. Every keyboard test passed.

Now: a click on the chevron toggles and consumes; a click on the rest of the row chooses if it is an answer and otherwise opens. A row that is both — a parent in an any tree — chooses, and its chevron is how it opens, which is every file manager’s arrangement.

The wither check covers the catalog

RecordWitherTest covered Box and ComputedStyle (ADR-0181). The argument has since moved: Select grew to twelve components across four sessions, and every option added churned every hand-written positional copy in the file.

WidgetWitherTest walks the compiled classes — HolderShapeTest’s approach, for its reason: a list somebody must remember to extend is a list that stops being true — and asks every wither on every widget record to set its component to the value it already holds. Verified by swapping two same-typed arguments in Select.placeholder; the test named the method.

Consequences

  • Five widgets cannot be built by the check — Toaster, SplitPane, CheckMark, CheckIndicator, SliderTicks — because their constructors refuse the values it invents. They are reported in the failure message rather than skipped silently, so the number can be seen going the wrong way. Twenty-seven withers across seventeen widgets are covered.
  • A component read through its accessor is not always the component. SplitPaneView.children() computes a divider between two panes rather than returning what it was built with, and throws on a fixture with none. The check reads the field, because the field is the component and the accessor is a method that happens to share its name.
  • The fourth defect is not fixed. Popups left open when the application loses focus to another window hang on screen after the window hides. The mechanism ADR-0144 describes is wired — the launcher watches FocusChanged and dismisses after a settle delay — so this is not a missing feature but a fault inside it, and diagnosing it needs a real window and a real compositor rather than the headless backend. Filed with what is known.
  • These three were found by running the application, and that is now twice. What would have caught them is a test that drives the loop rather than the widget. MenusTest does exactly that through the real launcher and the headless backend, and nothing in §3’s select family does. That is the gap worth closing next, and it is bigger than any of the three bugs it would have caught.

186. A panel that hangs off a field is not a menu

Date: 2026-08-23

Status

Accepted. Fixes two defects ADR-0185 reported fixed and did not fix.

Context

ADR-0185 stopped a suggestion list focusing its first row and called the keyboard-stealing bug closed. It was not. The field still took one character and went dead.

The reason is that there are two focuses and only one of them was addressed. Popup.takesFocus(false) governs the router — which element inside the popup’s own tree holds the keyboard. What actually took the keyboard was the window: a popup opened as PopupKind.MENU gets SDL’s POPUP_MENU flag, which is focusable by definition, and every window manager hands a focusable window the keyboard the moment it appears. The owner window stopped receiving text, so the editor stopped receiving keystrokes.

PopupKind had said so all along: “A menu may take the keyboard; a tooltip must never, or the caret leaves the field the tooltip is describing.” That sentence is about tooltips and describes this exactly.

Separately, a tree in a popup would expand and the new rows would not appear.

Decision

An attached panel is a tooltip-kind window

Host.attachedPopup opens a popup as PopupKind.TOOLTIP — NOT_FOCUSABLE at the platform level — while still measuring, placing, fitting and light-dismissing it like any other popup. Both autocomplete forms use it; every other select opens a menu, which is what it is.

The arrows still reach it, because the owner forwards keys to whatever popup is open (ADR-0104). That forwarding exists because a popup may or may not have platform focus depending on the driver — and it is what makes a deliberately unfocusable panel operable rather than inert.

takesFocus(false) stays. The two are not alternatives: one keeps the platform keyboard on the owner window, the other keeps the router’s focus off a row. A panel that hangs off a field needs both.

A popup measures itself again when its content changes

A popup was measured once, at open, and never again. For a menu that is correct. For the two things that now re-describe themselves — a select multiple’s list and a tree — it was a bug with no workaround: an expanded branch drew its rows into a window still the height of the collapsed one, so they were not there, and no viewport appeared either because the Fit that would have added one had already run.

Popup.content now re-measures and asks for the new size, re-applying the Fit with the measurement — which is what puts a viewport in when content outgrows the screen rather than only when it was already too big. The measuring is the launcher’s, handed to the popup as a callback, because it needs the window’s scale and the Fit the popup was opened with and neither belongs in Popup.

Consequences

  • Two focuses, and the vocabulary did not distinguish them. “The popup takes focus” meant the router’s to me and the window manager’s to SDL, and the fix for one read as a fix for both. Worth remembering the next time a popup misbehaves: ask which focus.
  • The suggestion popup is now unfocusable at the platform level, which should also stop it hanging on screen when the application is deactivated: it can no longer be the window that keeps anyWindowFocused true. That is a prediction from reading the code, not a verified fix — see below.
  • A popup still hangs when the application loses focus to another window, and this ADR does not fix it for menus. ADR-0144’s mechanism is wired and the fault is inside it. What is now known: anyWindowFocused() counts popup windows, so a popup that holds or believes it holds platform focus keeps the whole check true. A menu popup is focusable and is the likely culprit; a tooltip-kind one cannot be. The next step is a real window and a log of FocusChanged per window id, which the headless backend cannot produce.
  • Neither fix has a test. Both live where the toolkit has no coverage at all — the platform’s window flags and a popup resizing itself — and both were found by running the application. This is the third time, and it is the same gap ADR-0185 named: nothing drives §3’s select family through the real loop the way MenusTest drives menus. That harness is now the most valuable thing left undone in this area, above any individual defect.

187. A panel takes the pointer and leaves the keyboard

Date: 2026-08-23

Status

Accepted. Fixes what ADR-0186 got wrong and what it missed.

Context

ADR-0186 stopped a suggestion panel stealing the keyboard by opening it as a TOOLTIP-kind window — NOT_FOCUSABLE at the platform level. It worked, and it made the panel unusable: a value could no longer be picked with the mouse or the arrows.

That is the tooltip flag doing exactly what it says. PopupKind.TOOLTIP is for something “shown, read and never interacted with”, so platforms give it no input at all. Borrowing it bought the focus behaviour and paid with every click.

And a tree in a popup still would not grow when a branch expanded, which ADR-0186 also claimed to fix.

Decision

A third kind, because there were always three things

PopupKind.ATTACHED: POPUP_MENU and NOT_FOCUSABLE. A menu window in every respect the window manager cares about — it takes the pointer, it is placed and shadowed like a menu, it is in no window list — and unfocusable, so the keyboard stays on the field it hangs off.

The two flags were always independent; only the enum forced a choice between them. MENU is “the user is acting on this instead”, TOOLTIP is “the user is only reading this”, and ATTACHED is “the user is acting on this as well as the thing it hangs off” — which is what a combobox’s list is, and which is the case neither of the first two describes.

A popup measures itself after its own tree rebuilds

ADR-0186 put the re-measure in Popup.content, which is the door the opening widget pushes something new through. A tree expanding a branch does not go through that door at all: it is a setState in the popup’s own element tree, which flushes inside Popup.paint and never reaches the widget that opened the popup.

So the measure belongs where the flush is. paint re-measures whenever the tree actually needed a build, which is also what stops it looping — a settled popup measures nothing.

The general shape is worth naming: a popup has two sources of change, one from outside and one from within, and a fix applied to one of them looks complete until the other happens.

flex-wrap is not in the subset, so the stylesheet stops writing it

select.multiple asked for flex-wrap: wrap and got “ignoring unsupported property” once per frame and no wrapping. Yoga has the setter bound and Box has no field for it — the same gap min-width was in before ADR-0181, and the same fix would close it.

Not taken here: this is a defect pass, and adding a layout property to the subset is its own change with its own churn across Box, ComputedStyle and every positional copy in both. The chips shrink instead, and a field with more than it can show shows fewer of them whole. Filed.

Consequences

  • Three focus-shaped mistakes in three ADRs, and each was a real distinction the code already drew and I had not read: the router’s focus versus the window’s, and now the pointer versus the keyboard. PopupKind’s own documentation described all of it before any of these were written.
  • Still no test for any of it. The window flags and the resize both live outside what the headless backend can reach. This is the fourth defect round found by running the application, and the harness MenusTest has for menus — real launcher, headless backend, posted events — remains the thing that would have caught all of them. It is now the single most valuable piece of undone work in this area, and it has been filed three times.
  • The popup still hangs when the application loses focus, unchanged from ADR-0186 and for the reason recorded there. An ATTACHED popup is NOT_FOCUSABLE, so it cannot be what keeps anyWindowFocused true; a MENU one can.

188. A control opens on one signal, and the loop is what proves it

Date: 2026-08-23

Status

Accepted. Builds the real-loop harness for §3’s select family, and fixes the defect it found in its first run.

Context

Six defects reached a running application across ADR-0182 to ADR-0187 while every unit test passed, and three of my fixes for them were wrong. The remedy was filed four times and not built: MenusTest drives menus through the real launcher, the headless backend and posted events, and nothing did that for select, tree or the suggestion panels.

Every one of the six lived in a seam a hand-driven test cannot reach — the application’s rebuild, the platform’s window flags, the pointer, the popup’s own build schedule.

Decision

SelectLoopTest posts events at a window

Four tests, one per defect class that got through: the list opens below the field, the field keeps the keyboard, a value can be picked with the mouse, and the popup grows when a tree branch expands. Nothing in it calls a handler directly — anything assertable that way belongs in SelectTest.

It found a defect on its first run, which is the argument for it.

A control opens on one signal

A click on a combobox opened the list and closed it again in the same gesture.

The press focuses the editor, and focus is what opens an editable control (ADR-0185). Then the click arrives at the field and toggles — reading open from the description that was built before the press, seeing false, and toggling a list that was by then already open. Shut.

So the pointer does nothing on an editable field. One signal opens it, and the signal is focus, because focus is what a click, a Tab and Alt+Down all produce.

The general fault is worth naming because it is the second time this session: a widget’s flags describe the frame that built it, not the moment the event arrives. ADR-0185 recorded the same thing about widget() after reporting upward. open here is the same mistake in a different field — two paths that each reason from a stale flag will contradict each other whenever both fire.

Consequences

  • The harness proves three of the four fixes and cannot prove the fourth. Reverting the popup resize fails the tree test; reverting Popup.content’s role, the placement, or the click path fails others. Reverting the NOT_FOCUSABLE flag fails nothing: the headless backend has no window flags, so a popup there never takes platform focus whatever kind it is. That test covers the router half only, and says so in its own documentation — a test that looks like it guards something it does not is worse than no test.
  • Anchoring is now covered, which was the open question from the last round. The list opens below the field’s painted rectangle, asserted in the window’s own coordinates against the position the backend was actually given. If the reported mis-placement persists on a real compositor it is not the anchor, and this test is what narrows it.
  • This should have been built four rounds ago. The cost of not having it was six defects reaching a user and three wrong fixes; the cost of having it was an afternoon and it found a seventh defect immediately. The lesson is not about select — it is that a widget whose behaviour lives in a seam needs a test that drives the seam, and that “the unit tests pass” is not evidence about a control nobody can use.

189. No popup holds the keyboard

Date: 2026-08-23

Status

Accepted. Fixes popups and tooltips outliving the window they belong to.

Context

A menu, a dropdown or a tooltip left open when the user switched to another application stayed on screen, over somebody else’s window, after the owner had hidden. ADR-0186 narrowed it and could not close it: anyWindowFocused() counts popup windows, so a popup that held platform focus kept the check true and the dismissal never fired.

ATTACHED popups had already been made NOT_FOCUSABLE for an unrelated reason, which is why suggestion panels stopped hanging and menus did not.

Decision

NOT_FOCUSABLE on every popup, of every kind

Which reads like a restriction and is the opposite. A popup was never allowed to rely on having focus. SDL gives a POPUP_MENU window focus on some drivers and not on others, so the owner has forwarded keys to whatever popup is open since ADR-0104, and a menu is operable by arrows either way.

So what varied by driver was never the behaviour — only whether the application still looked focused to itself. And that is precisely what ADR-0144’s check reads to decide a popup has been left behind.

Taking focus off all of them makes that check mean what it says: the application is focused exactly when one of its own real windows is. The dismissal then works for menus, dropdowns and tooltips alike, and works the same way on every driver rather than on the ones that happened not to focus popups.

Consequences

  • One fewer thing that varies by driver. The forwarding path in ADR-0104 existed to tolerate both behaviours; now only one of them happens, and the tolerance is what makes removing the other safe rather than being made redundant by it.

  • The reported popup mis-placement is still not reproduced. SelectLoopTest now asserts the list opens below the field from a window at the origin and from a window moved to (220,160) on a display with a 48px taskbar — the case that hides a coordinate-space mistake, because at the origin screen and window coordinates are identical. Both pass. placeableArea converts the work area into the window’s space correctly, Placement clamps into it correctly, and the position handed to SDL is in the logical points SDL3 wants.

    What is left is below the harness: SDL_CreatePopupWindow’s interpretation of the offset on the reporter’s compositor. The debug line added in 6c0618e prints the anchor the list was placed against; with the popup’s own reported position beside it, the two numbers say whether the fault is before SDL or inside it. I have stopped guessing at this one.

190. A content module brings its own natives, and links against the export list

Date: 2026-08-23

Status

Accepted, as the plan for docs/content-widgets.md. None of the modules it describes is built; what is agreed here is the shape each of them has to take and the one native rule they all share.

Context

docs/content-widgets.md describes eleven optional modules — HTML/markdown, PDF, charts, scientific plotting, code, terminal, vector, media, camera, microphone, and a parked web engine. Until now it was a document the rest of the plan did not reference: docs/ARCHITECTURE.md named charts and nothing else, status.md had no place to say “none of this is built”, and TODO.md had no entry saying what each is waiting on. A design document nothing links to is one that gets re-argued rather than read.

Two things in it are load-bearing enough to need a record of their own.

The licence shape. goldberry-core is Apache-2.0 with notice-only obligations: Blend2D, SDL3, Yoga, HarfBuzz, Inter, JetBrains Mono, Lucide (ADR-0015). An application that depends on it owes a notice file and nothing else. Half the content modules break that if they land in core — libVLC is LGPL and wants dynamic linking with a relink guarantee, OpenMoji is CC BY-SA and wants visible attribution in an about box, PDFium is tens of megabytes of prebuilt binary. Each of those is a cost some applications will happily pay and no application should pay by accident.

The native shape, which is the part content-widgets.md does not settle. libgoldberry is one statically linked library with hidden visibility and an explicit export list — 203 symbols: 69 YG*, 59 SDL_*, 46 bl_*, 25 hb_* and the shim’s own four goldberry_*, with every static upstream excluded from re-export (ADR-0007, ADR-0010). Three of the proposed modules need a native library of their own, and each of them needs to paint: litehtml’s document_container is C++ callbacks that draw text and boxes, ThorVG rasterizes into a buffer, libvterm hands over a cell grid. The obvious build — statically link Blend2D into libgoldberry-html as well — produces two copies of Blend2D in one process, each with its own runtime, allocator and JIT state. A BLContextCore created by the toolkit’s copy and handed to the module’s copy is undefined behaviour, and the failure would be a corrupt frame or a crash, not a link error.

Decision

One module, one artifact, one notice file

Every content module is its own Gradle subproject, its own published artifact, its own module-info, and — where it has native code — its own goldberry-<name>-natives-{platform}-{arch} classifier jars, with its own THIRD-PARTY-NOTICES covering only what it links. Nothing in content-widgets.md becomes a dependency of :core or :widgets. An application opts in per module, and the obligation it takes on is listed in that module’s README before its first line of code exists.

It never statically links Blend2D, HarfBuzz, SDL3 or Yoga a second time. libgoldberry.so is a shared library with a curated C surface; a module’s native library becomes an ordinary consumer of that surface, and the symbols it needs are added to goldberry.symbols like any other binding. The export list stays what it already claims to be: the complete native surface, in one file, reviewed as a whole.

That has a consequence worth stating plainly rather than discovering: the export list is sized for what Java binds, and Java binds the paint calls the toolkit itself makes. The twenty bl_context_* entries have no gradient, no rounded geometry and no bl_context_save — the file says why in its own comment, that there is only ever one clip depth here. §1.5 of content-widgets.md promises litehtml’s linear and radial gradients and its border-radius, and a native document_container needs a nested state stack because CSS has one. So goldberry-html is not a module that adds a dependency — it is a module that first widens the toolkit’s own native surface, and that widening is reviewable before any of litehtml is compiled.

The two upcalls, generalized

content-widgets.md §1.1 gives litehtml exactly two Java upcalls — fetch and anchorClicked — and keeps the thousands of per-page draw calls native. That is the rule for every content module, not a fact about HTML: the hot path does not cross FFM, and everything that touches the network, the filesystem or a policy decision does. libVLC’s frame delivery, PDFium’s page raster and ThorVG’s scene traversal each get the same treatment.

What already departs from the document, and stays departed

  • Charts are not goldberry-charts. The table lists it as a module; ADR-0014 merged it into :widgets before this document was written, on the grounds that five canvas-based widgets with no dependencies of their own do not justify an artifact. §3’s actual argument — no third-party chart engine, borrow the algorithms — is unaffected and is what matters. goldberry-plot may still want its own artifact; it is the bigger vocabulary and the one with colormap data to disclose.
  • Emoji is not goldberry-emoji. ARCHITECTURE.md §6.2 puts OpenMoji in core’s text stack, which means core already carries the CC BY-SA visible- attribution obligation the table wanted quarantined. That is a real disagreement and it is recorded in §17.1 rather than settled here: moving the font out is a change to the text stack’s fallback chain, not a packaging edit.
  • Camera and microphone claim “zero new natives” and are not free. SDL3 is linked in, but no SDL_* audio or camera symbol is on the export list, and none was a tray call either — which is the case M3’s own tray-icon met first and has since fixed (ADR-0191): eleven symbols added, the list at 203. SDL_OpenAudioDevice and SDL_OpenCamera are still not among the 59 SDL entries. “Already in the binary” means the code is there; reaching it is still an export-list entry and a binding apiece, which is now a measured claim rather than a predicted one.

Consequences

  • The publishing matrix grows by an artifact plus four classifier jars per native module. That is the cost ADR-0014 refused to pay for five chart widgets, paid deliberately here because the thing being bought is different: not code separation but a licence boundary and tens of megabytes.
  • goldberry-media is the first module that cannot be statically linked. LGPL relinkability requires libVLC to stay a separate shared object, plus its plugin tree. Every packaging assumption in :natives — one library, one export list, hidden visibility — is a static-linking assumption, so that module needs a loader that sets a plugin path and a native layout nothing else in the toolkit has. It is correctly the last one in the ladder.
  • Golden-image testing survives the split, which is the quiet win. Every engine here rasterizes on the CPU into a buffer the toolkit already knows how to compare, so a full HTML document, a PDF page and a terminal grid are all golden-testable in CI on three OSes with no hardware — and camera and microphone ship synthetic sources precisely so their widgets are too.
  • A module that widens the export list widens it for everyone. There is one libgoldberry and one symbol file; the paint surface goldberry-html needs is then available to any binding, whether or not that was intended. The discipline the file already documents — a symbol nothing binds is dead weight — is what keeps that honest, and it is a review rule, not a mechanism.
  • Nothing here is scheduled. M3 owes tray, client-side decorations, charts and the rest of §4; M4 and M5 are untouched. The ladder in ARCHITECTURE.md §16 now names where each module would attach, which is a different claim from saying when.

191. A tray is a menu somebody else draws

Date: 2026-08-23

Status

Accepted. docs/core-widgets.md §9’s tray-icon, and the first thing M3 owed that begins in goldberry.symbols rather than in a widget.

Context

Backend’s own note said trays were “absent from this cut, not dropped — each needs a consumer before its shape can be decided” (ADR-0019). §9 is that consumer, and the shape it needs turns out to be unlike every other widget in the catalog.

Nothing here is painted by Goldberry. A tray menu is a GTK menu on Linux, an NSMenu on macOS and a Win32 popup on Windows. The shell chooses the font, the row height, the highlight colour and the animation; it opens the menu, tracks the pointer through it and closes it. The toolkit’s cascade, Blend2D, Yoga and PointerRouter reach none of it. So the parity invariant — Java record, KDL node, CSS-styleable — has nothing to attach its third clause to, and a tray-icon in the catalog would be a widget no stylesheet could ever affect.

The other half of the context is the export list. ADR-0190 predicted, of the camera and microphone modules, that “already in the binary” is true of the binary and not of the surface — and named SDL_Tray* as the case M3 would meet first. It did. Before this, 48 of the 192 exported symbols were SDL’s and not one was a tray call.

Decision

The value is not a widget, and says so

TrayIcon is a record in …widgets.shell.tray holding an icon, a tooltip and a Menu; Trays.show(host, tray) is what puts it on the desktop. The same split menu has had since ADR-0106 and toast since ADR-0177, with the argument one step stronger: a toast is at least drawn here.

The menu it holds is an ordinary Menu. Not a parallel description — the same value a menubar holds and Accelerators walks, for ADR-0163’s reason: what is short-lived about a menu is the popup, not the description. A tray menu, which the shell holds for as long as the icon is up, is the longest-lived opening there is. So an author writes one description and can show it in a window, in a context menu, or here.

What the platform cannot draw is dropped loudly

A tray row’s whole vocabulary is a label, an enabled state and a tick. Three things an author may reasonably have written on an Item therefore go nowhere, and each is logged rather than ignored:

  • an icon, which no platform’s tray API takes;
  • an accelerator, which is a key bound to a window and a tray has none — the same command in a menubar still registers one;
  • any widget that is neither item nor separator, because Menu.children takes any widget and a shell has nowhere to put a text.

A tray that quietly ignored half a description would be a menu an author kept editing without effect.

Absence is reported, and no error string is read

Backend.createTray returns Optional, like createPopup. What differs is how emptiness is decided. SDL_CreatePopupWindow’s caller reads the error to tell “this driver has no popups” from “you passed nonsense”, because SDL says not supported for the first. The tray has no such line: the Linux path fails with Could not load AppIndicator libraries, which is an absence wearing the words of a failure, and a session that has removed its notification area fails in a third way. §9 asks for absence to be reported rather than thrown either way, so every null from SDL_CreateTray is empty, logged at debug with SDL’s own words. Every platform’s own guidance says a tray-using application must work without one; the showcase logs tray unavailable on this desktop and carries on.

One upcall stub per row, with its index bound in

SDL_TrayCallback is (void *userdata, SDL_TrayEntry *entry). The obvious use of userdata is an index cast to a pointer — correct, and a lie in the type: a value that is never an address travelling in a void *. Instead each row gets its own stub with its index already bound (MethodHandles.insertArguments), userdata is NULL, and the stubs share one arena that lives exactly as long as the tray. They are released after SDL_DestroyTray, for ADR-0060’s reason: native code must stop being able to call a stub before the memory holding it goes away.

Labels and the icon surface get a call-scoped arena instead. SDL copies a label into its own storage and converts the icon immediately — a HICON, an NSImage, a file in the user’s cache directory — which was checked against the pinned SDL source rather than assumed.

The checkbox belongs to the platform

SDL toggles a checkbox before it calls back, so the handler is told the new state rather than asked to work it out, and SDL_GetTrayEntryChecked is on the export list for exactly that one read. HeadlessTray.choose applies the same order, which is what makes it a test double rather than a second implementation: a test that toggled afterwards would be asserting an order no platform uses.

The menu cannot be changed while it is up

BackendTray has setters for the icon and the tooltip and none for the menu. Its rows are platform objects the shell may have open; replacing one would mean removing and re-inserting entries underneath a user. A tray whose menu changed is closed and opened again, which is what a declarative caller does anyway.

A tray row asks for a frame, because nothing else will

Found by running the showcase, where every row except Quit did nothing. Quit closes a window, which is a platform effect; the rest set a field on a model, and a jar-bound model is swept at the top of a frame (ADR-0155). A tray row is the only input in the toolkit that arrives with no event behind it — it is delivered from inside SDL_PumpEvents by way of SDL_UpdateTrays, and no pointer moved, no key arrived and nothing asked for a frame. So the sweep never ran, and a handler that changed the theme changed nothing anybody could see.

Host.tray therefore wraps every row with the window’s repaint (spec.andThen(this::repaint)), which is what every other input path gets for free. Submenus and separators are left alone: no platform calls back for either.

The general shape is worth keeping, because it is the third time it has come up and the first time it was invisible: a source of input the frame loop cannot see has to say so itself. The event watch (ADR-0060) and the timer (ADR-0105) both had to; a tray is the one that fails silently, because there is no missing frame to notice — only a menu that does nothing.

Consequences

  • The export list grew from 192 symbols to 203, and 48 SDL entries to 59: nine tray calls plus SDL_CreateSurfaceFrom and SDL_DestroySurface, which are how a painted BGRA buffer becomes an icon. SDL_UpdateTrays is deliberately absent — SDL calls it from its own event loop, and this toolkit pumps events. The five SDL_TRAYENTRY_* values went into the constant probe with everything else, which is what catches DISABLED being 0x80000000 and therefore a negative int.
  • HeadlessTray is the only place a tray menu can be observed at all. There is no golden image of a GTK popup and nothing to hit-test, so every rule about choosing a row — the toggle order, a disabled row refusing, a submenu reached by path — is asserted against the headless backend, and the SDL backend’s job is to be the same translation twice.
  • The libayatana-appindicator is deprecated line on Linux is not ours and cannot be silenced from here. It is printed by the distribution’s own library as SDL loads it, and SDL’s loader tries libayatana-appindicator3.so.1 and libappindicator3.so.1 and nothing else — the -glib successor the warning names is not on its list. Fixing it is a change to SDL, on a pinned commit.
  • Two platforms are unverified. This ran for real on Linux/X11 under libayatana-appindicator, in the test suite and in the showcase. The Windows and macOS paths are SDL’s, are compiled, and have not been looked at by anybody. That is in TODO.md rather than implied by silence.
  • A checkbox’s tick can disagree with the application. The shell has already toggled it by the time the handler runs, and an Item’s command takes no argument, so a handler that declines leaves the platform showing a tick the application does not believe in. Rebuilding the tray is the way to say so, which is the same answer the missing menu setter gives.
  • The window’s menubar and the tray now share a description and not a behaviour. The same Item registers an accelerator in one and has it dropped with a warning in the other. That is the platform’s limit, and it is the first place in the catalog where one value means two different things depending on who draws it.

192. A row of chips wraps, and the chevron does not

Date: 2026-08-23

Status

Accepted. Adds flex-wrap to docs/ARCHITECTURE.md §8’s subset, which ADR-0187 filed and select multiple= had been living without.

Context

§8 has listed flex-wrap from the beginning and nothing had needed it: every row in the catalog was a row that fitted. select multiple= was the first that did not (ADR-0182) — a field holding five chosen values is wider than the field — and what happened instead was that the chips shrank, because Yoga’s default is one line however much it overflows and shrinking is what a flex item does when there is nowhere else to go. So a field with more values than it could show displayed all of them squeezed and none of them whole.

The gap was in exactly one place. Yoga has YGNodeStyleSetFlexWrap bound and YogaNode.setFlexWrap wraps it; Wrap has existed in natives.yoga.style since the enums were written. Nothing above the native boundary could say it: Box had no component, ComputedStyle had no component, and the parser had no case — so flex-wrap: wrap in controls.css logged “ignoring unsupported property” once per frame and did nothing. controls.css said so in a comment, which is the honest form of a gap and not a substitute for closing it.

Decision

One component each, and the same shape min-width took

Wrap wrap on Box and on ComputedStyle, beside alignItems, applied to the Yoga node in RenderObject.apply under the same “only when it changed” guard as every other style. ADR-0181 grouped four properties into Limits because they were one question asked four ways; this is one property and gets one component, like overflow and position before it.

nowrap is spelled with no hyphen, and that needs a line of code

The generic keyword parser upper-cases a CSS ident and turns - into _, which maps wrap-reverse onto WRAP_REVERSE and wrap onto WRAP correctly. It maps nowrap onto NOWRAP, and YGWrap’s constant is NoWrap — two words. So flex-wrap gets a parser of its own that special-cases the one keyword and defers for the other two, which is the shape overflow already has for auto.

The wrapping is on the chips, not on the field

This is the part that had to be seen rather than reasoned about. Putting flex-wrap: wrap on select.multiple is the obvious move and produces a worse picture than the shrinking it fixes: a field is a row of the chips and the chevron, so the row wraps by dropping the chevron onto a second line underneath them, where it reads as a stray mark in the bottom-left corner. The golden image is what said so; nothing in the CSS looked wrong.

So the chips get a box of their own — select-chips, a part in ADR-0065’s sense: a CSS type selector, not a widget in the catalog, because it has no meaning outside its parent. That box wraps and grows; the field stays the one-line row it always was. The flex-grow also takes over the job the field used to give a spacer — a box that grows is what pushes the chevron to the far edge, and there is now one that does.

Consequences

  • 48 positional reconstructions gained an argument, 26 in Box and 22 in ComputedStyle, which is the churn ADR-0181 described and the reason it grouped four properties into one value. Two of them were inserted in the wrong slot by the mechanical pass and were caught by the compiler, because Wrap is a type no other component has — the same protection RecordWitherTest provides for components that are same-typed, and the reason its fixture now holds WRAP_REVERSE rather than a default.
  • select multiple= shows whole chips on as many lines as it needs. The control grows, which height: auto; min-height: 32px had been written for since ADR-0182 without anything able to make it happen.
  • A second golden covers it. The existing one has three chips, which fit — and its javadoc claimed they “wrap onto a second row”, which was never true and is the kind of sentence a picture is supposed to prevent. select-multiple-wraps has five in the same 220px field, so the corpus now holds the case the property exists for rather than a case that never exercised it.
  • flex-wrap is available to every widget and stylesheet, and nothing else uses it yet. wrap-reverse parses and is untested beyond the parser: no rule in the canon asks for it, and inventing a golden for a keyword nobody writes would be covering the toolkit rather than the design system.
  • align-content is still absent, which is what decides how wrapped lines share the cross axis. It does not matter to a chip row, whose height is its content; it would matter to a wrapped row in a box with a fixed height, where Yoga’s default spreads the lines. Filed rather than added, on the rule that has held all through §8: a property arrives when something in the catalog needs it.

193. A canvas is a second clip depth

Date: 2026-08-23

Status

Accepted. The first step of canvas — docs/core-widgets.md §1’s immediate-mode painting surface, which charts, meters and every application’s own drawing sit on.

Context

M3 owes charts, and charts are not where charts start. content-widgets.md §3 builds them on the canvas primitive so they inherit the theme, the text stack, hit testing and the golden corpus — and canvas is not built. It is already blocking something shipped: statistic’s sparkline is specified and absent for want of it (ADR-0164). So the order is canvas, then the chart substrate, then the five widgets.

A canvas is unlike every other widget in one respect that turns out to decide its implementation. Every painter inside the toolkit knows what it set on the context and unsets it: paintOne clips to a box, draws, and restores; the damage path clips to the changed region and restores. An application’s onPaint is not one of those. It runs inside whatever clip and transform the tree already established — a canvas inside a scroll is inside the viewport’s clip — and it may set a clip, a transform, a style or a global alpha of its own and leave any of them behind.

Frame.resetClip() cannot undo that. It maps to bl_context_restore_clipping, which goes back to the whole surface rather than to the region in force before. The export list said so in its own comment, and said why it was fine:

restore_clipping rather than a save/restore pair, because there is only ever one clip depth here and bl_context_save is still not exported.

That was true of the frame path and stops being true the moment a widget hands the context to somebody else. A canvas in a scroll viewport that reset the clip would paint over the viewport’s edge.

Decision

The export list grows a state stack

bl_context_save and bl_context_restore, taking the list from 203 symbols to 205, with Frame.save() and Frame.restore() over them. This is the second widening of the paint surface and the second time ADR-0190’s observation has paid: the export list is sized for what the toolkit’s own painter needs, and anything that hands the context to code the toolkit did not write needs more of it. goldberry-html’s native document_container will need the same stack for the same reason, one nesting level deeper.

The cookie argument is NULL. It is Blend2D’s guard against a mismatched pair, and the only pair here is the two lines around one call.

save/restore is for handing the frame away, and says so

resetClip stays and is still what the frame path uses — one depth is all it has and it is the cheaper call. The distinction is written on both methods rather than left as a performance note, because the failure it prevents is silent: a canvas that reset the clip paints correctly in every test that does not put it in a scroll view.

What comes next, so this record is not read as the whole of it

canvas proper is a content slot on Box — a painter callback beside text, icon and mark — invoked by paintOne inside a save/restore pair, with the frame translated so the painter’s origin is the box’s content corner and clipped to it. The widget is canvas in …widgets.core.canvas, with invalidate() asking for a frame the way every other state change does. None of that is built yet; this record covers the layer underneath it, which is the part that needed a symbol.

Consequences

  • 205 exported symbols, and the comment in goldberry.symbols that justified their absence is now the comment explaining why both exist.
  • A canvas can be nested. A canvas inside a scroll inside a dialog restores to the dialog’s clip and not to the window, which is what makes the primitive composable rather than a top-level-only escape hatch.
  • The cost is one save/restore pair per canvas per frame, and none for any box that is not one. A window with no canvas in it makes no extra call.
  • An application’s painter can still leave the context wrong for itself. The pair protects the toolkit from the painter, not the painter from itself: a canvas that sets a clip and draws outside it draws nothing, and that is its own bug. What cannot happen any more is that bug escaping into the rest of the window.
  • Nothing is drawn yet. This is a symbol, a wrapper and a test; the widget and the charts on top of it are M3’s remaining work, in that order.

194. A series colour is derived from Nord, not taken from it

Date: 2026-08-23

Status

Accepted. The categorical palette for docs/charts.md’s chart widgets, measured rather than chosen.

Context

content-widgets.md §3 says charts take “categorical series colors from aurora + frost hues” and “sequential/diverging ramps interpolated in OKLCH”. Read quickly that says use the Nord hues. Measured, it cannot mean that.

Nord is a UI palette: low chroma, high lightness, designed to sit quietly behind text. Series colours do the opposite job — they carry identity, at small sizes, next to each other. Checked against the six standard categorical checks, Nord’s nine hues used literally fail five:

CheckResult
Lightness bandFAIL — nord13 at OKLCH L 0.855, nord8 at 0.775
Chroma floorFAIL — six of eight below C 0.10, so they read as gray
CVD separationWARN — nord14↔nord13 ΔE 6.5 under protanopia
Normal-vision floorFAIL — nord9↔nord8 ΔE 8.5
Contrast vs surfaceWARN — six of eight below 3:1 on white

One measurement explains most of it. Nord’s frost family spans 23° of hue across four members, and nord9/nord10 are 5° apart — the same hue at two lightnesses. That is an elevation ramp, and it is why the toolkit uses it as one. It is not two identities.

This is the same finding ADR-0175 made about the semantic hues, where the theme’s own contrast claim was untrue in five of eight pairs. A palette’s stated purpose is not evidence that it serves a different one.

Decision

Eight slots, re-stepped from Nord’s hue angles

Each slot keeps a Nord hue’s angle and takes a chart-specific lightness and chroma: OKLCH L 0.62 light, L 0.66 dark, C 0.14 clipped into sRGB. So a series still reads as the Nord hue it came from, and also clears the floor that makes it legible as identity.

SlotHueLightDark
1nord14 green#679732#73a340
2nord15 purple#b663aa#c46fb7
3nord13 yellow#aa7e05#b88a07
4nord10 blue#4488d8#5094e5
5nord11 red#cc5e6a#da6a76
6nord8 cyan#0796b2#02a3c1
7nord12 orange#cb6443#d9704f
8nord7 teal#0d9999#06a7a7

All six checks pass in both modes: worst adjacent CVD ΔE 12.4 (protanopia), worst normal-vision ΔE 21.9, every slot ≥ 3:1 on its surface.

Dark is its own steps, not a flip. L 0.62 on --gb-surface #3b4252 measures 2.57–2.9:1 — below the 3:1 mark floor — so the dark set is stepped at 0.66, which is inside the dark lightness band and clears it. Two numbers rather than one transformation, for the reason ADR-0087 gives about the semantic hues: contrast is a property of a pair, and the pair is different in each theme.

The order is searched, not chosen

Adjacent slots are what touch in a stacked bar, a grouped bar and a multi-line chart, so the sequence is the CVD-safety mechanism. All 40 320 orderings were scored against the validator in both modes and the best kept. This matters concretely: ordering by Nord’s own numbering puts nord12 orange beside nord14 green, which is ΔE 0.8 under deuteranopia — two series a reader cannot tell apart at all.

Slots are assigned in order and never cycled

A ninth series is not a generated ninth hue: under CVD it is indistinguishable from one of the eight, and generating it would break the property the search just established. Nine series folds the tail into “Other” or becomes small multiples.

nord9 gets no slot

Four frost hues yield two usable identities. Leaving nord9 out is the honest form of that, rather than shipping eight slots of which two collide.

Consequences

  • A series red and a danger red are different reds, on purpose. --gb-danger means “this is bad”; slot 5 means “this is series 5”. A threshold band and a series must not be the same colour, or the chart says something it does not mean — which is design-system.md §1.2’s rule about aurora hues carrying semantic meaning, read the other way round.
  • The palette is checkable in CI, like the 3:1 sweep ADR-0175 added. The values are a table, the checks are arithmetic, and a slot edited by hand fails the same way a contrast regression does.
  • content-widgets.md §3’s sentence is honoured rather than contradicted. It said derived, and this is the derivation; what it did not say is that the literal hues fail, which is why this record exists.
  • Eight is the ceiling and it is a real one. The dashboard-grade scope (§3’s “deliberately small”) and the palette’s ceiling agree, which is a convenient accident and not an argument: past eight the answer is a different form, not more colours.
  • Nothing renders yet. This is a table and a set of measurements; the widgets that use it are M3’s remaining work.

195. A painter reads the theme through a custom property

Date: 2026-08-23

Status

Accepted. How a chart gets its eight series colours, and the first thing on Paints.Context whose answer is per node rather than per frame.

Context

ADR-0194 fixed what the eight series colours are. This is about where they live, and the obvious answer — a static final int[] in the widgets module — is wrong for a reason that only shows up later.

A chart cannot express its colours as CSS properties. Every other widget in the catalog is coloured by the cascade: a node has a color and a background, and a stylesheet reaches them by selector. A chart needs eight colours on one node, and there is no way to say “the fourth series” in a rule. Nor can the parts mechanism (ADR-0065) help: a part is a child node with a CSS type, and a canvas has no child nodes at all — its content is a painter, not a tree.

So a chart either reads its palette from Java, or the toolkit grows a way for a widget to ask the cascade a question that is not a property.

The cost of the Java table is not that it is ugly. It is that the palette stops being the theme’s. design-system.md’s whole claim is that a theme owns colour; a table in :widgets means a Nord-light chart and a Nord-dark chart are the same eight colours, an application cannot recolour one chart’s first series, and a third theme would have to be a code change.

Decision

--gb-chart-1…8 in the theme files, read through Paints.Context#color

The eight values live in nord-light.css and nord-dark.css beside the semantic hues, and Context.color(name, fallback) resolves one against the node being rendered — through StyleResolver.customPropertiesFor, which is the same mechanism var() already uses and which is cached by element identity (ADR-0152).

That inheritance is the whole point: #revenue { --gb-chart-1: #b48ead } recolours one chart’s first series and nothing else, because custom properties cascade and inherit like any other. A table cannot do that at any price.

It answers colours, not tokens

color(String, int) and not custom(String) → List<Token>. Every other custom property in the toolkit is consumed by a declaration the cascade already resolves; handing a widget raw tokens would invite it to reimplement the value parsers, and the second implementation of a colour parser is where rgb(0 0 0 / 50%) starts meaning two things.

The element is a field, like the frame’s clock

Paints.Context is one object per renderer, shared by every node — which is deliberate, and is why nowMillis is a field read once per frame rather than a call to the clock (two spinners in one window would otherwise be on their own ticks). This is the first question on it whose answer is per node, so the renderer sets currentElement immediately before render and clears it in a finally afterwards.

The clearing is not tidiness. A canvas painter closes over the context and runs later, during the paint — so a context that still held an element would let a painter read a stale node’s tokens, silently, in a frame where the tree had changed underneath it. Cleared, that read returns the fallback, which is a wrong colour rather than a wrong colour that used to be right.

A missing token is a colour, not an exception

SeriesPalette falls back to the derived dark steps. A chart rendered against no theme at all — a test, a bare tree, an application that forgot the stylesheet — draws eight distinguishable series rather than eight black lines or a stack trace.

A ninth series repeats the eighth, visibly

SeriesPalette.of clamps rather than cycling. Cycling would make series 9 and series 1 the same colour and look intentional; clamping makes 8 and 9 the same colour, which is a chart that needs folding into “Other” or faceting into small multiples, and it should look like one. Generating a ninth hue is the option that is actually forbidden: under CVD it is indistinguishable from one of the eight, and it would break the property the 40 320-permutation search established.

Consequences

  • A theme owns the series palette. A third theme is a stylesheet, not a code change, and charts.md §2’s table is documentation of the default rather than the definition of it.
  • Paints.Context has grown a per-node method, which its own doc said it was an interface in order to allow. The pattern is now established for the next such question, and the currentElement/finally pair is the part to copy.
  • A test fixture that implements Context by hand answers the fallback. TestFont.context() has no element and no cascade behind it — a test calling render directly is not styling a tree — so a test that wants the theme’s values drives a WidgetRenderer, which is the honest distinction and is documented on the fixture.
  • The lookup is a map read per slot per frame, on a cascade the resolver already caches by element identity. Eight slots is eight map reads; if a chart with many series ever measures, the answer is to read the palette once per render rather than to cache it here.
  • Nothing else uses it yet. sparkline is one series and takes color; this is built for line-chart, bar-chart, area-chart and donut-chart, which are the widgets that have more than one of anything.

196. A masonry is a layout that reads last frame

Date: 2026-08-23

Status

Accepted, and outside core-widgets.md. §5’s containers are panel, card, group-box, tabs, split-pane, accordion, collapse, carousel and skeleton, and the group was complete without this one. Recorded here and in ARCHITECTURE.md §17.1 rather than slipped in as though the canon had asked for it.

Context

The Charts screen is a wall of cards, and a wall of cards is where the catalog had nothing to offer. A donut-chart is square, a statistic is three lines tall, a line-chart is whatever height it was given. In a row of columns built by hand, whichever column got the tall cards hangs off the bottom; in equal rows, every card is as tall as the tallest beside it and the short ones sit in acres of empty surface. Both read as a mistake rather than as variety.

CSS has column-count and, recently, real masonry. Goldberry has neither: §8’s subset has no columns, and Yoga is a flexbox engine — flexbox cannot do masonry, which is precisely why the CSS working group spent years on a separate specification for it.

Decision

It reads the frame before

Nothing can tell a widget how tall a child will be before that child is laid out. So masonry does not try: every card reports what it came out as through Measured, the state banks it, and the next frame puts each card under whichever column is currently shortest. The first frame is round-robin and the second is a masonry — one frame of settling, at 60 Hz, that nobody sees.

Why that is allowed here and not in general

Measured’s third rule is that what it triggers must not change what it reports: a widget that resized itself from its own measurement would be told a new size, resize, and never settle. The rule is why the mechanism has had exactly one implementation (a scrollbar, which is absolutely positioned and so cannot affect what it measures).

A masonry passes it, and the argument is one sentence: the columns are equal width, so a card’s height does not depend on which column it is in. The number being reported is stable under the thing it causes. That is also why columns is a count and not a list of widths — unequal columns would make this a loop rather than a layout, and there would be no way to tell from the outside.

There is a test for the property rather than a comment claiming it: four frames, and the assignment must be identical after the second.

The column width is written by the widget

1/n of the row, where n is a number no selector can count — which is ADR-0099’s situation exactly and takes its answer: restyle writes the inline value the cascade cannot express. flex-grow: 1 alone sizes a column to its content, which would break the equal-width property above; flex-basis: 0 would have said it in CSS and is the one §8 property still unimplemented.

Ties go to the emptier column

Before anything is measured every total is zero, so a plain “strictly shorter” comparison never fires and the whole first frame stacks into column one. The tiebreak — equal totals, fewer cards — is what makes an unmeasured frame fill across. It was found by the test that asserts the first frame is round-robin, which existed because the two-frame behaviour is the entire widget and had to be pinned at both ends.

Reading order is down each column

Which is what masonry means, and is its one real cost: the third card is not necessarily beside the second. Where that matters — a form, a ranked list — the answer is a column and not this. Said on the class so nobody has to discover it from a screenshot.

Consequences

  • The catalog has a widget the design documents do not. That is a real divergence and is recorded in §17.1 with the others rather than being resolved by editing the canon, which is not mine to edit.
  • Measured has a second implementation, and it is the first that is not absolutely positioned. The rule it has to satisfy is now stated as an argument about equal widths rather than as “the one implementation obeys it by construction”.
  • A card keeps its element when it moves between columns, because a cell is keyed by position. A masonry that reset every card on its second frame — losing a chart’s animation, a scroll offset, a caret — would be worse than no masonry.
  • It is one frame behind on resize too. A window drag re-measures every card, so the wall re-balances a frame after the width changes. At 60 Hz that reads as the layout following the drag; at 5 fps it would read as lag, and the honest fix there is fewer cards rather than a cleverer layout.
  • The golden harness had to grow to photograph it, and that closed something else — see the note in GalleryGoldenTest: the gallery images never fed hit-test regions back between their two frames, so every self-measuring widget saw a first-frame answer for ever. text-area wrapped as though it were narrow in the Forms image, and TODO.md said so. Masonry made the gap concrete enough to close.

197. A painter’s transform composes onto its ancestors’

Date: 2026-08-24

Status

Accepted. Corrects the second half of ADR-0193, which got the clip right and was silent about the matrix.

Context

Scroll the Charts screen and the charts do not move. The cards slide, the headings slide, the axis labels slide — and the five plots stay exactly where they were laid out, clipped by a window travelling over them.

Everything about that is one line. paintCanvas moves the painter’s origin to the box’s content corner, which is what makes a painter draw from (0, 0):

frame.transform(1, 0, 0, 1, x + left, y + top);

Frame.transform assigns. It is bl_context_apply_transform_op(ctx, BL_TRANSFORM_OP_ASSIGN, m) — six numbers replacing whatever the context was carrying — and ADR-0068 chose that deliberately: Blend2D offers no transform stack the toolkit wants to depend on, the accumulated matrix is composed in Java as the walk descends, and each box assigns the answer. RenderTree.Painting.current exists precisely so that a run of untransformed boxes costs no native call at all.

Every other thing paintOne draws is in the context’s current user space, so the ambient matrix applies to all of them and none of them has to know it exists. A canvas is the only content that sets a matrix of its own — and by assigning, it discarded its ancestors’.

A scroll moves its content with a translate (ScrollContent.restyle), for §1.7’s reason: movement stays off layout properties. So “a canvas under a transform” is not an exotic case. It is every chart in a scrolling panel, which is every chart the showcase has.

The clip in the same method was already right, and for a reason worth writing down beside the bug: clipTo lands in the context’s current user space, which is the space the box’s own rectangle is written in, and Blend2D intersects rather than replaces. That is why the symptom was a chart standing still inside a moving window rather than a chart that vanished — the two halves of the same method disagreed about which space they were in.

Neither the unit tests nor the goldens could see it. Every canvas test paints at the root, and a golden is captured at scroll offset zero — where ScrollContent skips the transform entirely, because an unscrolled viewport should put nothing on the context.

Decision

A painter is handed the matrix the frame already carries, and composes onto it. BoxPainter.paintOne takes an ambient Affine — Affine.IDENTITY for the overwhelming majority of boxes, and for the four-argument overload every widget in the catalog calls — and the canvas branch spells its own translation as

var painting = Affine.translate(x + left, y + top).then(ambient);

The three callers that know the accumulated matrix pass it: RenderTree.paint and RenderTree.paintIntoLayer, which have it as the transform they just assigned, and BoxPainter.paintPlaced, which has it on the Placed.

Alternatives considered

  • Make Frame.transform compose. It is the obvious reading of the name and it would fix this at the source. It also reverses ADR-0068 for every caller: the walk composes the matrix in Java because the painter assigns, so a composing transform would double-apply every box’s matrix and every call site would need a reset before it. The one method that wants composition is the one handing the context to somebody else.
  • A Frame.translate(dx, dy) over BL_TRANSFORM_OP_TRANSLATE. Blend2D will compose a translation onto the current matrix natively, and the op is already bound. But the frame would then have state the toolkit cannot read back — the painter path tracks current in Java and would no longer know what the context holds — and the scale pre-multiply in BlendContext.transform exists because the caller’s matrix is assigned in logical pixels. Two mechanisms for one matrix is how the walk and the context start disagreeing.
  • Undo the canvas’s translation in the painter’s coordinates instead — pass the painter its box origin and let it draw at (x, y). That gives up the primitive’s first guarantee (ADR-0193: a painter draws from its own origin) to fix an implementation detail, and hands every application arithmetic it would get wrong exactly where the toolkit just did.

Consequences

  • A chart scrolls. So does a canvas in a carousel, in a split-pane being dragged, inside anything animating a transform, and inside a promoted layer — which composites at identity, so it was already right and stays right.
  • paintOne has a five-argument form, and the four-argument one it had is kept and documented as “a box drawn where it was laid out”. Nothing outside core changes: the eight widgets that call paintOne directly paint into their own untransformed frames.
  • The rule generalizes to the next caller. goldberry-html’s native document_container (ADR-0190) draws through these same exported symbols one nesting level deeper, and will set transforms of its own. Whatever hands it the context owes it the same composition.
  • It is asserted in pixels. CanvasPaintTest puts a canvas under a translate and reads the moved rectangle, which is the assertion the four existing guarantees were missing — each of them paints at the root, where identity hides this entirely.
  • A non-translating ambient matrix is still only as good as clipTo. A canvas inside a rotated subtree composes correctly and is clipped by an axis-aligned rectangle, because that is what a Blend2D clip is. No widget in the catalog rotates a scroll viewport; when one does, it is a clip question and not a transform one.

198. A chart’s readout is painted, and its legend is a control

Date: 2026-08-24

Status

Accepted. The first half of charts.md §3.1’s interaction layer: the crosshair, the readout, and clicking a legend entry to isolate a series.

Context

The five charts of §11 draw correctly and do nothing when a pointer arrives. charts.md §3.1 lists what they owe — a tooltip, a crosshair, thresholds, log scales, time axes and legend isolation — and calls the last of those “the one interaction Grafana users reach for first”. Two decisions had to be made before any of it could be written, and both of them are about where things live rather than about what they look like.

A chart is a value, and a hovered point is not

A widget is an immutable description rebuilt every frame ([ADR-0004]) and the painting is a pure function of it. Which point the pointer is over cannot live in that: it survives rebuilds, so it belongs to an element, which means a [Widget.Stateful] somewhere above the drawing. Which series is isolated is worse — the click lands on the legend and changes what the plot draws, and those two are siblings, so nothing below the chart can hold it.

The plot’s geometry is only known in the painter

Where a point is drawn depends on the size and on the shaped y labels: the gutter is measured from them, so an axis reading 1,000,000 starts further right than one reading 5. Neither is available when a pointer event is handled — an event carries a rectangle and no text stack — and the arithmetic must not be written twice, or a crosshair lands a few pixels off the line it belongs to at one window size and nowhere else.

And a tooltip is text, which the legend argued should be widgets

ChartLegend is real nodes rather than something the painter draws, and the reasons were good: a stylesheet reaches them, the shaping cache serves them, and the entries wrap when the chart is narrow. The same argument points at making the hover readout a node too — and it stops working when you follow it through.

Decision

Four things, and each one is the answer to one of those.

  1. ChartPlot becomes stateful and builds ChartSurface. The state holds the hovered index; the surface is the chart-plot part — the canvas, the painter, and the node that implements Handles. The shape is SplitPane’s: a stateful widget above, a leaf below that hears the pointer and reports upward ([ADR-0063]).
  2. The three axis charts become stateful too, through one ChartSpec, ChartState and ChartView. The state holds the isolated series and reaches both halves; the view is the chart’s own box, with the chart’s CSS type, id and classes — so the box tree, every stylesheet rule and every golden image are unchanged, and all the widgets bought was somewhere to keep an integer. DonutChart is not one of them: isolating one slice of a part-to-whole chart leaves a chart that no longer shows a whole.
  3. The geometry is lifted into PlotGeometry, and the painter leaves it in PaintedGeometry for the pointer to read. One arithmetic, used forwards to draw a crosshair and backwards to resolve a pointer, with a round-trip test over every point count from 1 to 40. Resolving against the painted frame is [ADR-0054]’s rule one level down: the toolkit already routes a pointer against the frame the user was looking at, and a chart deciding which point they pointed at owes the same answer.
  4. The readout is painted, not built. Its text is shaped in render — where the hovered index is known, so it is one point’s worth of strings and the cache serves the repeats — and placed by the painter, which is the only thing that knows where the crosshair is.

The readout takes hud’s tokens — --gb-hud-bg, --gb-hud-border, --gb-hud-text — read through Paints.Context#color, which is ADR-0195’s mechanism doing exactly what it was built for. A floating overlay over content the reader is looking through it at is a HUD, so a chart inventing a fourth surface token would be a chart the theme cannot restyle with the rest.

Alternatives considered

  • The readout as widgets, like the legend. It would need absolute positioning inside the plot, and the position depends on the gutter — which children() cannot know, because it has no context and therefore no shaped labels. So it would read a banked geometry one frame late, needing Measured and a rule about what happens on a resize, to produce a node that must not affect layout and cannot be laid out. The legend’s argument is about a static part: it wraps, it is selected by a stylesheet, and it is in the same place every frame. A readout is none of those. Painted is not the cheap option here, it is the correct one — and the cost is real and stated below.
  • The toolkit’s tooltip widget. It is a hover-delay popup for a control (ADR-0181): one string, a timer, a window. A chart readout follows the pointer continuously, shows a row per series, and must never leave the plot. Opening a popup window per pointer move is not a smaller version of that.
  • Isolation as a set of hidden series rather than one isolated index. Unhiding then requires remembering what you hid, and a chart with three of eight series showing has a legend that no longer says what the picture is. One click shows one series; the same click again puts them all back.
  • Filtering the series list when one is isolated. The index is the colour (ADR-0194), so an isolated fourth series would be redrawn in the first slot’s hue and its own swatch would then disagree with it. The list stays whole and the surface skips what it is not showing.
  • A chart-body node wrapping the plot and the legend, to hold the state without making the charts stateful. One node, one new CSS type, and every rule and golden that names line-chart > … rewritten — for the same integer.

Consequences

  • A crosshair, markers and a readout on line-chart and area-chart; a band highlight on bar-chart. A hairline down the middle of a group of bars points at the gap between two of them, so a bar chart highlights the band it owns instead. A stacked band gets no marker: a disc on the top of a stack marks the running total rather than the series.
  • The readout cannot be styled by CSS. Its colours are the theme’s and its padding, radius and layout are the painter’s. That is the cost of the decision above, and it is written on the class. If an application needs to restyle it, the answer is a token — or canvas, which exists for exactly this.
  • Isolating rescales the axis, because a series that was a flat line at the bottom of a chart scaled to a bigger one has nothing to read, and re-labelling is what turns it back into a chart.
  • The interaction is asserted through the real router. ChartHoverTest and LegendIsolationTest render, lay out, paint — the step a hover cannot work without — and then dispatch. They compare pictures to pictures rather than coordinates, because the hovered index is state and deliberately unreadable from outside; three goldens say what it looks like.
  • RoundRect is public. A canvas painter needs a rounded rectangle, and the alternative was a second derivation of ADR-0064’s four cubics in :widgets. Nothing new crosses the native boundary.
  • What is still owed, and it is most of §3.1’s list: thresholds, log axes, java.time axes, null handling, interpolation, soft bounds, gradient fills, empty and error states, a shared CrosshairGroup across charts, hover on donut-chart, and §3.5’s keyboard operation — arrow keys walking the crosshair, which this makes possible by giving the plot somewhere to keep the index but does not implement.

199. A chart answers the keyboard, and a step is relative

Date: 2026-08-24

Status

Accepted. The second half of charts.md §3.1’s hover story — the donut — and the whole of §3.5’s first item.

Context

ADR-0198 gave the three axis charts a crosshair and a readout and left two holes in its own row of the parity table.

The donut had no hover at all. §3.1 asks for “hover on bar/donut” and only the bar arrived. A ring is not an axis: there is no crosshair to draw, no gutter to measure, and nowhere along an axis to put a readout — but there is a reserved empty circle in the middle of it, which is the one region of the chart that cannot cover the data and cannot be clipped by the box.

Nothing could be read without a pointer. §3.5 is unusually pointed about it:

Keyboard operation of the chart itself. Arrow keys walk the crosshair point-by-point, Home/End jump to the ends, Tab moves between series. A browser dashboard is a pointer surface; a desktop application is not, and §2.2 requires everything to be reachable. Grafana is weak here and it is not a model to copy.

And writing that surfaced a defect that has nothing to do with charts. The crosshair index lives on the state and is handed down into the widget that draws it; a key handler on that widget computing “one to the right” reads the index from the description the last frame was built from. Two arrow presses between two frames — which is what a key repeat produces — both step from the position before either of them, and the crosshair moves once. It is the same trap the select family hit and the same sentence applies: after reporting upward, a widget knows nothing new until it is rebuilt, and reading its own fields there is reading the past.

Decision

A step is relative, and only the state may apply it. The plot reports onWalk(±1) and the state adds it to the field it owns; absolute positions — where the pointer is, which end Home and End mean — stay absolute, because they do not depend on where the crosshair already was. The callbacks answer whether anything changed, so a widget never has to consult its own stale copy to decide whether to consume a key.

A plot is focusable and owns Left, Right, Home, End and Escape.

  • Left/Right walk. On an axis they clamp: a line has two ends, and a crosshair that jumped back to Monday after Sunday would be a chart pretending its axis is a circle. On a ring they wrap, because a ring has no ends and stopping somewhere on it would be an edge the picture does not have.
  • Escape lets go, and is consumed only if it cleared something — so it still closes the dialog the chart is sitting in.
  • Up and Down are left alone. A chart is very often inside a scroll, and a focused widget that consumed the vertical arrows would swallow the keys that move the page. Two arrows reach every point.
  • Tab is not one of them, which is a refusal of §3.5’s own sentence: Tab is the focus traversal and a composite is one Tab stop with roving arrow keys inside it (ADR-0073). Recorded in ARCHITECTURE.md §17.1.
  • A plot with no data is not a Tab stop. There is nothing in it to walk, and a dashboard of empty placeholders should not cost a tab press each.

A donut writes in its hole. The hovered slice’s name and share, centred; the other slices fade to 0.3 so the ring says which one the hole is talking about. It shows the share rather than the value, because a part-to-whole chart is about the proportion and a reader who wanted the raw number wanted a bar chart. Only what fits is drawn: a hole is a circle and text is a rectangle, so a slice called “Uncategorised traffic” gets its share and no name.

The ring needs no banked geometry. DonutGeometry follows from the box alone, so — unlike PlotGeometry, whose gutter is measured from shaped labels and has to be left behind for the pointer (ADR-0054, PaintedGeometry) — it is computed fresh on both sides. The gaps between slices belong to a slice: the painter trims a sliver off each arc so they do not touch, and a hit test that respected those slivers would put a ring of two-pixel dead wedges through the chart.

Alternatives considered

  • Letting the widget compute the step from its own index. This is what was written first, and the test that pressed an arrow twice between two frames caught it. It is not a small bug: on a machine dropping frames, a chart would ignore most of a held-down arrow key.
  • Up/Down as a second pair of arrows. Convenient on a chart in isolation, and a theft everywhere else — §2.4 already bans nested same-axis scrollers because that class of interception is hard to notice.
  • A donut readout beside the ring, like an axis chart’s. It would need placing, flipping and clamping inside the box, to avoid a space the chart has already reserved for exactly this.
  • Exploding the hovered slice — nudging it outward — instead of fading the others. It changes the ring’s outline as the pointer crosses it, which reads as the chart wobbling, and the moved slice no longer lines up with its neighbours’ edges so its share becomes harder to compare rather than easier.
  • Isolating a slice by clicking the legend, as the axis charts do. A part-to-whole chart showing one part is no longer showing a whole; the donut’s legend stays a key, which is what ChartSpec says by not including it.

Consequences

  • Every chart with data is a Tab stop. An application with a wall of charts gains a tab stop per chart; that is what §2.2 asks for, and the alternative is a chart a keyboard user cannot read at all. chart-plot:focus-visible and donut-plot:focus-visible take the same ring every other control takes.
  • The keyboard and the pointer are held to one answer. ChartInputTest asserts that End and a pointer at the right-hand edge produce the same pixels — not two descriptions of the same intent.
  • A donut’s slices are reachable in order, which is also the first thing on the way to reading one aloud: whatever a screen reader eventually asks these widgets for, the index it would ask about now exists.
  • The relative-step rule generalizes. Any widget whose keys move a position it reports upward has this bug available to it. The shape that avoids it is the one here: relative intent up, arithmetic in the state, and a callback that says whether anything moved.
  • Still owed from §3.1: thresholds, log axes, java.time axes, null handling, interpolation, soft bounds, gradient fills, empty and error states, and a shared CrosshairGroup across charts. §3.5’s other two items — copying the hovered value with Ctrl+C, and the determinism claim — are a clipboard call and already true, respectively.

200. A chart with no data says so

Date: 2026-08-24

Status

Accepted. charts.md §3.1’s “empty / loading / error states”.

Context

A chart of no series drew its axes anyway: five gridlines, five labels, and 0, 5, 10, 15, 20 down the side. Every one of those numbers was invented. An empty grid is not a neutral thing to draw — gridlines and axis labels are an assertion about a scale, and asserting one over no data is the same class of untruth as a bar chart with a baseline at 90, which this toolkit refuses at construction.

§3.1 asks for three states rather than one: empty, loading, and failed. The second and third cannot be derived — only the application knows whether a query is in flight or came back angry — so they need a way to be said.

And they raise a question that looks like it has an obvious answer. An application can write loading ? spinner : chart in one line, so why should the widget grow a state for it?

Decision

A chart takes a ChartStatus: READY, LOADING or FAILED, with an optional message. When it is not ready — or when it is ready and the data is empty — the chart’s parts are one chart-message and nothing else: no plot, and no legend either, because a legend keying series nobody can see is noise.

Empty is not one of the three. A chart whose series are empty is READY: the application answered the question and the answer was nothing, and the widget notices for itself. A fourth state the application had to declare would be one that can disagree with the list beside it.

The message is widgets, not paint — the opposite of the hover readout (ADR-0198), decided by the same question: does it participate in layout? A readout is placed in plot coordinates and must not affect the box. A message is centred, wraps when the box is narrow, and is the content — so it is a node, a stylesheet reaches it, and the shaping cache serves it like any other sentence.

It keeps the chart’s box, and that is the answer to “why not the application”: the chart is the thing with the height. chart-message takes the plot’s flex-grow, so a chart in a 156px card is 156px tall while it loads, and a masonry of cards whose charts came and went as their queries resolved does not reflow the wall twice per panel. A spinner in a card has to be told to be card-sized by somebody, and only the chart already knows.

Two strings, and no spinner. No data and Loading… are the toolkit’s own words — the second and third it writes rather than the application, after Field.REQUIRED_MESSAGE, and for that message’s exact reason: an application that passed an empty list has supplied no words. An application with better ones passes them. There is no spinner because §1.7 keeps the frame loop idle when nothing is animating, and a dashboard’s charts are all waiting at once (ADR-0081).

Alternatives considered

  • Leave it to the application. It costs the box, as above — and it means every application writes the centring, the muted colour and the caption size again, differently.
  • A fourth EMPTY state the application declares. Two sources of truth for one fact, and the interesting failure is the quiet one: a chart told it is READY with an empty list and a chart told it is EMPTY with a full one.
  • Draw the message in the canvas, like the readout. It would need the text shaped in render and centred in the painter, and gain nothing: the message neither moves with a pointer nor has to avoid the data.
  • Throw, or refuse an empty series list at construction, which is what donut-chart does for two slices. Wrong shape here: an empty list is not a programming mistake, it is Tuesday. A query returning no rows must not be an exception in a paint pass.
  • A red panel for the failed state. §1.2 draws the aurora hues as a glyph and a border on a surface rather than as a filled block, and a chart-sized red rectangle reads as an alarm rather than as “this one query did not answer”. The text takes --gb-danger and nothing else does.

Consequences

  • Four widgets, one answer. ChartParts.messageFor is shared, so line, area, bar and donut cannot drift — including the one place “empty” means something different: a donut of three zeroes has no whole to be part of, so a positive total is what counts as data there.
  • The box is asserted, not argued. ChartStatusTest measures the laid-out height of a chart with data, loading, and failed, and they are the same number. It is the one claim the CSS has to keep.
  • Series gained no state. A chart’s status is about the chart — one query, one panel, one message. Per-series loading would be a chart that is half a picture, which is a dashboard-machinery feature §3.4 already refuses.
  • The two strings are a translation obligation. The toolkit has no i18n mechanism and this adds the second and third places that would need one. Both are overridable per chart, which is the whole of the answer until there is a mechanism.

201. A hole is not a zero

Date: 2026-08-24

Status

Accepted. charts.md §3.1’s “null handling: gap / connect / zero — three-way, explicit. A gap drawn as zero is a lie about the data and the default is the gap.”

Context

A sensor that was offline for an hour and a sensor that read zero for an hour are different facts. A chart that draws them the same way has destroyed one of them, and it is the cheap rendering — substituting zero needs no code at all, which is why so many charts do it.

Before this, the toolkit had no answer at all and the failure was worse than a lie. List.copyOf refuses nulls, so a missing reading had to arrive as Double.NaN — and Math.min propagates NaN, so one hole made Series.min() answer NaN, the axis found its domain was not finite, fell back to 0…0 and collapsed the entire chart onto one line. A single missing sample destroyed the picture, silently, and no test noticed because no test had a hole in it.

Three questions had to be answered rather than one.

How is a hole spelled? List<Double> can hold nulls if the copy allows it, and NaN is what a double[] and a DoubleStream already use.

Who chooses the policy? Grafana puts it on the series, as a field override.

What does a stack do with one? A band’s y is a running total, so an index where one component is missing is an index where the total is unknown — and there is no way to draw “unknown” in the middle of a stack without moving the bands above it.

Decision

A hole is Double.NaN, and a null is read as one. Series normalises at construction rather than refusing, because the alternative made every caller convert a nullable column themselves and the obvious conversion is orElse(0) — the one answer that destroys the distinction this record exists to preserve. Series.min/max skip holes, and valueCount() says how many readings there really are, so a series of nothing but holes is as empty as a series of no points (ADR-0200).

The policy belongs to the chart, not to the series. One picture, one convention: two series in one chart treating their holes differently is a chart a reader cannot interpret without being told which line is which kind — the same argument that gives a chart one x axis and refuses it a second y (§3.4).

The default is GAP, which is the only one of the three that invents nothing. Most chart libraries connect by default, because a broken line looks like a rendering bug; that is a reason to make the honest rendering legible, not a reason to draw the other one.

One place applies it. Gaps.resolve turns a series and a policy into substituted values plus the runs of consecutive drawable indices, and every mode reads that:

  • GAP substitutes nothing and produces a run per stretch, so a line is a polyline per run and a hole is a hole.
  • CONNECT interpolates the interior holes linearly. For a line that is exactly the straight segment across the gap; for a filled band it is the same shape filled — one substitution, both modes, no second definition of “connected”. A hole at either end stays a hole: connecting needs two ends, and a series that started late did not have a value before it started.
  • ZERO substitutes zero, which is right where a missing value genuinely means zero — a counter that reports nothing when nothing happened — and a lie everywhere else.

A hole in one series is a hole in the whole stack. For area-chart the runs are the indices where every component has a value, so the bands break together. Drawing the ones above a hole as though the missing one were zero would put them at a height nobody reported.

Nothing is dropped silently. A run of one has no segment to draw, so a line draws a dot and a stacked band draws its cross-section a pixel wide. That is the same objection LTTB exists to answer — a picture that omits a reading it was given — and it applies to one point as much as to a spike in a hundred thousand.

A bar chart needs no policy of its own: a bar is a length from zero, so a missing reading has no length and no bar, and ZERO draws the zero-height bar it asked for. The readout leaves a missing series’ row out entirely, which is also how it says which series was missing — the one that is not in it.

Alternatives considered

  • Nulls in the list rather than NaN. It reads better at the call site and breaks every mapToDouble downstream of it, including the ones in this file’s own arithmetic. Accepting a null and storing a NaN gets both.
  • Per-series policies, as Grafana has. Grafana needs them because its charts are configured through a form by someone who cannot write code; here the application is written in Java and can split a chart in two. What it buys is a picture with two conventions in it.
  • Substituting zero by default, or connecting by default. Both are the chart choosing what the data means, and the whole of §3.1’s sentence is that it must not.
  • A gap in a stack drawn as zero for the missing component only. It keeps the band continuous and moves every band above it to a wrong height — a chart that looks complete and is not, which is worse than a visible break.
  • Interpolating a leading or trailing hole by extending the nearest reading. That is adding data rather than joining it.

Consequences

  • One missing sample no longer destroys a chart. That is the largest of these consequences and it was a live defect, not a feature gap.
  • Downsampling happens per run. LTTB runs over each stretch with a budget shared out by length, because the alternative is downsampling across a hole and inventing a segment through it.
  • ZERO changes the axis, and should. A substituted zero is a value the chart is drawing, so it is in the domain; an axis scaled to the raw series would leave the substituted point off the bottom of a chart that is drawing it.
  • Three goldens, and the assertion that matters is that they differ. A chart whose null handling was wired up but never applied would pass every unit test about Gaps and draw one picture for all three policies.
  • smooth interpolation is still not built. §3.1’s monotone-cubic interpolation is a different axis of the same area — how the line gets from one point to the next, rather than what happens where a point is absent — and CONNECT is deliberately linear, so a chart cannot end up smoothing through data that was never there.

202. A limit is not a series

Date: 2026-08-24

Status

Accepted. charts.md §3.1’s “thresholds: lines and shaded regions — drawn in the semantic hues, never a series slot”.

Context

A dashboard chart is usually read against something: an SLO, an error budget, a disk that is nearly full. Grafana models that as a list of value/colour steps and lets the author pick the colour, which is how a threshold ends up the same red as the series beside it.

The colour is the whole question here, because this toolkit has two palettes and they mean different things. The series palette exists to keep the things being compared apart, and was searched over all 40 320 orderings to do it (ADR-0194). The semantic hues mean “this is fine” and “this is not”. A threshold is not one of the things being compared — it is a statement about them — so a threshold drawn from the series palette would both read as one more series and steal the hue of a real one.

There is a second question underneath: whether a limit you have not reached belongs on the chart at all.

Decision

A threshold takes one of four semantic levels — INFO, SUCCESS, WARNING, DANGER — and there is no way to give it a colour. It reads --gb-<level>-line, which is the rank the design system added for a stroke drawn on the page rather than for a label or a fill, measured against §1.2’s 3:1 floor for non-text (ADR-0088). An application that wants a different colour overrides the token on the chart, which is an ordinary rule and changes the meaning rather than working around it (ADR-0195).

A line or a band. Threshold.at(v) is a line — this is the limit; above, below and band are regions — this range is the bad one. A band is drawn under the data at low alpha so the series and the gridlines read through it: a warning that hid what it was warning about would cost you the reading you came for.

A band is a wash and its edges, which was not the first design. The wash alone failed a measurement: this theme’s warning hue at 16% over the dark surface computes to (76, 76, 76) — exactly neutral grey. A band whose semantic colour a reader cannot perceive says “something” rather than “warning”. So each finite edge is drawn at full strength, which is where the hue lives, and the wash marks the extent.

A threshold is part of the domain. The axis stretches to reach it, so a limit you are a long way from is on screen. A threshold that only appeared once it had been breached would be a warning light that comes on after the fire. An unbounded side contributes nothing: above(90) says the axis must reach 90 and says nothing about infinity.

NaN is refused at construction, with a message naming NullPolicy. It is the one place in this area where a NaN means something specific — a missing reading — and a limit that is missing is not a limit.

Not on donut-chart: a part-to-whole chart has no axis to draw a limit across, and a threshold on a share would be a statement about a number the chart is deliberately not showing.

Alternatives considered

  • Letting the application pass a colour. Every request for this will be for exactly that, and it is how a threshold ends up indistinguishable from series 5. The token override gives the same power and keeps the meaning attached.
  • Grafana’s value/colour steps, where the region above each step takes that colour and the series is recoloured by which step it is in. It reads well on a stat panel and badly on a multi-series line chart, where recolouring the line destroys the identity the legend just established.
  • Drawing thresholds over the data. Easier to see, and it hides the point.
  • Leaving a threshold out of the domain, so it only appears when the data reaches it. That is the reading nobody needs: by then you can see the problem in the series.
  • A dashed line, which is the convention. bl_context_set_stroke_dash_array is not on the export list, and widening the native surface for a line style is not worth it while a 1px solid line in a semantic hue already reads as “not the data” — the series are 2px.

Consequences

  • Four levels and no fifth. A chart that needs six thresholds has six of the four levels, which is right: a reader distinguishes “fine / close / over”, not six degrees of over.
  • The three axis charts grew a sixth component. series, categories, status, nulls, thresholds, attributes — and the next §3.1 item makes it seven. The next one should bundle them: a ChartOptions record holding everything that is not the data, with the withers kept as the public surface. Recorded here rather than done now, because doing it in the same change as the feature would have hidden the feature.
  • The measurement is kept as a test. ThresholdTest asserts both halves of the finding: that the band’s edges are in the warning hue, and that the wash really is the grey that made the edges necessary.
  • The showcase carries one. The p99 latency card has an SLO band, which is also the only colour on that screen that is not from the series palette — the point of the decision, visible on the wall.

203. A time axis is time, not a relabelled index

Date: 2026-08-24

Status

Accepted. content-widgets.md §3.1’s “java.time-driven time axes (tick stepping across sec/min/hour/day/month/year boundaries)”, and the last of charts.md §3.1’s big rows.

Context

Every chart in the toolkit has had one x: the point index. Points are evenly spaced and categories labels them, which is right for Mon…Sun and wrong for anything that arrived on a clock. A metric scraped every 15 seconds that missed four minutes has, on an index axis, exactly one step of gap — the same step as every reading that was on time. That is a picture of a schedule nobody kept.

So the question is not “how do I write the labels”, which is how a time axis is usually implemented. It is where the points go.

The second question is the calendar. Ticks is Wilkinson’s algorithm for numbers, and a nice number is a round multiple; time has no round multiples. Sixty seconds are a minute, sixty minutes an hour, twenty-four hours a day — and then a month is 28, 29, 30 or 31 days and a year is 365 or 366. A step of 2 592 000 000 ms is a month only in a year with no February in it and has drifted five days by December. A step of 86 400 000 ms is a day except on the two days a year a zone changes offset, when it is 23 or 25 hours — so an axis stepped that way reads 00:00, 00:00, 01:00, 01:00… from the last Sunday in March.

Decision

The x is time. TimeAxis carries one Instant per point index, the plot’s x scale runs over epoch milliseconds, and the drawn position of every point — line, band, crosshair, marker — comes from one method so the four cannot disagree.

The ticks are stepped in java.time. TimeTicks picks a rung from a ladder of the steps a clock is actually read in — 1, 2, 5, 10, 15, 30 seconds; the same minutes; 1, 2, 3, 6, 12 hours; 1, 2, 7, 14 days; 1, 3, 6 months; 1, 2, 5, 10 years — snaps to a boundary of that rung’s own unit, and advances with ZonedDateTime.plus. So a month is as long as that month is, and a day across a zone change is 23 or 25 hours, because java.time knows and this file does not have to. The only arithmetic on numbers is choosing the rung, where a month is 30.44 days and being approximate is the point.

The zone is the application’s; the format is the root locale. These land on opposite sides of the same-looking question and the reason is that they are different questions: a locale changes how a number is written and a zone changes which number it is. An axis in the machine’s language is an unfamiliar picture; an axis in the machine’s zone is the correct one, because a desktop application showing “Tuesday” means the user’s Tuesday. So times(list) reads ZoneId.systemDefault() and times(list, zone) is what a test — or a chart of a server’s clock — passes.

An axis that does not cover the data is not used. Fewer instants than points falls back to the index rather than drawing half a time axis: an axis that ran out would place the remaining readings at a time nobody measured.

A bar chart ignores it. A bar has a width and sits in a band; bands of unequal width are a different chart, and half-applying the axis — bars on bands, labels on instants — would draw labels that do not line up with the bars under them.

A sampling gap is not a hole. The axis makes an unscraped stretch wide; the line still crosses it, because both ends are readings that happened. An application that means “and nothing was measured in between” says so in the data with a NaN, and NullPolicy draws the break it already knows how to draw (ADR-0201). Two mechanisms, two meanings, and they compose.

Alternatives considered

  • Relabelling the index, which is what “time axis” often means: keep the even spacing and write times under it. It is a third of the work and it destroys the one thing the reader came for — a chart of a system that stopped reporting looks exactly like a chart of one that did not.
  • Times on the Series, so each series carries its own. A chart has one x (charts.md §3.4), and two series timed differently is the same mistake as two y-scales in the other direction.
  • Stepping in milliseconds with a table of “nice” durations. Simpler, and wrong twice a year and every February — see the context. The DST case is in TimeTicksTest because it is the one nobody writes a test for.
  • Duration-based steps for months and years. Duration is a fixed number of seconds by construction, so it cannot express “a month”; that is what Period and ChronoUnit.MONTHS are for, and it is why the ladder holds a unit and an amount rather than a length.
  • Defaulting the zone to UTC for reproducibility. It would make every application’s default wrong to gain what only a test needs, and the test can say UTC in one call.

Consequences

  • ChartOptions earned itself. The axis is a field on the bundle rather than a seventh component on three records, which is what ADR-0202 said the next feature should find.
  • A crowded time axis strides. Every nth tick for the smallest n that fits, like the categorical labels — the first version dropped colliding labels individually and produced 09:00, 09:30, 09:45, which keeps two neighbours and loses the one between them so the reader cannot tell what the spacing is. The stride allows for the end labels being clamped inward, which is the thing that makes the first two touch.
  • The readout says when, to the second. An axis label is a position and a readout is the reading, so 14:32:07 is right even on an axis stepping in hours; the date joins it only when the chart spans more than a day.
  • A 100 000-point timed series costs an array of doubles per frame. The point times are unpacked once in render and read by the painter; LTTB still downsamples what is drawn. If that ever matters, the fix is to keep the array across frames on the state, which is where PaintedGeometry already lives.
  • Still not built: log axes, interpolation, soft bounds, gradient fills, point markers, and a crosshair shared between charts. §3.1’s remaining rows are all smaller than this one.

204. A smooth line cannot overshoot

Date: 2026-08-24

Status

Accepted. charts.md §3.1’s “interpolation: linear, smooth, step — step matters for state-ish series; smooth is monotone-cubic, which cannot overshoot into impossible values”.

Context

Between two readings a chart has to draw something, and whatever it draws is a claim about what happened there. A straight segment claims the value moved steadily. A curve claims it moved smoothly. A step claims it did not move at all until the next reading.

The interesting one is the curve, because the obvious implementations are wrong in a specific and expensive way. Fit a Catmull-Rom or a natural cubic spline through 0, 0, 100, 100 and the curve dips below zero before it climbs and overshoots above 100 after. That is not a bug in those splines — it is what they are for; they minimise curvature, and swinging past the endpoints is how. It is simply wrong for data: on a percentage, a queue depth or a byte count the overshoot is not inaccurate but impossible, and it lands exactly where a reader is looking, because it lands where the interesting thing happened.

The step has a smaller question in it: whether a value holds forward from its reading or backward into it.

Decision

Curve.LINEAR is the default, because it makes the weakest claim and a chart should not make a stronger one without being asked.

Curve.SMOOTH is Fritsch–Carlson monotone cubic (SIAM J. Numer. Anal. 17(2), 1980), which takes the obvious tangents and then limits them so that a curve through monotone data stays monotone — and therefore never leaves the interval its own endpoints define. Two limits, not one, and the second is the one that is easy to miss:

  • the α² + β² > 9 circle, which scales a pair of tangents back;
  • and a local extremum has a flat tangent. Averaging the secants at the top of 1, 9, 2 gives +0.5, and the curve reaches 9.0013 on a series whose maximum is 9. The circle does not catch it, because scaling a tangent back is not the same as zeroing it.

Curve.STEP holds forward. A value read at 09:00 is what was true from 09:00 until somebody looked again, so the horizontal comes first and the jump lands on the next reading’s x. Holding backward would say the new value was already true before it was observed, which is the one direction the data cannot support.

One emitter, used by lines and by both edges of a band. A smooth band whose underside was straight would be thicker than its own numbers wherever the top bulged — a band that overstates itself. The underside is the same curve reversed, which for a cubic is its control points in reverse order, so the two edges agree exactly.

A bar chart ignores it, because a bar is a length from zero rather than a path between readings.

Alternatives considered

  • Catmull-Rom, which is what most charting libraries reach for and what “smooth” usually means. It is one line shorter and it draws negative percentages.
  • Clamping the drawn curve to the data’s range instead of choosing a monotone one. It hides the overshoot by flattening the curve against an invisible wall, which draws a plateau the data does not have — a different invented reading, and a harder one to notice.
  • Bézier smoothing with a tension parameter. A knob that turns a correct chart into an incorrect one somewhere in its range, and no value of it is right for all data.
  • Step-before, or offering both. Both is a choice nobody can make correctly without knowing how the series was sampled, and the toolkit knows: a Series is readings, and a reading is what was true from when it was taken.
  • Sampling the curve into a polyline rather than emitting cubics. Blend2D flattens a cubic better than a fixed sample count would, and the control points are three multiplications each.

Consequences

  • The property is asserted by sampling, not by inspection. CurvesTest evaluates the Hermite form densely and checks the bounds, which is the only kind of proof worth having about something one missing if away from being false — and it is what found the missing if: the local-extremum case failed the interval test before the flat-tangent rule was added.
  • Curves is public and pure. No renderer, no natives, no BlendPath: the painter asks for tangents and control points and does the drawing. A future goldberry-plot gets the same arithmetic without the widget.
  • Smooth composes with everything already there. The tangents are computed on the pixels the painter is about to draw, so an unevenly sampled series on a time axis curves correctly for free, a GAP run curves per run, and an isolated series curves alone.
  • The showcase’s stacked area is smooth, which is also the case that would expose a mismatched pair of band edges.
  • Not built: log axes, soft bounds, gradient fills, point markers, and a crosshair shared between charts. §3.1’s remaining rows.

205. A log axis has no room for zero

Date: 2026-08-24

Status

Accepted. charts.md §3.1’s “log axis, with correct log tick labelling”, and the last structural row of that table.

Context

A series that spends its life at 3 and spikes to 30 000 is, on a linear axis, a flat line along the bottom with one spike: every reading anybody came to read is in the bottom pixel. Four decades is not an unusual range for a latency, a rate or a queue depth, and a log axis is the standard answer — each decade gets the same room.

Three things make it more than a different multiplication.

Every scale in the toolkit is affine. Scale maps a domain onto a range linearly, and every chart, the sparkline included, is written against it. A log scale is the first thing that is not.

Wilkinson is the wrong labelling algorithm. Ticks.extended scores a candidate labelling on how round its numbers are and how evenly they cover the range, and on a log axis those pull apart completely: 1, 10, 100, 1000 is the only labelling anybody wants, and in the value space Wilkinson works in it is wildly uneven — three quarters of the axis carries one label.

And a logarithm has no room for zero. log10(0) is negative infinity and log10(-1) is not a number. This is not a rendering question with a nice answer hiding behind it; there is genuinely nowhere on the axis to put the reading.

Decision

Scale gains a flag rather than a subtype. Scale.log(min, max, …) maps log10(value) linearly, and at/from branch on it. Every caller wants a scale and none of them wants to know which kind — a painter asks where a value goes and a pointer asks what is at a pixel, and both have one answer either way. A Scale subtype would have made PlotGeometry generic in the axis for the benefit of one if.

LogTicks labels decades, striding them when there are too many — 1, 100, 10000 rather than thirteen powers of ten — and subdividing by the 1-2-5 mantissas when there are too few, which is what every sheet of log-ruled paper has ever used. Not every integer: 1, 2, 3 … 10 crowds the bottom of each decade, which is where a log axis has least room. The subdivision is chosen as the one nearest the label target rather than the first to reach it, which is a rule worth stating because the other one silently turns a four-decade axis into a seven-label half-decade one to gain a label it did not need.

A non-positive reading becomes a hole. Gaps.positiveOnly turns it into a NaN and NullPolicy draws it as one, so the line breaks where the data went to zero rather than sliding off the bottom of the picture. That is the honest rendering of “there is nowhere to put this”, and it costs the reading visibly rather than quietly — which is also why a log axis is opt-in and not something a chart could choose for itself when its numbers span enough decades. Choosing it costs data, and only the application knows whether the zeroes matter.

Only line-chart. A bar and a band encode their value as a length from zero, and zero is infinitely far down a log axis. A chart drawing one anyway would have to pick a bottom, and every choice is a number nobody gave it. logY on a bar or an area chart is ignored, exactly as a time axis is on a bar chart.

A series with nothing positive in it falls back to a linear axis and keeps its data. Scale.log refuses a non-positive domain, and a chart must not turn that into an exception in a paint pass — a query can return zeroes. The order matters: the first version filtered and then discovered it had nothing left, and drew an empty grid, which is worse than either honest answer.

Alternatives considered

  • Symlog — linear near zero, logarithmic outside it — which is matplotlib’s answer to the zero problem. It needs a threshold nobody can choose correctly without knowing the data, and it draws an axis whose scale changes partway along: two equal distances on it are not equal ratios or equal differences, and a reader cannot know where the join is.
  • Clamping non-positive readings to the axis minimum. It draws them, at a value they never had, on the one part of the axis a reader is most likely to believe. The current answer loses the same reading and says so.
  • A tiny epsilon floor (max(value, 1e-9)), which is the same lie with a smaller number in it and a spike to the bottom of the chart wherever a zero was.
  • A LogScale subtype, or making Scale an interface. Every call site would gain a type parameter to save one branch, and PlotGeometry — which is a record read by both a painter and a pointer — would gain a generic.
  • Log on the x axis too. Nothing in §11 has a numeric x: it is an index, a category or a time. When goldberry-plot’s scatter arrives it will want one, and Scale now has the flag it needs.

Consequences

  • The gridlines are unevenly spaced, which is the point. paintGrid takes an explicit list of tick values now rather than deriving them from a linear labelling’s min + i·step, because on a log axis there is no step.
  • It composes with everything before it. SMOOTH stays monotone because the tangents are computed on the pixels the painter is about to draw, whatever the scale did to get there; a time axis is the x and is untouched; a threshold is placed by the same scale as the data.
  • Scale.at can now return NaN. Deliberately: a caller that forgot to filter draws nothing rather than drawing a reading at the bottom of the axis that never happened.
  • charts.md §3.1’s rows are done bar the small ones — soft bounds, gradient fills, point markers and a crosshair shared between charts.

206. A crosshair may be shared, and a bound may be soft

Date: 2026-08-24

Status

Accepted. The last three of charts.md §3.1’s rows that do not need a native symbol: soft bounds, point markers, and the shared crosshair.

Context

Three small features with one thing in common — each is about a chart telling the truth about something it cannot see on its own.

A chart scales to its data, which is right until the data does not move. An uptime reading 99.94, 99.97, 99.91, 99.99 fills the plot with the difference between 99.91 and 99.99: a mountain range made of eight hundredths of a percent, shouting loudest exactly when the news is good. The chart cannot know that; only the application knows what range the number lives in.

A line does not say which of its bends are readings. A sparse series drawn as a polyline looks like a continuous measurement, and the reader cannot tell whether Tuesday’s kink is a sample or the place two segments happen to meet.

And a dashboard’s panels are read together. “What were the bytes doing when the requests spiked” is the question a wall of charts exists for, and it is the one it is worst at: the reader points at Thursday on one chart and then has to find Thursday on the other by eye.

Decision

Bounds are soft by default and hard on request. softAxis(99, 100) means “reach at least here, and further if the data does”, so an outage still pushes the axis down to meet it — a chart that hid the one reading anybody needed would be worse than the noisy one. axis(99, 100) does not move, and data outside it is drawn outside the plot and clipped, which is the correct rendering of a promise that was wrong. Soft is what nearly every application wants; hard is for a range that is a definition rather than an observation.

Markers are AUTO by default, drawn when a point’s neighbours are more than four marker-widths away — measured in pixels, because what makes a dotted mess is how close the dots are on screen rather than how many there are. The same chart therefore shows dots at seven readings, none at seven hundred, and shows them again when the window is widened. This changes what every sparse chart in the toolkit looks like, and it is the right default: a dot per reading is the difference between a measurement and a trace.

A CrosshairGroup is a mutable holder an application owns, like a ToastController (ADR-0177): charts subscribe on mount and unsubscribe on dispose, and a move notifies only when the index actually changes. What travels is the point index, so the charts in a group are assumed to be sampled together — which is what a dashboard’s panels over one time range are. A group over unaligned charts wants a shared time, which is a different type and is worth building when something needs it.

Every chart in the group draws the crosshair; only the one under the pointer draws the readout. A dashboard with six floating boxes on it, five of them about a chart nobody is pointing at, is worse than no linking at all.

Alternatives considered

  • Inferring soft bounds from the data’s own spread — “if the range is less than 1% of the value, pad it”. It would fix the uptime and break the chart of a quantity that genuinely varies by a hundredth, and neither the chart nor the reader could tell which had happened.
  • Markers off by default, which is what the toolkit looked like before. It makes every chart a trace and quietly loses the distinction; and the existing goldens are not an argument for anything.
  • A marker threshold in points (“show them below 30 readings”) rather than in pixels. The same chart in a wide window and a narrow one would then disagree with itself about whether its readings are visible.
  • Sharing through the widget tree — a CrosshairScope ancestor the charts find with findAncestorState, as scrollIntoView does (ADR-0120). It would bind the linking to the layout: two charts in different panels of a split-pane could not share one, and a dashboard is exactly where they would be.
  • Sharing the pointer’s x rather than the index. It is the more general answer and it needs the charts to agree about what x means; with an index the group is one integer two widgets read, which is what §3.1 asked for.

Consequences

  • The crosshair and the readout are separate conditions now. They were the same one — paintHover returned early when the readout was null — and a linked chart drew nothing at all until they were split. The marker’s ring colour moved off the Readout for the same reason: reading it off the thing that is null was the crash.
  • A chart in a group is rebuilt by a pointer that is not in it, which is one setState per linked chart per point crossed. Cheap, and guarded by the group notifying only on a change; a dashboard of twelve linked charts is twelve rebuilds per point rather than per pixel.
  • A leak is possible and is tested for. A chart that stayed subscribed would hold the group’s listener list — and through it the last window’s charts — alive. CrosshairGroup.listenerCount() exists for that test and for nothing else.
  • Every sparse chart in the toolkit gained dots, including the showcase’s. Deliberate; the goldens moved with it.
  • charts.md §3.1 is complete except for the gradient fill, which needs a Blend2D gradient on the export list — shared work with goldberry-html (ADR-0190) and recorded in TODO.md rather than faked with a stack of translucent strips.

207. A fill may be a ramp

Date: 2026-08-27

Status

Accepted. The last row of charts.md §3.1, and the first widening of the export list that is not for a widget.

Context

ADR-0206 finished §3.1 “except for the gradient fill”, and the exception was not a chart problem. Every drawing call on the export list takes its colour as an rgba32 argument — bl_context_fill_rect_d_rgba32, bl_context_fill_path_d_rgba32, bl_context_fill_glyph_run_d_rgba32 — because that is what the toolkit’s own painter has ever needed. A gradient is not a colour: it is an object with a geometry and a list of stops, it has to exist while the fill happens, and it reaches the context as state rather than as an argument. So there was nothing on the list a chart could call, and the entry sat in TODO.md with the reason recorded.

The way out that does not work was recorded there too. Faking a fade with a stack of translucent strips is forty rectangles per band per frame, it bands visibly on any gradient shallower than one strip, and it puts a Layer’s worth of overdraw on a chart that is otherwise two paths.

And it is shared work, which is what makes the width worth taking: goldberry-html needs the same symbols for CSS gradients and goldberry-vector for SVG’s (ADR-0190). Both would otherwise open by adding these five lines.

Decision

Six symbols, and the sixth is the interesting one. bl_gradient_init_as, bl_gradient_destroy and bl_gradient_add_stop_rgba32 build one; bl_context_set_fill_style puts it on the context and bl_context_set_fill_style_rgba32 takes it off again. The sixth is bl_context_fill_path_d — the plain fill, with no _rgba32 suffix, which draws with whatever style is set. It is the only styleless drawing call bound and the only way a gradient reaches a path.

A BlendGradient is a resource, like a BlendPath. Confined to its thread, closed by its owner, and closable the moment the fill has been issued — Blend2D retains its own reference when the style is set. Only the linear form is constructed: bl_gradient_init_as takes its geometry as a const void* whose shape the type implies, and a method that took any BLGradientType would let a radial gradient read six doubles out of a four-double allocation and report BL_SUCCESS. A second shape is a second method with its own layout row beside it.

The fill style goes back to opaque black after every gradient fill. Every other call on BlendContext states its own colour, which is what keeps a frame free of style state nobody set back; a gradient left set would be drawn by whatever reached for the styleless fill next, somewhere else in the frame entirely. This is globalAlpha’s rule and the opposite of its mechanism: an alpha has a neutral value to go back to and a fill style does not, so restoring one means choosing one.

A gradient is not moved by the origin its path is filled at. The path translates and the ramp does not, because the two answer different questions — a path is a shape drawn somewhere and a gradient is a statement about a region of the surface. One placed from the top of a plot to its baseline is the same ramp for every band drawn through it.

A fade repeats its own colour at zero alpha. 0x00000000 is transparent black, and a ramp from a green to it goes through grey on the way out. That is the classic wrong gradient and it is not a thing each caller should have to remember, so BlendGradient.fade is the two-stop constructor and the far stop is argb & 0x00FFFFFF.

On the chart side there is a Fill with three values and not an opacity number. NONE is the default, SOLID is a flat wash and GRADIENT is a fade. An opacity would be a number a caller could want any value of; a fill is a choice between two conventions, and a caller who wants a third writes a canvas, which is the escape hatch charts.md §4 names for exactly this.

NONE is the default so that nothing changed. A line chart draws no fill, which is what a line chart already was; an area chart reads NONE as SOLID, because a band with no fill is not a band. Every existing golden is untouched and GRADIENT is a thing an application asks for.

A ramp is anchored to the data, not to the plot. Under a line it runs from the furthest point of that run from the baseline back to the baseline; in a band it runs across that band’s own extent. Anchored to the plot instead, two series of different magnitudes would be drawn at different strengths, and a stack’s lower bands would be half gone before they started.

Alternatives considered

  • A Gradient value type in :core’s paint package, converted to a Blend2D object per fill. It would keep Frame’s surface free of a native resource — but Frame already takes BlendPath, BlendFont and BlendGlyphBuffer, so the boundary being protected is not there, and the conversion would allocate the same native object at the same rate with a Java object in front of it.
  • Caching one gradient per series across frames. A gradient’s geometry depends on the plot’s height and on the data’s extent, both of which change; a cache keyed on those is a cache that misses whenever anything moves, plus a lifetime to own.
  • Interpolating the ramp in OKLCH, which is the word the deferred entry used. It turns out to be vacuous for the thing being built: a fade between two alphas of one hue is the same curve in every perceptual space. What makes it correct is premultiplied interpolation, which Blend2D does, and repeating the colour at the far stop, which the caller must. OKLCH would start to matter for a ramp between two different hues, and nothing draws one.
  • An opacity number rather than three values — fill(0.3). It makes the gradient inexpressible without a second argument, and it invites the two degenerate values (0 and 1) that NONE and SOLID say better.
  • SOLID as the default for a line chart, which is what most dashboard libraries do. It would put an area under every line chart in every application that upgraded, which is a change to a picture nobody asked to change.
  • Binding bl_context_fill_rect_d as well, so a rectangle could be filled with a ramp. Nothing needs it: a chart’s ramps are on paths, and the export list holds what a painter needs rather than what one might.

Consequences

  • The export list is six symbols wider and has its first style object. The layout registry gains two rows — BLGradientCore, which is BLObjectDetail again, and BLLinearGradientValues, which earns its row the way BLMatrix2D does: it crosses as a const void* and nothing on either side checks its shape. Six enumerators joined the constant probe, three gradient types and three extend modes, although only one of each is used.
  • goldberry-html and goldberry-vector start one commit further along. Both entries in TODO.md named these symbols as their own first step.
  • A gradient is constructed inside the paint pass, which is the first Blend2D object that is. One per band per run per frame — three native calls beside a path that is already hundreds. Measured against the alternative rather than against zero: the strips it replaces were forty fills.
  • charts.md §3.1 is complete. Every row is built.
  • A degenerate ramp is filled flat. A perfectly flat series has a zero-length gradient, which Blend2D resolves as the last stop — that is, invisibly. Under a pixel of span it fills with the near colour instead, so a flat line keeps a fill rather than losing one.
  • The half-pixel is now written down. A gradient is sampled at each pixel’s centre, so the pixel on the start point is already half a pixel along the ramp. The native tests assert near the stop colour rather than equal to it, because the exact form would be an assertion about Blend2D’s sampling grid.

208. A context menu answers the keyboard

Date: 2026-08-27

Status

Accepted. The half of ADR-0108 that did not ship.

Context

docs/core-widgets.md §7 says a context menu is “opened by right-click or the keyboard menu key at the focused widget”. ADR-0108 built the first half and recorded the second as not built, which left the catalog with one entry whose only way in is a pointer — in a toolkit whose §2.2 requires everything to be reachable, and which has spent ADR-0199 and ADR-0206 making a chart readable without one.

It is also the smallest of the outstanding keyboard gaps, and it was left because of a real difference rather than an oversight: a right-click has a point and the keyboard does not.

Decision

Key.MENU is bound, and so is Shift+F10. The first is SDL’s SDLK_APPLICATION — the key between AltGr and Ctrl, whose scancode comment in SDL’s own header reads “windows contextual menu, compose”. The second is the companion binding on every platform that has the first, and the only one on the platforms that do not, which is every Mac. Both, rather than either: a keyboard with a menu key still has users who reach for the pair.

Bare F10 is not taken. It is the menu bar’s (ADR-0163), and an application with both would open a context menu where it meant to activate its bar.

It starts at the focused element, and walks up from there to the nearest widget that named a menu — which is the same walk the pointer’s half does from the hovered element, extracted so there is one copy of it. “A right-click on a button’s label is a right-click on the button” and “the menu key on a focused button is that button’s menu” are the same rule.

It is anchored to the focused element’s painted rectangle, not to a point, because there is no point. The menu therefore hangs off the bottom of whatever has the focus ring, which is where the reader is already looking. The rectangle comes from the last painted frame for Host.anchor’s reason (ADR-0054): a key event has no way to reach the geometry, and a placement needs it.

Only while nothing is open over the window. With a menu already showing, the keyboard belongs to the menu — the same rule the arrow keys already follow (ADR-0104).

Nothing focused opens nothing, and so does a focused widget that named no menu. A keyboard with no position has nothing to ask about, which is the keyboard’s version of a right-click over empty space.

Alternatives considered

  • A Shortcut registered through Host.addShortcut. It would put the binding in the same map an application’s accelerators live in, where removeShortcut is keyed by the shortcut rather than by who bound it — so an application binding Shift+F10 for its own reasons would silently take the context menu with it, which is a live entry in TODO.md rather than a hypothetical.
  • Anchoring to the focus ring’s centre rather than its rectangle. A menu emerging from the middle of a wide row is a menu whose top-left corner is nowhere in particular; a rectangle lets Placement flip and shift it against the work area with the same arithmetic every other popup uses.
  • Anchoring to the caret inside a text field, which is what a native text control does. It needs the caret’s rectangle to reach a key handler that is four layers above the field, and the field would have to publish it; worth doing when something asks, and the widget’s rectangle is not wrong in the meantime.
  • Binding the menu key inside the router so a widget could handle it. A context menu is opened by the application (ADR-0108’s split: only :core can notice, only the catalog can build one), so a widget-level key would arrive in the layer that cannot act on it.

Consequences

  • Key has one more enumerator, and it is the first one bound for something other than navigation, editing or an accelerator.
  • The walk is shared. openContextMenu(Element, LogicalRect) is what both halves call, so a change to which ancestor wins changes both — which was the point of extracting it rather than writing the loop twice.
  • docs/core-widgets.md §7’s context-menu row is complete.
  • A focused element that has not been painted yet opens nothing. Ordinary and unreachable in an application: the focus arrives through a frame.

209. A tree finishes its keyboard

Date: 2026-08-27

Status

Accepted. Three of the five things ADR-0184 shipped tree without.

Context

docs/core-widgets.md §3 says of tree that “keyboard is the part that has to be right”, and then lists what right means: Right/Left, Home/End to the first and last visible rows, * to expand every sibling, and type-to-select across visible rows only. ADR-0184 built the first pair and left the other three, along with checkable and multi-selection.

The three left over have one thing in common, and it is why they were left: each needs to know about rows the focused one cannot see. Right and Left are a row’s own business — only the row knows whether it is open — but the first row of the whole flattened list, and the siblings of this one, and the next row whose label starts with a letter are all facts about the tree.

Decision

All three are callbacks the tree hands down, in the shape onOut already had: the row reports the key and the state, which flattened the list, answers it. onEnd takes a direction, onSiblings takes nothing, onType takes what was typed.

Home and End mean the flattened list, not the viewport. End in a scrolled tree goes to the last row of the model and the focus ring asks the scroller to follow (ADR-0120) — which is what every tree does and what Ctrl+End means in every document.

* expands siblings and not descendants. That is the reading that makes it useful on a big tree: it opens the level so the reader can see across it. A key that opened everything underneath would hang a lazy tree by fetching its whole model on one keystroke. A lazy sibling’s supplier runs here exactly as it does for one opened by hand, which is the one place this key costs anything and the one place it is doing what was asked.

* and type-to-select both arrive as TextEvent. * is a character, and the key it sits on differs by layout — Shift+8 on a US keyboard, its own key on a numpad, neither on AZERTY. Asking for the key would be asking for the physical position, which §7.1 says this toolkit does not answer. Typeahead wants what was typed for the same reason select’s does (ADR-0141): one character can take several keys, and a tree of French cities has to answer to a dead key like everything else.

Type-to-select matches visible rows only, which is §3’s own wording and is the rule that keeps it honest: a search that opened branches to find a match would be a search, and a tree with a lazy model cannot have one without fetching everything.

Typing moves the focus and does not choose. A tree reports what the user asked for and selects nothing itself (ADR-0063); typing is a way of getting somewhere and Enter is what chooses. That is the same split select’s open list draws, where arrows move and Enter commits.

The typeahead’s three cases are select’s, including the middle one: the same letter again asks for the next row starting with it rather than searching for “ss”. A user pressing s four times to reach the fourth s-word is relying on it.

Alternatives considered

  • Home/End on the tree’s own node rather than as a row callback. The tree is not focusable — every row is, so the arrows rove between them (§7.2) — so there is no node for the key to arrive at.
  • Home/End meaning the first and last rows in view. It is what the words could mean and it is not what any tree does; it also makes the key’s effect depend on the size of an ancestor the tree cannot see.
  • * as Key-based Shift+DIGIT_8. It is right on exactly one layout.
  • Type-to-select selecting rather than focusing. It would make a keystroke commit a value in a controlled widget, which is the thing ADR-0063 exists to prevent — and it would make Enter redundant on the row the typing landed on.
  • Searching the whole model, opening branches to reveal a match. A genuinely useful feature and a different one: it is a filter, it needs a query the tree can show, and it cannot exist on a lazy model without fetching all of it.

Consequences

  • TreeState keeps the flattened list in a field, written by build and read only by the handlers. Safe because by the time a handler runs it is the list on screen; a build never reads it.
  • The typeahead is keyed by id, not by indexOf. A TreeNode is a record and equality is over every component, while the id is the whole model here (ADR-0184) and is what the rest of the class already keys on.
  • TestHost gained forgetFocusRequests(). focusRequests() hands back a copy, so a test that made several moves and cleared it between them was clearing a list nothing was writing to — a test that could not fail.
  • Two of tree’s five leftovers are still open, and they are the two that are not keyboard work: the checkbox per node with cascade and indeterminate, and multi-selection. The second is genuinely blocked — §3 says a tree shares list’s selection models and list is not built — and the first is a widget’s worth of work rather than a gap in one.

210. A tree checks and selects two different things

Date: 2026-08-27

Status

Accepted. The last two of tree’s five leftovers — §3’s checkable= and its selection models.

Context

ADR-0184 shipped tree and named five things §3 asks for that it did not build. ADR-0209 finished the keyboard three. What was left was the checkbox per node with cascade and indeterminate, and multi-selection.

Multi-selection was recorded as blocked: §3 says a tree shares list’s selection models and list is not built. That reading was too strict, and the precedent against it is tree’s own — ADR-0184 defined the node model here for exactly the same reason, and wrote down that list will have to agree with it. The selection models are the shape every desktop list has, which is what makes it a small promise to make on list’s behalf.

The checkbox needed a question answered first, and the question is in the design document rather than in the code. §3 spends the word checkable twice. On select tree= it is a rule about which rows are an answer — “leaf-only by default, because ‘Europe’ is usually a heading and not an answer”. On a standalone tree it “adds a checkbox per node”. Those are not the same feature and the existing code had already picked the first, as Tree.anyNode.

Decision

Selecting and checking are two values, reported through two callbacks. The selection is where the reader is; the checks are what they have marked. A file manager where those were one thing could not copy six files, because opening the seventh folder would clear the list. So Tree carries selected/onSelect and checked/onCheck, and a tree may have either, both, or neither.

checkable is the checkbox axis and leafOnly is the answer axis, under two names. The disagreement is recorded in ARCHITECTURE.md §17.1 rather than resolved by picking one, because both of §3’s sentences describe something real and it is the word that is doing two jobs.

A cascade parent’s state is derived, never stored. Checkable.CASCADE reads a branch from what is under it: all children checked is CHECKED, none is UNCHECKED, anything else is MIXED. A stored parent bit would go stale the moment one child was unticked, and the row would then claim “all of these” while showing one that is not.

A lazy branch nobody has opened reads its own membership. It has no known children to derive from, and fetching a model the user has not asked for in order to draw a checkbox is the one thing a lazy tree must not do.

Clicking a mixed branch asks for all of it. Checkbox.Value.toggled() has said so since it shipped — “the user is asking for ‘all of them’, which is the only reading of a click on a partial selection that is ever what was meant” — and this is its second caller rather than a second copy of the rule.

The box borrows check-indicator. It is already a 16px square that draws a tick, draws a bar for the mixed state, and takes its colours from :checked and :indeterminate rules a theme has written. A tree-check part wraps it to add the hit target and the click, because the indicator is a Paints leaf with no handler and a row that merely nested one would have a box you could look at and not tick.

Ticking consumes the click, which is TreeChevron’s rule and the same mistake it avoids: a tick that also selected would make the box unusable in a single-selection tree, since every tick would move the highlight. Space ticks and Enter still chooses — checkbox’s own split, for checkbox’s own reason, that Enter belongs to a dialog’s default action.

Selection reports the whole set, even when it holds one. A Shift range is computed over the flattened visible rows, which only the tree can see, so an id on its own would be an answer the application could not turn back into a selection. The three-argument constructor unwraps it again, so select tree= and every existing caller see the String they always saw.

Ctrl toggles, Shift sweeps, a plain press replaces, and the anchor is the last row chosen without Shift — so a run of shifted presses sweeps from one end rather than growing from wherever it last stopped, which is what makes an over-shot range recoverable without starting again. Ctrl+Enter and Shift+Enter are the keyboard’s halves of the same two gestures; Alt+Enter is deliberately left alone so an application’s accelerator on it still reaches the window.

Selection.NONE makes nothing an answer and leaves the rows navigable and openable — for a tree whose real answer is its checkboxes, where a selection highlight would be a second thing claiming to be the choice.

Alternatives considered

  • One value: checked is selected. It is what a select tree= wants and it is wrong for a tree: §3 asks for both, and the copy-six-files case is the ordinary one rather than the exotic one.
  • Storing the cascade parent’s bit and propagating on every change. It is fewer traversals and it is a second source of truth for one fact; the drift is invisible until a screenshot shows a ticked folder over an unticked file.
  • Fetching a lazy branch to derive its checkbox. It makes the mixed state exact and it makes drawing a tree fetch a model — which is precisely what mayHaveChildren exists to avoid, since a directory tree would stat the whole disk to draw its first row.
  • A second check indicator of the tree’s own. Two things to keep in step, and the reasoning about the tri-state lives in the first one.
  • Reporting the pressed id plus the modifiers and letting the application compute the set. It hands out a problem the application cannot solve: a Shift range runs over rows whose order and visibility are the tree’s.
  • Keeping the selection inside the tree so Ctrl had something to toggle against. It is what makes the widget uncontrolled, which is ADR-0063’s line — and it is what the two tests that failed first were assuming. They were rewritten to apply the answer back, which is what an application does.
  • A TreeOptions record, as ChartOptions did for charts (ADR-0202), rather than nine components. The same argument applies and the trigger has not been met: the charts bundled when the next feature would have made seven on each of three widgets, threaded by hand through four places. A tree is one widget and its withers already keep the public surface at one call per feature.

Consequences

  • Tree has nine components, and the three-argument constructor is what almost every caller uses. selected is a Set inside and a String at that door, because a caller with one selection has a value rather than a set of one.
  • select tree= is untouched. It builds the three-argument form and receives a String, exactly as before.
  • The default is unchanged in every direction: Selection.SINGLE, Checkable.NONE, leaf-only. Every existing golden is byte-identical; the new one is a cascade tree with a partly-ticked branch, which is the one state a picture is the only proof of — that the mixed mark is a bar and not a greyed tick.
  • Selection and Checkable are defined in panel.tree, and list will have to agree with them when it arrives — the same debt ADR-0184 took on for the node model, taken knowingly and in the same place.
  • The checkable disagreement is now in ARCHITECTURE.md §17.1, which is where a word doing two jobs in the design documents belongs.
  • tree owes nothing further from §3. All five leftovers are built.

211. A popup asks the desktop where the pointer is

Date: 2026-08-27

Status

Accepted. Fixes a dropdown whose rows cannot be chosen on macOS.

Context

A press on a popup’s row arrived where it should and the release did not, so no click was ever synthesized: on macOS every menu, dropdown and suggestion panel could be opened and hovered and not chosen. The pointer’s own half of the toolkit is correct — PointerRouter requires the release to land on the element that took the press, which is what makes a drag off a control cancel it (ADR-0054) — and it was being handed a release from somewhere else entirely.

SDL promises coordinates in the target window’s space and does not deliver them for a popup here. Cocoa_SendMouseButtonClicks rewrites a mouse event’s coordinates into the target window’s space only when the event’s NSWindow is not the key window; a mouse-up is delivered to the key window, and a NOT_FOCUSABLE popup can never be one — which it is by ADR-0189, so that every popup stops holding the keyboard. So the press arrives in the popup’s space and the release in the owner’s, both attributed to the popup, because the window id comes from mouse->focus and the coordinates come from mouse->x/y.

Worse than a fixed offset: the owner-space value is stale. Nothing updates mouse->x/y while the pointer is over the popup, so every release reports where the pointer was before the popup opened. A release the router looks for inside a 40-pixel row is reported at the coordinates of whatever the user clicked last.

The two ADRs that own the neighbouring decisions could not have caught it. ADR-0189 made the popup unfocusable for a reason that stands, and ADR-0103 gives a popup its own tree and its own router, so the wrong coordinates were routed confidently against the right tree.

Decision

A pointer event’s coordinates are reconciled against the window they were attributed to, in the backend, before the event leaves it. This is the layer that already knows both numbers and the last one that can tell them apart: above it a BackendEvent is a fact.

The bounds check is the detector. A coordinate inside the window it was delivered to is taken as given — every event on every other platform, and most of them here. One that falls outside is a coordinate whose space is in doubt, and only then is anything asked.

The desktop is the answer. SDL_GetGlobalMouseState minus the window’s own desktop origin settles the question without asking the platform what it thinks the event belongs to. It is the one reading of the pointer that does not depend on the attribution that is itself in doubt.

A popup’s desktop origin is its owner’s position plus the offset it was asked for. SDL_GetWindowPosition on a popup reports the display’s coordinates on some drivers and the parent’s on others, which Sdl3Popup already records; its owner is an ordinary window and answers reliably, and the requested offset means the same thing everywhere. Two readings that are not in doubt, rather than one that is.

Every window is reconciled, not only popups. A top-level window’s coordinates are already inside its own bounds, so the branch never fires for one — and a rule that named popups would be a rule that stops being checked the day something else needs it.

A window that will not say where it is keeps its coordinates. There is nothing better to offer, and a guess would be worse than the platform’s.

Alternatives considered

  • Make the popup focusable on macOS. It would let SDL rewrite the coordinates, and it would give back exactly the bug ADR-0189 fixed — a menu left open over another application’s window after its owner hid.
  • Offset by the popup’s own origin unconditionally, without a bounds check. It assumes every event is in the owner’s space, which is false for the press; the press and the release genuinely arrive in different spaces, so a correction applied to both breaks the half that was right.
  • Track the pointer in the owner’s window and use its last position. That is what SDL is already doing and what is stale — the owner sees no motion while the pointer is over the popup, which is the whole problem.
  • Correct it in PointerRouter. The router would have to know which windows are popups and which platform it is on, both of which are the backend’s to know; ADR-0103’s whole point is that the router sees one tree in one space.
  • Do it only on macOS, behind a platform check. The bounds check is already the sharper question — it asks whether the coordinates are wrong rather than whether the platform is one that gets them wrong — and it costs nothing where they are right.

Consequences

  • A pointer event may cost one more native call, and only after failing its bounds check: a SDL_GetGlobalMouseState and two floats out of a confined arena. Never on the ordinary path, where the coordinates are inside the window and nothing is asked.
  • Sdl.globalPointer() is a second polled reading beside modifierState(), taken at translation time for the same reason — inside the pump that produced the event is the closest to “when it happened” this layer can get (ADR-0089).
  • A drag off a control still cancels the click. The desktop reading agrees that the pointer is outside the window; only the magnitude changes.
  • The reconciliation is tested and the attribution is not. SdlEventBuffer gained writeMouseMotion and writeMouseButton for writeWheel’s reason — a test cannot move a pointer — so all three branches run under the dummy driver against the real translate: inside the window is untouched, the far edge counts as inside, and outside is replaced by the desktop reading. What no test here reaches is SDL deciding which window an event belongs to, which is a property of a real NSWindow; the dummy driver refuses popups outright (Sdl3PopupTest), so the bug itself is reproduced by running the showcase on a Mac.

212. A list owns the models a tree borrowed

Date: 2026-08-28

Status

Accepted. Settles the debt ADR-0184 and ADR-0210 each recorded.

Context

docs/core-widgets.md §10’s list is “a vertical list over an observable item model with an item-factory (any widget as row); selection models: none / single / multi (Ctrl/Shift semantics); full keyboard (arrows, Home/End, type-to-select when items expose text); item context menus”.

It has been specified and unbuilt while three widgets that §3 defines in terms of it were built. tree “shares list’s item-factory” and takes “list’s selection models”, and select tree= needed tree — so each time the question came up, the answer was ADR-0184’s rule: the widget that needs a model first defines it and writes down that the other will have to agree. That debt was taken twice, knowingly, and it accrues: every month list stays unbuilt is a month in which the definition of a selection model lives in the wrong widget and a second borrower could arrive.

It is also the cheapest of the outstanding catalog entries to get wrong quietly. A list is the widget every application has, and the shape of its API decides whether the virtualization §10 promises for v1.x is a performance change or a break.

Decision

Selection moves to list and tree imports it. That is the debt paid the way it was promised — the definition goes to the widget the specification names it after, and nothing about the shape changed, which is the evidence that the promise was a small one to make. Checkable stays in tree, because §10 gives a list no checkbox and a model with one consumer belongs to that consumer.

The class is ListView and the CSS type is list. A widget record named List would shadow java.util.List in every file that built one — including its own, whose model is a java.util.List. ListBox is the styled node, in the Tree/TreeBox arrangement every stateful widget in the catalog uses.

An item is anything, and three functions describe it. identity says what it is, factory says what it looks like, and text says what it reads as. Three lambdas rather than an interface to implement: the common case is three method references, and an interface would make the trivial list — strings drawn as text — the one that costs the most to write. ListView.of(List<String>) is that case as a factory.

text is optional, and its absence turns type-to-select off. §10 makes the feature conditional on “items expose text”, so a list of colour swatches gets no typeahead — and, more importantly, does not consume the keystroke. A row that swallowed text it could not use would stop a field elsewhere from ever seeing one.

§10’s item context menus are named on the row. A menu is a name on a widget (ADR-0108) found by walking up from an element. Naming it on what the factory returned would work for a right-click and not for the keyboard, because the menu key walks up from the focused element (ADR-0208) and that is the row. So ListRow carries an Attributes — the only part in the catalog that does — and itemMenu is a function, because a folder and a file do not offer the same commands.

A row’s focus name is scoped by its list’s id. host.focus takes a name global to the window (ADR-0176), so two lists over items with equal identities would each answer to the other’s Home. Prefixing settles it wherever the application named the list, which is the case a screen with two lists on it already has because a stylesheet needs to tell them apart too.

Home and End go to the ends of the model, not of the viewport. A list’s own scrolling is a scroll ancestor’s business and the focus ring is what asks it to follow (ADR-0120) — the rule tree already states, and what Ctrl+End means in every document.

The focus ring stays on a list row where a dropdown’s row has none. In a select the arrows move the value, so “where the keyboard is” and “what is chosen” are one row and a ring would be a second marker for one place (ADR-0112). Here the arrows move the focus and Enter chooses, so they are genuinely two rows and need two marks.

A NONE row is still focusable and does not consume its click. §10’s none says what may be chosen, not what may be read: a list nobody can select from is still one a keyboard user must be able to walk, or its rows are unreachable content. And a row that swallowed the click would stop a button the item-factory put on it from ever being pressed.

Alternatives considered

  • Naming the widget List and asking callers to qualify java.util.List. The CSS type is what a stylesheet writes and the class name is what an application writes; only one of them has to live in a file that also holds a collection.
  • An Item interface — id(), label(), widget() — as tree has TreeNode. Right for a tree, whose model is a shape (a node with children) rather than a value; wrong here, because §10’s item is explicitly the application’s type and wrapping every row in an adapter is the ceremony ADR-0184 avoided for the tree by making the node a record the caller builds.
  • Sharing TreeRow between the two. A tree row is an indent, a chevron, a box and a label; a list row is whatever the factory returned. The only shared part is the keyboard, which is fifteen lines, and a shared row would have to carry a depth and an expansion state that a list has no meaning for.
  • Building virtualization now. §10 defers it to v1.x and says why the item-factory makes it a performance upgrade rather than an API break — which is only true if the factory ships first and is used. Building both at once would be designing the recycler against no callers.
  • Reporting the pressed id rather than the whole set. ADR-0210’s finding, inherited: a Shift range is computed over rows only the widget can see, so an id alone is an answer the application cannot turn back into a selection.

Consequences

  • tree gains an import and loses a definition, and every existing tree golden is byte-identical, because the enum’s constants and their meanings did not move — only the package did.
  • docs/core-widgets.md §10’s list row is built, and table is the only entry left in that section — still deferred, still on virtualization.
  • The virtualization debt is now list’s own. §10 promises recycling as a v1.x follow-up and this ships the API it promised would survive it; whether it actually does is untested until a recycler exists, which is the honest state of a promise about work not yet done.
  • ListRow carrying attributes is a precedent. A part that says something on a widget’s behalf is new — every other part in the catalog is drawing and handlers only — and the next widget with per-item metadata will find it.
  • Nothing in markup builds one. An item-factory is a function and §8’s documents have no way to write one, which is canvas’s wall and autocomplete’s; a list is Java, like both.

213. A virtual list is two spacers and a window

Date: 2026-08-29

Status

Accepted. Delivers §10’s committed follow-up, and unblocks table.

Context

docs/core-widgets.md §10 says a list “renders instantiated rows — fine into the low thousands” and commits “virtualization/recycling is the committed v1.x follow-up (the item-factory API is designed for recycling from day one so it’s a performance upgrade, not an API break)”. ADR-0212 shipped the item-factory in that shape and recorded that whether it survived contact was untested, because it could not be tested until a recycler existed.

table is deferred behind the same work (ARCHITECTURE §17), so this is one mechanism two entries in §10 are waiting on.

The hard part is not deciding which rows to build. It is that the answer depends on geometry a widget cannot compute — where the list was painted, and what clips it — and every facility that hands geometry to a widget carries the same warning: what it triggers must not change what it reports (ADR-0117 rule 3, ADR-0119’s termination rule). A list that built fewer rows would be shorter, be told a new position, build again, and oscillate at the frame rate.

Decision

The window is computed from [Located], and the rows outside it become two spacers. clip.top() - self.top() is how far into the model the viewport has reached, because self is where the list has been scrolled to rather than where it was laid out. Divide by the row height for the first index, divide the clip’s height for how many fit.

The spacers are what make it terminate, and this is the whole of the safety argument. A spacer’s height is rowCount × rowHeight, so the column adds up to the same total however the window moves: the node that was measured does not move, the next frame reports the rectangle that produced this window, and the second report changes nothing. That is affix’s structural answer (ADR-0119) applied to a different problem — not a rule anyone has to remember, but an arithmetic identity.

Two spacers rather than one padded box. The one above and the one below answer different questions — how far down the window starts, and how much is left under it — and a single padding could not say both.

It is opt-in, and it takes a row height rather than a flag. The height is the one thing the widget cannot find out: a stylesheet resolves --gb-list-row-height and no widget can read a resolved custom property (a live TODO.md entry, and ScrollViewport.LINE’s reason for being a constant). A number nobody states is a number nobody has. It also makes the feature honest about its precondition — index × height is only a position if every row is that height, so a list with rows of varying height must not virtualize, and saying the height out loud is where that becomes obvious.

Four rows of overscan. Located is last frame’s, so a wheel that travels half a viewport between two frames would show a band of nothing for one of them. Four rows is 128 logical pixels at the default height — more than a detent moves — and it costs eight built rows on a model of any size.

The first frame builds a guess, because the window is computed from a painted rectangle and the first frame is what produces one. The spacers make the guess harmless: the column’s total height is right from the first frame however few rows are in it, so nothing jumps when the second frame corrects the window.

Home, End and the typeahead reach rows that are not built. This is the one thing virtualization breaks and has to put back. All three move the focus by name through Host.focus (ADR-0176), and a name resolves against the element tree — so a virtual list asked for its last row was asking for a row that does not exist, and End did nothing at all. The move becomes two steps: widen the window, then focus.

And the second step is a retry rather than a delay. The frame loop fires its timers after the platform pump, and whether the repaint a setState asked for was drawn inside that pump or is still queued depends on the pacer — so the first attempt may reach a tree that has not been rebuilt. Host.focus returns whether it found anything, which is what makes that recoverable rather than a lost keystroke. Bounded at two attempts, so an id naming no row costs two turns and stops.

Alternatives considered

  • Measuring the rows instead of being told a height. It is what a variable-height virtual list needs and it needs a second pass: to know where row 4,000 begins you must have measured the 3,999 above it, which is the work being avoided. The usual way out is an estimate corrected as rows are measured, which makes the scrollbar drift under the reader’s thumb. Not worth it for a row height the design system fixes anyway.
  • Recycling elements rather than rebuilding them. §10 says “virtualization/recycling” and this is the first half. The reconciler already keys rows by item identity, so a window that moves by one row reuses every element but one — which is recycling, done by the machinery that was already there rather than by a pool this widget owns.
  • Reading the scroll offset from a ScrollController. It would couple list to scroll, and a list is not always in one — Located reports the window’s own rectangle when nothing clips (ADR-0119), so an unclipped virtual list correctly builds a screenful and no more.
  • Turning it on automatically above some row count. The row height would have to be guessed, and a wrong guess is a broken layout rather than a slow one. A list of 20,000 that is slow is a list somebody will profile; a list of 20,000 whose rows overlap is a bug report about drawing.
  • Making the reach synchronous by flushing the tree. A widget that flushed the element tree from a key handler would be rebuilding the tree it is currently being dispatched inside.

Consequences

  • A list of ten thousand builds about twenty rows, and the tests say so by driving real painted frames — the assertions fail in seven places when the row height is set back to zero.
  • The focused row can be scrolled out of existence. Wheel a virtual list far from the focus ring and the focused row leaves the window, is unmounted, and the router drops it (ADR-0180’s rule). Arrow keys are unaffected, because the ring asks the viewport to follow it; only pointer-scrolling away and then pressing an arrow loses the place. Every recycling list has this unless it pins the focused index, and pinning it would keep a row nobody is looking at built for ever.
  • list-spacer is a part a stylesheet can select and must not decorate. A border or a background on it would be a band of paint where the model says there are rows, which is exactly the illusion the spacer exists to maintain.
  • A row height that disagrees with the stylesheet is a silent layout error. Nothing checks that the number passed matches what the rows measure; the symptom is rows that drift out of step with the scrollbar. A Measured assertion on the first built row could catch it, and is not built.
  • table is unblocked, which was the other half of why this was worth doing now.

214. A table is a list with columns

Date: 2026-08-29

Status

Accepted. Closes §10, and takes table out of ARCHITECTURE §17’s deferrals.

Context

docs/core-widgets.md §10’s entry for table was one sentence — “deferred (ARCHITECTURE §17); it awaits the virtualization work. Recorded here so the name is reserved in the registry” — and design-system.md §3 had no metrics row for it at all. So this ADR is unusual in that most of the work was writing the specification, which §5 requires before code: a spec, a §3 metrics row and a §3.1 transitions row, in that order, and then the widget.

What it was waiting for arrived with ADR-0213. And what it was waiting for turned out to be the whole answer: a table’s rows are a list’s rows with more than one thing in them.

Decision

It is a list, composed rather than reimplemented. Table builds a ListView whose item-factory returns a row of cells. The selection models, the typeahead, Home/End, the item context menus and the ten-thousand-row window are inherited rather than written twice — so a bug fixed in one is fixed in both, and TableTest asserts the seam rather than repeating ListTest.

This is what §10 meant by a table “awaiting the virtualization work” without saying so: the thing it was waiting for was list.

A column is a key, a header, a width and a cell-factory, and the cell-factory is list’s item-factory per column — the same function with the same rules, so any widget is a cell.

The width is a number or a share, and flexbox already had both. A fixed column is that many pixels and will not shrink; a weighted one is flex-grow over a zero basis. The layout engine does the arithmetic, so there is no second sizing model to keep in step with the first — and a zero basis rather than auto, or a column of long strings would quietly outgrow its weight.

One piece of code sizes a header and the cells under it. They have to come out the same width or the table is not a table, and the cheapest guarantee is that both go through Sized.apply rather than through two functions that look alike.

Sorting is the application’s. A click on a sortable header reports what the sort would become — not the column it landed on — because which way a second click goes is a rule about tables rather than something every application should restate. The rows arrive in whatever order the application hands back, and the caret is drawn for whatever Sort it is given. The same split select’s autocomplete draws: a table over a database sorts in the query, and one that sorted its own copy would be showing a different answer from the one the query would give.

A caret slot is kept on every sortable header, drawn or not. This is tree-chevron’s rule — a leaf keeps the gutter so that labels line up — and here what it prevents is worse than a misalignment: without it, sorting a column takes 16px away from that column’s own label at the moment the reader clicks it, so every header the sort visits shuffles its text. The golden image is what found this, which is the argument for §5’s rule that a picture comes before the widget is called done.

Box.Mark.Kind.CHEVRON_UP is new. A third chevron for the second one’s reason — the subset has no transform on a mark — and here the two are not decoration but the value: a caret pointing the wrong way says the column is sorted the other way, which is a lie a rotation would make easy to ship.

A header is a Tab stop only when it sorts. §2.2 wants everything reachable, and a header that does nothing is a label; a stop on it would answer no key, which is worse for a keyboard user than not being there.

No rule between the rows, and one under the header. §1’s restraint applied to the densest thing in the catalog: a grid of lines is furniture competing with the data in it, and the row height plus the hover wash already say where a row begins. The line that stays is a boundary between two kinds of thing.

Alternatives considered

  • Reimplementing the rows. It would have let a cell be the focusable thing rather than the row — which is what a spreadsheet needs and what §10 does not ask for — at the cost of a second copy of the selection models, the typeahead and the virtualization, three months after the first copy was written.
  • A grid layout instead of flexbox rows. §8’s subset has no grid, and adding one for this would be a large change to the style engine to avoid a small one to the widget. The columns line up because one function sizes them, which is the property a grid would have given for free and is cheap to guarantee by hand.
  • Sorting inside the widget, with a Comparator per column. It works for a table over a list in memory and is wrong for every other table, and the application that outgrew it would have to take the sorting back — which is an API break rather than an addition.
  • A three-state sort cycle (ascending → descending → unsorted). Most desktop tables do not offer it and the ones that do disagree about what the third state means. Sort.next is a suggestion the application may ignore, so one that wants the third state returns null from its own handler.
  • A sticky header through affix. Tempting, and it is what affix is for — but affix pins to the nearest scroll, and a pinned affix is not pushed out by the next one (a live TODO.md entry), so two tables on one screen would overlap their headers. Left until something asks.

Consequences

  • §10 is complete, and table leaves ARCHITECTURE §17’s deferred list — which had it behind virtualization, correctly.
  • The header is not virtualized, because it is outside the list entirely. It costs one row on every frame regardless of the model’s size.
  • A cell cannot be focused, only its row. Right for §10’s grid semantics and wrong for a spreadsheet, which is a different widget.
  • Column resizing is not built. §3’s metrics row allows for it (column resize: 1:1, like split-pane's drag) and nothing has asked; the drag would be a split-pane divider between the headers, which is a widget that already exists.
  • Horizontal virtualization is not built either, and is a different arithmetic — worth it past about fifty columns, which is past where a table is the right widget.
  • The header’s rule shipped as border-bottom and drew nothing, which §8’s subset does not have — corrected in ADR-0215, where it became a node and the toolkit’s own stylesheets gained a lint. The golden image was accepted with the line missing, which is a limit of a picture worth naming beside the praise above: it caught the caret’s reflow and it did not catch a single absent line of pixels.
  • Column.sortable takes a boolean rather than reading as sortable(), because a record’s accessor already has that name. The same reason Table’s selection and tree’s checkable take theirs, discovered the same way — by the compiler refusing an invalid accessor.

215. A property the engine drops is a rule that does nothing

Date: 2026-08-29

Status

Accepted. Fixes a rule shipped in ADR-0214 and closes a TODO.md entry open since ADR-0109.

Context

table-head shipped with border-bottom: 1px solid var(--gb-border), which §8’s subset does not have: there is one border and no per-edge longhands. The engine did exactly what it promises — logged at debug and carried on — so the line §3’s metrics row asks for under a table header was never drawn, and the golden image was accepted with it missing.

This is the fourth time. TODO.md has recorded it since ADR-0109: “margin is not in §8’s subset, which tab-new found after border-bottom and currentColor. Three properties a widget reached for and did not find, all silently ignored — the subset is right to be small, and nothing warns when a declaration is dropped.” menubar documents the same wall in a comment in the stylesheet itself.

The engine’s behaviour is right and is not what needs changing. A stylesheet naming box-shadow before it is implemented should not stop a window opening, because that stylesheet may be an application’s. What was missing is that the toolkit’s own sheets were being held to the same lenient standard as a stranger’s.

Decision

The rule is a node. table-rule is a box one pixel tall with a background, which is separator’s answer to the same problem and the only one the subset allows. Not a new border-bottom property: per-edge borders are a change to the box model and to Yoga’s edge handling, and one table’s underline is not the case that should decide it.

The toolkit’s own stylesheets are linted, and an application’s are not. SupportedPropertyTest resolves every rule the catalog and the showcase ship through the real ComputedStyle and fails on anything it reports as unsupported. The asymmetry is the point: leniency is for stylesheets the toolkit did not write.

It asserts the behaviour rather than a copy of it. There is no list of supported properties in the test to drift out of step with the engine — it attaches an appender and reads what the cascade actually said. A property added to the engine tomorrow needs no edit here; one removed is caught the same day.

It lives in :example. That module already ships logback, so capturing the cascade’s own output costs no new dependency on :widgets; and §14 makes the gallery the visual regression corpus, which is where a declaration that draws nothing is a screen that has been photographed wrong.

Custom properties are excluded. --gb-accent: … reaches the same branch and is logged the same way, and it is not a fault: custom properties are the resolver’s, computed for var() substitution before ComputedStyle sees a declaration (ADR-0049). Every theme is nothing but those, so the unfiltered check reported 158 failures on a healthy tree — which is how the filter came to exist rather than being foreseen.

The check checks itself. A third test feeds it border-bottom and asserts it is caught, because a change to the log’s wording or to the appender wiring would otherwise make the other two pass by seeing nothing at all.

Alternatives considered

  • Adding border-bottom to the subset. It is the fourth request, which is an argument for it — and it is a change to the box model rather than to a parser: Yoga takes per-edge border widths, but the painter draws one stroked rounded rectangle (ADR-0064), so a single-edge border is a different drawing and not a different number. Worth doing when something needs an edge the subset cannot fake; a 1px node is not a workaround here so much as what a rule is.
  • Raising the log from debug to warn. It is still a log, and the entry this closes says why that is not enough: one line per property per stylesheet is a message, but it arrives at start-up on a stream nobody is reading, and the three previous instances all had it.
  • A hand-written set of supported properties, checked against the sheets. Cheaper to write and the failure mode is drift — a property added to the engine and not to the list makes the test fail on a healthy tree, and the reflex fix is to edit the list rather than to question it.
  • Failing the engine on an unsupported property in a toolkit sheet, by marking sheets as trusted. It puts a test’s concern in the runtime, and it would turn a cosmetic mistake into a window that does not open.

Consequences

  • One more test guards a whole class of mistake, and it found nothing else: border-bottom in table-head and padding-bottom in the showcase’s #peaks were the only two live instances in the tree.
  • :example has logback on its test classpath, where it previously had it only at runtime.
  • The four table goldens changed, because the rule is now drawn. The difference between the accepted image and the corrected one is a single line of pixels — which is how a missing rule survives review, and an argument for the check rather than against the golden.
  • An application still gets silence. The TODO.md entry is narrowed rather than closed: what warns is a test over the toolkit’s sheets, and an author writing margin in their own stylesheet still gets a debug line and a declaration that does nothing.

216. A corner is four numbers, and the lint reads values too

Date: 2026-08-30

Status

Accepted. Extends ADR-0215, which caught the other half of the same fault, and answers the question ADR-0097 parked.

Context

Two lines in the running application’s log:

WARN ComputedStyle - dropping "border-radius": 7px 7px 0 0 is not a valid value
WARN ComputedStyle - dropping "background": none is not a valid value

Both are rules in the toolkit’s own controls.css, and both had been doing nothing since the widget that wrote them shipped.

  • group-box-title { border-radius: 7px 7px 0 0 }. §5’s frame is 8px round with a 1px edge, and the header fills the top of it — so the header’s top corners are the frame’s radius less its border, and its bottom ones are square where the body carries on underneath. The engine resolved one radius per box, so the whole declaration was dropped and the header drew four square corners, two of which spilled out of the frame’s curve.
  • select text-input { background: none }. §3’s autocomplete select holds a real text-input, and ADR-0183 says that editor is the select’s interior: no edge, no fill, no radius. The line above it, border: none, worked — stroke has taken none since borders existed. background did not, so the field kept the well colour text-input gives it.

This is ADR-0215 one property along. That record made the toolkit’s own sheets lint-able and closed the case where the property does not exist. These two properties exist; it was the values the engine would not take. The difference in the log is a warn instead of a debug — which is louder and was read exactly as often, because a start-up stream nobody is watching is a stream nobody is watching at either level.

Decision

A radius is four numbers. Corners(topLeft, topRight, bottomRight, bottomLeft) replaces Decoration’s single double radius, border-radius takes CSS’s 1-4 shorthand in CSS’s order, and RoundRect draws it. This is the change ADR-0215 declined to make for border-bottom, and the reason it is right here and wrong there is the same reason: a rule under a table header is expressible as a node — table-rule, a box one pixel tall — and a corner is not. There is no arrangement of boxes that rounds two corners of a header and leaves the other two alone, and nothing in this toolkit clips.

One drawing, not two. RoundRect.addTo takes Corners, and the uniform case emits exactly the point sequence the single-radius version always did — a square corner is a lineTo into the corner point and no cubic at all. That is what says the hundred boxes in the catalog that write one number did not move, and the golden images agree: the only two that changed are the two with a group-box in them, by 31 pixels each.

Circular corners only. CSS’s full grammar allows an ellipse per corner (border-radius: 10px / 20px); this does not. An elliptical corner is a different curve rather than a different number, and the declaration is dropped with the warning that names it — the same answer §8 already gives 50%, which a box cannot resolve because it has no size until Yoga has run.

background: none is transparent, and background-color: none is not. CSS divides them: none in the shorthand turns off the image layers, and the longhand takes a colour. The toolkit has no image layer, so the one thing “no background” can mean to a painter is what it means here — and it is how a rule turns a fill off without having to know what colour it is turning off, which is what border: none has always done one property up.

The lint reads values as well as names. SupportedPropertyTest (ADR-0215) now fails on dropping "…": … is not a valid value as well as on ignoring unsupported property. Both are the same fault — a rule that does nothing — and splitting them by which half was at fault would be a check that catches the mistake somebody made last time.

Which forced the lint to run the real cascade. Feeding raw declarations straight to ComputedStyle was good enough to check a property name; for a value it is not, because every colour in the toolkit is var(--gb-something) and substitution belongs to StyleResolver. The unmodified check reported 164 failures on a healthy tree. So the test now builds a probe element per selector — one node per compound, chained by parent, so select text-input is a text-input inside a select — and resolves it through the real resolver. The leftmost probe has no parent, which is what makes it :root and is how the theme’s custom properties reach the chain.

And ComputedStyle.forgetReportedDrops() is public. A drop is reported once per JVM, so that one typo cannot report itself sixty times a second; a lint in another module that did not clear it would pass by reading an empty log.

Alternatives considered

  • Rewriting the stylesheet to border-radius: 7px. Cheapest, and wrong in a way a screenshot shows: the header’s bottom corners would be notched away from the body underneath it, which is a hole where two surfaces meet.
  • Accepting the shorthand only when all four values agree. It would still drop 7px 7px 0 0 — the actual declaration — while claiming to support the syntax. A parser that takes a form and then refuses the reason anyone writes it is worse than one that refuses the form.
  • Keeping radius() on Decoration as a derived accessor. Nine test call sites would have kept compiling, and each would have been asserting something the name no longer answers: with four corners, “the radius” is a question with no correct return value. They now compare a Corners.
  • Elliptical corners, since the grammar was being widened anyway. No caller wants one, RoundRect’s four cubics are quarter circles, and an unused curve is an untested one.
  • Raising the value drop from warn to error, or throwing. The engine’s leniency is deliberate and ADR-0215 settled it: an application’s bad declaration must not stop a window opening. What was missing is a test over the toolkit’s own sheets, which is what this adds.

Consequences

  • Decoration.radius is now Decoration.corners, a Corners. decoration.radius(8) still exists and sets all four, which is what every design-system radius wants; decoration.corners(…) is the new one. Nine assertions across :widgets and :core compare a Corners instead of a double.
  • Two golden images changed, group-box-dark and gallery-panels, by the 31 pixels of two corners each. Every other golden is byte-identical, which is what says the shared drawing is shared.
  • The select’s inner field is transparent now and no pixel moved, because select and text-input are both --gb-surface-sunken wells: the fill that was wrong was the same colour as the fill behind it. It stops being invisible the first time either token changes.
  • ADR-0097’s parked question can be reopened. SegmentedTest says of the bar and its inset grid: “the day a per-corner radius exists this is the rule that should be revisited”. That day is today. Not revisited here — the inset works and a control that draws correctly is not a bug — but the note is no longer waiting on a mechanism.
  • The lint got stricter and slower: it runs the cascade per selector rather than ComputedStyle per rule. It found nothing beyond these two, on either half.
  • An application still gets silence, exactly as before. What warns is a test over the toolkit’s sheets; an author writing border-radius: 50% in their own stylesheet gets a warning line and a declaration that does nothing.

217. A segmented control is joined again

Date: 2026-08-30

Status

Accepted. Supersedes the drawing half of ADR-0097; depends on ADR-0216; keeps the grid ADR-0099 built.

Context

docs/design-system.md §3 asked for one drawing and segmented shipped another. §3’s row is “height 32 (28); segment padding-x 12; radius 8 outer, 0 between; 1px divider in --gb-border” — the joined-buttons look, where the segments meet, are square where they touch, and the bar is round at its two ends.

ADR-0097 declined it, on two grounds:

  1. A per-corner radius did not exist. §8’s subset resolved one radius per box.
  2. Nothing clips. There is no overflow: hidden in the subset and no clip in Box, so the usual escape — square-cornered fills inside a rounded, clipping parent — was not available, and a square fill in the corner of the bar paints over the bar’s own curve.

What shipped instead was an inset pill: the bar keeps the radius, the segments sit 2px inside it, and §3’s divider went with the joined drawing it belonged to. Both rows of the design system were amended to describe it, and SegmentedTest pinned the numbers with a note saying so — “the day a per-corner radius exists this is the rule that should be revisited rather than quietly left behind.”

ADR-0216 built per-corner radii, for a group-box header that could not be faked with nodes. That is the day.

Decision

§3’s drawing ships. The bar is round at its ends and its segments meet.

The second objection dissolved with the first, rather than being answered. Clipping was only ever needed to cut a square fill to the bar’s shape. A fill that rounds its own two outer corners is already that shape, so there is nothing to clip — the same move ADR-0216’s group-box-title makes one control up.

The bar’s padding is its border’s width. padding: 1px where it was 2px, which is not a spacing step off §1.3’s ramp but the width of the line it clears: it puts the track exactly on the bar’s inner box, so a segment’s fill stops where the border’s ink begins, and the 7px the segments and the pill carry is the bar’s 8 less that border — concentric, which is what makes a fill lie flat against a rounded edge instead of poking through it.

Which corners are kept is Java’s; the radius they keep is CSS’s. Whether a cell is at an end of the row depends on a count, and no selector can count segments — the same argument ADR-0099 used for the cell width and the travel. So controls.css declares border-radius: 7px on option and segmented-indicator, and SegmentedTrack.render and SegmentedIndicator.restyle square the corners that are not at an end, through the new Corners.inRow(atStart, atEnd). A theme changing the radius still changes it; a theme cannot get the geometry wrong.

The divider came back as a node. §8’s subset has one border and no per-edge longhands, so “a line on the left of every segment but the first” is not a declaration anything can write. segmented-divider is a box one pixel wide with a background — table-rule’s answer (ADR-0215) and separator’s before it.

It is out of flow. A hairline in flow would take a pixel of the row, and the row is a grid: three dividers between four segments make each cell (100% - 3px) / 4, which no percentage names and which would break the travel that depends on every cell being exactly 1/n. Absolute, at left: k/n%, costs the grid nothing.

The two hairlines beside the selection fade out. A line at the edge of the filled pill draws a border between the selection and its neighbour, which is a boundary the selection already is. Both go rather than one, so the control is symmetric; and they fade, on §1.7’s fast, because the pill takes base to travel and a hairline that blinked would beat the movement that explains it.

And the hairlines are painted under the pill. The track’s children are the dividers, then the indicator, then the labels — a box tree has no z-order beyond document order (ADR-0053), so that list is the stacking. Painted after the pill, a divider would draw a line across the moving fill for the 160 ms it takes to cross.

Alternatives considered

  • Leaving the inset pill and closing the note. It draws correctly and it looks like a current toolkit’s segmented control. But the specification was amended to it under duress and says so, and the reason has expired: keeping it would mean a design system that records a constraint that no longer exists.
  • Hiding only the hairline the pill covers. The pill covers the boundary on its left and abuts the one on its right, so hiding only what is covered is one line fewer of code and an asymmetric control — a line on one side of the selection and not the other, for a reason no reader could see.
  • Dividers in flow, with cells sized (100% - (n-1)px) / n. calc() is not in the subset, and adding it for this is a parser feature to place a hairline.
  • A divider drawn by the segment as a left border. Per-edge borders are the change ADR-0215 declined, for the reason it declined it: a single-edge border is a different drawing rather than a different number, and one control’s hairline should not decide the box model.
  • :first-child / :last-child so the stylesheet could round the ends. The §8 subset deliberately has no ordinal selectors — every one of them makes matching depend on sibling order, which is what makes invalidation expensive (ADR-0004’s seam, kept small on purpose).

Consequences

  • Ten segmented goldens changed, plus the gallery’s two Controls screens. The bar reads as one object with three cells rather than a plate with a pill inside it, which is what §3 asked for.
  • A segmented-unset golden is new, and it is the only image in which every hairline shows: with three segments and the middle one selected, both dividers are beside the selection. That is not a bug and it is why the image exists — a reader who never sees a divider should be able to check that one is drawn.
  • The focus ring moved off the bar’s edge. ADR-0097 recorded, as a coincidence of two independently derived numbers, that a 2px ring at a 2px offset landed exactly on the bar’s border when the segment was inset by 2. With the segment against the inner edge the ring sits just outside the bar and takes the segment’s own corners — rounded at the ends of the row, square between. segmented-focus.png is the evidence that a middle segment’s ring is still legible where it crosses the edge.
  • Corners.inRow is in :core, not in :widgets, because the next two callers are already named: §3 gives button.square radius 0 “where buttons butt against each other”, which is this drawing seen from the other side, and tabs will want it.
  • A test that counted past the track’s parts had to stop. Both SegmentedTest and SegmentedGoldenTest reached a segment as children().get(index + 1) — one past the indicator — and there are now n parts before the segments. They find an option by type instead, which is what they meant and does not change when the anatomy does.
  • design-system.md §3’s row is amended back, and the amendment is recorded rather than silently reverted: the row now describes the joined drawing again, with both ADRs cited, so a reader can see the constraint arrive and leave.

218. A paragraph approximates bidi rather than refusing it

Date: 2026-08-30

Status

Accepted. Amends ADR-0036’s last consequence; the run splitting it names is still ahead.

Context

Paragraph.of threw UnsupportedOperationException on any text java.text.Bidi.requiresBidi was true for. The reasoning (ADR-0036) was sound: every measurement in a paragraph is a prefix sum accumulated in logical order, HarfBuzz returns a right-to-left run in visual order, so a paragraph that accepted such text would measure the wrong glyphs and wrap confidently in the wrong places. Loud beat silent.

It was loud in the wrong place. A text-input does not choose its text — a user pastes it — and nothing between the clipboard and the paint catches that exception. So a user who pasted Arabic into a field lost the window: the paste succeeded, the field held the text, and the frame that tried to describe it threw. The one crash left on TODO.md, and the entry said what was missing: “refusing the paste is not acceptable and neither is crashing, so the interim behaviour has to be chosen.”

Decision

A paragraph never refuses text. Text that needs bidi is shaped with the direction forced to LTR, which is what makes the glyphs come back in the order every measurement here assumes.

The glyphs are right and the order is wrong. Joining and ligature forms come from the script, which is still guessed, so Arabic is shaped as Arabic; what the forced direction changes is the sequence. The text is therefore drawn mirrored — first character at the left — rather than reordered into nonsense.

Everything else stays self-consistent. Widths, wrapping, caret positions, hit testing and selection rectangles are all derived from the same prefix sums, so a click lands where the caret is drawn and a highlight covers the glyphs it appears to cover. The text is wrong in exactly one way, and it is the way that needs run splitting to fix.

It says so, twice. Paragraph.isBidiApproximate() answers for any caller that wants to know, and Paragraph.of logs one warning per distinct string — once, because a paragraph is shaped once and held by ParagraphCache, and nothing on this path runs per frame.

Font.shape(text, direction) is the new seam. One overload, one caller, and it is the same call the eventual run splitter needs: bidi run splitting is “shape each run in its own direction”, so the parameter that makes this approximation possible is the parameter that will make the real thing possible.

Alternatives considered

  • Catching it in the field. It puts a try/catch around a paint and leaves the question of what to draw unanswered — a field that swallowed the exception would draw nothing and hold text nobody can see. And every other caller of Paragraph would still crash: a label bound to a model, a menu item, a tooltip.
  • Refusing the paste. The field would keep working and the user’s text would vanish with no explanation. TODO.md ruled it out before this was written, and it is worse than mirrored text: data loss beats a drawing fault.
  • Substituting a placeholder — ? per RTL character. Also data loss, with the added property that the field’s contents would no longer be the text the model holds.
  • Building bidi run splitting now. The real fix, and not an interim: it is several runs per line, visual reordering within a line, per-run measurement, and a caret that has to know which run it is in and which side of it. Every one of Paragraph’s public measurements changes shape, and so does every caller that positions a caret from them. It is M5 work with a specification of its own, and it should not be smuggled in under a crash fix.
  • Handling a uniformly right-to-left paragraph correctly and approximating only mixed text. Tempting, and genuinely tractable — the prefix sums can be built by walking a visually ordered run backwards, and a line’s glyph range stays contiguous. It stops being tractable at the caret: widthBetween measures from the line’s left edge, and in a right-to-left line the caret before offset o is at lineWidth - widthBetween(start, o). That is a change to what every caller of these two methods means, which is the same change full bidi needs — so it is half the work of the real fix for a fraction of the correctness.

Consequences

  • The last crash on TODO.md is gone. TextInputTest pastes Arabic and asserts the field keeps it and the next frame is described; it fails against the old Paragraph, which is what says it is testing the crash and not the fix.
  • Right-to-left text is now visibly wrong instead of fatal, and that is a deliberate trade recorded in three places a reader will hit: the class doc, the warning, and isBidiApproximate().
  • ParagraphCache has no refusal path left. Its test for “text the shaper refuses is not cached” became “text that needs bidi is held like any other”, which is the same assertion about the same string with the exception removed.
  • Font.shape has an overload that takes a direction. The single-argument form delegates to it with a null direction — “guess”, the behaviour every existing caller had.
  • Nothing else changed. Text that never needed bidi takes exactly the path it always did, including the guessed direction, so every golden image is byte-identical.

219. An item tells its menu what the keyboard did

Date: 2026-08-30

Status

Accepted. Closes four TODO.md entries opened by ADR-0112, ADR-0113 and ADR-0163.

Context

Four entries on TODO.md, filed against three different records, in different words:

  • A keyboard Right into a submenu waits 150 ms, because it went through the same hover-intent path as a pointer. “Wrong, and one line to fix once Item can tell a hover from a keypress.”
  • Left does not close a submenu. “The arrow that opens one has no opposite: it needs a callback from the item to the popup it is in.”
  • Left and Right do not move between menus while one is showing. “Once a menu is open the focus is in a different window, so the bar never sees the arrow — the same missing item-to-popup callback. Fixing either would probably fix both.”
  • Nothing marks the row whose submenu is showing. A row is :focus-visible when the keyboard is on it and :hover when the pointer is, and neither says “this is the branch that is open”.

They are one missing sentence. An Item could tell the menu it is in exactly one thing — onHovered, “the pointer arrived on me” — and every one of the four is something else the row knows first and only the menu can act on.

Decision

MenuSignals replaces the single callback. Four signals, each with a no-op default, supplied by Menus and never by an author:

signalwhat happenedwhat a menu does with it
hovered()the pointer arrivedopen this row’s submenu after §8’s hover-intent delay, or put away what is showing
open()Right or Enter on a row with childrenopen it now
back()Leftclose this submenu, or move to the menu on the bar’s left
forward()Right on a row with no childrenmove to the menu on the bar’s right

The signals are what happened, not what to do. hovered() does not mean “open” — a row with no submenu sends it too, because that is how the menu knows to collapse. The names describe the row’s event; the menu decides the meaning. This is why Left can mean two different things without the item knowing either of them.

A delay is for a pointer. §8’s hover-intent stops a submenu dropping out of a pointer travelling past three rows on its way somewhere; a keypress has travelled past nothing. open() cancels the pending timer and opens in the same frame.

Left is the arrow that opened this menu, undone. In a submenu it closes back to the parent; at the root of a bar’s menu it moves along the bar; at the root of a context menu it does nothing, deliberately — a menu that vanished on an arrow key would be a menu nobody could navigate.

A bar’s two arrows are the bar’s, and it says so. Menus.Siblings(previous, next) is passed only by MenuBarState, only for the root menu of a heading. A submenu gets none, which is what makes Left in one go back one level rather than leaping to the next menu on the bar. The bar wraps at the ends and skips headings that cannot open — a separator between two groups is not a menu, and neither is a disabled one.

A menu is an object now, not a bag of parameters. Menus.OpenMenu holds the popup, the stack, the parent menu and which row is open; prepare is a method on it. Three of the four fixes need state that lived nowhere: which row’s branch is showing, and which menu is above this one.

The open row is marked with a class. item.open, the same shape menu-title.open already uses for the heading whose menu is down — “the branch that is showing” is not one of CSS’s states, and inventing a pseudo-class would put a menu’s internals in the selector engine. The menu re-describes itself through Popup.content, which reconciles from the root, so the keyboard keeps its place and nothing flickers.

Alternatives considered

  • A boolean fromKeyboard on the existing callback. It fixes the 150 ms and none of the other three, and it makes the item’s one signal mean two things depending on a flag.
  • Letting Item open its own submenu. It needs a Host and a Popup, and a widget is a value — ADR-0106’s whole argument, unchanged.
  • An :open pseudo-class. Every pseudo-class in the subset is a state the element tree tracks for any widget; this one is a fact about a menu’s own bookkeeping. select and menu-title already settled the precedent with a class.
  • Marking only the row the keyboard is on. That is :focus-visible, and it is a different question: with the pointer in the submenu, the parent row is neither hovered nor focused and is still the branch that is open.
  • Closing the whole stack on Left at the root of a context menu, so the key always does something. It makes an arrow key destructive, which no menu on any desktop does — Escape is the key that means “put this away”.

Consequences

  • Four TODO.md entries close together, which is what the entries predicted: “fixing either would probably fix both” was right about all four.
  • Item’s onHovered component is now signals, typed MenuSignals and never null. The accessor was only ever read by Menus, and hovering(Runnable) becomes signalling(MenuSignals).
  • Menus.open has a fifth-argument overload taking Siblings. Everything that is not a menu bar passes four arguments and behaves exactly as before.
  • Five tests, four of which fail against the old code: the timing one asserts the submenu is open 100 ms after the key, inside the delay the old path would still have been waiting out; the mark is read in pixels off the parent popup’s last frame, with the pointer moved out of that window first so that the only thing left on the row is the mark.
  • Which menu is showing is asserted by its height — a three-row File against a one-row Edit — because a popup’s content is in another window and a test can see its size but not its tree.
  • What is still open in this area: a bare Alt tap does not activate the bar (F10 does), and an accelerator is still unbound by key rather than by owner. Neither is about this callback.

220. An accelerator is given back by whoever took it

Date: 2026-08-30

Status

Accepted. Closes a TODO.md entry opened by ADR-0163.

Context

A menubar registers the accelerator of every command in its menus when it is mounted, and gives them back when it is not — an accelerator is an entry in a map that outlives the widget tree, so not giving them back is a leak of a kind a widget is otherwise incapable of.

The window’s map was keyed by the shortcut alone. So removeShortcut(Ctrl+O) removed whatever was on Ctrl+O:

new ElementTree(new MenuBar(new Item("File").submenu(
        new Item("Open…", …).accelerator("Ctrl+O"))), host);   // the bar binds Ctrl+O
host.shortcut(Shortcut.of("Ctrl+O"), openADocument);           // the application takes it over
tree.unmount();                                                // and the bar takes it away

The application’s binding is gone, and nothing said so. TODO.md recorded it as “two things claiming one key is already a conflict where the last registration wins; this is that conflict at the other end”, and named the fix: “the map remembering owners, and menubar would be the only thing that used it”.

Decision

The map remembers who bound each key. A binding is (action, owner), and the owner is compared by identity — “who bound it” is a question about an object, not about a value that might be equal to another one.

Two ways to give a key back, and they mean different things.

  • removeShortcut(shortcut) removes whatever is bound. That is what an application unbinding its own key means, and it is the behaviour every existing caller had.
  • removeShortcut(shortcut, owner) removes it only if that owner still holds it. That is what a widget giving back keys it took means, and it is a no-op when somebody else has taken the key since.

The bind side is unchanged. Two commands on one key is an authoring mistake and the later registration still wins — silently refusing the second would make a menu whose second Ctrl+O does nothing and says nothing. What changed is only that the loser can no longer unbind the winner.

A displaced binding is not restored. The map holds one binding per key, so a key the bar took from the application is not handed back when the bar goes away — it is simply unbound. Restoring would mean a stack per key, and a stack is a different feature with a different question in it (“which of the three things that wanted Ctrl+O should fire after the second one leaves?”). The entry this closes asks for neither.

menubar is the only owner in the toolkit, exactly as predicted. It passes its own state object — the thing whose lifetime the bindings match.

Alternatives considered

  • Returning a handle from shortcut(…) and unbinding through it. The principled version: the token is the proof of ownership, and there is no way to spell “unbind somebody else’s”. It changes the signature of a published method every application already calls, and the check it performs internally is the same identity comparison this does — so it buys tidiness at the cost of the API’s stability, for one caller.
  • Refusing a second binding for a key already taken. It makes the collision loud, and it makes the last-writer-wins rule — which applications rely on to override a toolkit default — impossible.
  • A stack of bindings per key, so unbinding restores the previous one. Real, and more machinery than the problem: it needs an answer for what happens when the middle of the stack goes away, and neither the bar nor an application has asked for one.
  • Leaving it and documenting it, which is what ADR-0163 did. It survived one record; the entry stayed open because “a widget can silently unbind an application’s key” is a bug that shows up as a shortcut that stopped working three screens later.

Consequences

  • Host gains two methods: shortcut(Shortcut, Runnable, Object) and removeShortcut(Shortcut, Object). The four-year-old two-argument forms mean “owned by nobody”, which is what an application’s own binding is.
  • Accelerators.bind/unbind take an owner, and MenuBarState passes this. The unowned overloads stay, because an application walking a Menu of its own is a legitimate caller with no owner to name.
  • TestHost mirrors the ownership, because MenuBarTest asserts against it and a fixture that ignored the owner would pass the test the real router fails.
  • Two tests at each level. In :core, that an owner gives back only what it still holds and that removing by key alone is unchanged; in :widgets, that unmounting a bar leaves a key the application took after it and still gives back the ones nobody took.
  • What is still open in this area: a bare Alt tap does not activate the bar (F10 does), which is a key-release rule and not a map.

221. A window may open maximized

Date: 2026-08-30

Status

Accepted.

Context

Application could say how big its window should be and nothing else about how it opens. That is enough for almost everything, and it is not enough for the showcase: a gallery is a layout whose whole subject is how much fits on a screen, and a wall of cards that shows three columns on a 1440px display shows two on a 900px one. Opening at 960×640 on a 4K monitor makes the argument the gallery exists to make about a third as well as it could.

The obvious workaround is to ask for a very large size. It is wrong in three separate ways, and each of them is the kind that only shows up on somebody else’s machine:

  • A size is not a state. A 3840×2160 window on a laptop is a window larger than the screen, positioned by the compositor wherever it can, with its bottom edge and its resize grip off the display.
  • The desktop owns the work area. A maximized window snaps to the space left over after panels, docks and menu bars; an application asking for “the display, minus what I guess a panel is” is guessing at something the desktop already knows.
  • It cannot be undone. The titlebar’s maximize button toggles a state. A window that is merely enormous has no state to leave, so pressing it makes the window bigger and then smaller than it was — and there is no size to restore to, because the enormous one was the only size the application ever named.

SDL has exactly the right thing: SDL_WINDOW_MAXIMIZED, a creation flag that sits beside the size rather than instead of it. The size stays what the window restores to.

Decision

Application.maximized() — a default false predicate beside size(). The launcher passes it to a WindowSpec, the SDL backend turns it into SDL_WINDOW_MAXIMIZED, and the headless backend ignores it, having no desktop to be maximized against.

size() keeps its meaning, and gains one. It is still the window’s opening size and it is now also the size a maximized window is restored to. Those are the same number for the same reason: it is the size the application thinks its window should be when nothing else has an opinion.

Maximized-and-not-resizable is refused, in the WindowSpec constructor. SDL silently drops the flag on a fixed-size window, which leaves the application with a small window it asked to have filled and the user with no maximize button to fix it with. Both readings of a warning would be wrong, so it throws.

--size= un-maximizes. The flag exists so a screenshot or a golden run can pin a window’s geometry, and a window that took the size and ignored the geometry would be the one combination nobody means.

The default stays false, and every example but the showcase leaves it there. An application that takes the whole screen without being asked is one the user has to undo before they can see anything else; a gallery is the case for saying otherwise, and it says so in one method with a paragraph under it.

Alternatives considered

  • A WindowState enum — NORMAL, MAXIMIZED, FULLSCREEN, MINIMIZED. The shape this grows into if it grows. Three of the four are not creation states at all: fullscreen is a mode with a display and a video mode attached, and minimized-at-startup is a thing no toolkit should make easy. A boolean that answers the one question asked is smaller than an enum with three members nobody may use.
  • Runtime maximize() / restore() on Window. Genuinely useful and a different feature: it needs SDL_EVENT_WINDOW_MAXIMIZED plumbed through so the application can find out the user did it, and a isMaximized() that is honest between the request and the event. Nothing has asked for it; this ADR does not close the door on it.
  • Sizing to the work area in the launcher — workArea() is already on BackendWindow. It computes what the desktop would have done, gets it wrong on a multi-monitor setup where the window has not been placed yet, and still leaves a window with no maximized state to leave.
  • Leaving it to the desktop’s window rules. Real on Linux, absent on Windows, and not something an application can ship.

Consequences

  • WindowSpec gains a fifth component. It is a record and every construction site in the repository is WindowSpec.of(...) plus withers, so the change is the record and its two callers.
  • SdlWindowFlag gains MAXIMIZED, which means goldberry_shim.c gains a GB_CONSTANT for it — the layout verification refuses a flag declared in Java that nothing checks against the real header. That refusal caught this exact omission on the first run.
  • The headless backend ignores it, which is right and is worth stating: every golden image in the repository is drawn at a size the test chose, so no test can see this flag. ShowcaseShellTest asserts the spec instead.
  • What is not built: reading the state back, changing it at runtime, and being told when the user changed it. A window opens maximized; after that nobody involved knows whether it still is.

222. A showcase is a window, a bar and seven screens

Date: 2026-08-30

Status

Accepted. Restructures the gallery ADR-0110 created.

Context

ADR-0110 split one sidebar of every widget in the toolkit into a gallery of screens, because a single pane stopped being able to hold the catalog somewhere around the eleventh control. That was right, and the rule it used to decide what went where — one screen per widget family — did not survive the catalog growing to fifty-one widgets. Twelve screens later it had produced a gallery that nobody could read:

  • Controls, Values and Text were three tabs you had to visit in turn to see what one screen’s worth of chrome looks like. Nobody builds a window of only checkboxes.
  • Overlays and Notifications were the two halves of one comparison — should this float or should it sit in the layout? — with a tab between them, so the comparison could not be made.
  • Tabs, Scrolling and Choosers were all “how do I get around”, filed under three different widget names.
  • Twelve screens, ten digits. Ctrl+1… ran out, and ADR-0110’s own note admitted that the last screen was reachable by the strip and the menu and not by a key.

Every screen was also a plain column, so each was a single tall stack that the gallery’s viewport scrolled. On a maximized window that is one narrow ribbon of content down the left of a very wide screen, and masonry — built for the Charts screen, which was the only one using it — was sitting right there.

And the content was Item 1, Row 3, Series 1, Peak. A label that is a placeholder cannot show whether a sortable header, a shared crosshair or a wrapped paragraph reads; only whether it draws.

Finally, the window itself was a title bar with a theme button on it. There was no application menu anywhere — menubar existed and the only one in the repository was a demo inside a screen, which is a widget being shown rather than a widget being used.

Decision

Seven screens, each a question rather than a widget family. Basic, Panels, Overlays, Forms, Navigation, Collections, Charts. Seven is inside ten, so every screen has an accelerator — which is the second thing the restructure bought and the one that closes ADR-0110’s open note.

Every screen is a Wall: a heading, a line of prose and a masonry of cards. One shape for seven screens, and a type rather than a convention because it was a convention first and four screens drifted off it. A card is as tall as its contents and no two are the same height, which is the case masonry exists for (ADR-0196).

Every card is a card or a group-box. A wall of bare columns is a wall with no edges in it; the panel is what makes a group of controls one thing.

A document supplies the cards it can and Java appends the rest to the same masonry. Four screens are basic.kdl, panels.kdl, overlays.kdl and forms.kdl, each with a masonry at its root, and Panes.wallOf refuses a document whose root is anything else — a column wrapped round it during an edit is a perfectly good document that quietly grows a second wall with different columns. The Java cards are the ones markup genuinely cannot write: an expression (disabled when the count is zero), a list the application edits (banners), a channel that hands values back (multiple, autocomplete, tree), and series data.

The window is three bands: a menubar, a bar, and the gallery. File, Edit and Help, built in Java because half their rows need a Host — opening a dialog, floating a HUD, closing a window — and a Runnable passed to a constructor needs no name at all. The bar under it is a document: two startup readings, the leagues counter, and a toggle that is the global light switch.

The theme is one fact in two spellings. app.theme is a name, because a radio-group, a segmented and a select pick from a list; app.light is a boolean, because a switch’s value is a boolean by definition — Toggle.resolved reads source.get() instanceof Boolean and falls back to its own flag otherwise, so binding it to "light" gives a switch that never moves. Both are written in pickTheme and nowhere else, which is four lines and the single route every theme control in the window goes through.

The content is Middle-earth. Not decoration: a table of nine companions with a Kindred column that repeats and a Leagues column that does not shows a sortable header doing something that Row 1…Row 6 cannot, and real names are of wildly different lengths, which is what a layout has to survive.

The window opens maximized (ADR-0221), because a wall of cards is a layout whose subject is how much fits.

Alternatives considered

  • Keeping twelve screens and adding a second row of tabs. Two rows of tabs is a menu with the wrong widget, and it does not fix the comparison between a banner and a toast being a tab apart.
  • Making every screen Java, or every screen a document. All-Java loses §9’s whole argument — a designer moving a card without a compiler. All-document is impossible: five of the cards need an expression or a channel markup does not have. The split is the interesting part, so it stays and each absence is documented where it is.
  • One Widget per screen that returns a Masonry, with the document’s cards merged inside it. What this does. The rejected version was a screen that returned its document’s wall and a second wall of its own — simpler to write and visibly wrong, because a masonry packs by column height and two walls cannot agree on where a column ends.
  • A card per notification kind, sharing one state object. Rejected in favour of a stateful widget per card: the hidden set only affects the resident banners and the spawned list only the stack, so two independent states mean neither card rebuilds when the other changes — and three cards can go under three different columns where one tall widget can only go under one.
  • A platform menu bar. On three of the four targets there is not one. A menubar is drawn by this toolkit, styled by the same stylesheet and routed through the same router, so F10, the arrows and the accelerators are one implementation (ADR-0163). The one menu handed to the desktop is the tray’s, which is why that one is an ordinary Menu value.
  • Dropping WindowActions now that the menu holds handlers directly. Tried, and put back: overlays.kdl presses app.open-menu by name, and only a registry can turn a string into a call. The two halves of §9 are now visible side by side in one window, which is better than either alone.

Consequences

  • Twelve golden images become eleven, at 1200×900 instead of 900×560. The old size was chosen for a single-column screen; a two- or three-column wall needs the width or the picture is of a layout the application never shows.
  • A narrow golden joins them. masonry’s columns are a count and not a media query, so two columns at 1200 are two columns at 720 — half as wide and twice as tall. gallery-basic-narrow asserts they still fit, because a card with a minimum width would overflow rather than wrap and §10’s wrap is not built.
  • Set.of is banned from anything a golden image prints. Its iteration order is randomized once per JVM, so a caption built with String.join(", ", checked) came out one way on one run and the other way on the next. One thousand pixels of caption failed the Collections image at random until the tree’s initial checked set became a LinkedHashSet.
  • SectionHeader becomes public and becomes the screen title. A text.screen-title did the job for eleven screens and stopped being honest: a class is something any node can wear and a heading is a kind of node. Being an element type is what lets the stylesheet say “a heading inside an affixed section takes a surface” without a second class travelling beside it.
  • Scrolling becomes a card and keeps the nested-scroll ban. It used to be the one screen the gallery did not wrap in a viewport, because §2.4 bans nested same-axis scrollers. It still is — but as a card in a two-column wall, so the screen fits without needing to scroll at all. The wall is what made the ban affordable.
  • Section names are one word each. A section’s name becomes its #section-<name> and its button’s #jump-<name>, and #jump-bag end is not a selector. Lower-casing is the whole of the transformation, which keeps the ids something a stylesheet and a test can both write down.
  • The two statistic cards carry no card title. A statistic has its own label, so a titled card printed the same words twice and the wall read as though two of its nine cards had stuttered.
  • What is now expensive to reverse: the wall. Seven screens, four documents and every test that finds a card by id assume a masonry of cards. Going back to a column per screen is a rewrite of the same size as this one.

223. A tap is a gesture, and a shortcut is a value

Date: 2026-08-30

Status

Accepted. Closes a TODO.md entry opened by ADR-0163 and left open by ADR-0220, whose last consequence was “a bare Alt tap does not activate the bar (F10 does), which is a key-release rule and not a map”.

Context

docs/core-widgets.md §8 asks a menubar for “Alt-style keyboard activation”. ADR-0163 shipped F10 instead and wrote down why:

A bare Alt is a modifier released with nothing in between, and a Shortcut here is a key plus modifiers — Key has no ALT to name, because a shortcut on a modifier alone can never fire.

That diagnosis is right and it is the whole difficulty. Three facts collide:

  • Shortcut is a value. (Key, Modifiers), hashable, a map key. It is looked up on a press — one event, no history.
  • Key deliberately names no modifier, and should not start: an accelerator on Key.ALT would be an entry nothing could ever match, and publishing the constant would invite exactly that.
  • A tap is neither. It is two events with a rule about the gap between them. Alt down then Alt up is a tap; Alt down, F down, F up, Alt up is Alt+F, and the second one ends with the same release as the first.

So the missing feature is not a fifth Key constant or a wider Modifiers. It is a detector with state, and the question is where the state lives and what counts as “nothing in between”.

There is a fourth fact that decides the location. Key.fromSdl answers Key.UNKNOWN for every modifier keycode — and for every letter that arrives as text, which is most of the keyboard. By the time input reaches PointerRouter, Alt and é are the same value. The platform keycode is the only place the distinction survives, and the last component that holds one is Window.

Decision

A tap is its own concept, in its own package — io.github.digitalsmile.goldberry.input.tap, beside input.key rather than inside it. input.key is a vocabulary of values; this is a gesture recogniser. Two types:

  • ModifierKey — the four modifiers considered as keys that can be tapped, each carrying its Mod and its two SDL keycodes. Left and right fold to one, the same fold Modifiers.fromSdl does. Four constants and no way to write a fifth.
  • ModifierTaps — the detector and the owner-keyed registry, one per window.

The rule is stated as what spoils it, because that is the part that has to be exhaustive:

What happens between down and upResult
nothingfires
another key goes downdisarmed — it was Alt+F
the same key auto-repeatsdisarmed — it is being held
a second modifier goes downdisarmed — Alt+Shift is a layout switch
a pointer button or a wheeldisarmed — it was a modified click or scroll
the window loses focusdisarmed — the window switcher took it
pointer motionstill fires — moving the mouse interrupts nothing

It lives on Window, fed raw keycodes from handleKeyPressed and handleKeyReleased before the InputWatcher and the router see them, and told interrupted() from handlePointerPressed, handlePointerWheel and handleFocusChanged. Before the watcher because a key a popup swallows still has to disarm a tap: Alt down, arrow into a menu, Alt up is not a tap of Alt.

The release is dispatched either way. A completed tap does not swallow the key-up. A widget tracking a held modifier — a slider that snaps while Shift is down — has to see the release whether or not the gesture was also a tap.

Host gains modifierTap(ModifierKey, Runnable, Object) and removeModifierTap(ModifierKey, Object), with ADR-0220’s ownership rule and nothing else: there is no unowned overload, because the only reason to bind a tap is a widget that will have to give it back.

menubar binds ModifierKey.ALT beside F10, and both toggle. Opening on the first press and closing on the second is what every desktop bar does with these keys, and it is what makes a modifier safe to bind at all: a user who tapped Alt by accident taps it again rather than hunting for Escape.

F10 stays. Not as a stand-in any more but as the companion binding it always was on the platforms that have both — and as the one that still works under a compositor that swallows Alt for its own window switcher.

Alternatives considered

  • Adding Key.ALT and letting Shortcut hold a modifier alone. The smallest diff and the worst outcome: it makes Mod.CTRL.and(Key.ALT) spellable and meaningless, and it puts a two-event gesture into a map looked up on one event — so it would fire on the press, which is the one thing a tap must not do.
  • Detecting the tap in PointerRouter, from Key.UNKNOWN plus a modifier mask that went from empty to {ALT}. It needs no new plumbing and it is guesswork: the mask is the platform’s account of what is held now, and inferring which key produced the change is exactly the ambiguity the raw keycode does not have. It also breaks the moment a driver reports the mask before the key rather than after.
  • A general “key sequence” or chord recogniser. One caller, one gesture, and a state machine with an alphabet is a great deal of surface to get wrong for it. The four-modifier vocabulary can grow into one later; nothing about this shape blocks that.
  • Leaving F10 alone and closing the entry as “won’t do”. Defensible for one more release and not past it: Alt is how a keyboard user reaches a menu bar on Windows and on most Linux desktops, and a toolkit whose bar does not answer it is a toolkit whose bar they do not find.

Consequences

  • A new exported package, io.github.digitalsmile.goldberry.input.tap, with two public types. It is the first package in input that holds a recogniser rather than a value or a dispatcher, which is why it is not in input.key.
  • Window grows five call sites — two feeds and three interruptions — and each is one line that nothing else would notice going missing. That is what ModifierTapWindowTest is for: it drives the real launcher and asserts each interruption separately, because a detector that is correct and unwired looks exactly like one that is absent.
  • Every Host implementation gains two methods. There are three: Launcher, TestHost and TourTestHost. TestHost mirrors the ownership rule and gains a tap(ModifierKey) beside its press(String), so a widget test can fire one without a window.
  • A menubar now holds two kinds of registration and has to give back both. MenuBarState keeps a separate tapped flag rather than pretending a tap is a Shortcut, and MenuBarTest asserts that unmounting returns it — the leak is the same one ADR-0220 was about, in a second map.
  • F10 and Alt now close an open bar. A behaviour change to F10, and the right one; the existing test asserted only that one press opens.
  • What this does not do: there is no way to bind a tap of a non-modifier key, no chord, and no “tap then type a mnemonic” — §8’s mnemonic underlines are a separate feature and are still unbuilt.
  • Unverified on Windows and macOS, like everything else that reads a keycode. The CI legs run the detector against fabricated events under SDL’s dummy driver on all three platforms, which proves the arithmetic and not the platform’s Alt.

224. A right-click selects what it is over

Date: 2026-08-30

Status

Accepted. Closes a TODO.md entry opened by ADR-0108.

Context

Every file manager selects the row you right-click before it opens the menu. The toolkit did not, and the entry that recorded it said why:

that is the application’s to do in its handler today, because the toolkit has no notion of what “select” means for an arbitrary widget.

That is true and it is not the end of the argument. The toolkit has no notion of selection — but the widget under the pointer does. A row in a list knows what selecting it means, knows whether it is already selected, and already reports through a callback the application owns. What was missing was not a concept of selection; it was a moment at which a widget could be told the gesture had happened to it.

Leaving it to the application is also worse than it sounds, because the rule is not “select the row”. It is:

  • select the row you right-clicked, unless it is already in the selection, in which case leave the selection alone;
  • do it before the menu opens, so a menu built from the selection reads the new one and the row is drawn selected in the frame the menu appears over.

Both halves are easy to get wrong by hand, and the failure mode of the first — right-clicking one of five selected files collapses them to one — destroys exactly what the user was about to act on.

Decision

A widget says what selecting it means; the launcher says when.

io.github.digitalsmile.goldberry.input.handler.Selects is one method, selectForContextMenu(), beside Handles, Located and Measured in the package of things a widget implements to hear about input.

The launcher’s existing walk supplies the moment. openContextMenu already walks from what the gesture landed on up to the nearest widget that named a menu. It now also remembers the deepest Selects it passed, and asks it — once — immediately before opening. So a right-click on a cell inside a row targets the row, by the same rule that makes a right-click on a button’s label a right-click on the button.

Nothing is asked when no menu opens. A selection that changed with nothing to show for it is a gesture with no visible cause, and a right-click over a widget that named no menu is meant to do nothing at all.

The keyboard’s menu key shares it, because it shares the walk (ADR-0208). The menu key on a focused-but-unselected row selects it. That is the same rule seen from the other device — the menu acts on what it opened over — and the two disagreeing would be worse than either behaviour on its own.

ListRow and TreeRow implement it, in four lines each: nothing when the row is unselectable or already selected, otherwise report with Modifiers.NONE. A table inherits it, because a table is a ListView whose item-factory returns a row of cells.

It asks rather than selects. The row reports through the same callback a click reports through, and the application’s answer is what the next frame draws (ADR-0063). Nothing about this makes a widget hold a selection.

Alternatives considered

  • Leaving it to the application, which is what shipped. It makes every application re-derive the already-selected rule, and an application that gets it wrong loses the user’s selection at the exact moment they were about to act on it.
  • A Consumer<Element> on Host.onContextMenu, so the application is handed what was clicked and decides. It is the same work moved: the application still has to know that the thing it was handed is a row, and which list it is in.
  • Making the router deliver a CLICKED for the secondary button so rows handle it in onPointer like any other click. Tempting, and wrong in two ways: the press that opens a context menu is deliberately taken by the launcher before the router sees it (ADR-0108), and a row that treated a right-click as a click would have to re-derive “unless already selected” and would fire for right-clicks that open no menu.
  • Selecting on the press and undoing it if no menu opened. Two frames of a selection nobody asked for, to save one field on the walk.
  • A wider Selectable contract — “are you selected”, “select yourself”, “what is your value” — so the toolkit could reason about selection generally. That is a notion of selection, which the entry was right to say the toolkit does not have and does not need: one method that fires at one moment has no invariants to keep.

Consequences

  • A fifth interface in input.handler, and the first one there that is a request rather than a report. Handles is told what happened; this asks for something to happen.
  • Two widgets implement it and a third inherits it. ListRow, TreeRow, and table through ListView. Nothing else in the catalog has a selection to disturb.
  • The launcher’s walk grew one field and one branch, and its cost is one instanceof per ancestor on a gesture that already walks them.
  • A behaviour change for applications that already did this by hand. One that selects in its own onContextMenu handler will now see the row selected before its handler runs — which makes its own call redundant rather than wrong, since asking for a selection that is already the selection reports the same set.
  • @Nullable is not part of it: selectForContextMenu() takes nothing and returns nothing. A widget that wants to know where it was clicked cannot ask, and nothing in the catalog wants to — a row is the unit either way.
  • What is still open: a right-click over a drag selection, and a right-click that should extend rather than replace on the platforms that offer it. Neither is in docs/core-widgets.md, and both would need the modifiers, which this deliberately does not carry.

225. A toast says it is worth interrupting for

Date: 2026-08-30

Status

Accepted. Narrows a TODO.md entry opened by ADR-0177 rather than closing it: the widget half is finished here, and the announcement itself is still M5’s AccessKit bridge.

Context

TODO.md recorded a toast as not announced, and said the gap was “M5’s AccessKit bridge like every other widget’s semantics — and the one place in the catalog where the absence really costs something”.

That last clause is the part worth taking seriously, and it is not a matter of degree. Every other widget in the catalog is announced because something happens to it: the focus lands on a button, a reader walks onto a row, a value changes under someone who went looking for it. In every case the reader’s own cursor is the event, and a role and a name are enough.

A toast has no such event. Nobody focuses it — it is not focusable, and making it so would trap the keyboard in a thing that vanishes in five seconds. Nobody has to click it. It appears, it is read, it goes. A reader that speaks only what is reached says nothing at all about the one thing on the screen that exists to be noticed, and it will still say nothing on the day the bridge lands — because Role and accessibleName describe a node, and what is missing is the claim that its appearing is itself worth speaking.

That claim is §7’s “live region”, and it was unspellable.

Decision

Semantics gains live(), answering a Live of OFF, POLITE or ASSERTIVE, defaulting to OFF.

On the widget, not derived from the role. The same role can be live in one place and not in another, and a role that implied liveness would make the choice unspellable in the other direction.

Role gains STATUS — a region that reports what just happened rather than what is true. Neither existing value fits: GROUP is a boundary with content in it and DIALOG is somewhere the user is until they leave, and a notification is a sentence that appears and goes.

ToastBox is POLITE, and it is the only live region in the catalog. A notification waits for the reader to finish the sentence they are on. ASSERTIVE exists and is unused, which is a statement: interrupting is for something that must be dealt with before anything else, and a toast is by construction dismissible and transient.

Its name is its text and not its button’s label. The action button is a Button with a name of its own, so folding the two together would have a reader say “Undo” twice.

The stack is not a live region. Three toasts must be three announcements, not four.

Rarity is enforced by a test, not by a convention. SemanticsSweepTest asserts that ToastBox is the only class in the catalog that overrides live(). A widget added later that decides it also deserves interrupting has to come to that test and say why — which is the review this decision is worth having.

Alternatives considered

  • Waiting for M5 and doing all of it at once. It sounds tidier and it makes the bridge harder: the bridge would then have to answer “which nodes are live” for a catalog of fifty-one widgets, from outside them, at the moment when the cost of getting it wrong is highest. The widget knows; recording what it knows is cheap now and free later.
  • Putting the live region on the stack, which is what the ARIA idiom does — a container marked live, announced when children are inserted. AccessKit’s model is per-node, and a stack marked live would announce the stack as well as each toast in it.
  • A boolean isLiveRegion(). Two values would make polite look like a default rather than a choice, and the third value is the one that documents why a toast is not it.
  • Making a toast focusable so the ordinary path reaches it. It is the worst option and the one that looks easiest: a Tab stop that disappears after five seconds moves the focus somewhere the user did not ask for, and a persistent toast becomes a Tab stop between every control and the next.
  • Reusing Role.GROUP rather than adding STATUS. It would be a lie of the cheap kind — a reader would say “group” where the right word is nothing at all, because the sentence is the announcement.

Consequences

  • The entry stays open, rewritten. Nothing is announced yet, because there is no bridge to announce it. What changed is that the remaining work is entirely M5’s and needs no decision from the catalog.
  • Semantics grew a defaulted method, so no existing implementation changed — which is the shape that made this affordable to add before its consumer exists.
  • Role grew a fifteenth value for one widget. That is the rule the enum states for itself: it grows when a widget arrives that is genuinely none of the others.
  • ASSERTIVE has no consumer. Deliberate, and the sweep will notice if that changes.
  • ToastGoldenTest is unaffected, because none of this draws anything. That is worth saying: this is the second facility in the toolkit (after Role itself) whose entire value is invisible until a bridge reads it, and whose entire cost today is one test.
  • What is still owed to §7 beyond the bridge: a message that changes under a reader is arguably live too, and is not — a banner is part of the layout and is read in document order, and announcing every re-render of one would be worse than silence. If that turns out wrong, the vocabulary is now there to say so.

226. A golden cannot see an animation that never ran

Date: 2026-08-30

Status

Accepted. Closes a TODO.md entry opened by ADR-0176.

Context

dialog shipped with a closing animation that did not animate. Every golden image passed, and the entry that recorded it said exactly why:

A golden drives render by hand and never asks whether the frame loop would have, so a widget that answered isAnimating with false while it was fading produced perfect pictures of an animation that never ran. […] the corpus cannot catch this class of bug by construction — an assertion on isAnimating is the only thing that can.

Both halves are true, and the entry stopped one step short. “The corpus cannot catch it” is a fact about goldens; “an assertion on isAnimating is the only thing that can” is a specification for a test that was never written. Left as prose in a TODO list, it is a note that a future author has to read at the moment they are least likely to — while adding the twelfth animating widget, after watching their goldens pass.

The defect is also asymmetric in a way that matters. Answering false while moving freezes the animation mid-way; answering true while still keeps a window awake for ever. Both are invisible to a picture, and only the second is noticeable by anyone not looking for it.

Decision

AnimationSweepTest — two rules, neither about pixels.

Rule one, structural: a widget that holds a Phase declares isAnimating. A Phase is this toolkit’s word for “arriving or leaving on the frame clock”, so holding one and never asking the loop for the next frame is the defect, spelled in a form reflection can read. It costs nothing and it fires on the day a new overlay is written rather than the day somebody runs it.

Scoped to widgets — things that implement Paints. A State and a value record may both hold a phase — a dialog’s closing, a toast’s Reflow — and neither is asked for the next frame: the loop walks the element tree and asks the widgets in it. Holding a phase somewhere unpainted is how a phase reaches a widget, not a fault. This distinction is the whole of what makes the rule usable; without it the sweep names six false positives and gets deleted.

Rule two, coverage: every declaration of isAnimating in the catalog has a test beside it that names the method. Not every animation is a Phase — a tab’s transition is a number, a scrollbar’s fade is an idle clock — so this catches what the structural rule cannot. Beside it, in the same package, because a widget whose only mention of isAnimating is in some distant integration test is a widget whose author did not think about it.

Rule two is deliberately weak about what is asserted. An arch test cannot tell a good assertion from a bad one. It can tell that there is one, which is the difference between this class of defect being found late by a human and not at all.

It found one gap immediately, which is the argument for it: ScrollViewport and ScrollFade had no isAnimating assertion anywhere. ScrollFadeTest now covers the fade in both directions — that it keeps asking through the idle period and the fade, that it stops once the bars are gone, that held bars are still rather than moving — and ScrollTest covers the viewport that delegates to it.

Alternatives considered

  • Leaving the lesson in TODO.md. It is a note, and notes are read by people who already know. The entry itself argued for an assertion.
  • Making the golden harness drive the frame loop, so a picture of a non-animation could not be produced. It is the version that would catch everything, and it changes what a golden is: the corpus’s value is that it rasterizes one frame deterministically at a chosen instant, and a corpus that ran a loop would be slower, flakier, and would still need somebody to decide how many frames “enough” is.
  • A behavioural sweep that constructs every animating widget mid-animation and asserts isAnimating(). The rule with real teeth, and it needs a plausible constructor call for each of a dozen package-private records — which is a fixture the next author has to extend before their widget compiles, at which point they have thought about the question anyway. Rule two gets most of the value for none of the fixture, and the per-widget tests it demands are better assertions than a generic one could be.
  • Requiring the assertion in a named test class per widget. Stricter, and it would have failed on MessageBox, whose isAnimating is asserted in MessageGoldenTest — a golden that also checks the loop, which is exactly the right thing and would have been forbidden by a naming rule.

Consequences

  • A new arch test with two rules, joining SemanticsSweepTest as the second sweep that enforces something no picture can show.
  • A gap closed on the way in. ScrollFadeTest is eleven assertions that nothing had: §2.4’s fade curve, and both ends of the frame contract.
  • Rule two is package-granular, so a package with two animating widgets and one test satisfies it. Stated rather than hidden: the rule is a floor, and the per-widget assertions are where the real checking happens.
  • A false negative remains, deliberately. A widget that answers isAnimating from something other than a Phase, and whose package already has an unrelated isAnimating mention, passes both rules while being wrong. Closing that needs the behavioural sweep above and its fixture.
  • The TODO.md entry can close. What it asked for now exists, and the lesson lives in a class that fails rather than a paragraph that is read.

227. A widget may describe nothing

Date: 2026-08-30

Status

Accepted. Closes a TODO.md entry opened by ADR-0175.

Context

message took no bind=, and the entry that recorded it had already worked out why the obvious fix was not one:

A bound banner would be present and empty when the value was blank — a bordered box with 12px of padding saying nothing — and §8’s subset has no display, so no widget can take itself out of a layout. […] What would close this properly is a way for a widget to describe nothing, which the element tree has no word for and which collapse, group-box and field-message have each worked around differently.

Every build has to return a widget. So a widget with nothing to show had two options, and both are wrong in the same way:

  • Describe an empty box. It takes no room of its own — and it is still a child, so a column with gap: 12px puts twelve pixels round it. The thing that vanished leaves a hole. MessageBox did exactly this for a dismissed banner, and its own documentation recorded the hole as somebody else’s number.
  • Have the parent leave it out. Correct, and it moves the decision one level up — so a message bound to an empty string can only be described away by whoever placed it, which is the application, which is the thing §9’s bind= exists to spare. Message.summary returns an Optional for this reason.

The surprise on looking at the renderer is that the mechanism was already there. A node that is neither Styled nor Paints contributes no box: its children become its parent’s directly, which is how every composition node works. A widget with no children and no paint therefore already renders to nothing. What was missing was a name for it.

Decision

Widget.nothing() — a singleton Leaf with no children that implements neither Styled nor Paints. Zero boxes, zero layout, no selector matches it. No new branch anywhere in the renderer or the element tree: the tree could always express this, and nothing could say it.

A method rather than a constant, and not for taste: a static final field on Widget holding an instance of one of its own subtypes makes initialising Widget depend on initialising Nothing and back again, which the compiler’s own class-initialisation-cycle analysis refuses.

It is still an element. The element stays in the tree, holding its state, its place in the reconciler, and its subscription. That is the point rather than an implementation detail: a widget that describes nothing this frame and something the next is one node whose value changed, not a node destroyed and rebuilt. A banner whose text empties and fills keeps its arrival phase and its binding.

message gains bind=. The value is read with toString like every other bound text; a null or blank value makes the banner not there, and it comes back when the value does. text remains the fallback for no binding at all and not for a blank one — an application whose error property is empty means “there is no error”, and showing the document’s placeholder words instead would be a banner reporting a problem that has gone away.

The dismissed case converges on it. MessageBox no longer carries a departed flag or a “draw nothing” branch; MessageState answers Widget.nothing() once the exit has run out. The gap the container used to keep round a departed banner is gone, and the record’s documented wart with it.

Not display: none. §8’s subset still has no way for a rule to take a node out of a layout. What a widget decides about its own content it may now say; what a stylesheet decides is unchanged, and this deliberately does not open that door.

Alternatives considered

  • A nullable return from build. The same meaning, spelled as the thing every null-safety convention in this codebase exists to avoid — and describe would need a branch where a singleton needs none.
  • Optional<Widget>. It puts an allocation and an unwrap on the hottest path in the framework to express a case that arises in one widget.
  • A display property in §8’s subset. The general answer, and a much larger one: it would need the cascade, the layout and the hit test to agree about a node that is styled and absent, and it would let a stylesheet remove content — which is a different feature with different failure modes.
  • Leaving message without bind=. Defensible while Message.summary’s Optional was the only caller; not once a document wants to write message bind="form.error", which is the ordinary shape of a bound banner and the one a markup-first toolkit should not make impossible.

Consequences

  • One new package-private record and one static method. The smallest change in this log that closes an entry described as needing a new capability, which is what happens when the capability turns out to be a missing name rather than a missing mechanism.
  • Message grew a component, so its canonical constructor has six arguments. The five-argument form is kept, because every caller written before bind= existed passes exactly those.
  • MessageBox lost one, and with it a branch in children(), a branch in render and a term in isAnimating.
  • A dismissed banner no longer leaves a gap. A behaviour change, and the one the entry was complaining about.
  • The other two workarounds are not converted. field-message draws a styled empty box when a field is fine, and switching it to Widget.nothing() changes the spacing of every form — five golden images say so. That is a design decision about §4’s “message slot”, not a bug fix, and it is not this entry’s to make. The vocabulary is there when somebody wants to make it.
  • A tree whose root describes only nothing still throws, which is the answer the renderer already gives a root that describes only composition: a window with nothing to paint is a mistake, not a blank screen.

228. A phase is asked whether it is still running

Date: 2026-08-30

Status

Accepted. Closes two TODO.md entries opened by ADR-0175 — one a defect, one recorded as a harmless cost that turned out to be avoidable.

Context

docs/design-system.md §1.7 promises that “the frame loop is fully idle when no animation is active”. It was false for any window with a carousel on it at all, and for any window with an open collapse.

Both widgets handed their moving part a function of the clock and decided at build time whether there was an animation:

showing ? this::visibility : null      // collapse
this::visibility                       // carousel — never null

and both parts then answered isAnimating() with visibility != null. So the question the frame loop asks — are you still moving? — was answered with were you built in a state where you could move? A collapse reported an animation for as long as it was open; a carousel reported one from its first frame and never stopped. Only a rebuild could take either back out of the loop, and an open section is exactly the thing nothing rebuilds.

message did not have the bug, and the difference is one word: it hands over the Phase itself. A phase settles itself on the frame that finishes it, so asking it is asking the only object that knows. A DoubleUnaryOperator closing over the same phase cannot say whether it has finished, because a function of the clock has no state to report.

A second entry sat beside this one and had been written off:

Every clock-driven arrival costs one wasted frame. The renderer asks whether a node is animating before it draws it, so the frame that finishes an arrival still reports one more […] Harmless and worth writing down.

Harmless, and not necessary. A phase learns it has finished by being read, and the only place a widget is handed the frame clock is render. Asked afterwards, the answer is current.

Decision

Hand the Phase, not a function of it. CollapseBody and CarouselViewport take a Phase and answer phase.isRunning(), which is what MessageBox already did. CollapseState.visibility and CarouselState.visibility are deleted; there is nothing left for them to wrap.

Reduced motion ends the phase rather than drawing past it. Both parts now call phase.skip() when the frame says motion is reduced, so a reader who asked not to be animated at also stops paying for frames spent standing still — MessageBox’s behaviour, applied to the two widgets that were guessing.

The node that carries a phase answers for it too. CollapseSection and CarouselView hold the phase and hand it down; they now override isAnimating as well. The renderer ORs over the tree, so this changes no behaviour — what it buys is that AnimationSweepTest’s rule (ADR-0226) stays sharp, with no exception for “it hands it to a child” that nothing could check.

CollapseSection guards on open, and the guard is the bug in miniature: a section shut half way through its arrival keeps an ENTERING phase that nothing will ever read again, so nothing will ever settle it. A shut section has no body and animates nothing whatever its phase remembers.

The renderer asks isAnimating after render rather than before. One line moved, and it is worth one frame of every animation in the toolkit — every arrival, every departure, every scrollbar fade, on every widget.

Alternatives considered

  • A BooleanSupplier beside the DoubleUnaryOperator, which is what Tab does and which works. It is two closures where one object will do, and it leaves the same trap set for the next widget: nothing about a pair of lambdas says they have to agree.
  • Fixing only the null check — showing && phase.isRunning() computed at build time. It is the same mistake with a longer expression: the answer is still frozen at the moment of the build.
  • Leaving the wasted frame. It is one frame per animation, which is genuinely small, and it is also a repaint() per animation on a battery. The entry called it harmless because it looked structural; it was one line.
  • Asking isAnimating both before and after. Belt and braces, and it would reinstate the wasted frame it was meant to remove.

Consequences

  • A behaviour change nobody can see and every laptop can feel. A window with a carousel on it went from repainting at the display’s refresh rate for ever to repainting when something moves.
  • TabMotionTest lost an assertion and gained a better one. It documented the wasted frame — “one more frame before the loop sleeps, and the reason is worth knowing” — and now asserts that the finishing frame is the last one.
  • CarouselTest’s animating() asserted the bug. It said a fresh carousel reports an animation, which was true and wrong. That is the shape of this whole entry: the test was written against the implementation rather than against §1.7.
  • A new IdleLoopTest in :widgets, asserting on the renderer rather than on a part, because the renderer is what the frame loop asks. Six cases: a shut section, an opening one, one shut mid-arrival, an untouched carousel, a moving one, and the no-wasted-frame claim on its own.
  • Two legitimate reasons the loop stays awake are now visible in that test and worth writing down: a CSS transition starts on the frame that observes the changed style, so a test must draw once before advancing its clock; and opening a collapse puts .open on it, whose chevron rotates under a transition of its own.
  • The AnimationSweepTest rule fired on this change, naming CarouselView and CollapseSection the moment they gained a Phase component. That is the sweep from ADR-0226 doing its job on the first real change after it landed.

229. A hue has a rank for words as well as for lines

Date: 2026-08-30

Status

Accepted. Closes a TODO.md entry opened by ADR-0175.

Context

ADR-0175 added a third rank to each semantic hue — --gb-<hue>-line, the hue moved until it clears §1.2’s 3:1 floor for a stroke on a surface — because measuring --gb-danger as a border disproved the sentence both theme files had been carrying: “what a label, an icon or a border is drawn in”.

message was its only consumer, and the entry that recorded that said what was owed:

a field’s :invalid edge and a badge’s border are the same thing and still read the hue directly. ContrastTest’s 3:1 sweep covers the tokens, not every widget that draws one, so this is a survey somebody has to do rather than a failure waiting to happen.

The survey found five rules drawing ink in a bare semantic hue, and it found something the entry had not anticipated. Only one of the five is a line:

ruledrawsbare hue, worst measured
field:invalid text-inputa border2.46:1 on dark --gb-surface
field-messagewords2.46:1 on dark --gb-surface
statistic-delta.upwords2.04:1 on light --gb-surface
statistic-delta.downwords2.46:1 on dark --gb-surface
chart-message.failedwords2.46:1 on dark --gb-surface
hud-reading.overwords, on the HUD’s own plate3.95:1

Four of the six are text, and §1.2’s floor for text is 4.5:1, not 3:1. The -line rank is a 3:1 rank by construction, so it does not cover them either: --gb-danger-line measures 3.53:1 on the dark theme’s --gb-surface. Pointing those rules at -line would have moved them from clearly wrong to quietly wrong.

The badge half of the entry turned out to be a false memory: a badge is a filled chip with its own --gb-badge-*-bg/-text pair, already covered by the text-on-fill sweep since ADR-0087. It has no border.

Decision

A fourth rank: --gb-<hue>-text. The hue moved in lightness until the worst of the three surfaces a window paints clears 4.5:1, per theme, with the measurement written beside it — the same derivation and the same house style the -fill and -line ranks use. Three are derived per theme and one aliases in each: only the dark theme’s yellow and the light theme’s red already carry, which is the usual shape (a dark theme’s trouble is the dark end of the palette and a light theme’s is the pale end).

So a hue is now four things, and the names say what each is for rather than how it was made:

tokenforfloor
--gb-<hue>a fill—
--gb-<hue>-fillwords on that fill4.5:1
--gb-<hue>-linea stroke or a glyph on a surface3:1
--gb-<hue>-textwords on a surface4.5:1

The HUD gets its own two tokens instead. --gb-hud-warning and --gb-hud-danger, identical in both themes, beside the --gb-hud-text and --gb-hud-bg that were already theme-invariant for the same reason: the plate lies over the application’s own colours, which the toolkit does not know, so it carries its own contrast. A theme-varying hue is the wrong thing to draw on it — the light theme’s -text red is a dark red, and a dark red on a near-black plate is not a warning, it is an absence.

field:invalid takes -line, which is the one case the entry named correctly and the rank’s whole purpose.

And the survey becomes a lint. ContrastTest.noBareHueDrawsInk scans controls.css for color: or border-color: set to a bare var(--gb-<hue>) and fails. background: is deliberately not included — a background is the fill rank, and the text on it is measured by the sweep that has existed since ADR-0087.

Alternatives considered

  • Pointing the text cases at -line. The obvious reading of the entry, and wrong: -line is derived against a 3:1 target, so --gb-danger-line at 3.53:1 would have left field-message below the text floor while looking fixed. The ranks are named for their use, and using one for the other defeats the naming.
  • One rank at 4.5:1 for both lines and words. Fewer tokens, and it drags every border to a lightness §1.2 does not ask for — a 4.5:1 border on a surface reads as a heavier box than the design system draws.
  • Changing the widgets not to colour their text. It is a real option for statistic-delta and no option at all for field-message, whose whole job §4 describes in terms of the danger hue.
  • Giving the HUD the theme’s -text rank. One fewer pair of tokens, and it puts a dark red on a near-black plate whenever the light theme is on.
  • Leaving it as a survey. What the entry itself argued against. A survey is done once; the next widget that colours a word is written by somebody who has not read this record.

Consequences

  • Eight new tokens, four per theme, plus two for the HUD. The tokens file is the largest single artefact in the design system and it grew by ten lines with a measured ratio on each.
  • Six rules changed and eight golden images with them — three field screens, the HUD, a statistic, a chart failure, and two gallery screens. Every one of those images was previously showing a colour below §1.2’s floor, which is the point: the corpus recorded the defect faithfully for months.
  • Three sweeps and a lint where there was one sweep. everyTextRankIsLegible measures the new rank on every surface in both themes; everyHudReadingIsLegible measures the HUD’s plate on its own; noBareHueDrawsInk is what makes the other two a guarantee rather than a sample.
  • The lint reads the stylesheet as text, which is exactly what the rest of ContrastTest refuses to do — its opening note says parsing CSS “would check that a token has the value someone wrote down”. That refusal is right for a contrast claim and wrong for a coverage one: the question here is which rules exist, and only the source can answer it. The two are separate tests for that reason.
  • An application’s own stylesheet is not linted, and cannot be: the rule is about the toolkit’s sheet. An application that draws its own words in var(--gb-danger) gets the same defect and no warning. That is the same limit --gb-* tokens have everywhere, and the entry about validating an application’s theme is still open.

230. A notification has listeners, and an event has one

Date: 2026-08-30

Status

Accepted. Closes a TODO.md entry opened by ADR-0105.

Context

PointerRouter.onPointingChanged is how something above the router learns that the hovered or the focused node moved. §7 shows a tooltip “on hover and on keyboard focus after delay”, so the thing that opens one has to hear about both; the router itself opens nothing, because it has no window and no notion of one.

It was one slot, and the record said why:

Not a list: a second listener would be a second thing deciding what a hover means, and there is exactly one.

The refusal is defensible and the implementation is not. A slot whose setter is named onPointingChanged reads like a registration and behaves like an assignment: a second caller silently drops the first. The failure mode is a tooltip that stops appearing, with nothing anywhere saying that anything was displaced.

The entry that tracked it stated the price of fixing it as “a real listener list and a decision about what it means for two things to react to one hover”.

Decision

The decision is that there is nothing to decide, and the reason is what this record is for: it is a notification, not an event.

Nothing is passed. Nothing can be consumed. No listener can change what another sees, because each reads hovered() or focused() from the router for itself, and the router’s state is the same for all of them. Order is therefore not a policy — it is registration order because a list has one, and no correct listener can depend on it.

An event would be the thing worth refusing. One that carried a target, or that could be consumed, would make a second listener a second thing deciding what a hover means — exactly the objection ADR-0105 raised, aimed at a shape this facility does not have.

So: a list, and a Subscription back. The registration is a thing you can give up, which the slot could not express at all: there was no way to stop listening.

Copy-on-write, and not for threads. A listener may cancel itself, or another, from inside a notification — a tooltip’s listener that fires once and unregisters is the obvious shape — and iterating a snapshot is what makes that safe without a copy per notification. Hovers are frequent; registrations are not.

The launcher keeps its handle and closes it in shutDown. Its router dies with it, so this is tidiness rather than necessity — and tidiness is what stops the next caller from assuming it does not have to.

Alternatives considered

  • Leaving the slot and documenting the hazard. The hazard is invisible at the call site: onPointingChanged(x) looks like every other registration in the toolkit, and the one that replaces rather than adds is the one nobody expects.
  • Throwing on a second registration. Honest, and it makes a legitimate second consumer impossible rather than merely surprising.
  • A List<Runnable> with a remove(Runnable). It works and it compares lambdas by identity, so a caller has to keep the exact reference it passed — which is what a Subscription is, with the mistake removed.
  • An event object with a target and a consume(). The shape ADR-0105 was right to refuse. It buys nothing here: the router’s state is the answer, and a snapshot passed alongside it would be a second copy that could disagree.

Consequences

  • onPointingChanged returns a Subscription and adds instead of replacing. There is one caller in the toolkit and it is Launcher; no application code changes, because the method is on a class an application does not hold.
  • input now names bind.Subscription. A general “registration that can be undone”, already in the module and already the shape the binding layer hands out; the alternative was a second identical interface in input.
  • Six tests in PointerRouterTest — that two listeners are both told, that each reads the router rather than being handed anything, that closing stops one and leaves the others, that closing twice is harmless, that a listener may cancel itself mid-notification, and that focus counts as pointing moving.
  • The distinction is now written down where it will be read. The next facility that has to choose between a slot and a list has a rule: if the thing being delivered can be consumed, one listener; if it is only a nudge to go and look, a list.

231. A popup is placed again when its anchor moves

Date: 2026-08-30

Status

Accepted. Narrows a TODO.md entry opened by ADR-0104: the resize half ships, and what is left needs an SPI event that does not exist.

Context

Nothing re-places an open popup. Move or resize the window with a menu open and the menu stays where it was put; Popup.move exists and nothing calls it.

Half of that sentence turns out to be wrong, and finding out which half is most of this record. A popup is positioned as an offset from its owner window — PopupSpec takes a point in the owner’s coordinates and Popup.offset() answers in them — so moving the window carries its popups along; the platform does it. What a move can invalidate is the clamping: a menu placed near the bottom of the screen was flipped or clamped against the work area at the position the window used to be in, and the toolkit gets no event when a window moves. There is no BackendEvent.Moved.

A resize is different in two ways. It is reported — BackendEvent.Resized exists and reaches Window.handleResize — and it moves the thing the popup was anchored to: a heading at the bottom of a column, a control aligned to the right edge, anything the layout places relative to a size. The popup stays at its old offset and the anchor is somewhere else.

Decision

The launcher remembers how each popup was placed and puts it back after a resize. A Placed record per open popup — the anchor rectangle, the placement, and the anchor’s id when it had one — kept in an IdentityHashMap keyed by the popup, because a popup is looked up by which popup it is.

An id is worth more than a rectangle, and that is the interesting half. A popup opened by popup(content, anchorId, placement) re-resolves its anchor against the frame the resize produced, so it follows a heading that moved. One opened against a rectangle the caller computed is re-placed against that same rectangle, which is right: the caller said where, and nothing has told the launcher otherwise.

A move, not a reopen. Popup.move exists for exactly this and is what the entry noticed nobody called. The tree stays mounted, the keyboard stays where it is, and nothing flickers.

And it happens at the end of the next paint, not in the resize handler. This is the part that is easy to get wrong and impossible to notice: anchor(id) answers from the hit-test capture the last paint produced, and during the resize handler that capture is still the old window’s. Re-placing there would put every menu back where its heading used to be — the bug wearing the fix’s clothes. So the handler sets a flag and the paint acts on it, after router.updateRegions.

Alternatives considered

  • Re-placing in the resize handler, which is where the event arrives and where it looks like it belongs. It reads the previous frame’s geometry, so it is confidently wrong rather than absent.
  • Closing popups on resize. Every desktop menu survives a resize, and a menu that vanished because the user dragged a window edge would be worse than one that lagged.
  • Adding BackendEvent.Moved now and re-clamping on window moves too. It is the honest completion and it is a different change: an SPI event, an SDL translation for SDL_EVENT_WINDOW_MOVED, a headless implementation, and a test that can only be written the way ADR-0061 wrote the wheel’s — by pushing a fabricated event onto SDL’s own queue. Worth doing; not worth bundling.
  • Having the popup watch its own anchor through Located. The right answer for the case the entry names next — “a popover that follows a scrolling anchor” — and it needs the anchor widget to implement Located and to have somewhere to send the report. A resize is a window-level event with a window-level owner, and that is the launcher.

Consequences

  • A menu follows its heading across a resize, which is what every desktop does and what the entry asked for.
  • One flag, one map and one method in Launcher, and the map is cleaned of closed popups on every re-placement rather than by a second bookkeeping path.
  • A test that fails by 200 pixels without the fix. replacedOnResize shrinks a window whose anchor sits at the bottom of a filling column and asserts the popup came with it — 504 before, 304 after.
  • Writing that test found a trap worth recording. A run bounded by --frames finishes in whatever wall-clock time the machine takes, so a callback scheduled 300ms out can arrive after the loop has gone — and then reads a live-looking anchor() from a dead launcher and gets an empty Optional from a shut-down backend. The test schedules by turns of the event loop instead, which cannot outlive it.
  • What is still open: a window move does not re-clamp, because nothing reports one; and a popup does not follow an anchor that scrolls, because nothing reports that either. Both are named in the entry, which stays open with those two halves.

232. Modality is one flag, and not a scrim

Date: 2026-08-30

Status

Accepted. Closes a TODO.md entry opened by ADR-0100.

Context

Nothing hit-tests an overlay by rule. The pointer router tests against the painted frame and an overlay is in that frame, so a button inside one is reachable today by the accident of paint order rather than by anything anyone wrote down. A modal dialog needs the rule stated — the topmost overlay takes the pointer first, and a modal one takes it exclusively.

Both halves were true and neither was written anywhere.

The first was already the behaviour: HitTest.at scans the capture backwards and the capture is in paint order, so whatever was drawn last answers first. The window’s overlay layer is described after the application’s root, so a button in a toast takes the pointer from what is under it. Nothing said so, and nothing asserted it.

The second was not one mechanism but two, and Handles.isModal said as much in its own documentation:

The pointer is not this flag’s business. A dialog is unreachable by mouse because its scrim covers the window and takes every press […] — modality by geometry rather than by a rule.

That is a defensible design and it has a hole in it exactly where the sentence stops. A widget that answers isModal() and is not wrapped in something that fills the window traps the keyboard and lets every click through. Nothing warns, nothing fails, and the two halves of “modal” disagree — which is the worst kind of bug to have in an accessibility feature, because the keyboard user is protected and the pointer user is not.

Decision

Modality is one flag. While a modal is mounted, the pointer reaches its subtree and its ancestors, and nothing else.

The ancestors are not a loophole; they are the point. A dialog’s scrim is the panel’s parent, and a click on it is what closes the dialog. An ancestor is on the path from the modal to the root — a path, not a subtree — so a button in the application is neither inside the modal nor on that path, and is unreachable.

Enforced in elementAt, which is the one place every pointer entry point resolves a target. So a press, a release, a wheel and a hover all obey it together: a control behind a dialog that lit up under the pointer would be claiming to be pressable when it is not.

The modal is found once per frame, in updateRegions, and kept beside the regions. That is the rule ADR-0054 already states for hit testing — input is answered against the frame the user can see, so the tree that frame came from is the tree to ask — and it turns what would be a tree walk per mouse move into one walk per paint.

A press on the unreachable application does not empty the trap. Normally a press on nothing moves focus off whatever had it; behind a modal, “nothing” is the application, and clearing focus only for the next frame’s refocus to put it back is a frame with nothing focused.

And the paint-order rule is written down on elementAt, where the code that implements it is, with a test that fails if it ever stops being true.

Alternatives considered

  • Leaving it to geometry and documenting the requirement — “a modal must be inside something that fills the window”. It is a rule a compiler cannot check and a reviewer will not remember, protecting a feature whose whole point is that it cannot be got wrong.
  • A separate blocksPointer() flag. Two flags that must agree, and the disagreement is the bug this closes.
  • Blocking at dispatch rather than at elementAt. It would leave hovered pointing at an unreachable node, so :hover would still light up behind the dialog — the same split, one layer down.
  • Confining the pointer to the modal’s subtree alone, without the ancestors. Simpler to state and it breaks click-outside-to-dismiss on every dialog in the toolkit, because the scrim is not inside the panel.
  • Making the overlay layer intercept by kind rather than the router by rule. It puts the decision in the layer, and Handles was right that the layer is the wrong owner: the next modal is a wizard step or a sheet, and neither will be the same shape as a dialog.

Consequences

  • No visible change to dialog, whose scrim already covered the window. The change is for the modal that does not have one, which is the case that was silently broken.
  • Handles.isModal’s note is now wrong in one sentence — “the pointer is not this flag’s business” — and the flag is better for it. Left in the record rather than only in the code, because the reasoning it gives is the reasoning that produced the hole.
  • One field and two loops in the router, and the field costs one tree walk per paint where the naive version would cost one per pointer motion.
  • A new ModalPointerTest in :core, built from bare widgets rather than from dialog — FocusTrapTest’s reason: the mechanism is the router’s and has to hold for whatever declares itself modal next.
  • The test’s overlay deliberately does not fill the window. A dialog’s does, and that is precisely why nothing could tell the rule from the geometry before: a filling scrim takes every press whether or not anything is modal. Removing it is what makes the assertions about the rule.
  • What this does not do: nothing stops an application from putting a modal in a corner overlay and leaving the rest of the window visible but dead. That reads as a bug and is now a bug the toolkit implements faithfully — the veil is a design decision, and §7 gives it to dialog and to tour by geometry.

233. Escape steps out of one menu

Date: 2026-08-30

Status

Accepted. Closes a TODO.md entry opened by ADR-0103.

Context

Two popups do not know about each other. A submenu chain — opening one closes its siblings but not its parent — is menu’s to arrange; the launcher’s light dismissal closes all of them at once, which is right for one popup and wrong for a chain.

The first half was already built: Menus keeps the stack and closes descendants. The second half is one line, and finding which line took most of the work, because there are two Escape handlers and only one of them ever runs.

A Popup watches its own window and closes itself on Escape — correct for a chain, and dead code in practice. Since ADR-0189 no popup holds the platform keyboard, so Escape arrives at the owner window, whose watcher called dismissPopups() — every popup, at once.

So opening File → Recent and pressing Escape closed both. The submenu the reader opened by mistake took the menu with it, and there is nothing to reopen that menu with but the mouse.

Decision

Escape closes the innermost popup; a press outside closes the stack.

The two gestures mean different things and the code had been treating them as one. A press that lands somewhere else is the user pointing at something other than the menu — the whole thing goes. Escape is the user stepping back out of what they opened, one menu at a time.

The topmost one that will actually go, which is not always the topmost one. A tooltip is lightDismiss(false) and refuses; stopping at it would leave Escape doing nothing with a menu open underneath. So Popup.dismissedByInput() returns whether it closed, and the launcher walks down until something does.

Focus loss still closes everything. The application is no longer in front; there is no chain to step out of.

Alternatives considered

  • Relying on the popup’s own Escape watcher and deleting the owner’s. It is the tidier shape and it depends on the platform giving a popup window the keyboard, which ADR-0104 established is per-driver and ADR-0189 decided against entirely.
  • Letting menu handle Escape as a widget. Menus has the stack and could close one level. It also would not fire: the key is taken by the owner window’s watcher before any router sees it, which is the whole reason the watcher exists.
  • Closing the topmost popup even when it refuses light dismissal. It would make Escape dismiss a tooltip, which is dismissed by the pointer leaving, and leave the menu under it open — a keypress that does the wrong one of two visible things.
  • Leaving it. A submenu that cannot be escaped without losing its parent is a small thing that is wrong every single time.

Consequences

  • Escape in a chain now behaves like every desktop menu: out of the submenu, then out of the menu.
  • dismissedByInput returns a boolean, which is also what makes “the topmost one that will go” expressible at all.
  • dismissPopups is unchanged and still has two callers — the press watcher and the focus-lost check — which is the distinction this record is about.
  • A test in MenusTest through the real launcher and real popup windows: hover a row with children to open the chain, Escape, assert one popup left, Escape, assert none. It fails at the first assertion without the change — 0 where 1 is expected.
  • Popup’s own Escape watcher is still there and still unreachable in practice. Left rather than deleted: it is correct for a driver that does focus popup windows, and it closes only itself, which is now the same rule the owner’s handler follows.

234. The overlay lifecycle is a departure and a phase

Date: 2026-08-30

Status

Accepted. Closes a TODO.md entry opened by ADR-0178, and settles the last of the case for the AnimationController that ADR-0081 first refused.

Context

docs/design-system.md §1.7 specifies an overlay lifecycle — opening → open → closing → removed, “the element stays mounted through closing, input is disabled the instant closing starts (no ghost clicks), removal fires on animation end” — and for a long time it was a specification with no subject, because none of the widgets it describes existed.

The entry that tracked it said what to do about that:

opening → open → closing → removed applies to menus, popovers, tooltips, dialogs and toasts, which now exist — so this is a survey of five built widgets that each arrive and depart their own way, rather than a mechanism nobody could write. […] What is left for the controller is the overlay sequence alone, which is now the whole of its case.

The survey, done:

widgetarrivesdepartsends the departure
dialogPhase 240msPhase 160msa timer, then two flags
messagePhase 160msPhase 100msa timer, then two flags
toastPhase + slidePhase + reflowthe stack’s own queue
tabPhasePhasePhase.hasDeparted, read in render
collapse, carouselPhasenothing — closing is instant—
menu, tooltipnothing — they are platform windowsnothing—

Two things fall out of that table and neither is what the entry expected.

The arrival needs nothing shared. Phase is already the whole of it: a beginning stamped on the first frame that draws, a duration, and a settle. Six widgets use it and none of them wants anything more.

The departure was the same code twice. dialog and message each held two flags, a timer and six lines, and independently got the same four rules right. That is not a coincidence worth admiring; it is a mechanism waiting to be named.

Decision

Departure — a timer and an ordering, and nothing else. It holds the four rules dialog and message had each written out:

  1. Idempotence. A second press during the fade is not a second answer, which matters most where it costs most: two handlers on a save dialog is two saves.
  2. Two flags, not one. hasBegun means input is off, from the instant the answer is given; isOver means there is nothing left to draw. Conflating them is why a closing dialog once stopped asking for frames on the frame it started closing, and therefore never faded at all (ADR-0176).
  3. Stop drawing, then tell the application — in that order, and it matters for one frame: the handler usually rebuilds the tree without the overlay in it, and a state still mid-fade would hand a half-faded panel to whatever element the reconciler reused.
  4. No host, or reduced motion, means gone now. §1.7 asks for movement to be removed rather than shortened, and a hundred milliseconds of nothing happening is not a courtesy.

It is not an AnimationController. ADR-0081 refused one for spinner and indeterminate progress because a loop that never ends has nothing to remember; ADR-0178 refused one for a toast’s reflow because the interruption turned out to be three lines of arithmetic. This is what was left of that idea after both refusals: it drives no value, interpolates nothing, and owns no clock. It owns a timer and an ordering, which is precisely the part that was duplicated.

The setState comes in as a Consumer<Runnable>. A departure is a field a state has, not a base class it extends — an overlay’s state holds other things too, and message’s holds an arrival as well.

Alternatives considered

  • A base OverlayState the two extend. It would carry the arrival as well, which the table says is not shared, and it would put dialog’s focus handling and message’s binding in a class that has to know about both.
  • Putting the departure on Phase. Phase is a value read from render, where a widget has a clock and nothing else; a departure needs a Host and a timer, which is a State’s world. Merging them would drag the window into the one type collapse and carousel use without ever seeing one.
  • A full opening → open → closing → removed state machine, with open as a named state. open is “neither of the other two”, and no widget in the table branches on it. Naming it would be a state nothing reads.
  • Leaving the duplication and closing the entry as surveyed. Defensible — the survey is the deliverable the entry asked for — and it leaves the next overlay author to get the same four rules right a third time, from scratch, with two examples to copy from that do not look alike.

Consequences

  • About forty lines leave DialogState and MessageState, and each loses a pair of flags whose distinction was the subject of a previous ADR.
  • The refactor is behaviour-preserving, which the existing dialog and message suites — including their golden images — say by passing unchanged. That was the point of doing it this way round: the tests were written first, by somebody fixing the bugs the rules exist for.
  • DepartureTest is eleven cases, one per rule and one per way a rule was once broken. It also records a fixture fact worth knowing: TestHost.tick() fires whatever was scheduled whether or not it was cancelled, so a cancellation is asserted on the timer rather than on the handler.
  • toast and tab are not converted. A toast’s departure ends when the stack’s queue says so and a tab’s ends inside render through Phase.hasDeparted; neither is a timer, and forcing them through this would be the generalisation ADR-0092 warns about — made from two examples that already agree.
  • stack is still owed, and it was bundled into a neighbouring entry with this. It is a layout widget where all of this is a window facility, and neither builds the other; it stays open on its own.

235. A cut label needs nowrap, not text-overflow

Date: 2026-08-30

Status

Accepted as a diagnosis, and acted on by ADR-0255, which added the white-space this record named and left unbuilt. What it says below about why clipping alone cannot cut a label is unchanged and is still the reason; what has changed is the last sentence of the Decision — the property has a consumer that is not a comment, four of them, so it was built.

No behaviour changed here: four comments did.

Context

Three widgets document the same limitation in almost the same words, and a fourth comment repeats it:

The cost is that a label longer than its cell overflows it, because nothing in this toolkit clips. — option

a label longer than the field ellipses rather than pushing the chevron out — except that nothing in this toolkit clips yet. — select-value

it depends on something this toolkit does not have: overflow: hidden. Nothing clips a box here. — ProgressFill

§8’s subset has no text-overflow and nothing in this toolkit clips, so there is no third behaviour to choose. — TODO.md, on a menu row

Every one of those sentences is false. overflow: hidden shipped with ADR-0114; it is read by two engines — Yoga for sizing and the painter for the clip — it reaches hit testing, and four rules in controls.css plus text-input, text-area and scroll use it today.

So the obvious move is to clip a menu row and be done. It does not work, and why it does not work is the whole of this record.

The finding

A clipped label wraps instead of being cut.

Box.text is a measured leaf: Yoga calls back into the text stack with the available width, and the paragraph lays itself out to fit. So the moment anything narrows the box the text is in, the text is re-measured at the narrower width and breaks onto a second line. Clipping never gets a chance — there is nothing overflowing to clip.

That is why ADR-0148’s fix for a squeezed menu row was flex-shrink: 0 on the text and on the row: a box that never narrows is a paragraph that never re-wraps. The label overflows the menu window, and a label one word too wide is legible where a menu of two-line rows is not.

Three attempts, each failing in a way worth writing down:

  1. overflow: hidden on the row. The row does not shrink (flex-shrink: 0), so nothing is clipped horizontally — and the one thing it did clip was the showcase’s 20px icon in its 16px column, which a golden caught immediately. Clipping a row punishes the overhang the toolkit already tolerates.
  2. A clip box around the label, shrinking. The wrapper shrinks, the text inside it is re-measured, and the label wraps — reintroducing exactly the defect ADR-0148 fixed, from the other direction.
  3. The same, flex-direction: row and align-items: center. Fixes a second, separate bug found on the way — a box’s default direction is Yoga’s column, in which align-items is the horizontal axis, so a wrapper left at the default stretches to the row’s height and drops the label to the top of it — and does nothing about the wrapping, because that is a measure-time decision and not a flex one.

What is missing is white-space: nowrap: a way to tell the text stack to measure a paragraph at its natural width whatever width it is offered. With that, a clip box works and an ellipsis becomes reachable. Without it, no arrangement of overflow and flex-shrink can cut a label, because the label is never too long for the box it is in.

Decision

Correct the four comments and leave the behaviour alone.

A wrong diagnosis repeated in four places is worse than the defect it describes: it sends the next person to implement text-overflow, which would not have helped, and it tells them clipping is unavailable when three widgets depend on it.

No white-space property is added here. It is a text-stack change, it wants a consumer that is not a comment, and §8’s subset has grown one property at a time against a named need — which is the rule that kept the subset small enough to believe in. (It got four: ADR-0255 counts them and builds it. This paragraph is what it had to answer, and the rule is satisfied rather than broken — the need was named before the property was written.)

ProgressFill’s note becomes a choice rather than a limit. Its indeterminate sweep travels there-and-back because the off-the-edges version needed clipping; clipping exists, so that drawing is now available and changing a shipped animation is a design decision rather than a bug fix.

Alternatives considered

  • Shipping overflow: hidden on select-value and option anyway. The build stayed green, and green only means no golden covers a label that long. Shipping a change that might silently turn an overflow into a two-line wrap, with nothing demonstrating an improvement, is worse than shipping nothing.
  • Adding white-space: nowrap now. The honest next step, and a text-stack change with a layout-property surface, an inheritance question and its own goldens. Bundling it into a comment fix would hide it.
  • Measuring the label and truncating it in Java, one frame late through Measured. It works — scroll and select already read last frame’s geometry — and it puts a text-layout decision in five widgets instead of one property in the cascade.
  • Leaving the comments. They are load-bearing: each one tells a reader not to attempt something that would in fact work.

Consequences

  • Four comments now say what is true, and each names the property that is actually missing.
  • Two TODO.md entries keep their subject and lose their reason. The menu-row entry said “§8’s subset has no text-overflow and nothing in this toolkit clips, so there is no third behaviour to choose”; the third behaviour exists and the missing property is a different one. The indeterminate-bar entry said nothing clips; something does.
  • The next attempt starts three failures ahead. All three are recorded above, and the second and third are traps anybody would fall into in the same order.
  • A separate bug was found and not fixed, because nothing currently hits it: a Box.of() wrapper defaults to Yoga’s column direction, so align-items on it centres horizontally. Any future clip box has to say flex-direction: row.

236. A wheel is consumed by whatever it moved

Date: 2026-09-05

Status

Accepted. Closes the two TODO.md entries opened by ADR-0089 — “a knob inside a scroll view is still untested, though both now exist” and “Kind.WHEEL had exactly one consumer, and it showed” — and opens one, recorded under Consequences.

Context

knob was the first widget in the toolkit to handle a wheel, and for a long time it was the only one. ADR-0089 wrote down what that cost:

Knob.wheel consumes unconditionally, which is the safe half of that pair and the wrong half if the other one turns out to matter.

The other half is scroll, which arrived in ADR-0116 and had already answered the same question for itself, in ScrollViewport.onPointer:

Returns whether anything actually moved — which is what the caller turns into consuming the event, and therefore what decides whether an ancestor scroller gets a turn.

So there were two rules in the toolkit for the same event. A viewport consumed what it moved; a knob consumed everything it was handed. With nothing above a knob for an unconsumed wheel to reach, the difference had never shown — which is exactly why the entry stayed open rather than being closed as theoretical.

Put a knob in a list and it shows immediately. A knob pinned at its maximum sits in a scrolling column swallowing every upward scroll: the list stops dead under the pointer, and the only way past is to move the pointer off the control. No desktop behaves that way, and the toolkit already knew it shouldn’t — it had written the rule down next door.

Decision

What is consumed is what moved. Knob.wheel computes the value it would ask for, compares it against what the user can currently see, and returns without consuming when they are the same. ScrollViewport’s rule, applied to a second widget rather than restated as a new one.

Three details decide whether it is the right rule or merely a plausible one:

  • The comparison is against what ask would pass on, not against the raw arithmetic. A stepped knob two units from its end on a grid of five still moves those two: snap(clamp(98 + 5)) is 100, which differs from 98, so the wheel moved something and is consumed. Comparing the raw 103 against the maximum would have called it “past the end” and thrown away the last part-step.
  • Only the direction with nowhere to go chains. A knob at its maximum still takes a wheel that turns it down, so a control being used does not let the list lurch out from under it halfway through.
  • A knob nobody is listening to is not a place a scroll stops. The same question asked of the wiring instead of the range: disabled, or a null onChange, means the value cannot change, so the event is not consumed. The router already refuses input to a disabled subtree; repeating the check here is what makes not consuming the answer rather than merely doing nothing.

Alternatives considered

  • Leave it, and let the application not put knobs in lists. This is the status quo and it is a constraint the document never stated. §2.4 gives chaining as the toolkit’s behaviour for a wheel, and a control that opts out of it silently is worse than one that never chained at all.
  • Consume whenever the pointer is over the knob, and let scroll look through it. It inverts the ownership: the ancestor would have to know which descendants “really” wanted the event. Chaining is a bubble, and a bubble is the child deciding.
  • A chainsWheel() on Handles, so a widget declares the policy rather than deriving it. Two implementors and both would answer the same way. The rule “you consumed it if you moved” needs no interface, because it is a fact about the event rather than a property of the widget.
  • Making ask return whether it asked, and consuming on that. It is tidier in wheel and wrong for the drag: a drag that runs past the end goes on reporting the clamped value every frame, which is the behaviour the slider and the knob have always had, and changing it to close this would be a second decision smuggled in under the first.
  • Accumulating unspent wheel across events, so three lines into a knob one step from its end spends one and passes two on. There is no state for a partly spent event and no toolkit hands one on in halves.

Consequences

  • KnobChainingTest is new, and is the first test in the catalog to drive a wheel through a real bubble between two widgets. Four cases, through the real router against painted regions: the knob takes its own wheel without moving the list; the knob at its maximum lets an upward scroll through; the same knob still turns downward; and the minimum chains the other way, which is the pair of signs the maximum case cannot check on its own.
  • The list is scrolled off its top before every case, and this is load-bearing rather than tidy. The direction a knob at its maximum rejects is the one that scrolls a list up — so against a list left at its top the wheel would have been refused by the viewport’s own edge rule, and the test would have passed before the fix for a reason that had nothing to do with it.
  • Three of the new assertions fail against the old code, which was checked by neutralising the range comparison and re-running: the two chaining cases and KnobTest’s unit-level “a wheel past the end is left for an ancestor”.
  • A disabled control still swallows a wheel outright, and it is not this widget’s doing. PointerRouter.dispatch returns before the chain is built when the target is in a disabled subtree, so an ancestor scroll never gets a turn — measured, not deduced: a disabled knob in a scrolling column stops the list dead. That cut is ADR-0059’s and it is right for a click, where bubbling past a disabled button to the row underneath would activate something the user did not aim at. A wheel is the kind of event where it is wrong, and “is the disabled cut per-event-kind” is a decision about the router rather than about knob. It is recorded in TODO.md rather than answered here.
  • Knob gained one private predicate, moves, and no public surface. The markup, the bindings and the keyboard are untouched.

237. The pointer’s state follows the frame, not only the pointer

Date: 2026-09-05

Status

Accepted. Closes the TODO.md entry opened by ADR-0057, and corrects a claim ADR-0059 left in the code.

Context

The entry stated the gap and its fix in two sentences:

Nothing recomputes the cursor when the tree changes under a still pointer. A widget that becomes disabled without the pointer moving keeps the shape it had. The fix is re-running cursorAt after each paint against the last known position; it is worth doing when something can actually change that way.

Something can. controls.css gives every disabled control cursor: not-allowed in two places, and the sequence that reaches it is the ordinary one: a button disables itself in its own press handler, or a form disables its submit as the user types, and the pointer does not move because the person deciding whether to click is the person holding still.

The router only ever recomputed the cursor from pointerMoved, so the shape was a function of the last motion rather than of the last frame. Everything needed to fix it was already there — updateRegions runs after every paint and holds the rectangles — except a place to remember where the pointer is. The three position fields the router had are gesture-scoped: pressOriginX/Y span a press-to-release and are NaN outside one, which is exactly what makes them useless here.

Measuring it turned up a second half the entry did not name, and a comment that was actively wrong. mark says:

Only setting is suppressed. Clearing always goes through, so a control that was hovered before it became disabled does not keep the state — which is a real sequence, because a button commonly disables itself in its own press handler while the pointer is still over it.

The control does keep the state. Clearing is not suppressed, but nothing calls it: updateHover is mark’s only caller for :hover, and it returns early when the element under the pointer has not changed. So the hover wash survived every subsequent move within the control and went away only when the pointer left it — on the exact sequence the comment names as the reason it is safe. docs/design-system.md §2.1 makes that non-discretionary: a disabled control that still lightened under the pointer would be telling the user it can be used.

Which makes it one defect rather than two. Fixing only the cursor would have left a control drawing its hover wash while its cursor said not-allowed — a state more confusing than either mistake alone.

Decision

The pointer’s state is re-asked once per frame, against the position the pointer is actually at.

  • A fourth position field, pointerX/pointerY, and it is the only one that outlives a gesture. Set from every entry point that carries a position — moved, pressed, released, wheeled — rather than from pointerMoved alone, so a window whose first event is a click is not left with nowhere to ask about.
  • NaN is the whole of “we do not know”, and it means it twice: before the pointer has ever arrived, and after pointerExited, which is another window’s pointer or none at all. updateRegions skips the recompute in both cases rather than asking cursorAt about a point the pointer is not at.
  • updateCursor’s capture freeze is reached through, not around. A repaint during a drag does not thaw the shape: the recompute goes through the same method, which returns early while something is captured.
  • restate() re-asserts :hover and :active over the hovered and pressed chains, and its whole implementation is mark(…, true) — because mark already knows the rule. It turns a set into a clear on a disabled element, so re-asserting what the pointer is over sets the state where the control is live and takes it away where it is not, in one call with no second branch.
  • No ENTERED or EXITED is emitted. Nothing entered or exited anything: the pointer has not moved and the element under it is the one that was there. That is the same line mark itself draws — this is about what a control looks like, not about what it is told.

Alternatives considered

  • Recompute from the widget side, when a control learns it is disabled. It needs every control to know, and the states are the router’s — the argument ADR-0059 already made for putting the :hover refusal in mark rather than in each widget.
  • Call updateHover from updateRegions instead of restate. It is fewer lines and it would fire ENTERED/EXITED on a repaint, because the early return it depends on is keyed on the element rather than on the state. A tooltip opening because a list repainted is a worse bug than the one being fixed.
  • Recompute only when the frame’s regions differ from the last. The comparison costs more than the recompute: cursorAt is a walk of the rectangles under one point, and setCursor and setPseudoClass are both already edge-triggered, so an unchanged frame is silent without help.
  • Track the position in Window and pass it back in. The router is the thing that already holds “where the pointer is” for hover, capture and hit testing; a second copy a window kept in sync would be a second thing to get wrong.
  • Fix the cursor only, and record the :hover half as a new entry. It is the narrower reading of the entry, and it ships a control whose cursor and whose paint disagree. §2.1 already says what the answer is, so there was no decision left to defer.

Consequences

  • This runs once per frame rather than once per motion, which makes the edge-triggering in setCursor and setPseudoClass load-bearing in a way it was not before. A 120 Hz repaint over a still pointer is 120 comparisons and no platform calls; a test asserts exactly that.
  • Eight cases in CursorTest, under a new group. Five for the cursor — the repaint that recomputes, the unchanged frame that is silent, the frame before the pointer has arrived, the frame after it has left, and the drag that stays frozen — and three for the pseudo-classes: :hover lost on disabling, :hover returned on re-enabling, and :active lost mid-press. Three of them were checked against the old code and fail on it.
  • Switchable is a new test widget, because a record cannot be disabled after the tree holding it is built and the whole point is that the widget changes under a pointer that does not.
  • mark’s comment is now true. It was describing an intended behaviour as an achieved one, which is the kind of comment that stops the next person looking.
  • A window that never sees a pointer pays nothing: the NaN check is the first thing the frame hook does.

238. A wheel chains past a dead control

Date: 2026-09-05

Status

Accepted. Closes the TODO.md entry opened by ADR-0236, and narrows the cut ADR-0059 put in PointerRouter.dispatch.

Context

ADR-0236 gave a knob the rule that it consumes what it moved, so a knob at the end of its travel lets an ancestor scroll have the wheel. Measuring that turned up a case the knob cannot fix for itself:

A disabled control still swallows a wheel outright, and it is not this widget’s doing. PointerRouter.dispatch returns before the chain is built when the target is in a disabled subtree, so an ancestor scroll never gets a turn — measured, not deduced: a disabled knob in a scrolling column stops the list dead.

The cut is ADR-0059’s, and its reasoning is sound as far as it goes:

One choke point for every control, present and future — the same argument that put the :hover refusal in mark rather than in each widget. A control’s own disabled check is then a second line of defence rather than the only one, and a control that forgets to write it is still unavailable inside a disabled container.

That argument is about the thing being aimed at. A click on a disabled button must not become a click on the list row holding it; ADR-0059’s companion rule — a disabled control still hit-tests, so a click cannot fall through to whatever is behind it — is the same thought. Both are right and neither is being undone here.

A wheel is not aimed at a control. It is aimed at whatever scrolls, and every platform treats it that way: a disabled <button> in a scrolling page, a insensitive GTK widget in a GtkScrolledWindow, a disabled Qt control in a QScrollArea — the wheel reaches the scroller in all three. Nobody positions a pointer over a dead control in order to scroll; they position it over the list, and the dead control happens to be under it. So “unavailable” was being read as “opaque to a gesture that was never about it”.

The existing test in DisabledPropagationTest could not see the difference, which is worth writing down. Its tree is a disabled form holding a button and nothing above it, so “the subtree refuses the wheel” and “the wheel is swallowed” produce identical logs. The distinction only appears when something live is above the dead thing.

Decision

The disabled cut is per event kind, and WHEEL is the one that chains.

dispatch still refuses a press, a release and a click aimed into a disabled subtree, exactly as before and for exactly ADR-0059’s reason. For a wheel it builds the chain and drops the disabled prefix instead of returning:

  • The dead subtree still handles nothing. What changes is only who gets a turn afterwards. A disabled knob does not turn — the trimmed chain never reaches it — and a scroll above it scrolls.
  • The disabled elements are a prefix, and that is a fact rather than an assumption. chain is deepest-first and isDisabled walks up, so it is true from the target to the outermost disabled ancestor and false at every step above. dropWhile is therefore exact, and it is one pass.
  • A wholly disabled tree still dispatches nothing. The trimmed chain is empty, which is the old behaviour arrived at by the new route — and is why the existing test’s assertion is unchanged rather than merely still passing.
  • isInput is untouched. The kinds it partitions are “the user doing something”, and a wheel still is one; taking WHEEL out of that set to get this would have made a second question share an answer with the first, and broken the hit-test guarantee that a wheel over a disabled control is not a wheel over whatever is painted behind it.

Alternatives considered

  • Remove WHEEL from isInput. One character of diff and wrong: isInput also governs whether the disabled subtree is skipped at all, so the disabled knob itself would start handling wheels and turning.
  • Let the widget opt in — a chainsWheelWhenDisabled() on Handles. The question is not the widget’s. A control does not know whether anything above it scrolls, and the answer is the same for every control there will ever be.
  • Dispatch to ancestors for every kind, and let each control’s own disabled check refuse. This is the cut ADR-0059 explicitly rejected — it makes a control that forgets the check a live control inside a disabled container — and it would let a click on a disabled button reach the row underneath.
  • Trim by “the outermost disabled ancestor” found in one upward walk, rather than by dropWhile over the chain. Identical result; isDisabled per element is O(depth²) on a path that is a handful deep and only walked for a wheel over a disabled subtree, and the dropWhile says what the rule is rather than how to find it.
  • Leave it, and let applications not disable controls inside scroll views. The list stopping dead under the pointer is the kind of bug that gets reported as “scrolling is broken”, with nobody suspecting the greyed-out knob.

Consequences

  • Three tests, two of which fail against the old code. In DisabledPropagationTest, a live scroll above a disabled form gets the wheel, and a press through the same tree still stops dead — the second is what keeps the change from being “let everything through”. In KnobChainingTest, a disabled knob in a real scrolling column no longer stops the list, which is the case that opened the entry.
  • The existing “the wheel is refused too” test is unchanged and still passing, because its tree has nothing above the disabled container. That is the honest reading: it asserts a disabled subtree does not handle a wheel, which is still true, rather than that a wheel dies there.
  • event.target() is still the disabled element for the ancestors that now receive the wheel. That is correct and worth stating: the target is where the pointer is, and bounds/part are re-measured per handler anyway, which is what a scroll reads.
  • The catalog gains a second line of defence it already had. Knob.moves refuses when disabled (ADR-0236), so the knob would not have turned even if the trimmed chain had reached it. Both checks stay: ADR-0059’s argument for the choke point is unchanged.

239. A mark is measured against the box it is drawn in

Date: 2026-09-05

Status

Accepted. Closes the TODO.md entry opened by ADR-0088, and opens one in its place — the nineteen pairs the measurement found.

Context

ContrastTest has enforced §1.2’s 4.5:1 for text since ADR-0087, and the entry recorded what it did not enforce:

Non-text contrast is not checked at all. ContrastTest measures text against the fill under it. §1.2’s other half — 3:1 for anything that is not text — reaches a checked checkbox’s mark, a slider’s thumb against its groove, a spinner’s ring, a border against the surface it separates, and the focus ring against whatever is behind it. None of them is measured, and ADR-0088’s argument that the accent ramp did not need to move rests on exactly that unenforced number. The arithmetic is already written; what is missing is deciding what counts as the “background” of a mark drawn onto its own box.

The last sentence is the whole of the design problem, and it turns out to have a simpler answer than it sounds.

Decision

What counts as the background of a mark drawn onto its own box

Its own box, and both halves come out of one ComputedStyle.

A mark is coloured by the color of the element it is drawn in, and that element supplies its own background. A checked checkbox’s tick is --gb-checkbox-mark-checked on --gb-checkbox-bg-checked, and both are set by the same check-indicator:checked rule. A slider’s thumb sits on its groove; a knob’s arc on its track. So the pair is one element’s two properties rather than a composite of anything, and nothing here needs the painted frame — which is what keeps this a cascade test like the two sweeps beside it.

Three sweeps, because there are three shapes of question

  • A mark against the box it is drawn in (everyMarkIsVisible) — nine pairs: the checkbox tick, the radio dot, the toggle thumb in both states, the slider thumb and fill, the progress fill, the knob arc and pointer.
  • A ring against the surface behind it (everyRingIsVisible) — the focus ring and the spinner, each on all three surfaces a window paints, because neither has a plate of its own and a control may sit on the page, in a card or inside a group-box.
  • A control against that surface (everyControlIsDistinguishable) — and this one is a maximum of fill and edge, not two independent measurements.

The maximum is the part that took thinking

§1.2’s non-text floor exists so a component can be identified, and a control offers two means at once: a fill that differs from the surface, and an edge drawn around it. WCAG asks that some means clears the floor, not that every one does — a filled button with no border is not a failure for having no border.

Measuring the two separately was the first version, and it is wrong in a way that matters: it reported --gb-border failing on every surface in both themes, which is a decorative divider doing exactly what a 1px separator is meant to do. The same token is also a control’s edge, and only in that role is it held to 3:1. The maximum is what tells the two roles apart without needing two tokens.

What the sweeps find is recorded, not fixed

Nineteen pairs are below the floor, held in three exact-set lists on KNOWN_FAILURES’ terms — a pair that newly breaks cannot be parked quietly, and one that gets fixed fails the test until it is taken out. Each carries its measurement.

They are not fixed here, and the distinction is deliberate. KNOWN_FAILURES is empty because ADR-0088 fixed the seven text pairs it found; the same move is not available, because every one of these is a theme colour and sliding a ramp to clear 3:1 changes what the toolkit looks like. That is a design decision with a golden-image tail rather than something a test may take on its own authority.

What this change owes is the measurement, and the entry said so itself: “ADR-0088’s argument that the accent ramp did not need to move rests on exactly that unenforced number”. It is enforced now, and it says the ramp does need to move.

Alternatives considered

  • Walk the real element tree and compare every box against its ancestors. It generalises and it is unusable: a card on the page is a deliberate, subtle surface change and would be reported as a failure, because nothing in the cascade says which colour differences carry meaning. The curated list is what encodes that judgement, and it is the same shape pairs() already uses.
  • Composite translucent fills over a stated surface, so button.ghost and --gb-selection could join the sweep. That is the backdrop-aware check the button.ghost entry already says would need the painted frame; adding half of it here would let a ratio look measured when what it composites over is still a guess.
  • Fix the themes in this change. Nineteen pairs across two themes is a redesign of the neutral and accent ramps, and every golden in the repository would move with it. Shipping the measurement first is what makes that a reviewable diff rather than an unreviewable one.
  • Lower the floor for the pairs that nearly clear it. Three of the four mark failures are one token pair at 2.98:1 — --gb-accent on --gb-border in the light theme, missing by 0.02. A floor that moves to admit what fails it is not a floor.
  • Leave the unchecked checkbox out, on the grounds that a checkbox is usually on a form’s own surface. It is 1.00:1 against --gb-surface-2 in the dark theme — the fill is the token — and a group-box paints exactly that.

Consequences

  • Three new sweeps and nineteen recorded failures, the worst of them §2.2’s focus ring, below 3:1 on all three surfaces of the light theme (1.74, 2.00, 1.64). A focus ring is the one mark in the system with no second means of being seen, so it is the first thing the follow-up entry names.
  • --gb-checkbox-bg is --gb-surface-2 in the dark theme, so on a group-box an unchecked box differs from its backdrop by nothing at all and is held up entirely by a 1.17:1 edge. That is the shape of all twelve boundary failures.
  • The class comment no longer says “the exemption list is empty”. It was true of the text sweep and is now only true of the text sweep, and a comment that overstates a guarantee is the kind ADR-0237 had just finished correcting elsewhere.
  • ratio(theme, background, color) is factored out of the three sweeps that had been building the same forced rule inline.
  • Nothing in the toolkit changed. This is a test-only change, which is why it can state the problem without also being the thing that solves it.

240. The ring follows the accent

Date: 2026-09-05

Status

Accepted. Pays the first and worst of the nineteen debts ADR-0239 recorded.

Context

ADR-0239 measured §1.2’s non-text floor for the first time and found §2.8’s focus ring below it on every surface of the light theme:

surfaceratio
--gb-bg1.74:1
--gb-surface2.00:1
--gb-surface-21.64:1

That entry named this the one to fix first, and the reason is not the size of the number. A focus ring is the only mark in the system with no second means of being seen: a control that is hard to make out still has its label, its shape and its position, and a keyboard user who cannot see the ring has nothing at all.

The cause turns out to be a ramp that was left behind rather than a colour anyone chose. Both themes set the ring to their accent — except that the light theme’s accent had already moved and the ring had not:

nord-dark    --gb-accent: var(--nord8);   --gb-focus: var(--nord8);
nord-light   --gb-accent: var(--nord10);  --gb-focus: var(--nord8);

--nord10 is where the light theme’s accent went for contrast, down the Frost ramp from the pale --nord8. The focus ring kept the pale one.

Decision

--gb-focus: var(--nord10) on the light theme — the ring follows the accent, which is what the dark theme has always done.

This clears the floor on every surface with room to spare: 3.50:1 on --gb-bg, 4.03:1 on --gb-surface, 3.31:1 on --gb-surface-2.

It is a palette value rather than an invented one, which matters here more than it usually would. The theme files open by saying the raw palette is theme-invariant and the semantic tokens are what differ; a hand-mixed hex for the ring would have been a fourth blue in a file with three, and the whole argument for the fix is that the ring belongs to the accent’s ramp rather than to one of its own.

The gap it exposed, which is the more useful half

Changing a shipped colour moved no golden at all, and that is not because the change is invisible.

Every focus golden in the catalog is NORD_DARK — segmented-focus, menu-focus, menubar-focus. §2.2’s ring had no picture of it on the one theme where it was broken, which is why nothing caught this in the first place and why nothing would have caught it coming back. segmented-focus-light is new, and it is the point of this record as much as the token is: a colour with no image is a colour nothing would notice going wrong again.

The two renderings side by side are the argument. At --nord8 the ring is a pale wash that reads as an artefact of the bar’s own edge; at --nord10 it is unmistakably a ring.

Alternatives considered

  • Darken past --nord10 — #4c6d94 reaches 4.40:1 and --nord3 6.06:1. Both clear by more and neither is a Nord colour or the accent, so the ring would stop matching the thing it is signalling about. 3.31:1 is a floor cleared, not a floor scraped.
  • Lighten the surfaces instead. --gb-surface-2 is the binding constraint at 1.64:1, and moving it moves every panel, card and group-box in the theme to fix one ring.
  • A ring with its own contrasting outline — a light halo around a dark core, which is what some systems do to be safe on any backdrop. It needs two colours and a second outline the subset does not have, for a case §1.2 already answers with one number.
  • Fix all nineteen at once. The other sixteen are control fills and the accent ramp, and they move goldens in bulk; keeping this one separate is what makes it a reviewable diff, and the exact-set lists in ContrastTest are what stop the remainder being forgotten.
  • Change --gb-focus in both themes for symmetry. The dark theme’s is already 4.31:1 at its worst and its accent is --nord8; changing it would be a redesign in search of a rule.

Consequences

  • RINGS_BELOW_FLOOR is empty, and ContrastTest’s exact-set assertion is what forced this to be noticed: emptying the token without emptying the list failed the build, which is the mechanism ADR-0239 built the lists for working on its first use.
  • segmented-focus-light is a new golden, and the catalog’s first picture of a focus ring on the light theme.
  • Sixteen non-text pairs remain below the floor, in MARKS_BELOW_FLOOR and BOUNDARIES_BELOW_FLOOR. They are the control fills and the accent-on-border pair, they move goldens in bulk, and TODO.md carries them.
  • The light theme now has one blue for “this is the accent” and “this is focused”, which is what the dark theme reads as and is the more ordinary design anyway.

241. A theme can be audited by whoever wrote it

Date: 2026-09-05

Status

Accepted. Closes the TODO.md entry opened by ADR-0087.

Context

The entry is four sentences and the last one is the whole problem:

Nothing validates an application’s own theme. §10 lets an application swap the alias tokens, and ContrastTest runs over the two themes the toolkit ships. A third-party theme that pairs --gb-badge-warning-bg with an unreadable --gb-badge-warning-text is a legibility bug the toolkit will not notice — the arithmetic is nine lines and is not exposed as anything an application can call.

ContrastTest is a good check and it is in the wrong place to be reused: it lives in :widgets, it is a test, and its nine lines of WCAG arithmetic are private to it. So the guarantee §1.2 makes stops precisely where §10’s extensibility begins — the toolkit promises legible colour, hands the application the means to replace all of it, and then has nothing to say.

Decision

A new package, css.contrast, in :core and exported.

:core because a theme is: an application that wants to know whether its colours are readable should not have to depend on the widget catalog to find out. Its own package rather than css or css.value because it is neither a stage of the engine nor a value type — it is a question asked about a resolved cascade, which is the shape ADR-0172 gave the other four packages.

  • Contrast — the arithmetic and the two floors, TEXT_FLOOR 4.5 and NON_TEXT_FLOOR 3.0. ContrastTest now calls it instead of its own copy, and that is the point rather than tidiness: an audit an application runs and a sweep CI runs that disagreed about the arithmetic would be worse than either alone.
  • ContrastFinding — a measured pair, returned for everything measurable rather than only failures. An application tuning a theme wants to see how much room a pair has; a caller that only wants the failures says so in one filter, and the other direction is impossible.
  • ThemeAudit — audit(sheets) and failures(sheets).

The pairs are found by convention, not by a list

This is the decision the entry did not anticipate and the one that makes the feature worth having.

A hard-coded list of the toolkit’s own pairs would check a custom theme’s overrides and miss everything it added. But the design system already names its pairs consistently — --gb-badge-warning-bg carries --gb-badge-warning-text, --gb-button-primary-bg carries --gb-button-primary-text — so the rule is every --gb-<name>-bg with a matching --gb-<name>-text, and an application that follows the same convention for --gb-mycard-bg is checked for free.

The surface pairs are stated explicitly beside it, because --gb-text on --gb-bg is the one relationship the convention cannot express: neither token is named for the other. The two overlap by exactly one — --gb-bg ends in -bg, so the convention derives --gb-text from it and finds the same pair — which is deduplicated rather than removed, because both routes are right.

Substituted, not raw

StyleResolver.customProperty rather than the raw token map, for the reason ADR-0195 gave the chart palette. A theme written the ordinary way says --gb-badge-warning-bg: var(--gb-warning), and an audit that read tokens without resolving would decide that is not a colour and skip the pair — auditing a real theme as having nothing to check, and passing.

What it will not measure

A translucent colour has no single ratio, because what it composites over decides the answer. A pair with alpha on either side is skipped rather than measured, and this is not hypothetical: a hud’s plate is #1c212ae6, deliberately translucent so the frame shows through. Scoring it would read the alpha off, treat it as opaque, and report a comfortable pass on a colour nobody receives — the same trap that keeps button.ghost out of ContrastTest.

So the audit returns fewer findings than the theme has tokens. That is the honest number rather than a gap.

Alternatives considered

  • Expose ContrastTest’s sweep as a test fixture. It resolves widgets through the cascade, so it needs :widgets, a font and a renderer — and the question an application swapping tokens can act on is about the tokens, not about what a Badge did with them.
  • Fail at start-up when a theme does not audit clean. A stylesheet is data and §8’s rule for bad data is to drop it and carry on; a theme that refused to load over a 4.4:1 badge would be the toolkit overruling a decision that is the application’s. failures() is offered so an application can make that its own policy.
  • Log a warning when a theme loads. The group-box-title precedent in ADR-0216 is the argument against: a dropped value already warns and the wrong corners shipped for months anyway. A call an application makes deliberately is read; a line in a log is not.
  • Take a Theme rather than a list of stylesheets. Theme is the two the toolkit ships, and the whole subject here is the third one. A list is what a window is given anyway.
  • Put it in css beside Theme. That package is the engine’s front door — Stylesheet, ComputedStyle, Theme — and this is a question asked about the result rather than a part of producing it.

Consequences

  • Both shipped themes audit clean, at seventeen pairs each — asserted as a count as well as a set, because a sweep that quietly stopped finding pairs would otherwise pass by measuring nothing.
  • ThemeAuditTest proves it on a theme the toolkit has never seen, including the entry’s own example: white on --nord13 at 1.56:1 is caught by name.
  • --gb-hud-bg is the shipped example of the alpha rule, and there is a test saying so, because the rule is only credible if the toolkit’s own tokens are subject to it.
  • ContrastTest lost its private arithmetic and its two literal floors. They are Contrast’s now, so the number CI asserts and the number an application audits against cannot drift apart.
  • This does not cover the non-text floor. NON_TEXT_FLOOR is exported and ThemeAudit does not use it: the non-text pairs are marks on their own boxes and rings on surfaces (ADR-0239), and which token is a mark is not something a naming convention can tell. Sixteen of those are still below the floor in the shipped themes, which is TODO.md’s.

242. em is the element’s own size

Date: 2026-09-05

Status

Accepted. Closes the TODO.md entry opened by ADR-0066.

Context

The entry is short and its last clause is why it stayed open:

em and rem do not resolve against the node’s own font-size. They use CssLength.Context’s fixed numbers, so font-size: 1.2em means 1.2 × 16 and not 1.2 × the parent’s size. Nothing in the toolkit’s own stylesheets uses em, so it has no effect today — but it is wrong, and the typography scale is what makes it reachable.

CssLength.Context was always the right shape — (fontSize, rootFontSize), with em and rem reading one each. What was missing is that nothing ever built one per element: WidgetRenderer holds a single Context for the whole tree and hands the same instance to every ComputedStyle.of call, so em was one constant at every depth.

Measuring it turned up a second number the entry did not mention. CssLength.Context.DEFAULT is (16, 16), and Typography.INITIAL’s size is 13. So 1em was not merely “not the parent’s size” — it was not the element’s own size either, and not any size the toolkit actually renders text at. The two constants had no relationship and nothing made them agree.

Decision

em resolves against the element’s computed font size, in two passes.

CSS has one exception and it is the reason a single pass cannot work:

  • 1.2em on font-size means “a fifth larger than my parent”, because the value being computed cannot be its own input.
  • 1.2em on anything else means “a fifth larger than my own text”.

So ComputedStyle.of now resolves font-size first against the parent’s size, then everything else against the size that produced. The parent’s size comes from parent.typography().size() — already in hand, because WidgetRenderer passes the parent’s ComputedStyle for inheritance. No plumbing changed: the fix is entirely inside the method that was already given everything it needed.

The undeclared case needs no branch. A node that says nothing about its size has whatever it inherited, and that is exactly what em should resolve against.

Transform was the same bug in a second place

Transform.parse reached for CssLength.Context.DEFAULT directly, and its comment named the gap:

em and rem against the fixed context numbers, which is the same approximation the rest of the cascade makes and the same known gap.

It takes a Context now, threaded from ComputedStyle.with, which had one all along. Two public call sites, both in ComputedStyle.

rem is the configured root size, and that is a smaller claim

rem continues to use Context.rootFontSize(). In CSS rem is the root element’s computed font size, so these agree unless the root element itself declares one — and recovering that inside ComputedStyle.of is not possible, because a node is handed its parent’s style and not the root’s. Nothing in the catalog styles a root’s font-size, so this is exact today and is written down rather than fixed.

Alternatives considered

  • Resolve every property in one pass against the parent’s size. Simpler and wrong for the common case: padding: 1em on a 20px heading would be 13, the parent’s size, which is the opposite of what em is for.
  • Give ComputedStyle a fontSize parameter instead of deriving it. Every caller would have to know the rule, and the two that matter — WidgetRenderer and the tests — would derive it the same way from the same parent style.
  • Make Context.DEFAULT (13, 13) so the constants at least agreed. It hides the bug rather than fixing it: the numbers would match for a node that declared nothing and diverge again the moment one declared a size.
  • Thread the root’s computed size for rem. It needs a third thing passed down beside the parent style, or a mutable field on the renderer that is correct only after the root has resolved. Worth doing when something styles a root’s font-size; nothing does.
  • Leave it, since no shipped stylesheet uses em. That was the state, and the entry’s own answer is the right one: it is wrong, and the typography scale makes it reachable. A unit that silently means something else is worse than an unimplemented one, because it looks like it works.

Consequences

  • No shipped rendering changes, and this was checked rather than assumed: not one em or rem appears in nord-dark.css, nord-light.css, controls.css or the showcase’s sheets, and the full golden corpus passes untouched.
  • One existing test changed meaning and was rewritten. ComputedStyleTest’s “em multiplies the font size in force” passed Context(20, 16) with no parent and asserted 1.5em was 30 — asserting the old semantics, on an element whose computed font size was 13. It now declares font-size: 20px and asserts the same 30 for a reason that is true.
  • Six new tests, four of which fail against the old code: em against a declared size, against an undeclared one (19.5, not the old 24), against an inherited one, on font-size itself against the parent’s, and two for transform: translate.
  • computeChild is a new test helper that resolves the parent through the real cascade and hands it down, which is what WidgetRenderer does and what the existing compute — every element a root — could not express.
  • Transform.parse and parseOrigin take a Context. Both are public; both have exactly two callers, both in ComputedStyle.
  • Context.fontSize now means “what the root’s em resolves against” rather than “what every em resolves against”. It is consulted once per tree instead of once per node, and Context.DEFAULT’s 16 no longer contradicts Typography.INITIAL’s 13 — because it no longer competes with it.

243. A missing token is a message, not a stream

Date: 2026-09-05

Status

Accepted. Closes the TODO.md entry opened by ADR-0121, and applies ADR-0216’s answer one stage earlier in the same pipeline.

Context

The entry names both the problem and the fix:

Nothing warns that a var() resolved to nothing — it logs, per node, per frame. One missing token is a stream rather than a message, which is how two of them survived long enough to reach a user. TokenClosureTest and ShowcaseTokensTest now fail the build instead, so the log is no longer the only line of defence; the log itself is still noise. Saying it once per property per stylesheet would make it a diagnostic.

ComputedStyle had already met this exact problem one stage later and solved it (ADR-0216): a stylesheet is static, so a declaration that cannot be applied cannot be applied on the next frame either — but a style is resolved per element per invalidation, so one typo reported itself sixty times a second for as long as the screen it was on kept moving. Its comment puts it better than a summary would: “That is not a louder warning, it is a quieter log.”

The cascade has two warnings with the same shape and neither was deduplicated: the unresolvable var(), and a custom property that refers to itself.

Decision

The same mechanism, one stage earlier — and an instance field rather than a static one.

ComputedStyle’s REPORTED is static because ComputedStyle is a record with static factory methods and there is nowhere else to put it, and it needs a public forgetReportedDrops() so tests in two modules can clear it.

StyleResolver is an object, built per stylesheet set and living as long as the renderer that holds it. So “once per resolver” is “once per stylesheet”, which is what the entry asked for, and it comes out better in three ways:

  • A theme swap reports again, correctly. Swapping builds a new renderer and therefore a new resolver, and what the new theme is missing is news rather than a repeat.
  • Tests get isolation from constructing a resolver, not from remembering to call a static forget hook — the failure mode where the second test in a class depends on whether the first tripped the same warning.
  • Nothing leaks between unrelated stylesheet sets in one JVM.

Keyed by property and element type

The entry says “once per property per stylesheet”, and this is one refinement of it. The same token failing on button and on text is two facts, and which types an unresolvable token reaches is exactly the blast radius somebody debugging it wants. It stays bounded either way: a stylesheet has finitely many declarations and a tree finitely many types, which is the property that makes this a diagnostic rather than a stream.

A cycle is keyed by the property name alone, because a custom property referring to itself is a fact about the property and not about whatever element happened to ask for it first.

REPORT_LIMIT is ComputedStyle’s 512 and its argument, unchanged: past that many distinct drops something is generating them, and a log that went quiet would hide it.

Alternatives considered

  • Reuse ComputedStyle’s static set. One mechanism for two stages sounds tidier and welds the resolver’s lifetime to a static that nothing resets on a theme swap — so the first theme’s missing token would silence the second theme’s report of the same property.
  • Log at DEBUG instead of WARN. That is the state ADR-0216 already argued against for dropped values: group-box-title drew square corners for months behind a DEBUG line. Quieting a diagnostic is not the same as making it one.
  • Report only the first drop and nothing after. It loses the blast radius, and the one line you get names whichever element happened to resolve first — which is frame-order dependent and therefore not reproducible.
  • Count and summarise at the end of a frame. There is no end-of-frame hook in the cascade, and a count without the property name is not something anybody can act on.
  • Leave it, since TokenClosureTest and ShowcaseTokensTest now fail the build. Those cover the toolkit’s own sheets. An application’s are exactly what the log is for, and it is the application author who cannot read it.

Consequences

  • substitute and expandVar are instance methods now. They were static and the cycle report needed the field; both are private-or-package and neither had a caller outside this class, so the change is invisible.
  • reportedDrops() is package-private, for the test. The thing worth asserting is that one bad var() is one message however many elements hit it, and only slf4j-api is on the classpath — there is no appender to read the log back from, and a logging backend bought for one assertion would be a dependency this does not need. The accessor says so in its own comment.
  • Four tests, three of which fail against the old code: five resolves of one element report once, a second element type is a second report, a new resolver reports again, and a self-referring property does not grow per frame.
  • descend walks depth, not siblings — which the first draft of two of those tests got wrong and which cost a NoSuchElementException to find out. They build a fresh window > type tree per case instead, and the helper says why.
  • The drop itself is unchanged. Only the report is once; making the drop conditional would be a stylesheet that behaved differently on the second frame, which is the trap ComputedStyle’s own test names.

244. A child may say where it sits

Date: 2026-09-05

Status

Accepted. Closes the TODO.md entry opened by ADR-0111, and takes one of the two things stack is blocked on.

Context

The entry names the gap and prices it:

align-self is not in §8’s subset, which a tab strip’s + found: a child shorter than its row sits at the top of it and there is no per-child way to say otherwise. It is the companion of align-items, which is in the subset, and Yoga’s setAlignSelf is already bound — what it costs is a component in ComputedStyle and one in Box, both of which are records whose every wither would have to be revisited.

Two corrections to that framing, both in the toolkit’s favour:

  • §8 has listed it all along. docs/ARCHITECTURE.md §8’s layout list reads align-items/self/content, and the sentence naming what is unimplemented says only flex-basis. So the document claimed this worked. What was missing was the implementation, not the sanction.
  • Align.AUTO was already waiting for it. The enum’s own comment says “AUTO only means anything for align-self” — a value that existed for a property that did not.

The price is real: ComputedStyle has 22 hand-written withers and Box 27, each rebuilding its record positionally. Adding a component means editing 47 argument lists, and alignItems and alignSelf are the same type, so a swap between them compiles, runs, and is wrong.

Decision

One component on each record, alignSelf, defaulting to Align.AUTO.

  • ComputedStyle gains the component, the wither and an align-self case in with.
  • Box gains the component, a builder method, and carries it through style(ComputedStyle) so a resolved declaration reaches the box.
  • RenderObject calls node.setAlignSelf beside the existing setAlignItems, guarded by the same “only if it changed” comparison every other property uses.

Align.AUTO is the default on both, which is Yoga’s and CSS’s: a child that says nothing is aligned by its parent’s align-items alone. It is also the only value that reads differently on a child than on a parent — “defer to my container” — so align-self: auto is a real declaration that undoes a more general rule rather than a missing one.

The mechanical edit was scripted, and the safety net already existed

47 argument lists is not a hand edit. The clean sites — 22 in ComputedStyle, 25 in Box — are one identifier per argument, so they were rewritten by a script that splits the argument list and inserts at a fixed index. The four that are not clean carry inline comments containing commas, and were edited by hand.

What makes that safe is that RecordWitherTest already exists, from ADR-0181 when Limits was added, and it is exactly the check this needs: it asks every wither to set its component to the value it already holds and requires the record back unchanged. A wither that writes its argument into the wrong slot, reads the wrong component into a slot, or passes one component twice all fail it. Its premise — that no two components of one type hold equal values — is asserted by componentsAreDistinct, which is why the fixtures give alignItems Align.FLEX_END and alignSelf Align.CENTER.

So the risk this entry priced was already insured, by a test written the last time somebody paid it.

Alternatives considered

  • Group the align properties into a sub-record, the way Insets and Limits group their four. It is the structural answer to long argument lists and RecordWitherTest’s own comment says so — and it would rename box.alignItems() at every call site in the toolkit for a benefit the test already delivers completely.
  • Put it only on Box and not on ComputedStyle. A widget could then set it in render and no stylesheet could, which inverts the whole arrangement: the point of the entry is that a document can say where a child sits.
  • Add align-content at the same time, since §8 lists all three. Nothing in the catalog wants one — it decides how wrapped lines share the cross axis, which matters only to a wrapped row in a box with a fixed height — and that is the rule §8’s subset has grown by all along.
  • Ship a consumer with it. The tab strip’s + is the case that found the gap, and changing it moves goldens for a reason unrelated to the mechanism. Better as its own diff.

Consequences

  • stack is one blocker lighter. Its entry named two: the layering half, which WindowRoot already does with position: absolute and Yoga insets, and the alignment half, which was this. What remains is stack itself.
  • Five layout tests, four of which fail against the old code, asserted against Yoga’s own output rather than against the record — the property is one line in RenderObject and the whole risk is whether that line runs, so a test reading box.alignSelf() back would pass on a box nothing laid out.
  • auto is asserted to be indistinguishable from saying nothing, and beside it a test that a non-auto value really does move the child — because the reason those two agree must not be that nothing is wired up at all.
  • RecordWitherTest’s two fixtures gained a value each, and its coverage count went up by two on its own.
  • No golden moved, because nothing in the catalog declares align-self yet. That is the honest state of a property added for the widget that will want it.
  • docs/ARCHITECTURE.md §8 is now true where it was optimistic: its list included align-items/self/content while only the first resolved.

245. The second surface stays, and says so

Date: 2026-09-05

Status

Accepted. Answers the TODO.md entry opened by ADR-0168.

Context

The entry asked one question and said what answering it needed:

--gb-surface-2 has now been mistaken for an elevation three times — by card (ADR-0166), by text-input and by select (ADR-0168). It means “the second surface” and promises no direction, and each consumer that assumed otherwise was wrong on one theme only. […] what is unresolved is whether --gb-surface-2 should keep existing at all, and that needs a look at what still reads it.

The look

Five things in the toolkit read it, and not one of them wants a direction:

readerwhat it is
--gb-badge-bga default chip’s fill
scroll:hover scrollbarthe track’s plate while the pointer is over it
group-box-titlethe header band above a body
skeleton-bara placeholder bar
split-divider.collapseda divider flush against an edge

Every one wants a plate that is merely distinct from what is under it. The three that were wrong wanted “raised” or “sunken” and have --gb-surface-raised and --gb-surface-sunken now.

Decision

It stays, because five live consumers want exactly what it promises, and naming it something else would not have prevented a single one of the three mistakes — those were consumers reaching for a direction from a token that never offered one.

And the trap becomes an asserted fact rather than a comment. ThemeTest gains three cases:

  • --gb-surface-raised is never darker than --gb-surface, on either theme. Equal is allowed: the light theme’s is the same white, because there is nowhere lighter to go and the edge carries the elevation instead (ADR-0166).
  • --gb-surface-sunken is never lighter, composited over the surface — it is rgba(0, 0, 0, …) in both files by design, so comparing its raw value would be comparing a black nobody paints.
  • --gb-surface-2 takes opposite directions in the two themes — a step up from --gb-surface on dark and down on light. That is exactly why each of the three consumers looked right to whoever wrote it and wrong to everybody on the other theme, and it is now something a test says out loud.

The definition in both theme files carries the same sentence, so the next reader meets it where the token is rather than in an ADR.

Alternatives considered

  • Delete it and give the five consumers their own tokens. Five tokens whose values would all be --gb-surface-2, and a sixth consumer would still have to pick one. The problem was never the token’s existence.
  • Rename it --gb-surface-distinct. More honest and it moves every reference for a benefit the assertion delivers. A name cannot stop somebody reaching for a direction; a failing test can.
  • Make the two themes agree on the direction, so the token could promise one. Then it is --gb-surface-raised under a second name, and the light theme’s ramp has nowhere to put it — the reason --gb-surface-raised is white on white there in the first place.

Consequences

  • The elevation tests would have caught all three original bugs, which is the test to write when a class of mistake has happened three times: not one that checks the three fixed sites, but one that checks the property they violated.
  • If the --gb-surface-2 direction test ever fails, the themes have been made to agree and the question this record answers is worth reopening. Its message says so, because the tempting fix is to edit the assertion.
  • The -2 reads are five and shrinking is not a goal. card, text-input and select left because they wanted something else, not because the token is bad.

246. Text has a capture phase, now that something wants one

Date: 2026-09-05

Status

Accepted. Closes the TODO.md entry opened by ADR-0141.

Context

The entry names the fix and the reason it had not been applied:

A select’s typeahead works closed and not open. §3 asks for typeahead and a TextEvent goes to the focused node, which in an open list is an option — so the list has nothing to intercept it in. Handles has an onKeyCapture and no onTextCapture, and adding one is the whole fix; it was not added on spec, because a capture phase is a routing rule and inventing one for a single consumer is how a router grows two.

The restraint was right and its condition is now met: there is a consumer. The open list is a second tree in a second window (ADR-0103) with its own router, and the focused node inside it is an option — a row that does not know what typing means and is where the letters stopped.

dispatchKey has captured root-first and then bubbled since the beginning. textInput only bubbled. One event kind had a phase the other did not, for no reason anybody had written down.

Decision

Handles.onTextCapture, and a capture phase in textInput that is dispatchKey’s shape exactly — the chain walked backwards, stopping on isConsumed, then the ordinary bubble.

SelectList reads the text on the way down and calls the same SelectState.typeahead the closed control calls. That is the point of fixing it this way rather than writing a second search for the open case: typing n, n, n cycles the same options in the same order whether the list is showing or not, because it is one implementation.

Three details:

  • It consumes what it acted on, so a letter the list searched with does not also reach a row.
  • Blank text is left alone. A space in an open list means “pick this one” everywhere else, and a typeahead that swallowed it would take the key from whatever means to act on it.
  • A tree gets none. select tree=#true puts a Tree in the panel, whose rows are nodes rather than options; matching a prefix against a lazily built hierarchy is a different search from the flat one, and inventing it here would be the same over-reach the entry warned about.

Alternatives considered

  • Give the popup’s router a rule that text goes to the tree’s root first. A routing special case for one widget, where the capture phase is the general mechanism the router already has for the other event kind.
  • Have option forward text it does not understand to its list. Every row would need to know what encloses it, which is the coupling Option#within already exists to avoid, and a row outside a select would have nowhere to send it.
  • Move the focus to the list rather than to a row when the popup opens. It breaks arrow-key navigation, :focus-visible on the highlighted row, and the reason the popup has a focus scope at all.
  • A second typeahead in SelectList over the options it was handed. It works and it is the version that drifts: the closed control’s cycling rule, its staleness window and its case-folding would have to be kept in step by hand.

Consequences

  • SelectList gains a component, onTypeahead, and a one-argument constructor without it — which is what a golden wants, since a picture has nobody to report to.
  • Four tests. Two in KeyboardTest for the phase itself: capture runs root-first before the focused node is told, and a container that consumes on the way down stops it reaching the focus. Two in SelectTest for the list: it forwards and consumes, and the callback-less form leaves the text alone.
  • KeyboardTest’s node gained a consumeText field, beside the consumeKey it already had, because a capture phase only means anything if something can stop the event there.
  • Every other widget is unaffected. onTextCapture defaults to doing nothing, and the bubble is unchanged — a text-input still receives text exactly as it did.

247. start is CSS, and flex-start is Yoga

Date: 2026-09-05

Status

Accepted. Answers the question left open by ADR-0216’s TODO.md entry.

Context

The entry recorded a fix and left a question:

The toolkit accepts Yoga’s spelling of these keywords and not CSS’s aliases, so start, end and space-between-style names have exactly one correct form each and a document that uses the other gets a warning rather than a mapping. Whether to accept the aliases is open; accepting them means a second table to keep in step with Yoga’s enum.

The framing has one word wrong in it, and the word is what decides the answer: align-items: start is not an alias, it is CSS. Box Alignment Level 3 defines start and end for align-items, align-self and justify-content, and every browser takes them. Yoga has only flex-start and flex-end.

So this was not a toolkit choosing one spelling among two conveniences. It was a toolkit dropping a declaration the specification allows, and telling the author they had made a mistake. It filled the Panels screen’s console for long enough to need deduplicating before anybody asked whether the declaration was actually wrong.

Decision

start and end resolve to FLEX_START and FLEX_END. Two entries in one map, applied in keyword — the one place every enum-valued property is parsed, so align-items, align-self and justify-content all get it without three edits.

After the enum’s own lookup, not before. An enum that ever gains a constant called START keeps its own meaning rather than being shadowed by a mapping written for a different one. This is a one-line ordering that costs nothing and removes a whole class of future surprise.

Two entries and no more, which is the part that keeps the “second table to keep in step” cost at zero:

  • start/end are writing-mode-relative in full CSS and identical to the flex pair in a subset with one writing mode and no grid. That is what makes the mapping exact rather than approximate.
  • left and right are deliberately absent. They are justify-content only, they are not the same as start/end under RTL, and §2.4’s bidi support (ADR-0218) means the toolkit cannot promise they would stay equivalent. They are dropped like any keyword it has not got.

Alternatives considered

  • Keep exactly one spelling and the warning. Defensible for a private vocabulary and wrong for CSS: §5 says the stylesheet language is CSS, and a subset that refuses valid CSS is a subset with a bug rather than a boundary.
  • Map the whole of Box Alignment. self-start, self-end, normal, safe/unsafe — none of which Yoga can express, so each would be a mapping that is nearly right, which is worse than a drop.
  • Translate in the parser rather than in keyword. The parser does not know which property it is reading, and a translation applied to every ident would reach font-family: start.
  • Accept them and warn anyway. A warning about correct code is what this record is removing.

Consequences

  • Two existing tests changed meaning and were rewritten, both in the group that exists because of this typo. alwaysDropped used align-items: start as its example of a value the toolkit has not got and now uses one it really has not got, sideways. And “start is not flex-start” is now “start and flex-start are the same value” — the original report was right that the value never reached Yoga and wrong that the author had made a mistake.
  • The deduplication ADR-0216 built is untouched and still needed. A dropped declaration is still reported once; there is simply one fewer thing that gets dropped.
  • keyword grew a helper, constant, so the direct lookup and the alias lookup are one expression each rather than two nested try/catches.
  • Four new tests: both spellings on all three properties, the flex spellings still meaning what they did, and left still being refused with the reason in the name.

248. Only the inherited half is handed down

Date: 2026-09-05

Status

Accepted. Closes the TODO.md entry opened by ADR-0142.

Context

The entry names the gap and what it needs:

A style that really changes still re-resolves its whole subtree, and only the inherited properties can matter. ADR-0142 stopped a node handing down a new instance for an unchanged value; what it did not do is narrow the comparison to the properties a child could actually inherit. So scrolling — which moves a transform, and a transform inherits nothing — re-resolves every node inside the viewport on every frame of the gesture. The fix needs a notion of which properties inherit, which the cascade has and ComputedStyle does not.

ComputedStyle does have it, and has had it all along: inheritingFrom is the list, and it is two lines — color and typography. A child reads nothing else from its parent. Its own comment even enumerates what is deliberately not there, cursor and opacity, and why.

So the notion existed. What was missing was reading it twice.

The trap, which is the whole of the difficulty

Element.stableStyle returns a value used for two unrelated jobs:

self = element.stableStyle(self);   // ← the cache key for children
...
var painted = self;                 // ← and what this node paints

Loosening the comparison without separating those would hand back an older instance whose transform is last frame’s — and then paint with it. A scrolling viewport would freeze at its first offset while every child cached happily. The narrowing is a one-line change; doing it safely is not.

Decision

Two variables, because there are two jobs.

  • self stays exactly what the cascade and restyle produced. The node paints it, transitions observe it, animations apply to it.
  • handDown = element.stableStyle(self) is the children’s cache key, and is self or an older instance that agrees with it about everything a child can read.

ComputedStyle.inheritsSameAs is that comparison, and it lives beside inheritingFrom deliberately: they are the same list read two ways, and a property that starts inheriting has to be added to both or the cache goes stale rather than merely cold.

Alternatives considered

  • Compare equals minus the transform. It fixes scrolling and nothing else, and the next property nobody inherits — opacity under a fade, decoration under a hover — is the same bug again under a different name. The question is not “which properties change often” but “which properties a child can see”.
  • Have stableStyle return both. A two-field return for a method whose callers want different things, where two locals say it plainly.
  • Give ComputedStyle an inherited() projection and key children on that. It allocates a second record per node per frame to avoid comparing two fields.
  • Let the child compare its own inherited values instead of keying on identity. That is the scheme ADR-0142 replaced: identity is what makes the check free, and a value comparison per node per frame is the cost this machinery exists to avoid.

Consequences

  • Scrolling stops re-resolving the viewport. A transform moving is now invisible to every node under it, which is what it always was semantically and what the cache now agrees with.
  • Three tests, all in StyleIdentityTest. A parent whose transform moved hands down the same instance; a parent whose colour moved hands down a new one, because color inherits and a child that kept its style would be drawn wrong; and — the one that matters most — the parent still paints the transform it resolved.
  • That third test was written against the mistake, and catches it. Folding the two roles back into one variable fails it, which was checked rather than assumed. It is the difference between a performance change that is correct and one that merely runs faster.
  • inheritsSameAs is public, because it is a claim about the record rather than about the renderer, and the comment beside inheritingFrom is what keeps the two in step.

249. A rule that can name a type, does

Date: 2026-09-05

Status

Accepted. Closes the TODO.md entry opened by ADR-0152.

Context

The rule buckets are only as good as the stylesheet. A sheet written entirely in classes puts every rule in the untyped bucket and gets none of ADR-0152’s saving. The toolkit’s own sheets are type-first and nothing enforces that they stay so.

ADR-0152’s saving is that a rule for button is never even looked at for a text. Rules are bucketed by the type their rightmost compound names; a rule that names none goes in the bucket nothing can skip, and is checked against every element of every kind on every resolve.

The entry assumed the toolkit’s sheets were type-first. Measuring found 16 of 340 rules untyped, and that they were two families rather than a scattering.

Decision

Qualify the seven that had a type to give, and enforce the rest as an exact list.

The 16 split cleanly:

  • Seven were tour’s parts — .tour-title, .tour-body, .tour-count, .tour-skip, .tour-next and its two states. The tour builds them from plain Text and Button widgets carrying a class, so every one of them was matching a known type and simply not saying so. text.tour-title matches exactly what .tour-title matched and lands in a bucket. Seven rules, one word each, no behaviour change — the golden corpus is untouched, which is the check that it was a re-bucketing rather than a re-selecting.
  • Eight cannot be qualified and should not be. The typography scale (§1.4) is seven ranks an application puts on whatever it likes — that is what makes it a scale rather than a widget’s parts — and :root is the theme’s token layer.

RuleBucketTest asserts the remaining eight as an exact set, on ContrastTest’s terms: a threshold is a number somebody raises, and a count that fails without naming anything is a count nobody reads. A new untyped rule has to be argued for in a diff, beside the reason.

Beside it, a second assertion that the sheet is large and nearly all of it is bucketed — because a check listing eight selectors would pass just as well against a stylesheet of eight rules.

Alternatives considered

  • Give tour’s parts their own element types, tour-title rather than text.tour-title. It is the more thorough answer and it makes seven new widget types whose only job is to be styled, where the class already says what they are and the type qualification costs one word.
  • A threshold instead of a list — “no more than 5% untyped”. It passes while the wrong five rules are untyped, and it is raised rather than read the first time it fails.
  • Warn at parse time when a rule names no type. Most of the eight are correct and an application’s sheet is its own business; a warning that is usually wrong is the log ADR-0243 has just finished quietening.
  • Bucket by class as well as by type. ADR-0152 considered and deferred it, and this makes it less pressing rather than more: after the change the untyped bucket is eight rules, and a second index is a lot of machinery to skip eight.

Consequences

  • 16 untyped becomes 8, out of 340 — under one in forty, and every one of them named with its reason.
  • StyleResolver gains three read-only accessors: untypedRuleCount, ruleCount and untypedSelectors. Public because the lint that reads them is in :example, beside the two that already check the toolkit’s own sheets — a sheet’s shape is the same kind of fact as a dropped declaration.
  • untypedSelectors returns the selectors and not a count, which is the difference between a failure that names .tour-title and one that says a number went up.
  • No golden moved, which is the evidence that qualifying a class selector with the type it was already matching changes what the cascade looks at and not what it finds.

250. A stack is one child in flow and the rest over it

Date: 2026-09-05

Status

Accepted. Closes the TODO.md entry opened by ADR-0100, whose last blocker ADR-0244 removed.

Context

docs/core-widgets.md §1 has asked for one since the beginning:

stack — z-order layering; children positioned by alignment or absolute insets. Basis for badges-over-things and custom overlays.

The entry tracking it said the mechanism was never in doubt — WindowRoot already lays an overlay out with position: absolute and Yoga insets — and named one thing missing: the alignment half, which needed align-self. That arrived in ADR-0244, and the entry’s own last sentence became “what stack still wants is stack”.

Decision

The first child stays in flow; every child after it is position: absolute.

That is the whole of the widget. It is nine lines, and each of the two halves is answering a question the other could not:

  • Something has to give the stack a size. A box whose children are all out of flow is a box of nothing, so the first child is left alone and the stack takes its size. This is also what the name implies — the thing, and then what goes on top of it — and it makes wrapping an existing widget in a stack a change that cannot move it.
  • An overlay must not resize what it sits on. Taking the rest out of flow is what stops a badge widening the avatar under it.

Nothing here positions anything, and that is the point

§1 asks for children “positioned by alignment or absolute insets”, and stack implements neither. Both already worked:

  • An absolute child with no inset is placed by its container’s align-items and justify-content, and by its own align-self. So a badge reaches a corner with two declarations and no arithmetic.
  • An absolute child with an inset goes exactly where it says.

This is the case ComputedStyle.INITIAL has been describing since before anything could reach it. Its comment on the inset field reads: “an inset of zero pins a node to its container’s edge, and ‘no inset at all’ is what a node that never mentions one must get. Yoga spells that undefined, and the difference only shows on an absolute node — where zero would stretch it and undefined leaves it where the alignment put it.” stack is the widget that finally shows it.

Z-order is document order, which is the painter’s existing rule for siblings rather than anything this widget arranges.

Alternatives considered

  • Every child absolute. The stack then has no intrinsic size and collapses to nothing, so every use would need an explicit width and height — which is the opposite of “badges over things”, where the thing already knows how big it is.
  • A layer= or z= attribute. Z-order is document order and the painter already works that way; a second spelling of “which is on top” is two things to keep in step. elevated remains the way to lift a box out of order (ADR-0069), and a stack does not use it.
  • An align= attribute on the stack, so a document could write stack align="top-end". It is a second vocabulary for align-items and justify-content, in a toolkit whose whole argument is that layout is the stylesheet’s.
  • Positioning the overlays in render. It needs the boxes’ sizes, which do not exist until Yoga has run, and it would put a layout engine inside a widget that already has one underneath it.

Consequences

  • The catalog is 52 widgets, and §1’s core group is complete but for image.
  • Ten tests, six of which fail against a stack that does not position anything — asserted against Yoga’s own output, because every claim a stack makes is a claim about where boxes ended up.
  • The tests wrap the stack in a row that does not stretch it, and that is load-bearing rather than tidy: the root box is always laid out at the frame’s size, so a stack tested as the root is 300 wide whatever its children do and every size assertion passes for the wrong reason. That was found by writing the assertions first and watching four of them fail at 300.
  • The markup path is tested through the real catalog, not by constructing the record — stack is in §1’s core list, so what is under test is the registration.
  • No stylesheet rule ships for it. A stack sets no colour, no padding and no gap, and where its overlays land is the application’s to declare — which is row’s and column’s arrangement exactly.

251. A widget may read a token, and a nested scroller is named

Date: 2026-09-05

Status

Accepted. Closes two TODO.md entries opened by ADR-0116.

Context

Both entries are about scroll and about the same shortfall — something the design system says that nothing in the code could hear.

A widget cannot read a resolved custom property, so scroll’s line height is a constant. […] ScrollViewport.LINE is 20 logical pixels and a --gb-scroll-line was deliberately not shipped, because a token no widget can read is a number an author sets and nothing honours.

Nested same-axis scrollers are banned in the canon and nothing enforces it. §2.4 says so outright. Chaining means a nested pair behaves reasonably rather than badly, so the ban costs nothing today; what is missing is the diagnostic that would tell an author they wrote something the design system rules out.

The first entry was half stale. Paints.Context.color has read a resolved custom property since ADR-0195 — that is how a chart gets --gb-chart-1…8. What was missing was the same door for a number.

Decision

Paints.Context.length, and the widget banks it

color’s companion, resolved through the same cascade and answering logical pixels. Deliberately still narrow — lengths and colours and nothing else — on color’s own terms: both are values the cascade already parses, and a general token-returning accessor would invite a widget to reimplement the parser.

A percentage answers the fallback, because a percentage is of something and a widget asking for a token has no containing block in hand to be a percentage of.

The wheel arrives where there is no context to ask, so ScrollViewport reads the token in render and banks it through a callback into ScrollState — the shape onMeasured already had. That makes the value a frame late, which is ADR-0117’s bargain unchanged: a paint always precedes an input, so a real window has spent that frame before anybody can turn a wheel. The banking is guarded on the value having changed, because the callback sets state and a setState every frame is a rebuild every frame.

--gb-scroll-line ships at 20px, and ARROW follows it — §2.4 gives the arrow keys one line, and the two had always been the same number.

A nested same-axis scroller says so, once

BuildContext.findAncestorState is the whole implementation. It exists for scrollIntoView and answers this question with nothing added, which is the argument for asking it in ScrollState.build rather than teaching the renderer about scroll views.

It stays a diagnostic and not a refusal: the arrangement still works, because turning a design rule into a crash is worse than the rule going unheard. And it is deduplicated by axis, statically, for ADR-0243’s reason — build runs per element per invalidation, and a document that nests scrollers in four places has one mistake rather than four.

Alternatives considered

  • Put the resolved custom properties on ComputedStyle. Then anything with a style could read a token, including restyle. It adds a map to a record that is compared and cached per node per frame, for a door two widgets want.
  • A general token(String) returning tokens. It is the accessor color’s comment already argued against: a widget would parse them, and there would be two parsers.
  • Read the line height in onPointer from the event. The event would have to carry the element’s tokens, which is a cascade lookup per wheel event rather than per frame.
  • Refuse to build a nested same-axis scroller, or drop the inner one’s wheel handling. Both turn a canon rule into a behaviour change, and chaining already makes the arrangement work; the author’s problem is that nobody told them, not that it broke.
  • Warn from the renderer, which sees the whole tree. It puts knowledge of a particular widget in :core, where Scroll is not even visible.

Consequences

  • Paints.Context has a second implementor to update, and the test one in :widgets answers the fallback — which is right: a widget rendered by hand has no cascade behind it.
  • Six tests. Three for the token — the default is 20, an override is obeyed, and the arrow keys follow it — and three for the nesting: same axis is reported once, crossed axes are not (a wide table in a page is exactly that), and one scroll on its own is not.
  • The override test paints twice, and says why. The first paint banks the token; the rebuild after it is what puts the value on the widget the router hands the wheel to. Writing it with one frame is what found that, and the comment is there so the next reader does not “fix” it.
  • ScrollState gained a static report set and two accessors for the test, because only slf4j-api is on the classpath and there is no appender to read the log back from — StyleResolverTest’s arrangement exactly.
  • list is unchanged, and its entry stays open. ListView.virtualized(h) takes the row height as an argument, which is an API choice rather than a missing reader: the number decides which rows to build in children(), and a value banked from render would be a frame late in the one place a frame late means building the wrong rows. The door this opens is the one scroll needed; list needs a different one.
  • A comment in controls.css is written without a selector, because ButtonTest asserts the base sheet contains no # as a proxy for “names no colour of its own” — a crude check doing a useful job, and worth working around rather than weakening.

252. A window is maximized when the platform says so

Date: 2026-09-05

Status

Accepted. Closes the TODO.md entry opened by ADR-0221.

Context

The entry lists three absences and the question that had kept them:

Application.maximized() is a creation flag: it becomes SDL_WINDOW_MAXIMIZED and after that nobody involved knows whether the window still is one. There is no Window.maximize(), no restore(), no isMaximized(), and no SDL_EVENT_WINDOW_MAXIMIZED plumbed through — so an application cannot find out that the user maximized it, which is the half that makes a “remember my window size” preference possible. Each of the three is small on its own; together they are a window-state feature with a question in it that nothing has asked yet (what does isMaximized() return between the request and the event?).

Decision

isMaximized() answers what the platform last reported

That is the question, and the answer follows from what maximizing actually is. ADR-0221 already established it: maximized is a state, not a size. Every platform routes the ask through a window manager that may refuse it, delay it, or grant it in part — a tiling compositor has its own idea, and a window with a maximum size may be given less than the work area.

So a flag set on the way out would be a lie the moment one of those happened. isMaximized() reports what SDL_EVENT_WINDOW_MAXIMIZED and …_RESTORED last said.

The cost is stated rather than hidden: between maximize() and the event, isMaximized() still answers false. That is a window that has been asked and has not yet agreed, and there is no third answer that is true. A test asserts exactly this, because it is the kind of thing a later reader would “fix”.

It is also what makes the interesting half work. An application can find out that the user maximized it, which is what a “remember my window size” preference needs and which no amount of tracking one’s own calls can produce.

One event for both directions

BackendEvent.MaximizedChanged(window, boolean), which is FocusChanged’s shape and for its reason: SDL reports two events and every consumer wants the boolean.

SDL’s RESTORED fires for un-maximizing and un-minimizing, and both mean the same thing to a window that tracks only the one state.

setMaximized is a request, all the way down

BackendWindow.setMaximized(boolean) returns nothing, at every layer, because at no layer is the answer known yet. It defaults to a no-op, so a backend with no window manager has nothing to ask; the headless one overrides it to agree and report, which is what lets a test drive the whole path.

HeadlessWindow.reportMaximized is the other half — a change the application did not ask for. Every other route into this state starts with the application, and that is the route that does not.

Alternatives considered

  • Report the request optimistically, and correct it if the event disagrees. It makes isMaximized() true for a window that never became one, on exactly the platforms where the answer matters most.
  • Return a boolean from maximize(). What could it mean? SDL’s return says whether the ask was accepted, not whether the window changed — a true that does not imply the thing the caller wanted is worse than no return at all.
  • A Minimized state beside it. Nothing has asked, and SDL’s RESTORED collapses the two undos into one event — so tracking both needs a rule about what RESTORED means when both were set, for a state no entry mentions.
  • Expose SDL_GetWindowFlags and ask on demand. It is a synchronous call per question rather than a field per event, it does not exist on the headless backend at all, and it still cannot tell an application that something changed.

Consequences

  • Two native bindings, and the export list and the C shim both had to learn them. SDL_MaximizeWindow and SDL_RestoreWindow go in goldberry.symbols; the two event constants go in goldberry_shim.c, because LayoutVerificationTest refuses a constant declared in Java that nothing verifies against the compiled library.
  • That refusal earned its keep immediately. SDL_EVENT_WINDOW_MAXIMIZED is 0x20A and RESTORED is 0x20B, which were derived by counting an unnumbered C enum from the last explicit value — precisely the arithmetic that is silently wrong. The verifier checked both against the real library.
  • GoldberryRuntime’s switch is exhaustive over a sealed interface, so adding the event failed the compile until it was routed. That is the design working rather than an inconvenience.
  • Six tests, including the one that pins the decision — asking does not make it so — and the one that makes the feature worth having: the user maximizing a window nobody asked to.
  • Application.maximized() is unchanged. It is still the creation flag ADR-0221 shipped; what is new is that the window can now be asked and told afterwards.

253. A caret is as wide as the theme says

Date: 2026-09-05

Status

Accepted. Closes the TODO.md entry opened by ADR-0167, which ADR-0251 unblocked.

Context

--gb-caret-width is not a token and the caret is one logical pixel. The width is set in the same call that sets the caret’s position, so a stylesheet that disagreed would move it rather than resize it. A theme that wants a fat caret is a design-system decision and a token, which is Principle 3’s order.

The diagnosis is right and the phrasing understates it slightly: the width is set by Box.size and the position by Box.inset, two calls rather than one — but both run after the cascade, so a caret { width: 3px } is overwritten rather than honoured. Either way the conclusion holds: width is the wrong spelling for this, and the right one is a token.

The token was not shipped because nothing could read one. That is what ADR-0251 changed.

And it is not a preference. A thicker caret is a low-vision aid, which is why §13 lists that kind of switch — so the number being unreachable was an accessibility gap wearing a styling question’s clothes.

Decision

--gb-caret-width, defaulting to 1px, read through Paints.Context.length.

One constant, in a package both controls can see

text-input and text-area each had their own CARET_WIDTH = 1, and the second’s comment said it was the first’s. Two constants that must agree and cannot see each other is one constant with a comment where the compiler should be. widgets.form.Carets holds the number and the token name; both controls read it, and a test asserts they agree.

It is read once and used in three places, not two

The site the entry did not mention is the one that would have made a fat caret wrong. TextInputState.laidOut computes the scroll offset with

var offset = Math.max(scrollOffset, caretAt - room + 1);

— where the 1 is the caret’s own width of room, so the field does not scroll one pixel short of showing it. A three-pixel caret against a one-pixel reserve is a caret clipped at the end of the text. laidOut takes the width now, which is possible because it is already called from render, where the context is.

Alternatives considered

  • Ship the token and leave the scroll reserve at 1. It is the smaller diff and it makes the feature quietly wrong at exactly the width somebody would set it to. A token that is honoured in two places out of three is worse than no token, because the failure looks like a text-rendering bug.
  • Make the caret a real element with a width the cascade resolves. Its box is computed from the shaped paragraph in the same render that positions it; splitting that so the cascade could own one dimension means the field measuring text in one pass and placing the caret in another.
  • Put the constant in text-input and have text-area import it. It is package-private and in another package, so it would have to become public API of a control — a widget exporting a number for another widget.
  • Bank it into the state, as scroll does with --gb-scroll-line (ADR-0251). Unnecessary here: every consumer of this number is reached from render, so nothing has to survive until an input arrives.

Consequences

  • Four tests, two of which fail against the old code, and the two that fail are the ones that matter: an override is honoured, and text-area honours the same one. The default case passes either way, which is the point of keeping it.
  • The tests find the caret by its width rather than by counting children, because a field’s anatomy changes and an index into it is a test that breaks for an unrelated reason.
  • TextEditor.laidOut gained a parameter, which is an interface with one implementor and one caller.
  • Two dangling doc comments were left behind when the constants moved, and -Werror with dangling-doc-comments refused the build until they went. Worth recording as the check doing its job on a refactor rather than on new code.
  • --gb-caret-width is the third component-token default to ship since a widget could read one, after --gb-scroll-line. --gb-list-row-height is still waiting, and still on the other door: its number is an API argument consumed in children(), not in render.

254. A build may ask the cascade for a number

Date: 2026-09-05

Status

Accepted. Closes the TODO.md entry ADR-0251 left open, and with it the last of ADR-0213’s.

Context

ADR-0251 gave a widget Paints.Context.length, and said plainly what it did not close:

list is unchanged, and its entry stays open. ListView.virtualized(h) takes the row height as an argument, which is an API choice rather than a missing reader: the number decides which rows to build in children(), and a value banked from render would be a frame late in the one place a frame late means building the wrong rows. The door this opens is the one scroll needed; list needs a different one.

The stakes are higher than “a number is stated twice.” density-compact.css sets --gb-list-row-height: 26px. A list written virtualized(32) against the regular density therefore virtualizes on the wrong pitch the moment an application switches density — the spacers and the window disagree with the rows. The number was not merely inconvenient to repeat; repeating it was a bug waiting for a setting to be changed.

Decision

BuildContext.token(name, fallback) — Paints.Context.length’s build-time twin, for the numbers wanted before there is a box to paint.

It is three lines, because the pieces were already there and had not been put together: Element already implements both BuildContext and StyleElement, and ElementTree has held a StyleResolver since ADR-0149 so a node whose state changed could ask what the sheets say. What was missing was the method.

WidgetRenderer.prepare(tree) is new and is about ordering. render already hands the tree its resolver on the way in, and that is a frame too late for a reader in build — a build runs before the frame it produces. Launcher and Popup now call prepare before the flush.

ListView.virtualized(), with no argument, is the form to prefer. virtualized(h) is unchanged.

rowHeightFromToken is a component, and the first attempt was a sentinel

rowHeight is already a tagged number — 0 means “do not virtualize” — so a second tag, -1 for “ask the token”, looked consistent and cost nothing.

It cost a guard. ListVirtualTest asserts that virtualized(-1) throws, and the test’s name says why: “a negative row height is refused where it is written”. -1 is what a typo looks like, and the refusal catches it at the call site. Making it meaningful would have deleted a check that exists to catch a real mistake, in exchange for not adding a field.

So it is a boolean beside the number. Seven constructor sites, and none of them could be got wrong silently: double, boolean, Attributes in a row is a transposition the compiler refuses.

The first build of a tree has no cascade, and that is worth writing down

A Stateful widget builds once inside the ElementTree constructor — before any renderer has taken the tree on, and therefore before any cascade exists. So a token asked for there answers its default, and the second build is the first that can see the stylesheet.

This was found by measuring rather than assumed: the value resolved correctly on every build except the first, which is the one prepare cannot reach.

It is acceptable because everything that reads a token is expected to settle, and a virtualized list settles by construction — its window is recomputed from the geometry every frame, so the frame after the first is already right. Closing it means handing the resolver to the tree at construction, which nothing has needed enough to widen the constructor for.

The token must be declared at or above the list

ListView is a composition node whose state builds the list element, so the build that decides the row count runs one level above the node a list { … } rule would match. Custom properties inherit downward, so the token has to be set on an ancestor.

That is where it ships — :root, in both controls.css and density-compact.css — so the case this exists for works. It is stated here because list { --gb-list-row-height: 26px } looks like it should work and will not.

Alternatives considered

  • Bank it from render, as scroll does. ADR-0251 already gave the reason it does not transfer: the number decides which rows to build, so a frame-late value builds the wrong rows rather than moving the right ones.
  • Read the token in ListBox, which is the list element and would honour list { … }. It is built by the state that needs the answer, so the question would be asked after it was needed.
  • Make virtualized() mean virtualized(32). It is the same repeated number with the repetition moved into the toolkit, and it is wrong under compact density in exactly the same way.
  • A general token(String) returning tokens rather than a number. The argument Paints.Context.color made and this inherits: a widget would parse them, and there would be two parsers.

Consequences

  • Four tests, and the one that says what this is for asserts that a compact density changes the pitch with no Java change at all.
  • ListVirtualTest’s harness calls prepare before its flush, matching Launcher. Without it the tests would pass for the wrong reason — the harness would be measuring the fallback and so would production.
  • The token tests settle over two extra frames, and the comment says which property that is rather than treating it as flakiness.
  • --gb-list-row-height finally has a consumer, which is what its TODO.md entry has wanted since it shipped with the density tokens: “it shipped before any of them existed, because the density they would have to honour was decided here rather than there”.

255. A label that does not fit is cut, not wrapped

Date: 2026-09-05

Status

Accepted. Builds what ADR-0235 diagnosed and declined to build, and closes the four TODO.md entries that were waiting on it: the menu row, option, select-value and the segment label.

Context

ADR-0235 spent a whole record establishing that the toolkit could not cut a label, and named the property that was missing:

What is missing is white-space: nowrap: a way to tell the text stack to measure a paragraph at its natural width whatever width it is offered. With that, a clip box works and an ellipsis becomes reachable. Without it, no arrangement of overflow and flex-shrink can cut a label, because the label is never too long for the box it is in.

It then declined to add it, for a reason that has now expired:

No white-space property is added here. It is a text-stack change, it wants a consumer that is not a comment, and §8’s subset has grown one property at a time against a named need — which is the rule that kept the subset small enough to believe in.

There are four consumers, and none of them is a comment. A menu row clamped to the work area, an option in a segmented bar whose cells are exactly 1/n of the track, a select-value in a field an application gave a width, and — since ADR-0182 — a suggestion row in an autocomplete popup. Each one currently overflows its box, and each one’s stylesheet or render carries a paragraph explaining that it cannot do otherwise.

The rule ADR-0235 invoked is satisfied rather than broken by building it now: the need is named four times over, and it was named before the property was written rather than after.

Decision

Add white-space and text-overflow to §8’s subset, with two values each, and give the text stack the one thing it was missing.

white-space is the whole mechanism, and it lives in the measure function

Paragraph.measureFunction(TextFlow) ignores the width Yoga offers under nowrap and reports the width the text actually wants. That is the entirety of the change ADR-0235 was asking for. Everything else here is consequences of it: a box may now be laid out narrower than its own content, which is the state overflow: hidden and text-overflow were always waiting for and which the toolkit could not previously reach.

text-overflow is a paint decision and never a layout one

An ellipsised line is drawn short and measured long. The measure function does not read text-overflow at all, and ParagraphFlowTest asserts that the two flows measure identically.

This is the load-bearing constraint of the whole design. A paragraph whose measurement shrank because it had been truncated would be a box that shrank because it was too narrow — which either settles at a width nobody asked for or oscillates, and either way lets the ellipsis decide the width it is supposed to be a consequence of. It is the same trap Measured carries a standing warning about: read geometry to draw something that cannot affect layout, never to decide a size.

The cascade carries two properties; everything below it sees one value

ComputedStyle gained two components, not one. TextFlow — the record they are handed out as — is built by ComputedStyle.textFlow() and is what Box.Text, the measure function and the painter all read.

The split is not tidiness. CSS inherits white-space and does not inherit text-overflow, and a bundle cannot be half-inherited. Both halves of that are what an author means as well as what the specification says: menu { white-space: nowrap } is a statement about the rows, and text-overflow on a container that draws no text of its own would otherwise put a mark on every label underneath it.

So whiteSpace joins color and typography in inheritingFrom — and in inheritsSameAs, which is the style cache’s key and which ADR-0248 warns has to be edited in the same breath or the cache goes stale rather than merely cold. A test asserts the pair.

TextFlow.ellipsises() refuses an ellipsis that has nothing to mark

text-overflow: ellipsis without white-space: nowrap marks nothing, which is what every browser does with the same two rules and follows from the mechanism: a line that is allowed to wrap is never too long. It is decided in the value rather than in the painter so that the measure function and the paint cannot reach different conclusions about one style.

An anonymous label box inherits by hand

Box.style(ComputedStyle) carries the flow onto a Box.Text exactly as it carries color, so a text element and a select-value need nothing. A menu item and an option build their label as a child box that no style is applied to, so their render passes style.textFlow() to a new Box.text(paragraph, argb, flow) overload. That is the same inheritance one level below the cascade, and it is written down in both widgets because it is the thing a fifth consumer would otherwise get wrong.

flex-shrink: 0 comes off the menu label

ADR-0148 put it there, and said why: a box that never narrows is a paragraph that never re-wraps. With nowrap that is no longer the only way to stop the wrap, so the label shrinks again and is cut. The accelerator keeps its flex-shrink: 0 — a cramped row spends its missing pixels on the label, which has an ellipsis to say so, and never on the shortcut, because half of Ctrl+Shift+K is not a shortcut.

What is deliberately not built

  • pre, pre-wrap and pre-line. All three are statements about collapsing runs of spaces and newlines, and Goldberry never collapses anything: a Paragraph draws the string it was handed. So pre-wrap is what normal already does here and pre is what nowrap already does. Naming them would be four spellings of two behaviours.
  • CSS’s newline collapsing under nowrap. A hard \n still breaks a line under either value; only soft wrapping is turned off. The difference is invisible to every consumer in the catalog, all of which are single-line labels, and the alternative is a paragraph whose text is not the string it was given.
  • CSS’s “last line only” ellipsis. The mark is applied per line. The two agree for every single-line label, which is all four consumers, and per-line is the reading that stays true of a nowrap paragraph with hard newlines in it — where CSS would leave every line but the last running off the edge.
  • text-overflow: <string>. CSS allows a custom marker. Nothing has asked, and it would put a string in ComputedStyle where an enum is.

Alternatives considered

  • A clip box, again. ADR-0235 records three arrangements of overflow and flex-shrink and why each failed. All three fail for the same reason and this removes it; a clip box is now possible and is not needed, because the ellipsis lands inside the width by construction.
  • Truncating in Java through Measured. It works — scroll and select already read last frame’s geometry — and it puts a text-layout decision in four widgets instead of one property in the cascade, one frame late in each.
  • One TextFlow component on ComputedStyle. Simpler by one field and wrong about inheritance, which is the only thing that actually differs between the two properties.
  • Bundling overflow: hidden into nowrap. They are separate questions: nowrap says how the text is measured and overflow says what an ancestor does about the result, and a label that overflows visibly is a legitimate drawing — it is what a menu row did before this and what TextOverflow.CLIP still means.
  • Memoising the ellipsis width on Paragraph. It would be memoised once per distinct string for a number that never differs between them. It is a fact about the font, so Font.ellipsisWidth() is where it is cached — one shaping of one character per font, ever.

Consequences

  • §8’s subset grows by two properties, both with a named consumer, which is the rule that has kept it small.
  • Four widgets stop overflowing: a clamped menu, a narrow segmented cell, a select whose value is longer than its field, and an autocomplete suggestion. Four stylesheet comments and two render comments that explained why they could not are replaced with what they now do.
  • RenderObject rebinds its measure callback when the flow changes, not only when the paragraph does. A restyle that turns nowrap on has to re-measure a node whose text did not change, and Yoga does not dirty a node when its measure function is replaced — the same trap ADR’s note on applyMeasure already records for a changed paragraph. The flow is compared by equality where the paragraph is compared by identity, because the cascade hands out a fresh TextFlow on every resolution.
  • inheritsSameAs widened by one field, so a subtree under a node whose white-space changed re-resolves. That is correct and is a real cost: it is one more way for a style cache to miss.
  • Font gained a memo, its first. It is not volatile for the reason nothing else in that class is: a font holds native handles and is confined to the thread that made it.
  • An ellipsis draws even where a single letter would not fit. A cell that went blank as it narrowed reads as a missing value rather than as a truncated one.
  • ProgressFill’s indeterminate sweep is still a there-and-back, and is still a design decision rather than a missing mechanism. This changes nothing about it; ADR-0235 already moved it out of the “blocked” column.
  • text-input and text-area are untouched. A field’s text is drawn by its own machinery rather than through Box.text, it scrolls rather than truncating, and truncating an editable value would hide characters a caret can still reach.

256. A line is placed by the paint, not by the box

Date: 2026-09-05

Status

Accepted. Closes the slider value-label entry in TODO.md and removes text-align from docs/ARCHITECTURE.md §8’s list of properties that resolve into nothing. Extends ADR-0255, whose value this adds a third component to.

Context

§8 has listed text-align in its paint half from the beginning, and §8’s own note explained why it did not resolve:

box-shadow, backdrop-filter, letter-spacing and text-align are absent, because Box cannot express them and a property that resolves into nothing is a property with no test that means anything.

Three of those four are true. box-shadow needs a drawing Box has no field for; backdrop-filter needs a second pass over what is underneath; letter-spacing needs the shaper to be told something before it shapes. Each is a real absence in a real place.

text-align is not like them, and the entry that has been open longest says so without meaning to:

A slider’s value label is left-aligned in its box, because §8’s subset has no text-align — docs/ARCHITECTURE.md §8.1 lists it among the properties Box cannot express, so it resolves into nothing and has no test that could mean anything. A right-aligned readout is what the column of numbers beside a row of faders wants. It arrives with whatever else needs Box to place text inside a box rather than at its origin.

Nothing has to be added to Box. Paragraph.paint is already handed the box’s width — it has to be, or the text could not wrap to it — and every TextLine has already measured itself. The two numbers the alignment needs have been in the same method the whole time; what was missing was a keyword saying what to do with them. ADR-0255 then put the last piece in place by giving that method a style value to read.

slider-value is the consumer that has been waiting: it is width: 40px by declaration, because a label that sized itself to its digits would resize the track under the finger setting it (ADR-0080). A fixed box is exactly the condition under which alignment means something — and left-aligned, 9% and 100% start in the same column and end four pixels apart, which is the opposite of what a column of numbers is for.

Decision

text-align: start | center | end, resolved by the cascade and applied by Paragraph.paint. Box is untouched.

It is a third component of TextFlow, not a fourth thing to thread

TextFlow was already the value the paragraph reads about its box. white-space and text-overflow answer the too-wide question and text-align answers the too-narrow one, and all three are answered in the same place for the same reason: the paint is the only code that holds both the line’s width and the box’s.

It inherits, like white-space and unlike text-overflow, which is CSS’s own split and the reason ComputedStyle carries three components rather than one record. It also has to inherit to be usable: text-align is written on a container far more often than on the node that draws the text.

Per line, and never negative

The offset is max(0, boxWidth - lineWidth) × fraction, computed per line.

Per line is what text-align means — a centred paragraph centres each of its lines rather than centring the block they make up, and the difference is exactly the short last line.

Clamped at zero for two separate reasons, both reachable. A nowrap line wider than its box would otherwise be pulled left by text-align: end, hiding the beginning of the text to show an end the reader can already guess. And maxWidth is UNCONSTRAINED wherever a caller is measuring rather than placing, which without the clamp is an infinite offset and a blank frame.

An ellipsised line is not moved

A truncated line fills its box by construction, so there is no slack to share out. Falling out of the arithmetic rather than being special-cased would also have been correct; it is written as a branch because the reader should not have to derive it.

What is deliberately not accepted

  • left and right. ADR-0247 settled the same question for align-items in the other direction: start and end are what CSS Box Alignment defines and what Yoga lacked, while left and right name sides of the screen. They coincide under LTR and part company under RTL, so accepting right as a synonym for end writes down an answer that is right today and silently wrong the day bidi run splitting lands. A stylesheet that writes one gets the ordinary dropped-value warning, and SupportedPropertyTest makes that a build failure for the toolkit’s own sheets.
  • justify. Not a placement but a respacing. A paragraph here is shaped once and sliced into lines, so there is nowhere to put the extra advance without re-shaping — which is the one thing ADR-0036’s design is built to avoid.
  • Vertical alignment. align-items on the box already does it, because a paragraph is the whole of a measured leaf’s content.

Alternatives considered

  • A field on Box and an offset in BoxPainter. It is where §8’s note said the work was, and it puts a text decision in the box painter — which would then need the line widths, which only the paragraph has. The box painter would have to ask the paragraph and then tell it, one call apart.
  • Leaving the spacer in a menu row. It stays, and is not an alternative to this: text-align places a line inside one box, and a menu row shares its room between five. A growing box is flexbox’s own answer to that and the only one that keeps the chevron after the accelerator rather than under it. The comment in Item.render now says which of the two each is for.
  • Accepting right and mapping it to end with a warning. A warning nobody reads on a rule that works is a rule that works, and the day it stops working is the day the warning was needed.

Consequences

  • slider-value is text-align: end, and slider-value.png moved: 9%, 50% and 100% now line up on their trailing edge, which is what a fixed width was always for. Four goldens changed and all four are the same readout — slider-value plus the showcase’s Basic screen in its three variants, where the diff is 274 pixels and every one of them is the 40% on the gain slider. Nothing else in the corpus draws a slider-value, which is why the count is four rather than the number of images with text in them.
  • ComputedStyle grew a fourth wither and a third text component, and inheritsSameAs grew a third field. That is one more way for the style cache to miss and is the cost of the property inheriting, which it must.
  • TextFlow gained a two-argument constructor so that every caller written before this keeps drawing exactly what it drew. TextFlow.NORMAL and TextFlow.ELLIPSIS both say START.
  • §8’s “properties Box cannot express” is down to three, and the three that are left are there for reasons that are actually about Box.
  • Nothing centres anything yet. CENTER is built and has no consumer in the catalog, which is one more property than the “grow against a named need” rule strictly allows — it is one enum constant on a property that had to be parsed anyway, and refusing the middle value of three would be a stranger thing to explain than shipping it.

257. A diagnostic is asked for, not logged

Date: 2026-09-05

Status

Accepted. Closes four TODO.md entries that share one shape: the toolkit knows something is wrong and says nothing, or says it where nobody is listening.

Context

Four entries, written at four different times, arrive at the same conclusion from four directions.

Nothing warns when a declaration is dropped for being unsupported — in an application’s stylesheet. … the four of them cost more to find than a warning would have cost to read. The toolkit’s own sheets are linted now (ADR-0215), on the value half as well (ADR-0216); what is left open is the author writing their own. And the second record is the argument that a louder log is not the answer: a dropped value already warns, and group-box-title drew square corners for months anyway.

An application’s stylesheet can still be all classes, and nothing says so. … A warning that is usually wrong is the log ADR-0243 has just finished quietening; what would help instead is a diagnostic somebody asks for, next to the hud.

flex-grow means nothing inside a scroll, and nothing says so. … The showcase had it on five screens where it did nothing and on one where it was load-bearing, which is exactly how long it takes for a dead declaration to look like a live one.

A row height that disagrees with the stylesheet is a silent layout error. … the symptom is rows drifting out of step with the scrollbar, and it gets worse the further down the model you are.

The first two name the answer in as many words: asked for, not logged. The second two are the same problem one level down — a widget that knows a declaration will do nothing and draws it anyway.

Decision

css.lint, which is the test with the test taken off it

SupportedPropertyTest had the machinery already and it worked: resolve every rule through the real cascade, hand every declaration to the real ComputedStyle, and report what came back. What it did not have was a caller other than itself. It installed a logback appender on ComputedStyle, set it to DEBUG, and read the complaints back out as sentences.

StyleLint is that, returning Finding values. An application asks:

new StyleLint(everythingLoaded).check(mine).forEach(f -> LOG.warn("{}", f));

Two sheets, not one, and it is not ceremony: half of what a declaration means is what its var()s stood for, and a sheet linted without its theme reports every colour in it as a value the engine refuses. That was 164 false findings while the test was being written, and the constructor is where the lesson is kept.

A finding carries the line and column the parser saw, which a log line never did.

ComputedStyle.applies is four lines, and could not have been fewer

The question a lint asks is “does the engine do anything with this?”, and the answer was already sitting in the control flow: with returns this in exactly two places and both of them are failures — the default arm, where §8’s subset has no such property, and dropped, where it has one and the value would not parse. Every success goes through a wither and every wither allocates.

So identity is the answer, and it cannot drift, because it is not a copy of the behaviour — it is the behaviour. A list of supported properties would have been a second source of truth that needed editing every time the first one changed.

That is a real constraint on ComputedStyle rather than a happy accident, and it is written down in both places: an arm that returned this on success would silently become “does nothing”.

The two failures are not told apart. Distinguishing “no such property” from “no such value” means the engine reporting rather than being asked — a sink threaded through thirty switch arms — for a difference the author reads off §8’s list in either case. What was invisible is that the rule does nothing.

An unresolvable var() is the resolver’s report and not a finding

A var() naming a token nothing defines takes the whole declaration with it before the engine ever sees one, and the resolver already says so once, which is the shape ADR-0243 settled on. Saying it again here would be a second mechanism for one fault, disagreeing with the first the day either changes. A test asserts the silence so that the next reader finds the reason rather than the gap.

Two widgets that knew and did not say

Both follow ADR-0251’s warnIfNestedOnTheSameAxis exactly — a diagnostic and never a refusal, deduplicated in a static set, because turning a rule into a crash is worse than the rule going unheard.

ScrollContent says once that a child inside it declares flex-grow and will get nothing. Read off the boxes rather than off the cascade, which makes it exact and free: flex-grow is resolved by the time render is handed its children, so it is a field comparison — and it catches a widget that set the growth itself, which no rule in any stylesheet would have shown.

ListRow says once that the pitch its list is spacing rows at is not the height list-row resolved to. Read off the cascade: list-row declares height: var(--gb-list-row-height), so the number exists before the row is laid out and no Measured round trip is needed to learn it. A row whose height is auto says nothing, because there is no declared number to disagree with.

That is a simpler mechanism than the entry predicted — it asked for “a Measured assertion on the first built row” — and it reaches the same fault a frame earlier.

The pitch check has to see the mismatch twice, and ADR-0254 is why

The first build of a tree has no cascade. A Stateful widget builds once inside the ElementTree constructor, before any renderer has taken the tree on, so a list reading --gb-list-row-height answers the token’s default on that build and the stylesheet’s value on the next (ADR-0254). Under a compact density that is one frame of a 32px pitch against 26px rows: a real disagreement, for one frame, that nobody sees and that settles by itself.

Reported naively, the form that cannot be wrong would have been the noisiest one. So a mismatch is believed on its second sighting: a genuine one recurs every frame for as long as the list is up, and a settling one is seen once and never again, because the pair it is keyed under stops occurring.

That forced the second decision. The check runs on one row of the window, not on all of them — every row resolves the same height, so twenty rows would report twenty times per frame and “seen twice” could not tell a frame from a sibling. The first built row carries it, which is where the entry said the assertion belonged.

Alternatives considered

  • A louder log. The thing both entries rule out, and ADR-0216 is the evidence: a dropped value already warns at WARN and group-box-title drew square corners for months. A stream nobody is watching is a stream at either level.
  • Failing the build on a finding. Not the toolkit’s call. Finding.Kind carries isDefect() so an application can draw the line itself — a dead declaration is a drawing that is not happening, and an untyped rule draws correctly and costs the cascade more than it needs to.
  • Reporting untyped rules from the engine. They are already StyleResolver.untypedSelectors(), which existed for RuleBucketTest and needed only somewhere to be asked from.
  • A Measured callback on every row. What the entry proposed. It is a frame late, it adds a callback component to a widget that already has three, and the number it would measure is one the cascade already resolved.
  • Threading a diagnostic sink through the engine. The exact version of the two-kind split, and thirty edited switch arms in the hot path of the frame loop for a distinction the author does not need.
  • A hud-style overlay. What the TODO.md entry literally suggested — “next to the hud”. A screen is a worse place to read a hundred findings than a list is, and nothing stops one being built on top of this later.

Consequences

  • SupportedPropertyTest lost a hundred and sixty lines, and with them a logback appender, a log-level override, two sentence-matching filters and its own two guard tests — which existed because a change to either log’s wording would have made it pass by seeing nothing at all. Findings are values, so the guards are StyleLintTest’s and are assertions about behaviour.
  • It gained two sweeps it could not previously afford: the light theme and the compact density. A var() that resolves to something legal in one theme and to nothing in the other is a rule that draws on one and not the other, and no golden of the dark theme could have shown it. Both pass.
  • css.lint is not @NullMarked, and the reason is not this package. StyleElement documents three members as “or null” — type(), id() and parent() — and annotates none, inside a css package that is marked. The lint’s probe is the first implementation of that interface to be written in a marked package, and it cannot say what the interface’s own javadoc says. Recorded in TODO.md rather than fixed in passing: annotating it moves every implementation and every caller.
  • ListRow grew a double, not a callback. It is set on one row of the window and zero on the rest, and both facts are documented on the component, because “the same number on every row would be fine” is what a later reader will think.
  • Three static report sets now exist — ComputedStyle’s, ScrollState’s and now ScrollContent’s and ListRow’s — each with a forget for tests. That is a pattern rather than a mechanism, and the fourth one is the point at which somebody should extract it.

258. The edge a measurement chose

Date: 2026-09-05

Status

Accepted. Takes fifteen of the sixteen pairs off ContrastTest’s recorded debt and leaves the sixteenth with an arithmetic reason rather than an undecided one.

Context

ADR-0239 built the non-text half of §1.2’s sweep and found nineteen pairs below the 3:1 floor. ADR-0240 fixed three of them — §2.2’s focus ring — because its cause was a ramp left behind rather than a colour anyone chose. The other sixteen were recorded as debt, with the reason stated plainly in the test:

Every one of these is a theme colour, and sliding a ramp to clear 3:1 changes what the toolkit looks like … That is a design decision with a golden-image tail, and it is not one a test may take on its own authority.

The TODO.md entry inherited that framing and filed all sixteen together, as one thing waiting for one decision.

They are not one thing. Measured against the arithmetic rather than against the sentence, they are three:

  • Twelve control boundaries where the palette has a gap and nothing had been put in it.
  • Three marks that miss by 0.02, which is what a ramp slide is for.
  • One mark that no solid colour can fix, and which is therefore not a ramp question at all.

design-system.md §1.2 already says “Every non-text pair meets 3:1”. So fifteen of these are the code being out of compliance with a decision the design system has already made, and closing them is obeying it rather than taking it.

Decision

--gb-checkbox-border, and a gap in Nord

The twelve boundaries are one fact: --gb-checkbox-bg is --gb-surface-2 on the dark theme, so an unchecked box on a group-box differs from its backdrop by nothing at all — 1.00:1 — and the whole control was held up by its edge at 1.17:1. The edge was --gb-border, and controls.css argued for it in as many words: “the border makes an unchecked box visible on a surface it would otherwise match; --gb-border is the token for exactly that and is why this is not an invented colour.”

The measurement refutes that sentence. A divider colour is chosen to be subtle, and a control’s edge is the one thing §1.2 does not allow to be.

Nord has nothing to put there. Between --nord3 (#4c566a) and --nord4 (#d8dee9) the palette simply stops, and against --gb-surface-2 those two measure 1.17:1 and 6.39:1 — invisible, or a bright white edge around a dark control. The light theme is the same gap read from the other end: 1.11:1 and 6.06:1.

So the colour is invented, and ADR-0088’s method says how far: slide until it clears and no further, and write the measurement beside it. #959dad on dark and #79818f on light are the midpoints of that gap, at 3.17:1 and 3.22:1 against the tightest surface each faces.

Inventing a value is not new here — --gb-accent-bg-hover, --gb-accent-fill and --gb-border-strong are all derived rather than palette colours, each with a comment saying why. What is new is that this one’s justification is a number.

--gb-radio-border is --gb-checkbox-border, for the reason every other radio token is a checkbox token: §2.1 gives the two controls one drawing.

The light accent slides by 0.02

Three of §3’s marks are --gb-accent on --gb-border — a slider’s fill, a progress bar’s, a knob’s arc — which is one pair wearing three names, and it measured 2.98:1. --nord10 moved to #5c7ea8, which is 3.11:1.

Every other pair the accent appears in moves the same way, because a darker accent on a light theme is further from every surface it is drawn on: the focus ring gains, the checked fills gain, and --gb-checkbox-mark-checked on the checked fill gains. There is no pair this trades against, which is what made it safe to do without a second sweep to referee it.

The sixteenth is arithmetic, and it is now written down as such

The light theme’s slider track sits between a white thumb and a dark accent fill, and §1.2 asks it to clear 3:1 against both. A white thumb needs the track’s relative luminance at 0.300 or below; the accent fill needs it at 0.688 or above. No solid colour is both, so there is no value of --gb-slider-track-bg that fixes this and no amount of deliberation that will find one.

What has to change is what a light-theme thumb is — a border around it, or a fill that is not white — and that is a sentence docs/design-system.md §3 does not contain. It stays on MARKS_BELOW_FLOOR, alone, with the impossibility recorded beside it so nobody spends an afternoon sliding the track.

Alternatives considered

  • A palette colour for the edge. --nord9 clears on dark at 3.21:1 and is a Frost blue: an unchecked control with a blue edge reads as selected, beside a checked one whose whole fill is the accent. --nord4 clears at 6.39 and is a near-white ring around a dark box. The gap is why this is derived.
  • Darkening --gb-checkbox-bg instead of lighting the edge. It fixes the fill measurement and makes the control recede — an unchecked box would become a hole rather than a component, which is the opposite of what “can be identified” asks for.
  • Lightening --gb-border itself. It is a decorative divider in every other use, and ADR-0239 already established that measuring it as one was the first version of this sweep and was wrong. Making the divider loud to fix the control would put the error back the other way round.
  • Sliding the light track to fix the thumb. Ruled out by the arithmetic above, and recorded rather than attempted.
  • Leaving all sixteen. What the entry proposed. It treats an impossibility, a 0.02 miss and a palette gap as one decision, and the effect is that the two solvable ones wait on the unsolvable one.

Consequences

  • BOUNDARIES_BELOW_FLOOR is empty, and asserted empty on ADR-0088’s exact-set terms: a boundary that newly breaks has somewhere it must be written down, and writing it there fails a test whose name says what happened.
  • Twenty-two goldens moved, which is the tail the entry predicted and the reason it had not been paid. controls-on-surface-dark and controls-on-surface-light are the two to look at: they exist because the glyph used to disappear on a panel, and they are now the images that show it does not.
  • The accent change is invisible and the edge change is not, which is the right way round: 0.02 of ratio is a colour nobody can see moving, and an edge that was doing no work now does some.
  • controls.css’s comment is inverted rather than deleted. It argued for --gb-border and the argument was wrong; the new comment says so and says what measured it, because the next person will otherwise re-derive the same reasonable-sounding mistake.
  • Two design-system claims are now true that were not. §1.2’s “every non-text pair meets 3:1” had fifteen exceptions and has one, and the one is annotated.
  • Nothing about the text sweep changed. KNOWN_FAILURES was empty before this and is empty after, which is the check that the accent move did not buy the marks at the labels’ expense.

259. A badge with one digit is a circle

Date: 2026-09-05

Status

Accepted. Amends docs/design-system.md §3’s badge row, and spends the last of ADR-0181’s four bounds.

Context

badge’s TODO.md entry has been open since the widget shipped, and its stated reason expired two records ago:

§8’s subset has no min-width at all, so a one-digit chip is a stadium rather than the circle a badge usually is. badge-digits.png is the record of it.

ADR-0181 added min-width, max-width, min-height and max-height. Three of the four found consumers immediately — dialog, toast and tooltip had each written a width where they meant a maximum. min-width had none, and this is it.

So the entry stopped being about the style engine and became about the design system: §3’s row says “height 20; padding-x 8; radius full; caption” and does not say a minimum width, and §5 wants a metrics row before code.

Decision

§3’s badge row gains min-width 20 and its padding-x drops from 8 to 4.

The minimum is the height, and is not really a second number

Equal width and height inside a full radius is what a circle is. So the row is not gaining an independent metric so much as saying its first one twice, and the test asserts minWidth == height rather than minWidth == 20 — because the two drifting apart is the failure worth naming, and a test on the literal would pass while they did.

The padding had to move, and the ramp says where to

min-width alone does nothing here. A caption digit is about 7px, so 8 + 7 + 8 is 23 in a 20-tall box: a badge with the default padding can never be round, whatever its minimum says. The minimum only bites once the content plus padding is under 20.

6 would have been the comfortable answer and is off §1.3’s ramp, which lists 2, 4, 8, 12, 16, 20, … and says “no off-ramp values” in as many words. 4 is the legal step below 8, it makes 4 + 7 + 4 = 15 so the minimum takes over, and it is what a count chip wants anyway.

§1.3 calls 8 the component padding default, not a floor, so this is a departure from a default rather than a breach of a rule — and badge is the widget in the catalog that most obviously is not a component with content in it.

select-chip keeps the default, and stops sharing the rule

The two used to share one block. A chip holds a label and a ×, is never one character wide, and has nothing round about it, so it keeps padding-x 8 and gains no minimum. The split is three lines rather than a duplicated block: badge follows the shared rule and overrides two declarations.

Alternatives considered

  • min-width with the padding left at 8. The obvious reading of the entry, and it changes nothing: every badge is already wider than 20, so the minimum never applies. This is the version that would have looked done and not been.
  • Padding-x 6. Off the ramp. §1.3’s “no off-ramp values” is the kind of rule that only means anything when it is inconvenient.
  • A conditional padding — 4 for one character, 8 otherwise. CSS cannot say it, the widget would have to count characters, and a badge whose padding changed as its number crossed 9 would be a chip that jumps.
  • Leaving it. The entry’s own position for two milestones, and reasonable while min-width did not exist. It does.

Consequences

  • badge-digits.png moved and now shows the thing it was named for. It has always been the record of the defect; it is the record of the fix in the same four numbers — 3 round, 12, 128 and 1024 growing sideways.
  • Every badge is 8px narrower. badge-variants, badge-on-surface and the showcase screens that carry one all moved, which is the visible cost of taking 4 off each end.
  • §1.3’s ramp did the deciding, which is what a ramp is for: the number was not chosen for how it looked and then justified.
  • select-chip is on its own rule now, and a change to one no longer reaches the other by accident. That is a small loss of the “one drawing” property the shared block expressed, and it is the honest shape: they were never the same control.

260. A name is an attribute every widget has

Date: 2026-09-05

Status

Accepted. Closes the TODO.md entry on icon-only controls and gives §13’s “role and name for every widget” its missing half.

Context

The entry is specific about the shape of the gap:

An icon-only segment has no accessible name, and neither does an icon-only button. §3 requires name= for both and the attribute does not exist anywhere; §13’s semantics are M5’s. Option refuses a segment with neither a label nor an icon, which is the half that can be enforced today, and the other half is a gap the whole catalog shares rather than one this control invented.

Two things in that are worth separating. “§13’s semantics are M5’s” is about the AccessKit bridge — the thing that carries a name to a screen reader — and that is still M5’s. But Semantics.role() and Semantics.accessibleName() have shipped for milestones, SemanticsSweepTest already enforces that every focusable widget answers both, and every control in the catalog already implements them.

So what was missing was not a subsystem. It was a place to put a name that a widget cannot work out for itself.

An icon-only control’s label is the empty string by construction. That is not an oversight in the widget — the icon is the whole of what is on screen, and there is no text to derive from. Button.accessibleName() returned label, so an icon-only button answered "": a control a reader cannot announce, passing a sweep that only checked for null.

Decision

name= on Attributes, beside tooltip and context-menu.

Those two are the precedent and the argument, already written in that file for tooltip:

Here rather than on each widget because that is what “any widget” means: a tooltip is not a property of being a button, and a catalog where each control had to remember to carry one would have thirty chances to forget.

A name is the same kind of thing, and more so: §13 asks for one on everything, which is exactly the set Attributes covers.

The label wins where there is one

accessibleName() returns the explicit name only when the derived one is empty. An author who writes both has said the same thing twice, and the one on screen is the one a sighted user reads aloud to somebody else — so it is the one a reader should say.

Blank is absent

name(" ") is null, which is tooltip’s and contextMenu’s rule. A reader announcing three spaces is a reader announcing nothing, at more length.

Two widgets read it, and the rest have it

Button and Option are the two §3 names, and they are the two that can be icon-only. Every other widget now carries a name it does not yet read, which is the right way round: the attribute is on the contract, and a widget that grows a reason to prefer it needs no new mechanism.

Alternatives considered

  • A name field on each widget that can be icon-only. Two today, and the entry’s own words are why not: it is “a gap the whole catalog shares rather than one this control invented”. Two fields become five, and the fifth is forgotten.
  • Deriving a name from the icon’s registered name. icon="trash" is not “Delete”, it is a key in a registry an application filled in; announcing it would read a developer’s vocabulary out loud. Worse, it would make every icon-only control look named while being unusable, which is the failure mode the sweep exists to catch.
  • Waiting for the AccessKit bridge. The bridge is what carries a name out; it is not what decides there is one to carry. Building it later against a catalog that cannot express a name would mean doing this then anyway, having shipped controls nobody could name in the meantime.
  • Making accessibleName() non-null and refusing an empty one. It would fail the sweep for tab-close and scroll, which are deliberately named by their surroundings — the interface’s own javadoc says null is an answer.

Consequences

  • Attributes gained a sixth component, and with it a five-argument constructor beside the three-argument one, for the reason the three-argument one exists: the positional form appears in every widget in the catalog and most of its tests, and a sixth null on all of them is four hundred edits to say nothing. A test asserts that every wither carries the new field forward, because that is the failure a widened record invites and null is a legal name so nothing else would complain.
  • An icon-only button answers null rather than "" when nobody named it, which is the honest answer and is now distinguishable from “named with the empty string”.
  • Markup gets it for free — Attributes.of reads name= off the node, so every widget that inflates through it can be named without touching its inflater.
  • SemanticsSweepTest is unchanged. It checks that a name is declared, and it always passed; what it could not check is that the declared name is something a person could hear. That remains a judgement rather than an assertion, and the tests that cover it are the two widgets’ own.

261. A ring is photographed on both themes

Date: 2026-09-05

Status

Accepted. Closes the TODO.md entry on focus goldens by answering the question it asked for rather than the one it looked like.

Context

The entry did not ask for images:

A focus ring is only ever pictured on the dark theme, apart from one. segmented-focus-light is new and is the catalog’s first; menu-focus and menubar-focus are still NORD_DARK only, and so is every other state golden in the catalog. That asymmetry is what let §2.2’s ring sit below §1.2’s floor on the light theme without anything noticing — the fix for it moved no golden at all. What would close this is a rule about which states are worth a second theme rather than one more image.

The evidence behind it is the strongest kind there is: §2.2’s ring measured 1.74:1, 2.00:1 and 1.64:1 on the light theme’s three surfaces (ADR-0239), and the change that fixed it (ADR-0240) moved no golden at all — because every focus image in the catalog was NORD_DARK.

Decision

Every *-focus golden has a -light twin, and a test says so.

Why the rule is only about focus

§2.2’s ring is the one mark in the system with no second means of being seen. A hover has a wash, a checked control has a fill, a disabled one has its opacity — and each of those is drawn in colours some other golden already covers, so a second image of it buys a second file and no new question.

A ring is only a ring, and --gb-focus resolves differently per theme. So a ring photographed on one theme is a ring nothing is watching on the other, which is not a hypothetical here but a thing that already happened.

Doubling the whole corpus was the obvious reading of “state goldens are dark-only” and is the wrong rule: it is thirty more files to answer one question, and the one question is answerable with three.

It discovers its subject

FocusGoldenPairTest reads the resource directory rather than a list, so a focus golden added next month is checked next month and nothing has to be edited to know about it. That is ExportedSurfaceTest’s and SupportedPropertyTest’s property, and it is the difference between a rule and a list.

Three assertions, because a discovering test has a failure mode of its own:

  • every X-focus has an X-focus-light;
  • the sweep finds at least the four rings the catalog has, so renaming the convention or moving the directory cannot make it pass by seeing nothing;
  • no -light twin is orphaned, since a light ring with no dark one to compare it against is an image of nothing.

Checked against a deliberate break: with tabs-focus-light.png moved aside, the first assertion fails and names the missing file.

The naming convention is what it leans on

Every focus golden is <widget>-focus, because a PseudoState golden is named after the state it captures. A ring photographed under some other name would be invisible to this — a real limit, and a smaller one than enumerating them by hand.

Alternatives considered

  • Three more images and a comment. What the entry explicitly did not want, and the reason is that the fourth ring would be added without one. A comment is not a rule; the next widget’s author does not read this file.
  • A light twin of every state golden. Thirty-odd files, most of them answering a question their siblings already answer. The rule would be “photograph everything twice”, which is not a rule so much as an absence of one.
  • Asserting the ring’s contrast instead. ContrastTest already does, and it is the check that found this — but a ratio says the colour clears the floor, not that it is drawn where a reader expects it. tabs-focus exists because the ring is inside the header there rather than at §2.2’s usual offset, which no ratio would ever say.

Consequences

  • Three new goldens: menu-focus-light, menubar-focus-light, tabs-focus-light. segmented-focus-light already existed and is what the entry called the catalog’s first.
  • The rule is a test, so the entry’s own request is satisfied rather than its symptom. A widget that grows a focus golden and forgets the twin fails on a message that says which file is missing.
  • The convention became load-bearing. -focus in a golden’s name is now checked rather than merely conventional, and the test’s javadoc says so, because a name that carries meaning silently is the thing this repository keeps finding.
  • It does not check the images are different. Two identical pictures would pass, which is the case where a theme swap changed nothing — and that is precisely what ContrastTest is for. The pair of checks is the coverage; either alone is not.

262. A delay is a metric, and metrics are tokens

Date: 2026-09-05

Status

Accepted. Closes the tooltip-delay entry, and builds the half of design-system.md §3’s tooltip row that nothing had noticed was missing.

Context

The entry said the delay was blocked twice over:

§7 says “after delay” and does not say how long, so 500ms is the toolkit’s number and an application cannot change it — and the obvious shape for one, a --gb-tooltip-delay custom property, is blocked twice over: nothing above the cascade can read a resolved custom property, and a delay is not a paint, so whether the design system should carry durations that are not motion is a question for it rather than for this.

Both have expired, and one of them was never true.

The first expired. Paints.Context.length (ADR-0251) and BuildContext.token (ADR-0254) both read resolved custom properties from outside the cascade. The launcher holds an Element, and an Element is a BuildContext.

The second was answered before it was asked. The entry says §7 “does not say how long”, which is true of core-widgets.md — and design-system.md §3’s tooltip row says it in as many words:

tooltip | padding 6/8; radius 4; caption; delay 500ms show / 100ms move-between

So the design system was never being asked a question. It had answered, twice, and the code had implemented the first number as a constant and the second not at all.

The bug this turned up

Launcher.pointingChanged scheduled TOOLTIP_DELAY for every target, including one reached from a tooltip that was already showing. So a user reading along a toolbar was served the full 500ms of hover intent at every button — which is what §3’s second number exists to prevent, and what “100ms move-between” means.

That is not a styling gap. It is a specified behaviour that was never built, and it was hiding inside an entry about tokens.

Decision

BuildContext.duration, a third accessor and not a general one

token reads a length. A delay is not one, and --gb-tooltip-delay: 500ms would answer the fallback through it.

duration(name, fallbackMillis) sits beside it, and it is a third accessor rather than a general token(String) for Paints.Context.length’s stated reason: lengths, colours and now durations are values the cascade already parses, and a general reader would invite a caller to reimplement the parser.

It calls ComputedStyle.durationMillis, which is the private ms/s reader transition has always used, made public. Writing a second one was the alternative and is the thing to avoid: two parsers for one syntax disagree the day either grows a unit, and this one already refuses a bare 200 for a reason worth keeping.

Two tokens, and §3’s own numbers as the fallbacks

--gb-tooltip-delay and --gb-tooltip-delay-move ship in controls.css, and Launcher carries 500 and 100 as constants. That is --gb-list-row-height’s arrangement exactly: the token is the catalog’s, the fallback is :core’s, and :core does not need the catalog to exist.

The delay is read off the target

Not off the window. A custom property inherits down the tree, so asking the node the tooltip is for is the only reading that lets a panel set the delay for what is inside it — and the only one that is not a global setting wearing a token’s clothes.

The shorter delay is about moving, not about being fast

moving is read before hideTooltip(), because the hide is what makes it false. The full delay is hover intent — the question “did you mean to stop here?” — and a user who is already reading tooltips has answered it. A test asserts that the first tooltip in a row still waits the full one, so the shorter number stays a statement about moving between rather than a faster tooltip.

Alternatives considered

  • A setter on Application or Host. It makes the delay a program’s rather than a theme’s, and §3 is explicit that component metrics ship as token defaults. It also could not have been per-subtree.
  • A unitless token — --gb-tooltip-delay: 500. It would have gone through the existing token, and it spells a duration as a length. The cascade refuses a bare number for transition and would be refusing it here in one file and accepting it in another.
  • Putting the number in §1.7 with the motion durations. A tooltip delay is not a motion: nothing is moving, and §1.7’s durations are how long a change takes. §3 already had it, which settles where it belongs.
  • A general token(String) returning tokens. ADR-0251’s argument, inherited: a widget would parse them, and there would be two parsers.
  • Leaving the move-between number. It was not in the entry, so nobody was waiting for it — which is exactly why it would have stayed unbuilt.

Consequences

  • ComputedStyle.durationMillis is public, and is the second thing that class has been asked from outside for the same reason applies was: something above the cascade has a question only the cascade’s own parser can answer honestly.
  • BuildContext gained a method, which every implementer must now provide. There is exactly one — Element — and the interface is not an extension point an application implements, so this is a one-line cost.
  • A specified behaviour that was never built now is, and the test that covers it fails against the old constant. Four new tests: the token honoured, a non-duration token ignored rather than guessed at, the move-between delay, and the first-hover delay staying long.
  • TooltipTest’s app takes extra CSS now, which is how a token that only exists in a stylesheet gets in front of the launcher — and a two-target scene, because the case §3’s second number is about cannot be produced by one full-window node.
  • --gb-tooltip-delay-move has no design-system row of its own, because it is half of one that already existed. §3’s tooltip row is unchanged by this record, which is the point.

263. Three numbers in one row, and nothing watching

Date: 2026-09-05

Status

Accepted. Amends one number in design-system.md §3’s tooltip row, records two in ARCHITECTURE.md §17.1, and puts a test under all four.

Context

This was not the entry being worked on. TODO.md’s popup-inheritance entry says a tooltip “wants the styling of the thing it describes”, so the tooltip’s own styling was read to see what it would inherit — and the row it is specified by turned out to disagree with the rule that implements it in three places out of four:

design-system.md §3controls.css
padding6/88px 12px
radius48px
type rankcaptionbody
delays500ms / 100msboth, since ADR-0262

Only the third had a comment saying so. The other two had nothing at all.

Nothing was watching, and that is the finding. SupportedPropertyTest asks whether a declaration does something; ContrastTest asks what colours measure; RuleBucketTest asks how rules are shaped. No test asks whether a metric is the metric §3 pinned, so a row can drift a number at a time and each drift looks like the file it is in.

The one that is a document bug

§3 said padding 6/8. 6 is not on §1.3’s ramp — that section lists 2, 4, 8, 12, 16, 20, 24, 32, 40, 48, 64 and introduces it with “no off-ramp values”.

So the row as written could not be implemented without breaking a rule one section above it. That is not a design decision the code overrode; it is the document contradicting itself, and the shipped 8/12 is two legal steps.

Amended in §3, with the reason, because there is nothing to decide.

The two that are decisions

The radius. §1.5 groups radii as 4 (inputs, small controls) · 8 (buttons, cards) · 12 (dialogs, popovers, frost panels), and names no tooltip in any group. The nearest named thing is a popover at 12, and a tooltip is a small popover — so §3’s 4 and the shipped 8 are both readings and neither follows from §1.5. Recorded rather than resolved.

The type rank. §3 says caption; the rule writes body, with its argument beside it: §1.4 gives caption to secondary text under a control, where the reader has the control itself for context, and a tooltip is the only text on screen at the moment it is read. That is a good argument and it is not an agreement. Recorded rather than resolved.

Both go to §17.1, which exists for exactly this: “the design documents are the authority and each of these needs a decision rather than an edit”. Amending §3 to match the code on either would be taking the decision by writing it down, which is the move §17.1 was created to prevent.

Decision

Amend the padding. Record the other two. Test all four.

TooltipMetricsTest asserts the shipped numbers, and each assertion’s javadoc says where that number stands — settled and amended, or open and in §17.1. So a fourth departure is a failing test rather than a fourth silent one, and the two open ones stay exactly one number wide until somebody decides them.

Asserting the shipped values rather than §3’s is deliberate. A test that asserted the document would fail today and would have to be disabled, which is how a disagreement becomes invisible again.

What this says about the entry it came from

TODO.md’s popup-inheritance entry proposes that a tooltip should be passed “the anchor’s resolved style”. The tooltip’s own rule pins its typography for a stated reason, so inheriting the anchor’s font-size would override the one departure in that row somebody had actually thought about — a tooltip on a display-ranked heading would be drawn at 28px.

So the entry’s suggested answer is wrong for the widget it names, and its subject is unchanged: a popup inheriting nothing is right for menu, and what tooltip wanted from it was already decided the other way.

Alternatives considered

  • Amending §3 to match the code on all three. Fast, and it converts two open design decisions into edits by an implementer who noticed them. §17.1’s opening paragraph is the argument against.
  • Moving controls.css to match §3 on all three. It would put an off-ramp 6 into the stylesheet, which SupportedPropertyTest would not catch and §1.3 forbids.
  • A general test that every §3 row matches the cascade. The right shape and the wrong week: §3’s rows are prose with numbers in them — “height 20; padding-x 8; radius full; caption” — and parsing thirty of those is a markdown parser with opinions. Doing it per widget, as BadgeTest.metrics and this do, is what the catalog has been doing and it works; the general version is worth having once a third row has drifted.

Consequences

  • §3’s tooltip padding is 8/12, and the row says why in one clause.
  • §17.1 gained an entry, which is the seventh open disagreement and the first found by reading a metrics row against a stylesheet rather than by building something.
  • Four tests, and they are the first that assert a widget’s metrics against the document for a widget that has no widget class — tooltip is an attribute, and the only thing carrying its type is TooltipPanel in :core. The test lives in :widgets because that is where controls.css is.
  • The drift was three deep before anybody looked. The lesson is not about tooltips: every other metrics row in §3 is currently unchecked the same way, and this closes one of about thirty.

264. A widget may find the toast stack

Date: 2026-09-05

Status

Accepted. Closes the last open half of the overlay-layer entry.

Context

The entry had already shrunk once and said so:

BuildContext.host() exists now … so a control that must open something for itself can. What that answers is the popup half; Host.overlay is still the door a toast raised from a handler deep in the tree would want, and nothing wraps it in the Overlay.of(context)-shaped call that would put a toast up from there without the application’s help. Smaller than it was, and the same shape.

Toasts.at(host, controller, corner) mounts a stack, and from then on the application raises toasts through the ToastController it holds in a field. That works for an application and not for a widget: a control deep in the tree that wanted to say “Saved” had to be handed a callback by whoever built it, and every layer in between had to carry one.

BuildContext.findAncestorState — the mechanism that answers this shape for scroll and form — cannot. A toast stack is mounted in the overlay layer, which is a sibling of the application’s root under window-root, not an ancestor of anything inside it. Walking up from a deep widget reaches window-root and stops.

Decision

Toasts.of(BuildContext), returning the stack on this widget’s window if one has been attached.

Toasts.of(context).ifPresent(toasts -> toasts.show("Saved"));

BuildContext.host() already gives the window, and Toasts.at is the one place that knows which stack is on it. So the whole mechanism is a map from the first to the second, and the only real decision is where it lives.

Not on Host

The obvious shape is host.toasts(), or a general host.service(Class). Both put :core in the position of knowing what a toast is — and the reason Toasts, Menus and Dialogs are three classes in :widgets rather than three methods on the window is precisely that it must not. A general service locator is the same problem with the type erased: it would let anything be registered against a window, which is a larger mechanism than one lookup and a worse one to have guessed at from a single consumer.

So the map is here, keyed by Host.

Weak on the key, and plain

WeakHashMap, so a window that goes away takes its entry with it. A Host outliving the application that made it is the one leak a convenience like this could cause, and nothing else in this class is in a position to notice a window closing.

Not a concurrent map, for the reason already at the top of the file: everything that touches a Host is on the UI thread, and a second thread reaching a toast stack has a larger problem than this map.

Last attachment wins

at returns an Overlay handle so a window can move its toasts to another corner. The lookup follows the most recent attachment, because handing out a controller whose overlay has stopped drawing is the one answer that is certainly wrong.

Empty is an answer, twice

A widget built into a tree with no window — which is what most unit tests are — and a window whose application never called at. Neither is a fault. A control that threw on either would be a control that cannot be tested without a window, and a toast nobody arranged to show is a toast that does not appear, which is what the application decided by not attaching a stack.

Alternatives considered

  • host.toasts(). :core learns what a toast is, and the module boundary that keeps the catalog swappable stops meaning anything.
  • A general host.service(Class). One consumer is not enough to design a service locator against — the argument BuildContext.host() itself was held to (ADR-0140), which waited for select to be the second. If Menus and Dialogs grow the same need, that is the moment, and this map is what would be replaced.
  • Making the stack an ancestor so findAncestorState works. It would put the toast stack inside the application’s tree, which is exactly what the overlay layer exists to avoid: an overlay takes no space from the content and is painted after it (ADR-0100).
  • Threading a callback down. What applications do today, and what the entry is about. It works and it costs every intermediate widget a parameter it does not otherwise want.

Consequences

  • A control can raise a toast, and nothing between it and the window has to know. That is the whole of §7’s “something that just happened” being available where things happen.
  • Toasts holds static state, which it did not before. It is one weak map, documented, with a package-private forgetAttachments for tests — the fifth such pair in the toolkit, and the point at which the pattern is worth extracting was already noted in ADR-0257.
  • Seven tests, including the two ways of answering nothing and the two windows not seeing each other’s stacks. One of them mounts the stack as well as attaching it, because TestHost.overlay records rather than builds — a controller registered by at alone is still detached and swallows what it is shown, which would have made the test pass by asserting nothing.
  • Menus and Dialogs are unchanged, and now visibly asymmetric with this. Neither has been asked for; when one is, the question of whether these three share a mechanism is the one to answer rather than repeating this map twice.

265. Yoga measures an inset from the border box

Date: 2026-09-05

Status

Accepted as a finding. Answers the question the text-input entry left open and prices the fix; no behaviour changes.

Context

The entry asked one question and did not answer it:

An absolutely positioned child is placed against the border box, and the clip is the padding box. text-input allows for it by adding its own padding to every child’s left, which works and is a workaround: the next widget that places a child absolutely inside a padded box will hit the same thing and will not know to. Whether Yoga or the painter is the one disagreeing with CSS has not been established.

CSS is unambiguous. An absolutely positioned box’s containing block is the padding box of its nearest positioned ancestor, and overflow: hidden clips to that same padding box. The two agree by construction, which is why placing against one and clipping to the other is a disagreement rather than a pair of choices.

The finding

Yoga. And it contradicts itself.

A 40×20 absolute child in a root with padding: 12px, laid out at 200×100, with errata at its default of None — Yoga’s spec-compliant mode, so this is not a setting anybody turned off:

childYogaCSS
left: 0; top: 0(0, 0)(12, 12)
no insets at all(12, 12)(12, 12)

So it is not that Yoga does not implement CSS absolute positioning. It is that one path of two does. Given no insets it uses the padding box and is right; given an inset it measures from the border box and is wrong.

The painter is exonerated. It clips to the padding box, which is what CSS says, and it is doing so against positions Yoga computed against a different box.

Two permanent tests in YogaLayoutTest record both halves, against the compiled library rather than against this record.

Why this is not fixed here

The fix is one line of arithmetic in the wrong place. RenderObject applies an inset to its own Yoga node and has no reference to its parent’s resolved padding at that point, so making the toolkit CSS-correct means the parent pushing its padding down to each absolutely positioned child — a change to how the render tree is applied, not a change to a number.

And the widgets that use this already compensate, differently:

  • text-input and text-area add their own padding to every child’s left, which is the workaround the entry names.
  • SegmentedIndicator, TourVeil, TourStop, WindowRoot and scrollbar place absolutely inside parents that have no padding, so they are correct by accident of their surroundings.

Fixing the placement centrally therefore means removing text-input’s compensation in the same commit, or it double-counts — and it moves the layout of anything in the second group whose parent later grows padding. That is a correctness change with a golden tail across text-input, text-area, segmented, tour and scroll, and it is worth doing deliberately rather than as a rider on an investigation.

What has changed is that the next person does not have to find this out. The entry said “has not been established”; it is established, with numbers, in a test that runs against the library on every platform.

Alternatives considered

  • A Yoga errata flag. There is one for the other case — YGErrataAbsolutePositionWithoutInsetsExcludesPadding — and none for this one. The flags exist to opt into legacy wrongness, and the default is already the spec-compliant setting, so there is nothing to turn on.
  • Clipping to the border box instead, to make the pair consistent the other way. It would contradict CSS twice rather than once, and overflow: hidden is read by hit testing as well as the painter — a clip that included the padding would take presses in a control’s margin.
  • Fixing it now. Priced above. The reason not to is that it must be paired with removing a compensation in two widgets, and doing that inside an entry about establishing a fact is how a golden moves without anybody looking at it.
  • Leaving the question open. The entry had been open for two milestones with a workaround in the tree and a comment saying the next widget would not know. An hour of Yoga answered it.

Consequences

  • The entry keeps its subject and gains its answer, and the answer names the path rather than the library: Yoga’s inset path, not Yoga.
  • Two tests in :natives, which is where a claim about the compiled library belongs — and they are the first tests in that file that assert Yoga is wrong about something, so both say what CSS would have said instead.
  • The fix is priced and located: parent pushes padding to absolutely positioned children in RenderObject, minus text-input’s and text-area’s compensation, plus whatever goldens move. Nothing about that is discovery any more.
  • text-input’s workaround is now a documented workaround rather than a local oddity, which is the difference between the next widget hitting this and the next widget reading about it.

266. A null button is unequal to everything

Date: 2026-09-05

Status

Accepted. Closes the onPointer guard entry.

Context

The entry records a bug and then generalises it, and the generalisation is the part worth acting on:

A guard at the top of onPointer is a guard on every pointer kind, and the kinds do not carry the same fields. text-input tested button() == PRIMARY there and silently lost every drag, because PointerRouter.pointerMoved builds its event with a null button — a motion is not a button event (ADR-0168). Slider asks per kind and reads as a style choice until this happens. Nothing warns; a PointerEvent accessor that is meaningless for the kind in hand answers with a default rather than refusing, which is right for dragX’s NaN and quietly wrong for a null button.

That last sentence is the whole of it, and it is exactly right. The two defaults are not the same kind of default:

  • dragX’s NaN is arithmetic. The meaninglessness propagates: every comparison against NaN is false, in both directions. A caller cannot act on it by accident, which is why Toggle’s handler can read it with no guard at all.
  • A null button is a reference. It compares equal to nothing and unequal to everything — so button() != PRIMARY is true for a move, and the guard fires backwards: the press it was written for keeps working and every drag is dropped. Which is precisely what happened.

Decision

Report a button read from a kind that carries none. Once, and keep answering null.

Not a refusal

Throwing would turn a lost drag into a window that falls over, from inside an input handler, on a mistake an application can make in its own widgets. Every other diagnostic in the toolkit is held to the same rule — ScrollState’s nested scroller, ScrollContent’s flex-grow, ListRow’s pitch — for the reason ADR-0251 stated: turning a rule into a crash is worse than the rule going unheard.

Not a sentinel

Button.NONE was the other shape and it fixes nothing: button() == PRIMARY is still false and != PRIMARY is still true, so the guard still fires backwards and now does so against a value that looks deliberate. It would make the null safe and the bug invisible, which is the wrong half.

Keyed by kind and node type

(MOVED, text-input) is one report however long the pointer is over it, and two widgets making the mistake are two reports. A pointer event is read per event per handler, so an unguarded warning is a few thousand lines a second on a trackpad — ADR-0243’s rule, applied at the highest event rate in the toolkit.

The message says the general thing rather than the local one: “a guard at the top of onPointer is a guard on every kind; ask inside the arm that has a button.”

Nothing in the catalog trips it

All nine button() reads in :core and :widgets are already inside a kind check — seven in a switch arm, and Toggle’s behind a short-circuiting || that only reaches it for a RELEASED. So the diagnostic fires on the mistake and on nothing else, which is what makes it worth having at this event rate.

Alternatives considered

  • Optional<Button>. It makes the mistake unwritable, and it changes the signature of the most-called accessor on the busiest event in the toolkit — nine call sites, an allocation per read unless the caller is careful, and a switch arm that already knows the answer having to unwrap it.
  • Throwing. Discussed above. It is also the one behaviour that would make a third-party widget’s guard take down a window that works today.
  • A compile-time answer — separate types per kind. PointerEvent would become a sealed hierarchy and every handler a pattern match. It is the correct design and it is a rewrite of the input layer; the entry describes a trap, not a request for one.
  • Leaving it. The entry’s own words are the argument against: “Slider asks per kind and reads as a style choice until this happens.”

Consequences

  • The trap announces itself, and names the widget and the kind. A text-input written next month that loses its drags says so on the first move instead of on the first bug report.
  • PointerEvent gained a static report set — the sixth in the toolkit, with its forget for tests. ADR-0257 already noted the point at which that pattern should be extracted; this is past it, and extracting it is worth doing on its own rather than inside an entry about pointer events.
  • button() costs a null check on the hot path. It was already a field read; it is now a field read and a branch that is not taken for any event that has a button.
  • Six tests, two of which are about the difference between this default and dragX’s — because the entry’s insight is that comparison, and a test that only checked the warning would lose it.

267. A text scale scales the text, and not the layout

Date: 2026-09-05

Status

Accepted. Implements §1.4’s global text-scale token, which docs/ARCHITECTURE.md §17 has recorded as “neither implemented nor gallery-enforced” since the accessibility baseline was written. The enforcement half is now buildable and is not built here.

Context

Two documents ask for this and one entry complains about the consequence.

Global text-scale token 90–150%; every component must survive 150% without clipping (gallery-enforced). — design-system.md §1.4

text scale to 150% without clipping — §13’s accessibility baseline

The gallery goldens cannot see typography at all. … what is still missing is any image that would show a layout wrong because of a font size — text that clips at 150% scale is §1.4’s explicit gallery-enforced requirement and nothing enforces it. — TODO.md

The entry reads as a gap in the tests. It is not: nothing enforces the 150% case because nothing implements it. There was no way to ask for 150% text, so there was nothing for an image to be of.

Decision

renderer.textScale(double), and the factor is applied where a ComputedStyle becomes a Font — nowhere in the cascade.

A switch on the renderer

Which is where §13’s other accessibility switches are. reducedMotion is the same shape for the same reason: it is a user preference applied to a whole window, and there is no selector that could express one. The TODO.md entries about reduced motion, density and the scrollbar gutter all name the same missing “settings mechanism”; this is a fourth switch on the same object rather than an invention.

It scales the text and not the layout, which is the whole point

The factor multiplies the Typography at the one seam where the renderer turns a style into a font. So a paragraph is shaped larger and a measured leaf grows around it, while a height: 32px stays 32.

That is exactly the condition §1.4 asks components to survive: “every component must survive 150% without clipping”. A control whose box grew with its text could not fail that test, and the requirement would be vacuous.

Scaling inside the cascade was the other design and is wrong twice over. font-size: 1.2em resolves against a parent that would already have been scaled, so an em chain takes the factor once per level and a nested label ends up at 1.5² or worse. And a padding: 0.5em would grow with it — so the boxes get bigger too, and the clipping this exists to reveal is hidden by the mechanism meant to reveal it.

The line height scales and a line-height ratio does not

Typography.lineHeight is a length or, when negative, a ratio. The length scales, because a line box that did not grow with its text is a paragraph whose lines overlap. The ratio does not: a multiple of the size already scales by the size scaling, and multiplying it too would square the factor. A test asserts the resolved line height grows exactly once.

The range is a clamp, not a refusal

§1.4 says 90–150%. textScale clamps rather than throwing, because a text scale is a user setting: a window that failed to open because someone’s accessibility preference was 200% is worse than a window whose text is as large as the design system allows. A non-finite factor is still refused, since there is no reading of NaN that draws anything.

One is the default, and no golden moved

Typography.scaled(1) returns this, so the default path allocates nothing and draws exactly what it drew. That is what made it safe to add the mechanism before anything enforces the 150% case — the whole corpus is the evidence that the default is inert.

What is deliberately not built

The gallery enforcement. §1.4 says “gallery-enforced”, and an image at 150% is now takeable. It is not taken here because what to assert is a real question: with text-overflow: ellipsis shipping (ADR-0255) some cutting is now correct, so “no text is clipped” is no longer the assertion — and a golden image of eleven screens at 150% would pin every one of those decisions at once, in a picture, before anybody had decided them.

The mechanism is the part that was missing. The entry can now say what it is waiting for.

A --gb-text-scale custom property. §1.4 calls it a token, and the tokens are read by the cascade — which is the half that must not see this factor, for the em-compounding reason above. An application sets it on the renderer, exactly as it sets reduced motion.

Alternatives considered

  • Scaling CssLength.Context.fontSize. The obvious place, and it only reaches em and rem. Every font size in the design system is px, so it would scale nothing that matters.
  • Scaling in ComputedStyle.of after resolution. Compounds through em chains, as above, and cannot tell an inherited size from a declared one without threading a flag.
  • A zoom — scaling the display scale instead. That is a different feature and the toolkit already has it (DisplayScale). Zoom scales everything including the boxes, so it can never clip; §1.4 asks for the one that can.
  • Waiting until the gallery enforcement is designed. The enforcement needs this to exist. Doing them together would mean deciding what a clipped label means in the same change that makes 150% expressible.

Consequences

  • §13’s accessibility baseline gains an item. ARCHITECTURE.md §17’s “text scale to 150% is neither implemented nor gallery-enforced” is half true now, and the half that is left is the half §1.4 spells “gallery-enforced”.
  • Seven tests, in two classes: the unit half on Typography.scaled — including the ratio that must not be scaled twice — and the end-to-end half that says the factor reaches a laid-out frame at all. A scale applied to a Typography nothing shapes with would pass every unit assertion and draw the same picture.
  • The end-to-end test measures a text box in a row, and says why: a flex child is stretched on its cross axis, so a text box in a column is as wide as the window and in a row is as tall as the row. The first version asserted a width that was 400 at both scales.
  • A button’s height is asserted not to change, which is the condition being created rather than a side effect. It is also the assertion that would have caught the cascade-scaling design, since that one grows the padding.

268. A tour card says how tall it came out

Date: 2026-09-05

Status

Accepted. Closes the second of the three tour entries. The first is verified and stands; the third is TabPhase’s promotion and is not this.

Context

A tour card’s height is estimated, not measured. It decides whether it fits below its target from a constant. Measuring needs the measure-then-place machinery ADR-0104 built, which works on windows rather than on boxes. Being wrong puts a card above its target when it would have fitted below.

The last sentence is the defect and the middle one is wrong about why.

ADR-0104’s machinery does work on windows, and it is not what this needs. TourStop already banks the window’s own rectangle from the frame before, through Located, and uses it to clamp the card horizontally. The card is one node further in, and Measured is the same door: “here is what you turned out to be”, reported after a frame is laid out.

So the mechanism was already in the file. This is the fourth entry in this section whose stated blocker had expired or was never right — the tooltip delay, the popup-inheritance answer, masonry’s column count, and now this.

Decision

TourCard reports its height; TourState banks it; TourStop uses it, and falls back to the estimate on the first frame.

ESTIMATED_HEIGHT stays and its meaning narrows: it is what the first frame decides with, before anything has been laid out and had a height to report. It is no longer the number for every frame.

It cannot oscillate, and the reason is not a promise

Measured’s third rule — what it triggers must not change what it reports — is the one that has kept the mechanism to two implementations. It holds here by construction: the card’s width is fixed at 280 (so the sequence does not shuffle between stops) and its content is the stop’s own title, body, counter and buttons. None of that depends on whether the card was placed above its target or below it, so the height it reports is the same either way.

That is the same shape as masonry’s argument — “the columns are equal width, so a card’s height does not depend on which column it is in” — and unlike a scrollbar’s, which obeys the rule by being absolutely positioned. A test asserts it rather than a comment claiming it.

Half a pixel of hysteresis

cardMeasured ignores a change under 0.5, so a height that rounds differently between frames does not schedule a rebuild for ever. The same threshold TextAreaState uses on its width, for the same reason.

Alternatives considered

  • Leaving the estimate. It is wrong in one direction only — a card placed above when it would have fitted below — which is why it survived: the tour still works, and the stop points at the right thing, from the wrong side.
  • Measuring in render from the paragraph. A card is a title, a body, a counter and a row of buttons in a padded box; adding those up means reimplementing the layout in the widget, and getting a different answer from Yoga the first time a stylesheet changes the padding.
  • The measure-then-place machinery. What the entry proposed. It opens a window, measures its content and places the window — three of which a tour must not do, for the reason TourCard’s own javadoc already gives about not being a popover.
  • Making TourStop stateful. It is a record whose values TourState computes, and it already receives one banked measurement as a component. A second is a component, not a state.

Consequences

  • A tall card is placed above and a short one below, from the card’s actual height. The test’s fixture is chosen so the two answers differ — below is 186, a 132-tall card needs 330 and fits in a 400 window, a 260-tall one needs 458 and does not — because any target where the estimate and the measurement agree would pass against the old code.
  • TourCard gained a second component and became Measured. It is the toolkit’s fourth implementation of that interface, after the scrollbar, the masonry and the toast stack, and the third whose argument for rule 3 is “the thing measured does not depend on what the measurement decides”.
  • One frame of settling, which nobody sees: the first frame places from the estimate and the second from the measurement, at 60 Hz. Where the two agree — which is most stops — there is no second frame at all, because the banking ignores a change under half a pixel.
  • The first tour entry stands and is now verified. A tour still cannot find the viewport its target is in: findAncestorState walks up from the element being built, and what a tour needs is a walk up from the target it names, which is a different question and one the tree cannot answer.

269. A tour arrives, and its cut-out travels

Date: 2026-09-05

Status

Accepted. Closes the third tour entry, and corrects the TabPhase entry it depended on, which had been describing a promotion that already happened.

Context

Two entries, one of which is the other’s stated blocker:

A tour has no arrival or exit. §1.7’s overlay curve wants one to arrive rather than appear, and stops change instantly — §5’s row asks for the veil cut-out to translate and resize between stops. That is TabPhase again: the enter/exit lifecycle built for one widget, wanted by a third.

The enter/exit lifecycle is a tab’s own, not the toolkit’s. TabPhase is what §1.7’s “overlay enter/exit lifecycle” asks for, built for one widget: toast, dialog and popover all want the same thing, and promoting it should wait for the second consumer rather than be guessed at from the first.

The promotion happened two records ago. TabPhase is widgets.core.Phase — moved there by ADR-0166, whose own javadoc says “there was never anything tab-shaped in it” — and the closing → removed half was extracted into widgets.core.Departure by ADR-0234. Six families use one or both: tabs, carousel, collapse, toast, dialog and message.

So both of the consumers the entry named as wanting the mechanism have been using it for milestones. What was missing was not a promotion. It was a tour that used it.

That is the fifth entry in this section whose stated blocker had expired, and the first where the expiry was hiding a second entry behind it.

Decision

Two phases, and they belong to different things.

The arrival is the tour’s

One Phase for the whole tour, not one per stop. §3.1 says a tour’s card animates “as popover”, and that row is “opacity 0→1, translateY −4→0, scale 0.98→1 from anchor origin, base”. Two of the three are built.

The scale is not, and deliberately. transform-origin resolves against a box the painter measures, and a card scaling from its own centre rather than from its anchor reads as a pop rather than an arrival. popover itself has the same gap for the same reason, so this is consistent rather than incomplete.

A tour arrives once. A card that faded in again at every stop would be a sequence that restarts rather than advances, and a test asserts the second stop holds the same Phase instance as the first.

The travel is the cut-out’s

§3.1’s tour row is “stop change: veil cut-out translate+size base”, and both halves fall out of interpolating one rectangle: a target that moves and changes size does both at once. Doing it as one rectangle rather than as a translate and a resize is what keeps the ring, the veil’s hole and the card agreeing on every frame — they are three drawings of the same geometry, and three separate animations would let them disagree mid-flight.

beginTravel runs before the index moves, because anchorOf has to answer the stop being left — the rectangle the travel starts from. After the move it would bank the destination as the origin and animate nothing.

Interpolated in render, in two places, on purpose

The frame clock reaches a widget in render and nowhere else, so the node that draws a thing is the only one that can know how far through the travel it is. TourStop does the arithmetic for the ring and the card; TourVeil does the same arithmetic for its hole, from the same two rectangles and the same Phase.

Handing the veil an already-interpolated rectangle was the alternative and is not available: children() builds the veil, and children() has no clock.

What the arch sweep caught

AnimationSweepTest failed twice, and both were real:

  • TourVeil held a Phase and did not override isAnimating — “a widget that holds a phase says it is animating”. Without it the veil is painted once at whatever the loop caught and left there.
  • The tour package had no test naming isAnimating — “a widget that stopped asking for frames would still pass every golden”.

Neither would have been caught by an image, which is what that sweep exists for. It found them within a minute of the phase being added.

The goldens did not move

TourGoldenTest warms five frames on a system clock, which pass in microseconds — so a 160ms arrival would have been photographed at whatever opacity the loop happened to catch, differently on every machine. It has a virtual clock now, advanced past the duration, which is GalleryGoldenTest’s answer to the same problem.

With that, both tour goldens match unchanged: the settled tour is the picture it always was, and the animation is what happens on the way there.

Alternatives considered

  • A transition on the ring’s inset. The cascade never sees these rectangles — they are computed from an anchor the router reported — so there are no two styles to interpolate between. That is Phase’s founding argument and it applies here unchanged.
  • A phase per stop. It makes the card re-arrive on every Next, which is the behaviour §5 is asking to replace.
  • Animating the ring and the veil separately. Two phases over one geometry, which can only ever agree by accident.
  • Promoting TabPhase. What the entry asked for, and it was done in ADR-0166.

Consequences

  • A tour fades and rises in, and its cut-out slides and resizes between stops. §3.1’s tour row is built bar the scale, and §1.7’s overlay curve reaches the last widget that was appearing rather than arriving.
  • TourVeil gained two components and an isAnimating. It keeps a two-argument constructor, so every caller that has nothing to travel from — which is the tour’s first stop and every test — is unchanged.
  • The TabPhase entry is corrected rather than closed by this: it was describing a promotion that ADR-0166 had already made, and both consumers it named were already using the result.
  • Five tests, including the one AnimationSweepTest requires by name and the one asserting the arrival is the tour’s rather than the stop’s — which is the difference between a sequence that advances and one that restarts.

270. A popup is placed again when its window moves, and when its anchor does

Date: 2026-09-05

Status

Accepted. Finishes ADR-0231, whose resize half shipped and whose other two halves were left as a TODO entry naming what each of them needed.

Context

The entry was precise about what was missing, which is what made it answerable:

A window that moves does not re-clamp its popups, and a scrolling anchor does not drag one. The resize half is built […] A window move is not an event: there is no BackendEvent.Moved, so re-clamping a menu that was flipped against the work area at the old position needs an SPI event, an SDL translation and a fabricated-event test of ADR-0061’s shape. And a popover following a scrolling anchor needs the anchor to report that it moved, which is Located’s shape and a widget-level wiring rather than a window-level one.

Both halves are the same sentence — put the popup back where it belongs — and they are missing for two different reasons. A move changes nothing about the anchor and everything about the screen: a popup is placed as an offset from its owner, so dragging the window carries the menu along, and the only thing that moved underneath is the work area’s position in the window’s own coordinates. A menu flipped above its button because there was no room below it has to be asked the question again at the new position, and nothing was asking.

A scroll is the mirror image. The window is where it was, the work area is where it was, and the widget the menu hangs off is drawn a hundred pixels higher than it was last frame — because a scroll is a translation on the content and Yoga never sees it (ADR-0114, ADR-0116).

Decision

A move is an event, because a move is not a resize

BackendEvent.Moved carries the window and its new position in the desktop’s logical coordinates. SdlEventType.WINDOW_MOVED is 0x205, and like every constant in that enum it is checked against the compiled SDL by the layout probe — which caught it being unregistered in goldberry_shim.c before anything else did, exactly as SdlEventType’s own javadoc promises it would.

Three things fall out of it being a separate case rather than a flavour of Resized:

  • No repaint follows one. The frame on screen is still correct: nothing inside the window moved. Window.handleMoved therefore does not call repaint(), which is the one line that distinguishes it from handleResize.
  • The re-placement happens immediately, not after the next paint. ADR-0231 had to defer the resize case because anchor(id) answers from the capture the last paint produced, and during a resize handler that capture is the old window’s. A move produces no new capture and invalidates none: the current one is the right one, and waiting for a paint would mean waiting for an unrelated frame that may never come.
  • The position is read off the window, not out of the event, for the reason the sizes already were: one place asks the platform, and position() is what every other caller uses.

Sdl3Window.movedTo deduplicates, because SDL sends WINDOW_MOVED for every pixel of a title-bar drag and again for a move that put the window back where it was. What a move costs above the SPI is a re-placement per open popup.

The event watch is deliberately not extended to it. ADR-0060 has the watch draw during a resize drag because the contents change while the platform’s modal loop is running; during a move drag they do not, so the queued events are enough and a popup is re-placed once the drag ends rather than per pixel of it.

A scrolling anchor is a frame, not a report

The entry proposed Located, and the anchor does not need it. Host.anchor(id) already answers from HitTest.capture, which is taken every frame — so the question “where is that widget now” has a fresh answer on every frame without anybody reporting anything. What was missing was somebody asking it.

So replacePopups runs at the end of any frame in which a popup is anchored by id, alongside the two events. Anchored by id and not by rectangle, which is the whole of the guard: a popup opened against a rectangle a caller computed has nothing to re-resolve — the rectangle is all there ever was — so a window with no id-anchored popup open pays one field read per frame and nothing else.

Located would have been the wrong shape twice over. It reports on a change, which is one frame after the change for the first frame of a scroll; and it would put the wiring on the anchor, so every widget an application wants to hang a popover off would have to opt in. The popup is what wants to follow, and the popup is where the wiring now is.

An anchor rectangle is the painted one

This is the part that had to change rather than be added, and it was wrong before anything scrolled.

HitTest.Region has two rectangles. bounds() is what layout produced; painted() is where the box was drawn, which differs exactly when something above it was transformed. Both javadocs said, in as many words, that a popup anchors to bounds() — a menu belongs under where its button sits in the flow.

A button inside a scroll sits in the flow four hundred pixels below the viewport it is drawn in. Anchoring to the flow rectangle opens its menu four hundred pixels away from it, and this was true on the day a menu was first opened from a scrolled list — following the anchor afterwards would only have kept it faithfully in the wrong place.

So Launcher.anchor, Popup.anchor and Menus.open all read painted(). For every box nothing transformed the two rectangles are identical, which is nearly every anchor there has ever been and the reason this was not a visible bug sooner. tour had already reached the same conclusion for its veil (ADR-0123) and named the popup rule as its contrast; that note now says the two agree.

Alternatives considered

  • Re-placing every popup on every frame. Simpler by one method, and it moves popups that have nothing to follow: a rectangle a caller computed is not a question with a new answer, and re-clamping it per frame is a window that can drift under a control that never asked to move.
  • Located on the anchor. What the entry proposed. One frame late at the start of a scroll, and it makes following a property of the widget being anchored to rather than of the popup that wants to follow.
  • Re-opening the popup rather than moving it. Popup.move exists for this and is cheaper: the tree stays mounted, the keyboard stays where it is, and nothing flickers. ADR-0231 settled this and it has not changed.
  • Treating a move as a resize. It would repaint the window for a move, which is a full frame per pixel of a title-bar drag for a picture that did not change.

Consequences

  • A menu re-clamps when its window is dragged near a screen edge, and a popover travels with an anchor that scrolls under it. §7’s “placement with flip/shift when near edges” is now a promise about where the window is rather than about where it was when the popup opened.
  • BackendEvent gained a case, so every exhaustive switch over it had to say what it does with a move — which is what that interface being sealed is for.
  • The headless backend can produce one. HeadlessWindow.moveTo now posts the event a window manager would send, so the whole path is reachable in CI, and the SDL translation has a fabricated-event test of ADR-0061’s shape under the dummy driver.
  • A popover follows; a menu and a select do not. Following is a property of having been opened by id, and Popover is the widget that is — host.popup(new Popover(items), "menu-button", Placement.BELOW) is its documented shape, and it is the widget the entry named. Menus and SelectState resolve their anchor to a rectangle themselves because they need a minimum width and a Fit as well, and there is no Host overload that takes all three. Giving them one is a small piece of work nobody has asked for; it is in TODO.md rather than done here on the guess.
  • A popup whose anchor scrolls out of sight follows it out of sight, clamped to the work area rather than dismissed. Whether it should instead close is a behaviour decision nobody has asked for; it is in TODO.md rather than guessed at here.

271. A frame that never happened is counted by the pacer

Date: 2026-09-05

Status

Accepted. Answers the “nothing reports a dropped frame” entry, and adds the reading ADR-0146 and ADR-0150 had no source for.

Context

The entry, in full:

Nothing reports a dropped frame. The ring behind hud records frames that were painted, so a frame the platform refused after it was painted is in the mean and a frame the loop never reached is not. “3 late” needs the pacer’s view as well as the painter’s, and the pacer belongs to the sdl3 backend.

Everything a hud shows is a mean over the frames in FrameRing, and a frame gets into that ring by being painted. So the two ways a frame can fail to reach the user are both invisible, in opposite directions:

  • A frame the loop never reached leaves no record at all. Sixty frames at 30 fps and sixty frames at 60 fps are both “sixty frames”; only the interval is different, and an interval also gets longer when the user stops touching the window, which §1.7 makes the ordinary idle case. So the number that would tell them apart is the number that cannot be read off the ring.
  • A frame the platform refused after it was painted is in the ring as though somebody had seen it. Window.paint already catches this — the window became a different size while the frame was being drawn for the old one, which during a resize drag is ordinary rather than exotic — and logged it at debug.

Decision

One number, FrameStats.lateFrames(), fed by both.

The pacer’s half

FramePacer.missedRefreshes(pendingSince, now) is the whole of the new arithmetic, and it is a pure function of two stamps like everything else on that class:

due  = max(pendingSince, lastFrameAt + interval)
late = (now - due) / interval        // whole intervals, floored

pendingSince is what makes it honest. The naive number — the gap since the last frame, divided by the interval — says a window nobody touched for a minute dropped three and a half thousand frames. It drew every frame it was asked for. What counts is the span between the moment a frame could have been handed over and the moment one was: the request has to already exist for a refresh to be a refresh anybody missed.

So Sdl3Window stamps framePendingSince when a request arrives and the backend reads it in emitDueFrames, before the request is consumed and before the pacer is stamped — both of which are what the lateness is measured against.

The counter on the window is monotonic, like a frame count, because the window above it keeps a ring of sixty frames and a backend has no business knowing that.

The painter’s half

Window.paint increments refusedFrames where it already caught the refusal, and banks it with the next frame. A frame that was refused asks for a repaint on the line below, so there is always a next frame to bank it on.

Banked with the frame that follows the gap

FrameRing.late(n) is set before record, in the same pending-then-consumed shape the four stage timings already use. A gap has to be attached to some frame in the ring or it ages out on a different schedule from the frames it sits between — and then a HUD would show frames dropped during a resize that finished long enough ago for the resize itself to have left the window.

A window rather than a total, therefore, like every other number on FrameStats and unlike count(). A total since start-up only ever goes up, so a loop that dropped four frames a minute ago would still be reporting them, and somebody watching the HUD while they work would never see it come back to zero.

late is a reading

Reading.LATE prints whole frames with no unit — late 3 — and is in Hud.STAGES, which is what the showcase turns on. It is not a stage and it is in the breakdown anyway: the frames that did not happen are the one thing a breakdown of the frames that did can never account for.

Its level is stated rather than derived. Every other reading is a share of a display frame (ADR-0153) and a count of frames is not a duration. Zero is fine; one is near, because a resize refuses a frame that was painted for the size the window has just stopped being and that is ordinary; more than three of the ring’s sixty is over, which is one in twenty and is stutter somebody can see. A threshold derived from capacity() was the first draft and is wrong for the reason that method documents: a source that keeps no window reports zero, so one dropped frame would be an alarm for every fixed source there is.

Alternatives considered

  • Counting the gap between painted frames. Free, already in the ring, and it reports an idle window as a catastrophe. §1.7’s idle loop is the common case, not the exception.
  • A monotonic total. Simpler, and it never goes back down — which for a diagnostic somebody watches while they work is the difference between “this is happening now” and “this happened”.
  • Two readings, one per source. They are two ways for the same thing to happen, and a reader wants the one number. The javadoc on lateFrames() says which two, for whoever needs to know which half moved.
  • Leaving the refusal at debug. It is the half that was already known and already invisible: a log line is not something anybody is watching during a resize drag.

Consequences

  • hud readings="stages" reports the frames nobody saw, and the over-budget golden now photographs late 7 in red beside the paint time that caused it — which is the pairing the image is for: 22 fps on a 60 Hz display is seven frames in sixty going missing, and nothing else on the plate could say so.
  • Two golden images moved, both HUD plates, both by one row.
  • BackendWindow.lateFrames() defaults to zero, so a backend with no display under it reports “nothing measured” in the same voice refreshRate does. The headless backend does not pace and never claims a dropped frame.
  • The pacer is the only thing that can answer this, which is why the number comes up through the SPI rather than being computed in Window: the request’s arrival and the display’s interval are both the backend’s, and neither is visible from above it.

272. An absolute child is placed inside the padding

Date: 2026-09-06

Status

Accepted. Implements the fix ADR-0265 priced and located, and removes the two compensations it named.

Context

ADR-0265 established the fact and did not act on it. An absolutely positioned child’s containing block is, in CSS, the padding box of its nearest positioned ancestor — and overflow: hidden clips to that same padding box, so the two agree by construction. Yoga implements one path of two: given no insets it places the child at the padding edge and is right; given an inset it measures that inset from the border box and is wrong by the padding.

That record priced the fix — “parent pushes padding to absolutely positioned children in RenderObject, minus text-input’s and text-area’s compensation, plus whatever goldens move” — and said it was worth doing deliberately rather than as a rider on an investigation. This is that commit.

Decision

ContainingBlock owns the rule, and it is applied where the inset goes onto the node.

RenderObject.update now takes its containing block’s padding, resolves the inset through ContainingBlock.insetFor(position, inset, blockPadding), and puts that on the Yoga node. A parent passes its own box.padding() down when it reconciles its children; the root passes Insets.ZERO, because the window has no padding to be placed inside of.

On the style, not on the answer. A correction applied after the layout pass — shifting each child’s computed rectangle by its parent’s resolved padding — is the version that handles percentages, and it is wrong. With left and right given, Yoga derives the child’s width from the containing block’s width less the two insets; shifting both insets makes that width the padding box’s, and a correction applied afterwards could have moved the child and could not have resized it.

Per edge, and only the edges the box named. Yoga’s fallback for an edge with no inset is the static position, which already includes the padding and is already right. Defining an edge in order to correct it would replace a right answer with a placement nobody asked for. The trailing edges shift too and in the same direction: right: 0 has to stop at the far padding edge, so the padding is added there as well.

Percentages are not corrected, on either side of the sum. A percentage inset resolves against a size the layout pass has not produced yet, and a length in points cannot be added to it before then. This is the restriction TextField.leftPadding already stated for the same reason, and it is now stated once, in the class that owns the rule.

And acrossBorderBox is the way out, because two places mean the border box and had been getting it by accident:

  • tab’s underline. left: 0; right: 0 inside a padding: 0 12px header now means 24 points narrower than the tab. An underline that stops short of its own label is not an underline. Tab.render widens the indicator using its own resolved padding, so density-compact.css moves it without mentioning it — a tab-indicator { left: -12px } in the stylesheet would be the same 12 written twice, three rules apart, and both would have to change together.
  • The overlay layer. A toast pinned 12 points from a corner means 12 from the corner of the window. window-root { padding: 16px } is an application saying where its own widgets start, and a toast is not one of them; a filling overlay means the window too. Without this the toast golden moved by 16 points on both axes, which is how it was found.

What was removed

text-input added its own left padding to all three of its children’s left, and text-area added its left and top to every selection rectangle, to the value and to the caret. Both are gone. Keeping either would have counted the padding twice and started the text a padding’s width too far in — which is why ADR-0265 insisted the removal happen in the same commit as the fix.

Both controls still read their padding, for the three things that are not placement: the width the text wraps at, the room the scroll offset has to leave, and turning a pointer’s x into an offset into the text.

Consequences

  • No damage flag was added, and that is checked rather than assumed. The only thing that shifts a child without touching its own box is its parent’s padding — which sameAppearance compares, so the parent is selfChanged and its rectangle is damaged. And collectDamage reports a node whose remembered rectangle differs from its current one, which is a comparison of results rather than of styles and does not care why the node moved. A flag was written first, then deleted when removing it failed to break anything.
  • The guard moved from the declared inset to the applied one. RenderObject compared previous.inset() against box.inset() to decide whether to call Yoga; that is now the wrong question, because the value on the node is not the value on the box. It keeps appliedInset instead. ContainingBlock returns its argument by identity when nothing shifts — which is almost every node — so the comparison stays a reference check and no absolute node costs an allocation per frame.
  • The golden tail was smaller than priced and pointed somewhere else. ADR-0265 expected movement across text-input, text-area, segmented, tour and scroll. Every one of those is unchanged: the two text controls because their compensation came out in the same commit, and the other three because their parents genuinely have no padding. What moved was tabs and toast, and neither was in the list — the second group was surveyed for parents with padding today, and a tab and an overlay layer both had some.
  • YogaLayoutTest’s two tests are deliberately unchanged. They assert Yoga’s raw answer, which is still (0, 0), because they are about the compiled library. AbsolutePlacementTest is the toolkit’s half and asserts (12, 12). If Yoga ever fixes its inset path, the :natives tests fail first and say so.
  • The next widget that places a child absolutely inside a padded box gets CSS, which is the whole point. text-input’s comment said the next one “will not know to” compensate; there is nothing to know now.

Alternatives considered

  • Correcting the computed rectangle instead of the style. Priced above: it cannot resize a child pinned on both edges, and that is a real CSS shape rather than a hypothetical one — it is how a text-area’s value box would be written if it did not already carry an explicit width.
  • Resolving percentages by deferring the shift to a second layout pass. Two passes for a case no widget in the toolkit writes, and Yoga does not offer a hook that would make the second one cheap.
  • Leaving tab and the overlay layer to the stylesheet. A negative left in controls.css is the same number written twice and a density file obliged to change both. acrossBorderBox reads the padding that is already resolved.
  • Clipping to the border box — rejected in ADR-0265 and still: it would contradict CSS twice rather than once, and hit testing reads overflow too.

273. A code is a string, and the boxes are a drawing

Date: 2026-09-06

Status

Accepted. Builds docs/core-widgets.md §4’s code-input.

Context

§4 gives this widget one paragraph, and it is unusually complete:

code-input — the one-time-code field: length=6 separate single-character boxes over one value, type="digits|alnum". It exists as its own widget rather than a styled text-input because its editing model is different, and that is the whole of the specification: typing advances, Backspace on an empty box moves back and clears the previous one, a paste of the full code fills every box at once (the thing users actually do), and focus lands wherever the first empty box is. complete fires when the last box fills, which is what lets a form submit without a button. mask=#true for authenticator-style secrecy. Semantics: a single textbox with the whole code as its value — six boxes are a drawing, not six fields, and announcing them separately would be a lie.

The interesting thing about that paragraph is that its four editing sentences are not four rules. They are two, seen from four directions.

Decision

CodeEdit is a string and a box count, and nothing else.

No caret, no anchor, no undo stack, and no per-box array. The active box is min(filled, length - 1) — derived, not held — and everything §4 asks for falls out of that:

  • “Typing advances” is appending.
  • “Focus lands wherever the first empty box is” is not implemented at all. It is what the derivation says, on every frame, with nothing to keep in step.
  • “Backspace on an empty box moves back and clears the previous one” is dropping the last code point, because the box the ring is on is always empty, so the box to clear is always the one before it. There is no second case.
  • “A paste of the full code fills every box at once” is the same append as typing: committed text arrives as a string, one character from a keystroke and six from a paste, so one operation does both.

No holes. The filled boxes are always a prefix. The alternative — an array of length slots, each filled or not, with a caret that can be moved into the middle — is what an editing model with a caret would have to be, and it fails on the sentence §4 ends with: six boxes are announced as a single textbox with the whole code as its value, and a code holding 12 and 56 with a gap between them has no honest string to announce. A gap is not a state a one-time code has.

Per-character filtering, which is the toolkit’s one exception to TextFilter’s rule. TextFilter is asked about the whole value an edit would produce and answers yes or no, and its javadoc argues the case: a filter that rewrote what was typed would move the caret out from under somebody mid-word. CodeType is asked one code point at a time and drops what it does not want, because neither half of that argument survives here. There is no caret to disturb, and the case §4 calls “the thing users actually do” is a paste out of Your code is 123 456 — which a whole-value filter rejects entirely and a per-character one turns into six filled boxes. Refusing the paste a user was told to make is a worse answer than ignoring a space. The alphabets themselves are TextFilter’s, not copies: TextFilter.ALPHANUMERIC has said “what code-input type=\"alnum\" will want” since it was written.

complete fires on the edit that filled the last box, guarded by a flag rather than by the code being full. A field that raised it whenever it was full would submit a form again on every rebuild. A Backspace clears the flag, so a mistyped code corrected and finished completes a second time — which is right, because that is two codes.

One Tab stop, one textbox, and the boxes are parts. CodeField is the focusable node, is Role.TEXT_FIELD, and is what a document names; CodeBox has no focus, no keys and no semantics. That is §4’s last sentence built rather than quoted.

Two things §2’s metrics row asked for that CSS could not say

  • “Group gap 16 at the midpoint when length is even.” §8’s selector subset has no :nth-child, so nothing in a stylesheet can say “wider after the third one”. The boxes go into code-group parts instead — the row of groups carries the 16 and a group carries the 8 — so both numbers are written where they are read. An odd length is one group, so the outer gap never applies and the row is the flat one §2 describes; the tree is the same shape either way, which is what keeps the stylesheet from having to know which case it is looking at. The alternative was a zero-width spacer between the halves, which turns one 8-point gap into two and arrives at 16 by an arithmetic nobody reading the CSS would see.
  • “Box 40×48 (36×44).” The one control in the catalog whose density is two numbers rather than a height, so --gb-code-box-width and --gb-code-box-height are both tokens and density-compact.css moves both. A code box is a character in a frame, and six boxes that narrowed without shortening would be a code drawn on graph paper.

What it does not have, and why

  • No arrow keys. text-input consumes its arrows because it has a caret they move, and consuming them is what stops Left from walking the focus scope out from under somebody editing. This has one insertion point that is a function of what is filled, so Left would either do nothing visible or move a ring the next keystroke moves back. They are left alone, so a code field sits inside an arrow-navigated scope and behaves like the single control it announces itself as.
  • No copy and no cut. §4 asks for the paste by name and asks for no way out, and a masked code must not have one for password’s reason. Offering it on an unmasked field only would be a control whose keys depend on how it is drawn.
  • No caret, so no blink timer. The focus ring on the active box is what says where the next character goes, and §2.2 asks for it to be instant. A window with a focused code field therefore asks for no frames at all, which is §1.7’s idle loop holding for one more control — text-input had to build a 530 ms timer to keep that true.
  • No Validator seam. A code is text until something checks it, which is exactly what Validator<String> already is. The typed-value seam the TODO list names is date-picker’s to open, not this one’s.

Consequences

  • Five golden images, because every number in §2’s row is a geometry no assertion can see: the 16 at the midpoint and not at every gap, the ring on one box rather than around the six, a title character centred in each box, and what a mask draws. code-input-focus has its -light twin, which FocusGoldenPairTest requires rather than this record.
  • Fifty tests, and most of them need no widget. The editing rules are CodeEditTest’s, against the value, with no font and no frame — TextEdit’s arrangement, and the reason §4’s paragraph is testable sentence by sentence.
  • The Forms screen gained a seventh markup card, wired the way a real one is: bind= down, change= back up, and complete= writing a status line. It is the only card on that screen that shows a control raising two events, which is the difference a screenshot can otherwise not show.
  • code-box gained a filled class §2 does not ask for. A row of six identical frames says nothing about how far through a code somebody is; the stronger edge is the vocabulary card already uses for “this edge is the meaningful one”, and border-color is on §1.7’s whitelist so it may fade.
  • §4 has three widgets left, and they are the pickers. Every one of them is a typed field plus a popover, which is a different shape from this one: this was the leftover that reused the least.

Alternatives considered

  • A styled text-input. §4 rules it out and the rule-out is right: TextEdit is a string, a caret and an anchor, and this widget would have had to hold all three still while re-deriving the box index from the caret on every frame.
  • An array of slots with a movable caret. Priced above. It is the model a code field would need if a code had holes, and it does not.
  • A :nth-child selector for the group gap. Not in §8’s subset, and adding one for a single metric is a change to the styling language for a widget.
  • Announcing the boxes. §4 calls this a lie in as many words. The boxes carry no Role at all.

274. A calendar is told what day it is

Date: 2026-09-06

Status

Accepted. Builds docs/core-widgets.md §10’s calendar and §4’s date-picker, and closes the Validator entry the TODO list said the picker would open.

Context

Two widgets, one pair: §4’s picker is “a text-input that parses, plus a popover holding a calendar”, and §10’s calendar is a widget in its own right with three selection models, two kinds of gate, a per-day renderer and a keyboard of its own. Building the picker without the calendar is not possible and building the calendar first is most of the work.

The finding, which was an existing rule refusing to bend

The first version read the clock. CalendarView.today defaulted to LocalDate.now() and the grid opened on YearMonth.now(), which is what every calendar API does and what nobody thinks twice about.

DeterminismTest failed:

Method CalendarView.resolvedToday() calls method ZoneId.systemDefault() … ADR-0203: a time axis is time, and which zone it is drawn in is the application’s answer. One seam is what makes passing UTC enough to pin a chart’s picture

The rule was written for TimeAxis and it is exactly as true here, for a reason that had not been anticipated: an instant is only a date in some zone, and a calendar that decided which one would be answering a question only the application can. So:

  • [CalendarView#month] is required. The grid is told which month to show; a change to it pages the grid, cross-fade and all.
  • today may be null, and null means no day is marked. A calendar that has not been told what today is does not guess.

Neither is a workaround. Both are better API, and they buy what ADR-0203 bought: a golden image of September 2026 is the same image tomorrow, and in Auckland. DatePicker inherits both.

The calendar

  • DateSelection is one value for three models, where list uses a Selection enum and a separate set of rows. The split works for a list because its three models differ only in how many rows may be chosen; a calendar’s third is not a count. A range of two dates is not two dates — everything between them is shaded and neither end means anything without the other — so the mode and the dates travel together and contains, isStart, isEnd and covers are four different questions.
  • A range’s ends are :checked and its middle is not, which is what makes §2’s “radius full on the selected day, range ends only” one CSS rule rather than a rule and an exception.
  • Grid gap zero is load-bearing, and §2 says so without saying why: a range is drawn by shading the days between its ends, and a gap would break that shading into seven stripes a week.
  • Six rows always. A month occupies four to six weeks, and a grid that changed height between them would move everything under it — a popover would resize under the pointer, and §3.1’s cross-fade would be a cross-fade between two shapes. The spare cells come from the months either side, drawn quieter and still pressable.
  • The cross-fade is two months, and one is out of flow. A single grid dipping to transparent is a dissolve to the background, which on a popover reads as a blink. So the incoming month is in flow and sizes the box while the outgoing one is absolutely positioned over it at the complementary opacity — a CalendarMonthLayer each, so the pinning is inset: 0 0 auto 0 on one node rather than a row height multiplied by an index that render cannot measure. It lands correctly because ADR-0272 made an absolute child respect its containing block’s padding.
  • One Tab stop, and the cells are parts. §10 asks for “one Tab stop with a roving day”, and a FocusScope is the other way to say it and the wrong one: a scope roves between focusable children, and forty-two focusable cells is forty-two Tab stops from anywhere the scope does not reach. So CalendarBox takes every key and the roving day is a class.
  • The arrows clamp to min and max and not to the disabled predicate. A bound is a window and a predicate is a rule inside it; a Right that skipped four days because a weekend was refused is a grid whose arrows lie. The roving day may therefore sit on a refused date, which draws as :disabled and cannot be chosen.
  • A month header, which §10 does not ask for. It gives this widget only a keyboard for changing month, and §2’s “header row caption” is the weekday row. A calendar a mouse cannot page is not a calendar, so calendar-header is an addition — written down here, and docs/design-system.md §2 gained a row for it in the same change rather than it being an undocumented part. Neither arrow is focusable, for TabClose’s reason.

The picker

  • The text is the state and the date is derived. §4: “the typed field is the source of truth, not the popup”. Holding a LocalDate and rendering it into the field has to answer what the field says while somebody is halfway through typing, and every answer to that either fights the caret or eats characters.
  • The grid writes text into the field, exactly as a user would, so a value takes one path and is parsed in one place.
  • One gate, two readers. §4: “min, max and a disabled predicate gate both the field and the grid, so an unreachable date cannot be typed either.” allows is that one place. A refused date is left in the field and not committed — deleting what somebody typed is how a field loses a keystroke they were halfway through.
  • Alt+Down is taken on the capture pass, because text-input reads a plain Down as “go to the end of the line” and does not ask about the modifier. Teaching text-input about pickers was the alternative and is worse.
  • The only date syntax the toolkit writes is a range’s separator. §4 forbids inventing one and no locale service answers “how does this language join two dates”, so a range is an en dash with spaces — and parsing accepts a plain hyphen too, because it is what a keyboard has. That looseness is why the separator cannot be a bare hyphen: 9-1-2026 is a date in some locales.
  • A document’s change carries text and Java’s carries a value. §9’s valued actions cross as a String and nothing else, so a document is handed the formatted date and an application in Java is handed a DateSelection. Found by the binding weaver refusing the showcase’s first handler, which is the check doing its job.

The Validator seam, closed by composition

The TODO list had said, for two milestones:

A Validator is over a String, and date-picker will want otherwise. … That is a second seam — a field that validates a parsed value — rather than a change to this one, and it is the picker’s to open.

It is Validator.parsing(parse, message, rule): a rule over the parsed value, as a rule over the text it was parsed from. Field needed no change at all — it still holds a Validator<String>, FieldState still reads its control’s binding as text, and neither knows a date was involved. That is the evidence the second seam was a composition rather than a type parameter, and it keeps Validator’s own doctrine intact: what the user typed is text until something parses it, and a validator is exactly the thing that decides whether it can be.

parse may throw or answer null and both mean the same thing, because java.time throws and a hand-written parser returns null, and a seam that took only one would make the other an application writing a try/catch to satisfy a method.

Consequences

  • Three architecture sweeps caught this before any test did, and each was a real decision rather than a formality: DeterminismTest on the clock, TokenClosureTest on a --gb-accent-on that does not exist, and SemanticsSweepTest on two parts that overrode isFocusable to say false — which is what makes a type owe a role. They do not override it now, and the default already said what they meant.
  • border-radius: full is not a thing, and the first calendar asked for it in two rules. §2 writes full and controls.css spells it as half the height in points, because §8’s subset has no keyword and no calc(). It cost a token — --gb-calendar-day-radius, beside --gb-calendar-day so a density moves both — and it was found by looking at the image, since a dropped declaration warns and fails nothing.
  • Role.GRID is new, and distinct from GROUP by the arrows: a group is a boundary with content in it, a grid promises all four arrows mean something and that a cell has a position in two axes.
  • Two widgets owe the same M5 entry. §10 asks for “each cell’s full date as its name” and §4 for “the formatted date as its value text”, and Semantics carries a role, a name and a liveness with no channel for either. That is the entry code-input opened; it now has three widgets behind it.
  • §4 has two widgets left — time-picker, which is this one with three columns instead of a grid, and color-picker. Both reuse everything here except the month.

Alternatives considered

  • Reading the clock and adding a second seam beside TimeAxis. Two doors is one more than ADR-0203 allows, and the argument for one door does not weaken when a second widget wants it.
  • A FocusScope over the cells. Priced above: forty-two Tab stops.
  • Dipping the grid to transparent instead of cross-fading. Cheaper, and it reads as the popover blinking.
  • A Validator<Object> on Field. A breaking change to a shipped API, to express something composition already expresses.
  • A time-picker in the same change. It shares the field, the popover, the gates and the revert, and shares nothing with the grid. It is a smaller piece of work now than it was this morning, and a separate one.

275. A wheel is a column that wraps

Date: 2026-09-06

Status

Accepted. Builds docs/core-widgets.md §4’s time-picker, and fixes the date-picker popover width reported against ADR-0274.

Context

§4 writes date-picker and time-picker in one entry, and they differ in one thing: what is inside the popover. A date’s is §10’s calendar; a time’s is “an hour/minute/second column set”, which §10 does not specify and §2 does not give metrics for — its row is field = text-input; popup radius 12, padding 8; day cell 32 square, and every number on it is the date half.

The width bug, and why it was the wrong rule borrowed

date-picker opened its calendar with the field’s width as the popup’s minimum. That is select’s rule and ADR-0145’s argument — “a list narrower than the control it hangs off reads as a mistake” — and it is wrong here for a reason the argument does not survive: a list’s rows stretch and a grid’s cells do not. A month is seven cells of --gb-calendar-day and can be no other width, so a floor produces a panel as wide as the field with the grid stranded at one end of it. On the showcase’s Forms screen the field is 350 points wide and the grid is 224, which is what “the popover width is full” looks like.

Both pickers now ask for no minimum, and both say so with a named constant rather than a bare zero, because the interesting thing about the number is that it is a different answer from select’s to the same question.

The wheels

A wheel and not a scrolling list. Sixty minutes in a viewport is the other answer and it costs a scroll per column, a ScrollController per column and a Located cell to reveal — a popover whose height depends on how much of a list it decided to show, and a first frame that has to scroll before it is right. Instead: five rows centred on the value, wrapping at both ends, which is what every platform’s own time picker does. The popover is one height always; there is nothing to scroll into view because the value is already in the middle; and 23 → 00 is one press rather than a journey back up sixty rows.

The wrap is what makes the quiet neighbours honest. A column showing 58 59 00 01 02 is telling the truth about what comes next, where a list clamped at 59 would stop.

The arrows split by axis, and that is the difference from a calendar. A calendar is a grid and all four arrows move a cell. A column set is a row of independent wheels, so Up/Down change a value and Left/Right change which column — which is why §4 could give the two pickers the same sentence (“arrows move within the grid”) and mean different things by it. Home/End are the ends of the column, because a row of three is two presses wide already.

The wheels report on every turn, where a calendar’s roving day reports nothing until Enter. There is no “not yet” state for an hour: the columns always show some time, and a user turning one is changing the value they can see. It is also what keeps the field in step, since §4 puts the field in charge and a field that only caught up on Enter would show a stale time beside a wheel showing the real one. Enter therefore commits what is already committed, which is not a no-op — it is what closes the popover, and a control with no keyboard way to say “done” is one a keyboard cannot finish with.

selected is a class and not :checked. A chosen date is one of a set a user picked from, which is what :checked says everywhere else in this catalog. An hour is one digit of one value: a column always has exactly one, nobody chose it, and it changes when its neighbour does not.

Three pickers, one control

PickerField, PickerToggle and PickerPanel moved into …form.parts — the package that already exists for a part more than one field-shaped widget needs, and which is public-within-module and not exported, so the only callers are the packages in this module that build one.

PickerField answers cssType() with what it was given, which is the one unusual thing here: everything else in the catalog answers with a literal. The three pickers are one kind of thing that a stylesheet has to tell apart — §2 gives date-picker/time-picker one row and color-picker another — so a shared picker type would make those rows unwriteable. It is not a hole in ADR-0065’s rule: a part is styleable and not constructible, and this is neither a part nor constructible by an application.

What a sweep caught

WidgetWitherTest failed on TimePicker.precision(its own value). The wither was rebuilding the format as well, so that a picker growing a seconds column got a field that could show one — and two TimeFormats built the same way are unequal, because a DateTimeFormatter has no value equality.

The fix is better than the thing it replaced: format is null until somebody sets it, and resolvedFormat() derives the locale’s default for the current precision. The format still follows the precision, and it follows because nobody pinned it rather than because a wither reached over and rewrote it. format(null) puts it back, which is what lets that wither take its own value too.

Consequences

  • --gb-time-cell-height is --gb-list-row-height and the column width is its own. Two digits in a 32-tall box would be square, and three columns of squares reads as a calculator. design-system.md §2 gained a row, as calendar-header did, rather than the metrics being undocumented.
  • min/max do not wrap, and the constructor refuses a pair that would. A shift from 22:00 to 06:00 is two ranges, and a picker that let a bound wrap would have no way to say which of the two a time at 03:00 was in. An application with a night shift supplies a predicate, which can say it.
  • The default format differs by precision, which is the one place TimeFormat writes a pattern: FormatStyle.SHORT on a time is never seconds, so a picker with a seconds column would have a field that cannot show one, and no FormatStyle produces a time with seconds and without a zone.
  • value(String) was missing from both pickers and is there now. The text, not a typed value, because that is what these controls hold.
  • §4 has one widget left, color-picker, and it is the third user of PickerField.

Alternatives considered

  • Scrolling columns. Priced above.
  • Keeping the field-width floor and centring the grid in it. A popover twice as wide as its content, with the anchor edge meaning nothing.
  • TimePrecision.columns() as ordinal() + 1. Error Prone refuses it and is right: an ordinal is a declaration order, and a constant reordered here would silently change how many wheels a picker draws.
  • A separate TimePickerBox. A hundred and twenty lines that would have to stay identical to date-picker’s by hand, for one string.

276. A plane is HSV, and the hex is the value

Date: 2026-09-06

Status

Accepted. Builds docs/core-widgets.md §4’s color-picker, which completes §4.

Context

§4’s entry is unusually specific about the model:

Value is the toolkit’s CssColor, so a picked colour is directly usable in a stylesheet; the model carries OKLCH internally because that is what §1.7 interpolates in and what the ramp utility uses, and round-trips to hex without drift.

and, one clause earlier, equally specific about the shape:

a swatch button opening a popover with a saturation/value plane, a hue slider, an optional alpha slider, a hex text-input, and an application-supplied palette of preset swatches.

Those two sentences want different things, and building the control is what makes that visible.

The model is HSV, which is a departure

A saturation/value plane is HSV. The axes are S and V; that is what the words mean and what the picture is.

OKLCH’s axes are lightness, chroma and hue, and its chroma has a gamut boundary that varies with both of the others — the most saturated blue an sRGB screen can show is a long way from the most saturated yellow. A rectangular plane over OKLCH therefore has large unreachable regions: corners that clamp to something else, a cursor that cannot be put where it was clicked, and a colour that changes when the hue slider moves under a stationary cursor. A picker whose plane lies about where its colours are is worse than one that interpolates in the wrong space — and this control interpolates nothing. Dragging is not a transition.

The sentence’s stated reason survives intact somewhere else, which is the test that it was a good reason for the wrong thing: “that is what §1.7 interpolates in” is about transitions, and every colour transition in the toolkit still goes through Oklch. A colour picked here is an ordinary 0xAARRGGBB and fades like any other.

“Round-trips to hex without drift” is kept and asserted: 4,096 colours across the cube, plus every 24-bit corner and six alphas, all exact through sRGB → HSV → sRGB.

Two pieces of state, and the second one is the point

§4 makes the hex field the source of truth — “for the same reason the date field is” — and it is: ColorPickerState holds text, and the plane, the ramps and the presets write hex into it exactly as a user would, so a value takes one path and is parsed in one place.

But the plane’s cursor and the hue ramp cannot be derived from a colour, because the conversion is lossy in exactly the place a user drags to: every colour with s == 0 is a grey and has no hue, and black has neither hue nor saturation. A picker that re-derived HSV each frame would swing the hue slider to red the moment somebody dragged to the left edge, and lose the hue altogether at the bottom.

So dragging is the editing state — TextEdit’s arrangement one level up — and HsvColor.withArgb is the rule that keeps the two honest: a colour with no hue of its own keeps the one being dragged. That is CSS Color 4’s powerless-hue rule, which Oklch already applies for the same reason.

The closed control is a swatch, not a field

Which is the one place this picker’s chrome departs from the other two. §4 says “a swatch button”, so it is one: focusable, Role.BUTTON, Space or Enter opens the popover, and its accessible name is the hex — which is the one half of “the hex as its value text” there is anywhere to put.

The presets are not focusable, and that is a decision rather than an omission: a palette of twelve colours would be twelve Tab stops inside a popover, and §4 gives them no roving mechanism to be one stop with. The keyboard’s route to any colour is the hex field, which is the source of truth anyway.

Painted, not styled

Everything this control draws is a picture of what a value means, and §8’s subset cannot express any of it: the plane is a hue with two gradients over it, the hue ramp is six stops round the wheel, the alpha ramp is a chequerboard under a fade. BlendGradient is a fill style the painter has and not something a stylesheet can ask for (ADR-0207), so these are canvas boxes with drags on them.

  • Three fills and no per-pixel loop. A 200×160 plane is 32,000 pixels, and computing each of them in Java once a frame is a colour picker that makes the frame budget its problem.
  • White then black, and not the other way. Saturation is a wash towards white and value a wash towards black; black over a half-washed white is the colour at that corner, where white over black is grey. This is the kind of wrong that a picture catches and no assertion does, which is why there are five goldens.
  • The plane’s cursor picks black or white from the colour under it, because it is the one mark on this control that has to be visible over everything it can sit on. A ramp’s thumb crosses colours it cannot choose between, so it is white with a black edge instead.
  • The alpha ramp is drawn over a chequerboard, or its transparent end is the popover’s own surface and the slider says nothing about what transparent looks like.

alpha=#false refuses in both directions

§4: “hides the alpha slider and refuses translucent values”. The second half matters as much as the first: a picker with no way to change alpha must not report one, or a bind= carrying #88c0d080 leaves the control showing a colour it cannot express and a form holding one nobody chose. ColorPicker.gate is the one place that is asked — by the field that parsed a colour, by the ramps before they move it, and by a preset before it is offered.

Consequences

  • §4 is complete. text-input, text-area, field, form, the validation model, autocomplete, code-input and all three pickers.
  • SemanticsSweepTest asked what a plane is, and the honest answer is Role.SLIDER — a control whose value you move continuously — with a note that it has two axes and neither this enum nor ARIA has a word for that. GROUP says “a boundary with content in it” and this has none; GRID promises cells addressed by row and column, which is the one thing a continuous plane is not.
  • A fourth widget joins the M5 semantics entry. A plane cannot say which colour its cursor is on, for the same reason a code-input cannot say what it holds.
  • #88c0 is a colour, which a test found by asserting it was rubbish: it is CSS’s four-digit #rgba form. The picker takes whatever CssColor.parse takes, because one that second-guessed the engine would refuse text a stylesheet accepts.
  • The affordance’s placement is scoped now. picker-toggle was absolutely positioned over its field’s right padding, which is right for the two pickers that have a field and lands on top of a 24-point swatch for the one that does not. Found in the first picture of the closed control.

Alternatives considered

  • An OKLCH plane. Priced above: unreachable corners and a cursor that cannot be put where it was clicked.
  • Deriving HSV from the hex each frame. The hue vanishes at the left edge and at the bottom, which is where people drag.
  • A per-pixel plane. 32,000 pixels a frame in Java.
  • Focusable presets. Twelve Tab stops in a popover, with no roving mechanism specified to make them one.
  • Role.GRID for the plane. It promises cells addressed by row and column.

277. A path is a value, and the rasterizer’s is package-private

Date: 2026-09-12

Status

Accepted. Opens the work that closes docs/gaps.md G1, G2 and G10, and the first of four steps toward :natives exporting to :core and to nobody else.

Context

docs/ARCHITECTURE.md §3.1 states one boundary rule and ExportedSurfaceTest enforces it: a raw MemorySegment never leaves :natives. That rule is kept. There is a second rule nobody wrote down — no :natives type appears in an application-facing signature — and it is broken in two families.

The one that was noticed is drawing. Frame could fill a rectangle and nothing else without a BlendPath:

public void fillPath(double x, double y, BlendPath path, int argb);
public void fillPath(double x, double y, BlendPath path, BlendGradient gradient);
public void strokePath(double x, double y, BlendPath path, double width,
                       BlendStrokeCap cap, BlendStrokeJoin join, int argb);

So an ellipse, a polyline or an arrowhead forced a caller into :natives. Five widgets in this repository did it — Sparkline, DonutSurface, ChartSurface, ColorRamp, ColorPlane — and so did the first application built on the toolkit, which filed it as G1. paint.Arc and paint.RoundRect were already in :core, typed on BlendPath, because :core had no path type of its own to hang them on.

The one that was not noticed is layout, and it is larger: paint.Box and css.ComputedStyle each carry thirteen Yoga-typed components, and Box is what every custom widget returns from render(). That is ADR-0279’s, and it is named here because it is why this ADR is the first of four rather than the whole answer.

Decision

A Path is an immutable value in io.github.digitalsmile.goldberry.paint, built from two parallel arrays — a verb per segment, and the coordinates those verbs consume. Beside it: Stroke (width, Cap, Join, miter limit, Dash) and a sealed Gradient. None of them mentions a :natives type in public.

The seam is one package-private method:

void replayInto(BlendPath path);

Path and Frame are in the same package, so the one place the two models meet is invisible from outside it. An application holds a Path; nothing it can name holds a BlendPath.

Replaying is cheaper than what it replaces, which is the argument for it

The obvious objection is that a Path is a second geometry model over the same rasterizer, and that building one and then replaying it must cost more than building the native path directly. It costs less, because of what the two modules were already doing.

A BlendPath is a confined Arena and a bl_path_init. :core knew this and worked around it: BoxPainter pools one path per paint walk and reset()s it between shapes, threading it through its own public signature —

public static void paintOne(Frame frame, BlendPath path, Box box, ComputedLayout layout);

— with a comment explaining that forty rounded controls would otherwise make eighty arenas a frame. :widgets did not know it. Every path site there is a fresh try (var path = BlendPath.create()) inside the paint call: four in ChartSurface, two each in ColorRamp, ColorPlane and Sparkline, one in DonutSurface.

So Frame owns the scratch path now. Building a Path allocates two Java arrays and no native memory at all, the pooling nobody remembered to do happens once in the place that can see every drawing call, and paintOne loses a parameter that was only ever there to carry the workaround.

What it measured

./gradlew :core:benchmark, same machine, before and after the whole of phase 1 (the value types, the retyped Frame, and the five migrated widgets). The showcase frame at 960x640:

beforeafter
frame, 0 threads (median)0.957 ms0.966 ms
frame, 4 threads (median)0.760 ms0.751 ms
three stroked icons add0.206 ms0.106 ms

The frame is unchanged inside the run-to-run spread, which is what was wanted: BoxPainter already pooled its path and still does, through the frame instead of through its own signature.

The icons halved, and that is the pooling arriving somewhere it had not been. An Icon used to hold a BlendPath and stroke it directly; it holds a Path now and is replayed into the frame’s pooled one — so the three icons in that scene no longer each carry a native allocation through the frame. The same change is waiting for every chart: ChartSurface opened four confined Arenas per paint and now opens none.

No number here is a test. PaintBenchmark asserts no timing, deliberately — “a timing assertion on shared CI hardware fails for reasons that have nothing to do with the code” — so these are evidence recorded where they can be argued with.

Two arrays rather than a list of records

Path.Segment is a sealed interface of six records, and segments() returns a list of them — but that is the readable view, built on demand, and drawing does not use it. Replay is a loop over primitives with nothing boxed, which matters because a smoothed chart line is hundreds of segments and replay happens once per shape per frame.

The list is not decoration. It is what a test asserts on, and it is what an application writing a board out as SVG needs: the d attribute is a switch over exactly those six records, and a path it cannot read is a path it cannot serialize. Sealing means that switch needs no default branch.

An arc’s two boolean flags live in the verb byte rather than as two doubles holding 0 or 1 — five coordinates, not seven.

Dash is a type because Stroke is a record

A double[] component would give a record whose equals compares array identity, so two strokes written the same way would be unequal — and a widget caching “have I already drawn this?” would cache nothing, silently, with no symptom but a frame time. Dash holds a List<Double>, which boxes two to four numbers per stroke and makes the enclosing record behave like a value.

SVG’s odd-length rule is applied in the constructor rather than left to the rasterizer, so pattern() reads back what will actually be drawn: Dash.of(5) is [5, 5].

What is deliberately not here

  • The colour. It stays an argument to the drawing call, like every other fill in the toolkit, so that one style and eight series colours is one Stroke and eight ints.
  • Radial gradients. Gradient is sealed with one implementation because BlendGradient binds one. Sealing is what makes adding Radial later a record and an exhaustive switch that stops compiling, rather than a default branch that quietly draws the wrong thing.
  • An origin, which docs/gaps.md G1 did not propose. fillPath(x, y, Path, …) and strokePath(x, y, Path, …) are kept beside the plain forms: the origin moves the shape without transforming the frame, which is what lets one 24x24 icon path be drawn at several places without being rebuilt or bracketed in save/restore. Icon.draw and a chart’s readout are both that case.

Consequences

  • Join has a MITER the rasterizer’s enum does not. Blend2D has three miter variants differing only in what happens past the limit; SVG and CSS have one miter and a separate number. The toolkit takes SVG’s shape, and Stroke.miterLimit is where the number goes — which means Join cannot be translated by ordinal and needs a real switch. That is the general cost of owning a vocabulary rather than re-exporting one, and it is the point.
  • Cap and Join carry no wire values. The C numbering is not alphabetical — round is 2 for a cap and 4 for a join — and that ordering stays in :natives, checked against the compiled library where it belongs.
  • paint.Path collides with java.nio.file.Path by simple name. A single-type import shadows a same-package type, so a class in paint that needs the file one can still import it; everywhere else it is an import like any other. G4’s proposed Image.decode(Path file) will have to be written with that in mind.
  • A non-finite coordinate throws where it is written. Blend2D accepts a NaN and fills nothing, which is indistinguishable from arithmetic that went wrong three methods earlier. This moves that failure to the line that caused it.
  • Arc and RoundRect have one implementation between them and Path. The KAPPA arithmetic moved rather than being copied, so ADR-0050’s tolerance argument and ADR-0064’s no-new-symbols argument both still hold, unchanged.
  • No golden image may move. The point sequences are the ones RoundRect and Arc emitted before, asserted in PathTest rather than assumed. A diff out of blessGoldens during this phase is a bug in the value type, not a picture to re-record.

Alternatives considered

  • Keep the BlendPath overloads public as an escape hatch. Then the module can never be sealed, and the rule stays a convention that a test cannot check. The overloads are package-private instead; :core’s own painters keep using them.
  • Memoize a BlendPath inside each Path. It removes the replay, and it gives an immutable value a native resource with a thread affinity and no close() — a lifetime problem in a type whose whole appeal is not having one.
  • List<Segment> as the storage. One object per segment, hundreds per chart line per frame. The list is the view, not the model.
  • A float-based path. brd is a board with a viewport transform over it, and Frame’s coordinates are already double. Narrowing in the middle would be a precision cliff nobody could see coming.
  • Deprecate rather than remove. 0.1 is an M5 item and the only consumers are :example and brd, both in hand. A deprecation cycle here would buy nothing and postpone the seal.

278. A dash is Goldberry’s arithmetic, and not the rasterizer’s

Date: 2026-09-12

Status

Accepted. Closes docs/gaps.md G2’s arithmetic half, and follows ADR-0277, whose Stroke could describe a dash and not draw one.

Context

ADR-0277 gave Stroke a Dash and a miter limit, and wired neither: the plan was that this commit would widen the native surface, bl_context_set_stroke_dash_array and its neighbours would be bound, and Frame would then take a Stroke with every field honoured. Six symbols, one CMake change, a probe constant, a four-target CI run — the shape tray-icon paid for eleven symbols in ADR-0191.

That was written, and it does not work.

Blend2D does not implement dashing. It has the API — bl_context_set_stroke_dash_array, bl_context_set_stroke_dash_offset, BLStrokeOptions::dash_array — and the raster context stores what it is given, validates the array’s element type, retains it, releases it, copies it on save and restores it on restore. Then nothing reads it. core/pathstroke.cpp is 988 lines and the word “dash” does not appear in it. Of the eight files in the library that mention dashes, not one is the stroker.

So every call returned BL_SUCCESS and the line came out solid, which is the worst way for a missing feature to present itself: no error, no warning, no signature to inspect, and six tests failing on pixel comparisons that looked like arithmetic mistakes of our own.

Decision

Dashing happens in :core, over paint.Path, before the path reaches the rasterizer. A dashed stroke is a solid stroke of a different path.

Two public classes in a new io.github.digitalsmile.goldberry.paint.geom:

public static Path Flattener.flatten(Path path, double tolerance);
public static Path Dasher.dash(Path path, Dash dash);

Dasher flattens, then walks the result accumulating arc length, emitting a sub-path per on run. A solid pattern returns the input path itself — not a copy — so the drawing path stays free for the overwhelming majority of strokes that are not dashed.

The native surface gains one symbol rather than six: bl_context_set_stroke_miter_limit, which is implemented and which closes a gap BlendStrokeJoin had admitted to in its own javadoc for as long as it has existed — it binds three of Blend2D’s five joins, and the two omitted are the miter-with-a-fallback variants “whose behaviour depends on the miter limit, and nothing binds that yet”.

Flattening is not an optimisation, it is the only way

A dash is a statement about arc length, and the arc length of a cubic Bézier has no closed form. There is no walking four pixels along a curve; there is only walking four pixels along a polyline that approximates it.

The segment count comes from a bound on the curve’s second derivative rather than from its control-polygon length: subdividing into n pieces leaves an error of at most max|B''| / (8n²), so n is that inequality solved. This spends segments where a curve actually bends and gives a nearly-straight cubic the single segment it deserves — which the tests pin by asserting that a cubic with collinear controls comes back as one line.

The tolerance is a tenth of a logical pixel and is not a parameter on the drawing path. Below the rasterizer’s own antialiasing at 1× and a quarter of that at 2×, a caller choosing it would be choosing between two invisible options and one slow one.

Elliptic arcs go through SVG’s endpoint-to-centre conversion (implementation notes F.6.5 and F.6.6), including F.6.6’s rule that radii too small to span the endpoints are scaled up until they exactly do rather than refused.

The two zeros are not the same zero

A zero in a dash pattern means two different things depending on which side of the alternation it falls, and the code has to tell them apart:

  • A zero-length gap does not break the run. 4 0 4 is a solid eight, and stopping there would put two caps in the middle of a dash — visible with a round one. So a zero gap is stepped over and the pen stays down.
  • A zero-length dash is a dot, and is kept. stroke-dasharray="0 4" with a round cap is how a dotted line is written; it is an idiom, not a degenerate case, and skipping it the way the gap is skipped leaves the line blank. What a dot looks like is then the cap’s business, exactly as SVG says.

The first implementation skipped both, and the dotted-line test is what found it.

…and the rasterizer does not draw it

Correction, from the showcase. Dasher emits the zero-length sub-path correctly — DasherTest asserts the coincident points — and Blend2D drops it. A zero-length sub-path contributes no outline, so a round cap on nothing is nothing: Dash.of(0, 8) at width 4 inks zero pixels, measured in DashRenderingTest. The canvas screen in the showcase was drawn with two dotted rings that simply were not there.

So the geometry follows SVG and the picture does not. A dotted line is written with a short dash — Dash.of(1, 7) round-capped is a dot to every eye and is drawn by every rasterizer — and the zero-length case is left as SVG-faithful geometry that this backend declines to ink. Recorded as a test rather than as a sentence, because the failure mode is a line that silently does not appear.

Consequences

  • The export list is five symbols shorter than planned, and the CI risk goes with it. Dashing is the same Java on all four targets, so there is no platform on which it can behave differently — a stronger guarantee than binding would have given, arrived at by accident.
  • :natives grows a public strokeMiterLimit and Join.MITER now means something. The limit is refused below 1, where a miter is shorter than the bevel it would fall back to.
  • A dashed path is flattened, so its curves are gone. A dashed circle is a many-segment polygon. At the tolerance above this is invisible, but it is true, and a caller that dashed a path and then measured its segment count will find a different number than it put in.
  • Closed sub-paths come back open. A dashed ring is arcs with gaps between them; there is nothing left to close, and the join at the start point goes with it — so a dashed rectangle’s corner is drawn by the cap. SVG does the same.
  • The walk stops at the path. A run that would begin exactly where the path ends is not drawn, and a run still open when the path ends is cut there. Stated as a rule because two tests depend on it and it is otherwise the sort of thing each caller rediscovers.
  • The pattern is not reset per sub-path. SVG’s rule: a rectangle drawn as four sides has one dash pattern around it, not four, or every corner is a seam.
  • Dashing costs a path allocation and a walk. It is not free the way setting a rasterizer flag would have been — but the rasterizer flag did not draw anything, so the comparison is with not having the feature.

Alternatives considered

  • Bind the dash API anyway and file the gap upstream. Five exported symbols that provably do nothing, on a list whose whole discipline is that “a symbol here that nothing binds is dead weight”. It would also have shipped a Stroke whose dash field was silently ignored, which ADR-0277 explicitly set out to avoid.
  • Patch Blend2D. The ref is a pinned commit SHA (ADR-0030) precisely so that upstream is a known quantity; carrying a local patch to a statically linked C++ renderer is a different kind of commitment, and the Java is 300 lines.
  • Dash after stroking, by intersecting the outline. A boolean operation against a stroke outline, which needs a path intersector the toolkit does not have and would get the caps wrong at every dash end.
  • Flatten with a fixed segment count. Cheap, and wrong in both directions at once: sixteen segments is wasteful for a rounded corner and visibly polygonal for a full-screen curve.
  • Adaptive subdivision by flatness test. The usual recursive answer, and it allocates per level and recurses to a depth that depends on the input. The second-derivative bound gives the segment count in closed form up front.

279. Flexbox is the toolkit’s vocabulary, not Yoga’s

Date: 2026-09-12

Status

Accepted. Closes docs/gaps.md G13, and is what ADR-0280 needed before the module could be sealed.

Context

ADR-0277 closed the drawing half of a rule nobody had written down — no :natives type appears in an application-facing signature — and named the other half without fixing it. This is that half.

paint.Box and css.ComputedStyle each carried thirteen Yoga-typed record components: StyleLength, Insets, Limits, FlexDirection, Justify, Align, Wrap, PositionType, Overflow. BoxPainter.Placed carried a ComputedLayout. paint.tree.ContainingBlock and widget.style.Corner took and returned Insets. And css.value.CssLength.parse returned StyleLength, so the CSS engine’s own output type was a binding’s.

A Box is what every custom widget returns from render(). So the sanctioned way to write a widget — the one docs/core-widgets.md documents and the showcase demonstrates — meant reading :natives. Nineteen files in :widgets named StyleLength alone; :example named FlexDirection.

docs/gaps.md did not record this, because it measured the leak by what brd imports and brd draws rather than lays out. Its §0 said “exactly one leak, in one file”. That was true of brd and false of the toolkit.

Decision

A io.github.digitalsmile.goldberry.layout package in :core, holding the flexbox vocabulary as plain values: Length (sealed — Points, Percent, Keyword.AUTO, Keyword.UNDEFINED), Insets, Limits, FlexDirection, Justify, Align, Wrap, Position, Overflow, plus Measure, MeasureMode and MeasuredSize for the callback a paragraph answers.

Nothing in it touches foreign memory, and nothing in it carries a wire format. The C enumerators stay in :natives, checked against the compiled library by the layout probe, where they belong.

The translation is one package-private file. paint/tree/Yoga.java, beside RenderObject — whose Yoga-touching members (apply, update, reconcileChildren, node()) were all package-private already. Nothing else in the toolkit needs to know Yoga exists.

ComputedLayout is deleted rather than mirrored

render.model.LogicalRect was already the toolkit’s rectangle — input.hit.HitTest.Region has returned one since hit testing was written — so a ComputedLayout mirror would have been a second four-float rectangle kept alike by hand. BoxPainter.Placed, forEachBox and forEachPlacedBox take LogicalRect, and RenderObject.layout() is where the conversion happens.

That is one fewer type than the plan called for, and it is the only member of the family that had a toolkit-owned counterpart already.

Names travel; numbers do not

The two vocabularies agree on constant names and disagree on numbers. Align.CENTER is 2 and Justify.CENTER is 1 in Yoga’s headers; the stroke enums next door number round as 2 for a cap and 4 for a join. So the translation is an exhaustive switch rather than an ordinal cast — a coincidence relied upon against a pinned C header that a bump could reorder, and Yoga has inserted a constant into the middle of an enum before.

The compiler guarantees those switches are exhaustive. It cannot guarantee each arm names the right counterpart: case CENTER -> Align.FLEX_END compiles and moves every centred row in the toolkit to one end. So YogaTest checks every constant by name, generically, from values() — a constant added to either side is checked the day it appears — and asserts the mapping is injective, since two arms pointing at one constant would otherwise pass. Transposing one arm was tried; the test fails on it.

Consequences

  • :natives’ Yoga packages are sealed, which is the point: exports … to io.github.digitalsmile.goldberry.core, and a scratch module that tries to import StyleLength is refused by javac with “does not export it”.
  • yoga.measure had to go with yoga.style. Its MeasureMode implements YogaEnum, which lives in yoga.style, so qualifying one broke the other’s public surface — found by -Xlint:exports, not by reading. That is why Paragraph.measureFunction() returns a toolkit Measure now.
  • -Xlint:exports under -Werror is the check this needed. The plan called for a PublicSurfaceTest that read :core’s module descriptor and failed on any exported signature naming a :natives type. The compiler already does exactly that, better, and it named the three remaining sites in the text stack the moment requires transitive was removed. The test was not written, because it would have been a worse copy of something already running.
  • Position, not PositionType. CSS calls the property position; the binding’s name is the sort a binding carries. The constants are unchanged, so the name-based test still lines them up.
  • Insets.NONE is new, and is not Insets.ZERO. An inset of zero pins a node to that edge; an undefined one leaves it where flow put it (ADR-0272). Box.of() had spelled that out inline; now the vocabulary carries it.
  • ~1000 references moved, across :core, :widgets, :example and the test source sets — 84 files. Almost all of it is an import line and a simple name.
  • No golden image moved. The values translate to the same Yoga calls in the same order, and 192 :widgets goldens, 13 :example and 8 :core say so.
  • One cost, paid per node per layout: a switch and, for insets, four of them. Against a foreign call each, which is what follows it.

Alternatives considered

  • Re-export the Yoga types from :core under a layout alias. Java has no type alias, and a subclass of an enum is not a thing.
  • Make layout depend on :natives and have the values carry their own wire numbers. That is the binding’s job, it would put a nativeValue() on a type an application reads, and it would make the vocabulary unusable by any future layout engine — which is the thing ADR-0010 says is possible and this makes cheap.
  • Mirror ComputedLayout too, for symmetry. Two rectangles that must agree, when one of them was already the toolkit’s.
  • Translate by ordinal. Priced above: a coincidence, against a pinned header, with no failure mode short of a wrong picture.
  • Leave it and document the convention. It had been documented, in ARCHITECTURE.md §3.1, for as long as the module graph existed — and thirteen components of Box broke it anyway. A convention a compiler does not check is a convention that has already been broken somewhere nobody has looked.

280. :natives exports to :core, and to nobody else

Date: 2026-09-12

Status

Accepted, and partial — deliberately. Yoga’s three packages are sealed; Blend2D’s and HarfBuzz’s are not yet, and the reason is written down below rather than left as an absence.

Context

docs/ARCHITECTURE.md §3.1 states one boundary rule and ExportedSurfaceTest enforces it: a raw MemorySegment never leaves :natives. That rule has held.

There is a second rule, which nobody wrote down: no :natives type appears in a signature an application can read. It was broken in three families, and the reason it could be broken at all is four words in a module descriptor:

:core     requires transitive io.github.digitalsmile.goldberry.natives
:natives  exports …blend2d, …yoga, …harfbuzz   (unqualified)
:widgets  requires transitive io.github.digitalsmile.goldberry.core

An application requiring :widgets therefore read :natives, and did. ADR-0277 closed the drawing family and ADR-0279 the layout one. This is the descriptor change that makes those closures enforced rather than merely achieved.

Decision

exports io.github.digitalsmile.goldberry.natives.yoga to
        io.github.digitalsmile.goldberry.core;
exports io.github.digitalsmile.goldberry.natives.yoga.style to
        io.github.digitalsmile.goldberry.core;
exports io.github.digitalsmile.goldberry.natives.yoga.measure to
        io.github.digitalsmile.goldberry.core;

A module outside :core that names StyleLength is now refused by javac — “package … is declared in module io.github.digitalsmile.goldberry.natives, which does not export it” — which was checked against a scratch compilation rather than assumed.

What is not sealed, and why it is recorded here

blend2d, blend2d.enums, harfbuzz, harfbuzz.enums, the four sdl packages and natives.blend2d’s font types stay unqualified, and :core keeps requires transitive. Three APIs in the text stack still name a :natives type in an exported signature:

namescalled from
Font.shape / Font.draw / Font.widthOfGlyphRun, TextDirectiontext.Paragraph
Paragraph.glyphs()GlyphRunnothing
Frame.drawGlyphsBlendFont, BlendGlyphBuffertext.font.Font

None of the three has a consumer outside :core. Two of them are value-shaped and as mirrorable as the layout family was — GlyphRun is six int[] and nothing else, with no native memory and no lifetime. The third is not: Frame.drawGlyphs takes two native handles, and the question of where the text/paint seam should sit is a design decision rather than a transcription, so it gets its own ADR rather than being improvised at the end of this one.

-Xlint:exports is the test that was going to be written

The plan for this ADR included a PublicSurfaceTest in :core: read the module’s own descriptor, walk every exported package’s public members, fail if any signature names io.github.digitalsmile.goldberry.natives — the shape ExportedSurfaceTest uses for the MemorySegment rule.

It was not written, because the compiler already does it and does it better. With requires transitive removed, -Xlint:exports under -Werror named all eleven remaining sites, by file and line, in one build. A hand-written test would have been a slower copy with its own reflection bugs.

So the enforcement mechanism for this rule is the word transitive: while it is absent the compiler refuses any leak, and the eleven warnings above are the to-do list. It is currently present, and the comment in core/module-info.java says exactly why and what to do about it.

Consequences

  • A qualified export “upward” warns, and -Werror rejects it. :core cannot be on :natives’ compile module path — the dependency runs the other way — so javac reports module not found for every exports … to. The fix is @SuppressWarnings("module") on the module declaration itself, which is narrower than the -Xlint:-module the build file would otherwise have needed: it covers this descriptor’s twelve directives and nothing else in the project.
  • :gpu needed no qualification. It has zero natives references, which was checked rather than assumed.
  • Test source sets are unaffected. They run on the classpath, not the module path, so the descriptor does not bind them — which is right: a test should be able to reach past a boundary to check it. The boundary is enforced where it matters, at main compilation, and by the scratch-module probe.
  • The seal is reversible by one word, and that is the risk: adding transitive back, or an unqualified exports, would reopen it silently. The compiler catches the first of those the moment a leak appears; nothing catches the second, and BoundaryTest in :widgets is where a rule about it would go when the text family lands.

Alternatives considered

  • Seal everything now and mirror the text types in the same change. Two of the three are easy; the third is a seam question, and answering it badly in a hurry would be worse than one more ADR.
  • Leave requires transitive and rely on the arch test alone. A test that scans for imports catches a leak after it is written. The descriptor prevents it from compiling.
  • -Xlint:-module in goldberry.java-conventions.gradle. Switches the lint off for every module in the project to quiet twelve directives in one.
  • An io.github.digitalsmile.goldberry.internal package exported to nothing. Java exports by package and reads by module; a public method of an exported type may not name a type from an unexported one without the same warning. It moves the problem rather than solving it.

281. A canvas hears what it draws on

Date: 2026-09-12

Status

Accepted. Closes docs/gaps.md G3, which was the last now on that list and the one it called “the single biggest blocker: brd’s client is a viewer until it lands”.

Context

G3 asks for “pointer, wheel, keys, capture, cursor” on a canvas and says of the toolkit: “There is no wheel, no drag, no key, no pointer capture, no per-widget cursor.”

That is not true, and finding out it is not true is most of this decision.

Handles already declares onPointer, onPointerCapture, onKey, onText, isFocusable and the focus notifications. PointerEvent already carries the button, the click count, the modifier keys, the wheel’s fraction and its detents, and where the gesture started. PointerRouter already captures the pointer implicitly on press and releases it on the matching release, so a drag that leaves a widget keeps arriving — WheelAndCaptureTest has asserted that since the scrollbars were built. Box already carries a Cursor, and the hit-test snapshot already records it per region.

What was missing is that Canvas implemented none of it. It was Widget.Leaf, Styled, Paints, Attributed and not Handles, so a painter was handed a frame and no events. The gap was one widget’s declaration, not a subsystem.

Decision

Canvas implements Handles and takes an Input beside its Painter:

new Canvas(painter, event -> …)          // or an Input with onKey, onText

Input is Painter’s sibling and hands over the toolkit’s own PointerEvent and KeyEvent unchanged. A CanvasPointerEvent re-expressing the same facts would be a second vocabulary to keep in step, and the existing one already carries everything a board tool needs.

A canvas is focusable exactly when it has an Input that wants to be. A chart that took a Tab stop and did nothing with it would be a keyboard trap with no exit, which is what §2.2’s “everything reachable” is least served by.

The one thing that had to be built is the coordinate space

ADR-0193 made a canvas painter draw inside the padding and clipped it there, so that canvas { padding: 8px } is a framed drawing surface rather than a surprise. The hit-test snapshot recorded only the border box.

So a canvas reading PointerEvent.local() would have had every event offset from its own ink by exactly the padding — a press eight pixels from where it was drawn, silently, on the one widget whose entire job is to be drawn on. The painting decision and the input decision would have disagreed, and the disagreement is invisible in both signatures.

HitTest.Region therefore records a content rectangle beside its own, and PointerEvent.content() reports the pointer inside it. For a box with no padding — which is most boxes — the two are the same rectangle and this costs nothing.

There is one implementation of the arithmetic. Length.resolve(length, base) moved onto the layout vocabulary precisely so that the painter and the snapshot cannot drift: BoxPainter.paintCanvas and HitTest.collect now call the same method rather than keeping two copies of a switch that must agree.

A consequence worth stating: percentage padding resolves per axis — left against the width, top against the height. CSS resolves all four against the containing block’s inline size. That divergence is BoxPainter’s and predates this; what matters here is that input and paint agree, and they cannot fail to.

What did not need building

  • Capture. The router captures on press for every widget. G3’s sketched .capturePointer() is already the default, and a marquee dragged off the edge needs to ask for nothing.
  • The wheel. A PointerEvent of kind WHEEL, with deltaY() for a touchpad’s fraction and ticksY() for a mouse’s detents.
  • The cursor. canvas { cursor: crosshair } works like it does on any box, and the snapshot records it because the style that decided it is gone by the next frame.
  • Consuming. event.consume() is how a zoomable board stops the wheel scrolling the pane it sits in.

Consequences

  • A canvas is a Role.FIGURE — “a picture of data, which a reader reaches with the keyboard”. SemanticsSweepTest caught the omission the moment the widget became focusable, which is the invariant working: every focusable widget must say what it is. The name is the application’s, through Input.accessibleName(), because the toolkit knows only that something was drawn.
  • Canvas gained a record component, so its canonical constructor changed. The two existing shapes — new Canvas(painter) and new Canvas(painter, attributes) — are kept.
  • PointerEvent gained content(), which every widget now carries and almost none reads. It is four floats set per handler beside local(), which the router was already computing.
  • Markup still names neither a painter nor an input. Both are Java, and the registry indirection a document would need is filed rather than guessed at (ADR-0043).
  • In-canvas text editing is still G6. Input.onText delivers committed text; a caret, a selection and preedit are a different piece of work.

Alternatives considered

  • Fluent .onPointerDown(…) per kind, as G3 sketched. A canvas tool is a state machine over a sequence of events — press, drag, release — and five callbacks that have to share state between them is five closures over the same mutable object. One method and a switch on kind() is what a tool actually writes.
  • A CanvasPointerEvent in canvas coordinates. A second vocabulary over the same facts, and it would have had to grow a field every time PointerEvent did.
  • Report positions in border-box coordinates and document the offset. The exact surprise ADR-0193 refused for painting, reintroduced for input.
  • Make the canvas’s hit region its content box. Then a click on the padding would miss the canvas entirely — but the canvas’s background is painted on the border box, so the pointer would fall through something the user can see.
  • A sibling input-canvas widget. Two widgets that must be styled alike and kept alike, so that one of them can be the one that listens.

282. A shaped run is a value, and the last leak is one method

Date: 2026-09-12

Status

Accepted, and the seal is one method short — stated here rather than left as an absence. Closes most of docs/gaps.md G14.

Context

ADR-0280 sealed Yoga’s three packages and recorded that -Xlint:exports under -Werror, with requires transitive removed, names every remaining leak by file and line. It named eleven sites in three APIs, all in the text stack, none with a consumer outside :core.

Decision

GlyphRun and TextDirection are mirrored into :core as text.ShapedRun and text.TextDirection, and HarfBuzz’s two packages are sealed:

exports io.github.digitalsmile.goldberry.natives.harfbuzz to
        io.github.digitalsmile.goldberry.core;
exports io.github.digitalsmile.goldberry.natives.harfbuzz.enums to
        io.github.digitalsmile.goldberry.core;

Checked by compiling a module that imports GlyphRun and watching javac refuse it, the same way ADR-0280’s Yoga seal was checked.

ShapedRun is six int[] and nothing else — no foreign memory, no lifetime, nothing to close. Font.shape copies out of the shaper’s run in one pass; the copy is a doubling of something that happens once per text change rather than once per frame, and the alternative is handing an application a :natives class.

TextDirection keeps only LTR and RTL. The shaper’s vertical directions are deliberately absent: nothing in the toolkit lays out a vertical line, and an enumerator that can be named and would then be dropped downstream is worse than one that cannot be named.

What is left, and why it is a decision rather than a transcription

One method:

public void drawGlyphs(double x, double baseline, BlendFont font, BlendGlyphBuffer glyphs, int argb);

Font.draw is its only caller. It stages a run’s glyphs into a buffer it owns and hands both to the frame.

The other two leaks were values, and a value can be mirrored. These are handles, and the difficulty is not transcription but ownership: rasterizing a glyph needs a context, a font and a staged buffer; paint owns the first and text.font owns the other two. One of them has to cross, and within a single module Java offers nothing between package-private and public — so whichever crosses, crosses in a signature an application can read.

Three ways out, none of them free:

  1. Move the native font into paint. paint gains a pen that owns the BlendFont and the buffer; text.font becomes shaping and metrics, and Frame.drawGlyphs goes package-private. The cleanest end state, and it moves font creation — which today is FontFace’s, and which the fallback chain and the paragraph cache are both built on.
  2. Wrap the handle in an exported opaque type. Does not work: a public method of an exported type may not name a type from a package that is not exported either, so the wrapper has to be exported and then its accessor cannot be package-private. It relocates the warning.
  3. Leave it. One documented method, blend2d stays exported, and :core keeps requires transitive.

This ADR takes (3) for now and records (1) as the answer, because moving font ownership is a change to the text stack’s shape and deserves its own decision rather than being improvised at the end of a sealing pass.

Consequences

  • Three of :natives’ five families are sealed — yoga, yoga.style, yoga.measure, harfbuzz, harfbuzz.enums. blend2d and sdl are not: blend2d for the method above, and sdl because nothing has looked at it yet.
  • :core still declares requires transitive, and the comment in its descriptor says which method is why. Removing the word is how the remaining work is enumerated; it costs one build.
  • ShapedRun is not a record, deliberately: a record over six arrays hands them out through its accessors for anyone to write into, and a shaped run is a value a paragraph cache returns repeatedly. ShapedRun.of copies.
  • Paragraph.glyphs() and Font.widthOf still have no callers. They were public before and stay public; deleting unused API is a separate question from sealing a module, and answering both at once would have hidden one in the other.
  • No golden image moved. The shaping is identical; only the type the result is carried in changed.

Alternatives considered

  • Mirror the handles as well. They are not values. A BlendFont is a native allocation with a thread and a lifetime, and a “mirror” of it is a wrapper — which is option (2), which does not work.
  • Delete Frame.drawGlyphs and inline it into Font. Font would need the BlendContext, which is the same crossing in the other direction and a worse one: the context is the frame’s most dangerous object.
  • Keep GlyphRun and seal nothing. The two mirrors cost one copy per text change and bought a whole family. Waiting for the third would have meant shipping neither.

283. An image is a value, and the decoder is the one thing Blend2D allocates

Date: 2026-09-12

Status

Accepted. Closes docs/gaps.md G4, and unblocks G5 (offscreen render and PNG encode) and G7 (clipboard beyond text), both of which named a type that did not exist.

Context

G4: “Frame can composite a Goldberry Layer and nothing else. BlendImage is in :natives and exposes no decode or encode entry point at all, so there is no supported way to turn PNG bytes into something drawable.”

All of that was true. What it did not say is that the ingredients were already compiled in: CMakeLists.txt builds Blend2D with its built-in PNG, JPEG and QOI codecs, and has since M0. The export list simply never named them, because nothing had asked.

So the shape of this decision is not “how do we decode an image” but “who owns the pixels afterwards”, and the answer comes from the two leaks closed before it. ADR-0277 and ADR-0282 both ran into the same wall from opposite sides: a value can be mirrored into the toolkit’s own vocabulary, and a handle cannot, because someone has to own it and say when it dies. Path and ShapedRun became values and the leak closed; Frame.drawGlyphs is still open precisely because a font and a glyph buffer are handles.

An image could have been either.

Decision

An image is a value

package io.github.digitalsmile.goldberry.image;

public final class Image {
    public static Image decode(byte[] bytes);       // PNG, JPEG, QOI
    public static Image decode(ByteBuffer bytes);
    public static Image decode(Path file);
    public static Image ofArgb(int width, int height, int[] argb);

    public PhysicalSize size();
    public PhysicalRect bounds();
    public int argb(int x, int y);
    public PixelBuffer pixels();                    // read-only
    public byte[] encodePng();
}

Not AutoCloseable, which is the part G4’s sketch assumed it would have to be. Decoding allocates through Blend2D, copies the result into a PixelBuffer Java owns, and destroys the handle before decode returns. The pixels are a direct ByteBuffer and the collector owns them, like every other buffer here.

The cost is one copy per decode. What it buys is an image that can go in a field, a record, a cache or a document model with no lifetime travelling beside it — which is what the showcase does, and what an application holding a board’s worth of images would otherwise have had to get right.

It is its own package, not a class in paint

G4 proposed paint.Image. Three parts of the toolkit want this value and only one of them draws: a frame draws one, an offscreen render produces one (G5), a clipboard carries one (G7). paint already depends on render, so paint.Image would have made render.Clipboard depend on the paint package in order to name the thing it holds. io.github.digitalsmile.goldberry.image is the neutral home, and image.png beside it holds the encoder for the reason paint.geom is its own package: it is an algorithm over a value, testable without an image in front of it.

Drawing it is Frame’s, in four overloads

frame.drawImage(image, x, y);                                   // natural size
frame.drawImage(image, x, y, width, height);
frame.drawImage(image, x, y, width, height, alpha);
frame.drawImage(image, source, x, y, width, height, alpha);     // the crop

Natural size means one image pixel per device pixel, not per logical unit: a 96×64 image covers 96 logical points at 100% and 48 at 200%, and is crisp at both. That is ADR-0157’s arithmetic — the bug that drew every faded subtree at twice its size on a Mac — applied before it could happen again, and the scale sweep over the showcase golden is what says it holds.

The crop’s source rectangle is render.model.PhysicalRect, a new four-int rectangle beside PhysicalSize. Two coordinate spaces meet in that overload and they are deliberately unrelated: the source is which pixels, in the image’s own grid, and the destination is where they go, in logical units. Relating them would decide for the caller whether a crop is stretched or shown at size.

Decode is Blend2D’s; encode is java.base’s

The export list gains three symbols — bl_image_init, bl_image_read_from_data and bl_image_convert — and not one more.

Decoding is Blend2D’s because a JPEG decoder is thousands of lines nobody should write twice. Encoding a PNG is a Deflater, which is already in java.base, wrapped in four chunks and a CRC — so image.png.PngEncoder is 150 lines of Java and bl_image_write_to_data, bl_image_codec_init_by_name, bl_image_codec_destroy and the four bl_array_* calls an encode would have needed never cross the boundary at all.

That is ADR-0278’s reasoning a second time. A dash was Goldberry’s arithmetic rather than the rasterizer’s; so is a chunked, deflated byte stream. It also means the offscreen render this unblocks can turn a frame into a PNG with no native call in the encode.

The decoder is the one exception to “Blend2D never allocates our pixels”

ADR-0031 established the rule and BlendImage’s javadoc states it: Goldberry hands Blend2D the buffer, never the other way round, which is why the only constructor bound was the external-data one.

A decoder cannot work that way. The size of a PNG is inside the PNG, so nothing on the Java side could have allocated before the decoder said how much. BlendDecodedImage is where the rule bends, and it bends for exactly the length of one try block: init, read, convert to premultiplied BGRA, copy the rows out, destroy. Nothing outside that class ever holds a Blend2D allocation, and Image.decode is the only caller.

Converting at decode rather than asking at the blit matters more than it looks: a PNG with no alpha channel decodes to XRGB32, whose alpha byte is undefined rather than opaque. An unconverted image blits as whatever that byte happened to be, which on the test image was invisible.

A failed decode is ImageDecodeException

Bytes that are not an image are a normal branch — a pasted screenshot, a dropped file, a field written by an older version of an application — so it is catchable, and the type caught must be Goldberry’s. An application catching BlendException would be the :natives boundary leaking through a catch clause instead of a signature, which is the whole subject of ADR-0280. The rasterizer’s report is kept as the cause. A file that cannot be read is a different question and is UncheckedIOException.

Consequences

  • The export list is three symbols longer, and Layouts has a BL_RECT_I row — the first BLRectI to cross in either direction, for the crop’s img_area. The C probe had reported that struct since M0, so the row was verified on all four targets before any Java named it.
  • Blend2dImage.ImageData carries the size now, because an image the decoder filled in was never told one.
  • Image.pixels() hands out a read-only PixelBuffer, and blitting from it works: a read-only direct buffer still has an address, and Blend2D only reads. The alternative was handing out the writable buffer and asking callers not to write to it, which makes “an image is a value” a convention rather than a fact.
  • ofArgb is unpremultiplied and so is argb(x, y), matching every other colour in the toolkit. The round trip is exact for opaque and fully transparent pixels and within a level otherwise — a property of premultiplied storage, not of the methods.
  • The showcase’s Canvas screen has a fourth card drawing one decoded PNG four ways from a single static field, which is the claim “an image is a value” made visible. Its golden moved; nothing else’s did.
  • The golden harness keeps its own PNG writer in core/src/testFixtures rather than calling the shipped one. A golden image written and read by the code under test proves nothing about either half; the harness’s reader is the independent check on the encoder, and PngEncoderTest is the round trip between them.
  • No widget draws an image yet. An img widget — with object-fit, a loading state and a cache — is a catalogue entry and is not this. What exists is the primitive, and a canvas is how an application uses it today.
  • Animated formats are absent, not forgotten. bl_image_read_from_data decodes one frame; an APNG or a GIF is a sequence and a clock, and would be its own decision.

Alternatives considered

  • Image as a handle over a BLImage. No copy per decode, and every image in an application acquires a lifetime: closed twice, closed too early, or held open by a document that outlives the window. ADR-0282 is the record of how much harder a handle is than a value, and nothing about an image requires it.
  • Encode through Blend2D’s codecs too. One mechanism instead of two, and JPEG and QOI encoding for free — at the cost of seven more exported symbols, a BLArray and a BLImageCodec object family in :natives, to write bytes java.util.zip already writes. If JPEG encoding is ever wanted, that is a decision with a reason behind it rather than a side effect of this one.
  • Promote the golden harness’s PNG writer instead of writing a second one. It would have removed a duplication and removed the harness’s independence with it — see the consequence above.
  • Decode lazily inside the first draw. Then a decode failure surfaces during a paint pass, where there is nothing sensible to do with it, on a thread that is trying to hit a frame budget.
  • Let the crop clamp silently, as Blend2D does. A source rectangle that runs off the image draws a smaller picture in the wrong place and reports nothing. Crops come from documents edited elsewhere, which is exactly the case that should be told rather than approximated.

284. A picture with no window under it

Date: 2026-09-12

Status

Accepted. Closes docs/gaps.md G5, whose other half — Image.encodePng() — landed with ADR-0283.

Context

G5: “The pieces exist and none of them is a supported entry point: render.PixelBuffer.allocate, Frame.over(PixelBuffer, DisplayScale), a render.backend.headless backend — and the golden-image harness lives in :core’s testFixtures, which is not published to applications.”

That is exactly right, and it understates the problem. The pieces are public; the sequence is not, and the sequence is the part that is hard. Painting a painter into a buffer is four lines. Rendering a widget tree is:

prepare → flush → render → update → capture the regions → advance the clock
→ prepare → flush → render → update → capture the regions
→ prepare → flush → render → update → paint

and every one of those steps is there because leaving it out produced a picture that was wrong in a way nobody would notice for weeks. The only two places that knew it were Launcher, where it is private and interleaved with damage tracking, frame statistics and a window; and GalleryGoldenTest, where it was copied, with thirty lines of comments explaining what each step was protecting against.

A third copy was going to be written by the first application that wanted a server-side preview, and it was going to leave steps out.

Decision

var png = Offscreen.of(1200, 900)
        .stylesheets(Controls.stylesheets(Theme.NORD_DARK))
        .render(new BoardPreview(document))
        .encodePng();

io.github.digitalsmile.goldberry.offscreen.Offscreen, a builder with two terminals: paint(Painter) runs a painter over the whole buffer, and render(Widget) runs the window’s own sequence. Both return an image.Image, so a PNG is one more call and a crop or a composite is Frame.drawImage.

Its own package, not render.Offscreen as G5 proposed. render is the backend SPI — what a platform implements, underneath everything else — and this composes the layers above it: the element tree, the cascade, the render tree, the paint pipeline. A widget renderer inside the backend package would point the toolkit’s own layering at itself. image.Image had the same question and the same answer (ADR-0283).

Three passes, and the third one is not a rounding of the second

render(Widget) lays the tree out twice, drawing nothing, and then builds, lays out and paints it once:

  1. Pass one mounts and measures. A newly mounted element starts no transition, and a clock-driven arrival has no beginning until something reads the clock — so a tree painted here shows every arriving widget at the start of its entrance, which for a message is a banner at zero opacity holding its space and drawing nothing.
  2. The clock advances past the transition duration. It is virtual: a preview that depended on when it was taken would differ between two requests for the same document, and one spinner is enough to make that happen.
  3. Pass two measures again, and the regions from pass one have by now been delivered — Measured, through the router, from the rectangles a laid-out frame produced. That is how a text-area learns how wide it really is and how a masonry learns how tall its columns came out.
  4. The third pass is the picture, and it is a third pass rather than the second one painted because a widget told its size may rebuild in response. Painting pass two photographs every self-arranging widget one move from settled.

The measuring passes rasterize nothing at all, so the cost is one paint and three layouts rather than three of each.

The golden harness is now a consumer of it

GoldenImage renders through Offscreen instead of opening its own frame, and so does the scale sweep beside it. Every golden image in this repository — around a hundred of them, at three scales — is therefore a test of the API an application would use to take the same picture. If Offscreen and a window ever disagree about how a scene is drawn, a golden moves.

GalleryGoldenTest lost its copy of the sequence and its thirty lines of commentary along with it.

What the goldens said when they moved

Nine gallery images changed, and the reason is a bug this found.

The old harness never called ElementTree.flush(). Launcher.paint() calls it on every frame, so a setState triggered by the region feedback — a masonry rearranging its columns when it is told how wide they came out — was applied in a window and never applied in a golden. The images that were committed showed an arrangement the application does not draw.

The evidence is exact: removing only the flush() from Offscreen reproduces all nine old goldens byte for byte, with every other difference between the two implementations still in place. It is that one call.

So the nine images were re-blessed. Card distribution across masonry columns is what changed in them; no colour, no size, no text and no control state did.

Consequences

  • Image.of(PixelBuffer) is new: an image over pixels somebody else rasterized, handed over rather than copied. An offscreen render would otherwise copy a megabyte to say what it had just drawn. The buffer is the caller’s to stop writing to, which is PixelBuffer’s existing doctrine — and asReadOnly() is how it can be made impossible rather than agreed.
  • The teardown order is load-bearing. A frame’s Blend2D context is asynchronous, so end() is what joins the workers — and they are still holding the fonts, paths and layer rasters the tree lent them. Offscreen therefore ends the frame, then closes the render tree, then unmounts. Getting it wrong is not an exception: the first widget whose State owned a Font and closed it in dispose (the showcase’s sticky, ADR-0285) turned a wrong order into a SIGSEGV in Blend2D’s command processor, in a worker thread, intermittently.
  • A font book is opened and closed per render unless one is given. A server rendering many previews should hand over a Fonts and keep it; the javadoc says so at the method that costs it.
  • settle(int) is the one knob on the clock, and there is no way to ask for a system clock. A preview that is not reproducible is not a preview.
  • No Offscreen reuse across calls. Each render builds a fresh element tree and unmounts it, so two renders cannot share state through one and a State’s dispose runs. It also means the builder is not a cache: rendering the same document twice does the work twice.
  • The headless backend is still not involved. It never was: Frame paints into memory, and a backend is about presenting. G5 named it because it is what a reader expects to need — see GoldenImage’s own note, which has said “no window, no compositor and no xvfb” since M1.
  • An animation strip is not supported. One call, one picture: a caller wanting frame 3 of a transition would want to drive the clock between paints, which is a different object with a lifetime — and nothing has asked for it.

Alternatives considered

  • render.Offscreen, as G5 proposed. The layering above; and render is the package an application is least expected to reach into.
  • Extract Launcher‘s loop and have both call it. The right answer on paper and a refactor of the hot path — damage, statistics, the HUD, the frame ring and the models’ refresh are all woven through those forty lines. The duplication is real and is now two copies rather than three, with the goldens holding them together: every one of them goes through Offscreen, and Offscreen does what Launcher does.
  • Paint the second pass and skip the third. One less layout, and every self-arranging widget photographed mid-settle. This is what the first implementation did, and the gallery’s Basic screen is what caught it.
  • A system clock with an option for a virtual one. Backwards: the default has to be the reproducible one, because the caller who does not think about it is the caller writing a cache key.
  • Return a PixelBuffer rather than an Image. It is what the render produces, and it is the type with a borrowed-buffer doctrine attached. Image is the value that can be drawn, encoded, cached and put in a field — which is what every caller of this does next.

285. A caret is the text stack’s, not a control’s

Date: 2026-09-12

Status

Accepted. Closes docs/gaps.md G6 apart from IME preedit, which is filed as G15 and needs a native binding this does not have.

Context

G6: “widgets.form.textinput and widgets.form.textarea are complete editors as widgets. What a board needs is a caret in a sticky: an editor over shaped runs at an arbitrary canvas transform, with IME, selection, clipboard and undo.”

The entry is right about the shape of the problem and wrong about how much was missing, which is now the third time in this series (ADR-0281, ADR-0283). Two things were already built and in the wrong place, and two were not built at all.

Already built, in :widgets: TextEdit — a string, a caret and an anchor, with every movement and deletion as a pure function — and EditHistory, an undo stack that folds a typing run into one step. Neither has ever named a widget, a box or an element. Both sat in io.github.digitalsmile.goldberry.widgets.form.textinput, which is to say: the rules of text editing lived in the module that draws text fields, so anything editing text anywhere else had to reach into a control’s package or grow its own.

Already built, in :core: Paragraph.widthBetween and Paragraph.offsetAt, which are the two measurements a caret is made of.

Not built: the two-dimensional half — where a caret is on a wrapped paragraph, what Up means when lines are not the same length, and what shape a selection is when it spans a line break. TextAreaState had ten lines of it, tangled with scrolling and padding.

Also not built: a key map anything but a widget could reach. TextField.onKey knows that Ctrl+Shift+Z is redo and that Home is the start of the visual line; a canvas had no way to ask.

Decision

The editing model moves to the text stack

io.github.digitalsmile.goldberry.text.edit, beside the shaping and the layout it is arithmetic over. TextEdit and EditHistory move there unchanged — :widgets imports them from their new home, and text-input, text-area and code-input are otherwise untouched.

TextGeometry is the two-dimensional half

TextGeometry.caretAt(paragraph, layout, offset)          // → Caret(x, top, height, line)
TextGeometry.offsetAt(paragraph, layout, x, y)           // a click
TextGeometry.moveLine(paragraph, layout, offset, ±1, desiredX)
TextGeometry.selectionRects(paragraph, layout, start, end)
TextGeometry.lineOf(layout, offset)

Static, and every method takes the paragraph and its layout together, because the one bug this class exists to prevent is a caret measured against one wrap width and drawn against another.

desiredX is a column in pixels, not a character count: walking down through a short line and out the other side has to come back to the column it started in, and that is a property of the x rather than of the offset.

selectionRects returns one rectangle per visual line, because a selection that spans a wrap is L-shaped, and it widens a line whose selection runs past its last character by a space — otherwise a selected newline is invisible, which is the kind of thing nobody can name and everybody notices.

Editor is the whole editor, without a widget

var editor = new Editor(font).multiline(true).wrapWidth(240).clipboard(window.clipboard());

new Canvas((frame, size) -> editor.paint(frame, 8, 8, ink, focused), new Input() {
    public void onPointer(PointerEvent e) { editor.pointerAt(x, y, e.modifiers().shift(), e.clickCount()); }
    public void onKey(KeyEvent e)         { if (editor.onKey(e)) e.consume(); }
    public void onText(TextEvent e)       { if (editor.onText(e.text())) e.consume(); }
    public void onFocusChanged(boolean focused, boolean fromKeyboard) { … }
});

It holds a TextEdit, an EditHistory and the Paragraph it re-shapes when the text changes — one shaping, which is what makes the caret, the hit test and the paint agree by construction rather than by review.

Its key map is text-input’s, key for key, because two editors in one toolkit that disagree about Ctrl+Shift+Z is a toolkit with a bug in one of them. A key it does not handle is not consumed: Tab still moves focus, Escape still closes what it closes, and Enter in a single-line editor still reaches a form.

paint draws the selection, the text and the caret in that order — the only order that works — and draws the caret only when told to, because a blink is a clock and a repaint and both are the application’s.

Input gained onFocusChanged, and the keyboard gained a switch

Everything else a canvas draws looks the same focused or not. A caret does not. One default method on Input, passed through by Canvas.

And one more, which is the difference between this working and not: the platform produces no text until something says it is being typed into. SDL_StartTextInput was called by TextInputState from its own focus handler — so the first thing to hold an editor outside the catalogue got keys and never a character, which is exactly how this was found: the showcase’s sticky did nothing when typed into.

That call is the router’s now, and it is the cursor’s arrangement repeated:

  • Handles.wantsTextInput(), default false — a widget declares that it is typed into.
  • PointerRouter.onTextInputChange(sink), beside onCursorChange(sink) — the router asks the focused widget on every focus change and tells whoever is listening, knowing nothing about the platform.
  • Window wires that sink to the backend, which is the one wire between the two.
  • Input.wantsText() → Canvas.wantsTextInput(), so a canvas says it in the same place it says everything else.

A board that wants arrow keys and not an on-screen keyboard leaves it false, which is why this is declared rather than inferred from “has an Input”.

IME preedit is not in this, and is not close

SDL_EVENT_TEXT_INPUT — committed text — is bound and has always worked, which is what Editor.onText takes and is most of what an input method does. SDL_EVENT_TEXT_EDITING, the preedit string with its cursor and its underline, is not bound at all, and neither is SDL_SetTextInputArea, which is how the platform is told where to put the candidate window. That is a native binding, an event route, a preedit model in the editor and a rendering convention — its own decision, and M5 already owns “IME preedit”. Filed as docs/gaps.md G15 rather than claimed.

Consequences

  • TextEdit and EditHistory changed package. A breaking change for anyone who imported them from :widgets; nothing in this repository or in the application that asked for G6 did.
  • There are two key maps now, this one and TextField‘s, and they agree because they were written from each other rather than because anything enforces it. Converging them means making text-input and text-area hold an Editor, which is a rewrite of two controls’ state machines and is filed in TODO.md rather than done during a feature.
  • No scrolling. An Editor draws where it is told and does not know it has been clipped; text-area scrolls because it is a box with a viewport. A canvas that wants a long document scrolls its own transform, which it is already doing for everything else it draws.
  • No bidi caret. Paragraph.isBidiApproximate says what the shaping does not promise, and a caret in mixed-direction text needs a visual-order walk that the toolkit does not have yet. Latin, Cyrillic and CJK are exact.
  • No placeholder, no validation, no focus ring, no border. Those are what a control is, and text-input remains the answer for a form.
  • TextInputState no longer turns the platform’s input off when it is disposed. It used to, and with the router also tracking the state that was a stale-cache bug waiting: a focused field unmounting turned the platform off while the router still believed it was on, so the next field focused agreed with the stale answer and was never told. The router notices the unmount on the same frame through refocus, which is the one place that knows what has the focus after the field has gone.
  • It found a crash in the offscreen render. The showcase’s sticky is the first widget whose State owns a Font and closes it in dispose, and Offscreen was unmounting the tree before ending the frame — so a Blend2D worker was still rasterizing glyphs from a font that had just been destroyed. A SIGSEGV in a worker thread, intermittent, and invisible to every test that did not own a font. The order is stated and commented in Offscreen now (ADR-0284).
  • Font stays the caller’s. An editor holds one and closes nothing, which is why the showcase’s sticky opens its font in a State and closes it in dispose.

Alternatives considered

  • Leave the model in :widgets and document it. An application already depends on :widgets, so it would have worked — and it would have said that the rules of text editing are a property of the widget catalogue, which is the same category error ADR-0279 corrected for flexbox and ADR-0283 for images.
  • Make Editor a widget. Then it is text-input again, and the thing G6 asked for — a caret inside a drawing, at the application’s own transform — is exactly what a widget cannot be.
  • Put the geometry on Paragraph. It is arithmetic over a paragraph and a layout, and Paragraph deliberately does not hold its layout: the same shaping is laid out at several widths by the measure pass. A class that takes both is the honest shape.
  • Have Editor own its own scrolling and clipping. Two coordinate spaces and a viewport inside a class whose whole point is that the caller owns the transform.
  • Wait for IME before shipping any of it. The preedit is one part of one writing system’s input path; a caret, a selection, undo and a clipboard are every writing system’s, and they work now.

286. A clipboard write is an offer, not a copy

Date: 2026-09-12

Status

Accepted. Closes docs/gaps.md G7.

Context

G7: “render.Clipboard is hasText / text / text(String). Pasting a screenshot onto a board is the most-used way anything gets onto one. Copying shapes between two windows needs a custom type as well.”

Clipboard’s own javadoc had already written the reason it stopped at text, and it is worth quoting because it turned out to be exactly right:

A clipboard can hold images, files and arbitrary MIME types, and every one of those is a transfer negotiation rather than a value — the owning application advertises formats and serialises on demand, which means an interface that admits them has to admit lazy providers, format lists and cancellation.

The entry’s own note said the type it needed now exists (image.Image, ADR-0283) and that “the platform half is the whole of the remaining work”. That half is SDL_SetClipboardData, and it is the first place in this toolkit where the caller owns memory across an upcall.

Decision

The byte half is bytes and a MIME type, and nothing else

boolean has(String mime);
byte[]  read(String mime);
boolean write(String mime, byte[] bytes);
boolean write(Map<String, byte[]> byMime);
boolean clear();

Not hasImage() / image() / image(Image) as the entry proposed. A clipboard carries an image, a document’s own format, a file list and whatever two applications have agreed on; the only thing common to all of them is a type and some bytes. Putting Image in this interface would also make render — the backend SPI, which a platform implements — depend on the decoder, so every backend would have to know what a PNG is, and render and image would depend on each other.

So the image convenience lives beside the decoder instead:

Image.onClipboard(clipboard);       // cheap: what has been advertised
Image.fromClipboard(clipboard);     // Optional<Image> — this is a paste, and a round trip
image.toClipboard(clipboard);       // as image/png

which is the same direction Image.decode(Path) already goes: the value knows the places it can come from, and a file does not know about images.

Every byte method is a default that does nothing. A backend written before this existed reports that it holds nothing and accepts nothing, which is honest; Clipboard.none() needed no change at all.

write is an offer, and the bytes stay ours

SDL_SetClipboardText copies. SDL_SetClipboardData does not: it takes a list of MIME types and a callback, and calls the callback if and when somebody pastes — which is what the platform protocols do underneath, where an X11 selection owner is asked to serialise on demand. A second callback says when the offer has been replaced.

So a write allocates a shared arena, copies the bytes into it, and hands SDL two upcall stubs. Three things fall out of that and each of them is a decision:

  • Each offer carries its own id as userdata, because the cleanup for the previous offer arrives while the next one is being installed. A single “current offer” field would free the wrong arena, intermittently, under the one workload nobody tests: copying twice.
  • Ids are never reused, so a late cleanup cannot free the memory of the offer that took its place.
  • The stubs live in an arena that is never closed. They are two for the life of the process, and freeing a stub while it is running is the one way this arrangement crashes.

Nothing may be thrown out of an upcall — an exception crossing back into C takes the process down — so a request this application cannot answer returns NULL and a zero size, which is SDL’s own way of saying so.

Several types at once, in order

write(Map) rather than only write(String, byte[]), because one copy usually is several: a board copying a shape offers its own format and a PNG, so pasting back into the board keeps the shape and pasting into a chat window gets a picture. The platform advertises them in the map’s order and a well-behaved pasting application takes the first type it understands — which is why a LinkedHashMap says something a Map.of does not, and the javadoc says so.

Reading an image tries only what can be decoded

image/png, then JPEG, then QOI — the formats Blend2D was compiled with. A clipboard offering only image/webp is a paste this toolkit cannot do, and finding nothing is better than handing bytes to a decoder that will refuse them.

Bytes that were advertised as a PNG and then fail to decode raise ImageDecodeException rather than coming back empty: the clipboard said it had one, and an application offering to paste should be told that it lied rather than shown an Optional.empty() it will read as “there was nothing there”.

Consequences

  • Four symbols were added — SDL_SetClipboardData, SDL_ClearClipboardData, SDL_GetClipboardData, SDL_HasClipboardData — and SDL_free, which was already there for text, closes the read loop unchanged.
  • This is the second upcall family in the library, after Yoga’s measure function (ADR-0017), and the first where memory outlives the call. The test for it is a real round trip: writing and then reading back runs SDL’s own request path, which calls our callback — a wrong descriptor, a wrong size_t* write or a stub in a closed arena all fail there rather than somewhere later.
  • The headless backend’s clipboard gained the byte half, in memory, for the reason it has a text half at all: a paste that could not possibly have anything to paste tests nothing. What it cannot model is laziness or a refusal, and it does not pretend to.
  • Text and bytes are separate, because SDL keeps them separate: a widget that copies text must not silently drop an image somebody else put on the clipboard. clear() drops both, which is the one place they meet.
  • Nothing watches the clipboard. Clipboard’s existing note already argued this for text and it holds for bytes: the only consumer would be a paste button greying itself out, and one that asks has(mime) when its menu opens gets the same answer for none of the machinery.
  • The showcase’s image card pastes now — click it, Ctrl+V for a screenshot, Ctrl+C to copy its own picture out — and its caption grew a line, which moved the Canvas screen’s masonry and its golden.

Alternatives considered

  • hasImage() / image() / image(Image) on Clipboard, as G7 proposed. The layering above: a backend would have to decode, and the SPI would depend on the image package that depends on it.
  • A typed ClipboardContent union — text, image, files, custom. It reads well and it is a lie about the platform: a clipboard offers several types at once and the pasting application chooses, so a union would have to pick for it.
  • Copy the bytes to the platform eagerly. There is no such call for arbitrary data in SDL3, and there is none in X11 or Wayland either — the laziness is the protocol rather than SDL’s choice.
  • One “current offer” field instead of ids. Simpler, and wrong in the case that happens every time somebody copies twice.
  • Encode a PNG lazily in toClipboard. The image would have to stay reachable for as long as it was on the clipboard and would encode again for every paste. A UI-sized PNG is milliseconds and a copy is a deliberate act.
  • Watch for clipboard changes and expose it as a Property. Still no consumer, and X11’s answer to “what is on the clipboard now” is a round trip per question.

287. A file dialog is the desktop’s, and the answer comes back later

Date: 2026-09-12

Status

Accepted. Closes docs/gaps.md G9.

Context

G9: “No binding. SDL3 has SDL_ShowOpenFileDialog / SDL_ShowSaveFileDialog. Needed for: export a board as PNG or SVG and a note as Markdown, import an image, open a local .am snapshot while sync is still being built.”

Every one of those is the same sentence — a path the user chose — and the toolkit had no way to say it. The alternative to binding one is drawing one, and a file browser is the single worst thing a toolkit can draw: it would be the one piece of the desktop the desktop is certain to have already, in a theme that does not match it, without the places sidebar, the search, the recent list, the network mounts, or — on a sandboxed platform — the permission grant that only arrives because the system picker was used. A Flatpak application that drew its own file list would show the user a home directory it cannot open.

So the decision was never whether to bind it. It was what shape the API takes, and that is decided by one fact: a person is inside the call.

Decision

Asynchronous, and there is no synchronous overload

host.fileDialog(
        FileDialogSpec.saveFile().filters(FileFilter.of("PNG image", "png")),
        choice -> switch (choice) {
            case FileChoice.Chosen(var paths, var filter) -> export(paths.getFirst());
            case FileChoice.Cancelled ignored             -> { }
            case FileChoice.Failed(var message)           -> toast(message);
        });

Path openFile() would read better at every call site and cannot exist. The UI thread is the only thread allowed to touch windows, so blocking it until the user has finished browsing their disk stops the frame loop, the animations, the resize handler and the window’s own repainting. On Linux it would also stop the pump the portal dialog needs in order to answer, which is a deadlock rather than a stutter — SDL’s own header says as much: “apps that do not use SDL to handle events should add a call to SDL_PumpEvents in their main loop”.

Three kinds, not one with flags

FileDialogKind is OPEN_FILE, SAVE_FILE, OPEN_FOLDER, because the platforms have three and they are not the same conversation: an open dialog names files that exist, a save dialog names one that may not — which is the whole point of it — and a folder dialog has nothing to filter. A single “file dialog” with booleans would have to document which combinations are real. FileDialogSpec refuses the two that survive the type system (filters on a folder, many on a save) in its compact constructor, rather than leaving each platform to drop them quietly.

The answer is sealed, and cancelling is not failing

FileChoice is Chosen | Cancelled | Failed. The C callback packs all three into one pointer — NULL is an error, a pointer to NULL is a cancel, anything else is a choice — and reading that convention is the native layer’s job, not the application’s. Sealed rather than an enum plus a payload so that a switch without a default stops compiling if a fourth case is ever added.

Cancelled is deliberately not a failure. The user pressing Escape leaves no error to show, and an export that toasted “operation failed” because somebody changed their mind would be wrong about what happened.

Extensions, not patterns

FileFilter.of("Images", "png", ".jpg", "*.jpeg") normalises to three bare, lower-cased extensions. Every platform spells a filter differently — Windows wants *.png;*.jpg, GTK wants a glob per entry, macOS wants uniform type identifiers, SDL wants png;jpg — so the toolkit’s vocabulary is the part they agree on, and the dialect is the backend’s. A leading dot is accepted because it is what a Path shows you and removed because none of them store it; anything that looks like one platform’s dialect is refused, because “undefined behaviour” in SDL’s header means a dialog that lists no files, and that is the hardest kind of bug to read backwards.

A filter is a suggestion, not a validator: several platforms let the user switch filtering off. Code that must not open an .exe checks the path it was given.

SdlFileDialogs lives in natives.sdl, not in natives.sdl.desktop

Beside the clipboard and the tray is where it belongs by subject matter, and it is not there, for one parameter: a dialog is modal for a window, and SDL_Window* is SdlWindowHandle’s package-private secret. Moving that pointer into another package to reach a wrapper would undo exactly what the class exists to do (docs/ARCHITECTURE.md §3.1). The values it traffics in — SdlFileFilter, SdlDialogKind, SdlFileDialogCallback — touch no foreign memory, so they get their own package, which is the split ADR-0172 asks for.

The request’s memory is in an automatic arena

SDL requires the filter array to stay valid until the callback runs, so each request gets an Arena of its own, registered under an id that travels as the userdata pointer and dropped when that id’s callback arrives.

The arena is Arena.ofAuto(), and this is the part that was learned by crashing. The first version used a shared arena and closed it in the callback, which is correct for the case everyone thinks about — a portal answering minutes later on the DBus thread — and fatal for the case nobody does: a machine with no XDG portal and no zenity refuses synchronously, calling the callback from inside the very downcall that handed the memory over. The linker still holds that arena’s session there, so close() throws IllegalStateException, inside an upcall, which takes the process down. An automatic arena has no close: the memory lives exactly as long as the request that owns it is reachable, which is precisely the contract SDL’s header states. pendingRequests() still reports what is outstanding, so the leak is still testable.

The hop to the UI thread is the pump’s, not an executor’s

SDL’s callback may arrive on any thread; the SPI promises the UI thread. So Sdl3FileDialogs parks the answer in a concurrent queue, calls Backend.wakeup(), and delivers it from deliverPending() at the top of the next pumpEvents — before the blocking wait, so the repaint the answer asks for belongs to that pump rather than the next one. A pending answer also zeroes the wait, so a dialog answered during the loop’s quiet second is not made to sit there.

UiExecutor is the toolkit’s general answer to this question and is deliberately not used: it belongs to the EventLoop, which is built on a backend and cannot be reached from inside one. A backend reaching up into the loop that drives it would be a cycle and a second lifetime to get right; the queue is nine lines.

Host.fileDialog owes the repaint

Like a tray row (ADR-0191), a dialog’s answer arrives with no input event behind it, so nothing asks for a frame and a handler that changed the model changes nothing anybody looks at. Host.fileDialog(spec, onChoice) fills in this window as the one to be modal for and repaints in a finally, so the frame is owed whether or not the handler finished. Host.fileDialogs() is the process-global facility underneath it, for the menu that wants to ask supported() before offering an “Export…” item.

Consequences

  • Three symbols were added — SDL_ShowOpenFileDialog, SDL_ShowSaveFileDialog, SDL_ShowOpenFolderDialog. SDL_ShowFileDialogWithProperties was not: it is the only way to set a title or relabel the buttons, and it costs a properties object and a second code path for three strings the platforms are entitled to ignore — macOS has no dialog title at all. FileDialogSpec has room for them the day something needs them.
  • This is the third upcall family in the library, after Yoga’s measure function (ADR-0017) and the clipboard’s offer (ADR-0286), and the first whose callback can arrive on a thread the toolkit did not create.
  • It is tested without a desktop. Pointing SDL_FILE_DIALOG_DRIVER at a driver that does not exist makes SDL refuse through the callback — the same upcall a real answer arrives on — so the NULL-is-an-error branch, the calling convention and the request’s release are all exercised on a CI runner with no display. What cannot be tested there is a person choosing a file.
  • The headless backend has real ones, scripted: answerWith(...) says what the user did and shown() says what was asked for. A backend that refused would make every test of an export button pass for the wrong reason, which is the argument its clipboard and its tray already make.
  • Backend grew a sixth facility and, like the clipboard, it is never Optional: absence is FileDialogs.none(), which answers every request with a Failed rather than a silent cancel, because a caller whose export quietly did nothing would go looking for the bug in its own code.
  • Nothing is drawn, so nothing is photographed. There is no golden image of a file dialog and cannot be one — it is the desktop’s window, not Goldberry’s. The showcase’s Basic screen gained a card anyway, and what it shows is the half that is Goldberry’s: three buttons and a line that fills in later. Its test presses them against a scripted host, which is the only way the asynchrony is checkable at all.

Alternatives considered

  • Optional<Path> openFile(...), blocking. The one call site everybody wants and a frozen window plus, on Linux, a deadlock against the event loop the portal needs.
  • Optional<FileDialogs> fileDialogs() on Backend, the way popups and the tray report absence. A caller that got empty would write FileDialogs.none() itself, which is the argument Clipboard already made and won.
  • A Dialogs static facade, as G9 proposed — Dialogs.openFile(...)/saveFile(...)/openFolder(...). Static access to a per-session facility is the thing Host exists to avoid (ADR-0140), and the names survive as FileDialogSpec’s factories, which read the same at the call site and carry the request as a value.
  • Message boxes (SDL_ShowSimpleMessageBox) in the same cut. A different question — the toolkit can draw a dialog, and docs/core-widgets.md says it should — and bundling them would have hidden that difference behind a shared package name.

288. A painter is told what the cascade resolved

Date: 2026-09-12

Status

Accepted. Closes docs/gaps.md G11.

Context

G11: “A painter is given a Frame and a size. It is not given the ComputedStyle of the box it is painting, so canvas text has to name a font (BundledFont.UI) rather than inherit the one the cascade resolved. … Painter.paint(Frame, LogicalSize, ComputedStyle), or a Frame.style().”

The showcase had the symptom in one line, and it is worth quoting because it is what every application would have written:

try (var font = Font.bundled(BundledFont.UI, 12)) {
    Paragraph.of(font, note).paint(frame, 8, size.height() - 22, width - 16, ACCENT);
}

A named face, a named size, a named colour, and a parse of the font file on every frame — because Font.bundled opens its own face. None of it moves when the theme does. The renderer knew all three answers and was not passing them on.

The same paragraph of the toolkit’s documentation says a chart is a canvas “which is what lets a chart inherit the theme” — and that is true of the widgets in the catalog, because they implement Paints and read the style in render. It was not true of an application’s canvas, which has no render to read it in.

Decision

A StyledPainter beside Painter, not instead of it

new Canvas((frame, size) -> …);                // unchanged
new Canvas((frame, size, style) -> …);         // a StyledPainter

StyledPainter extends Painter and adds paint(Frame, LogicalSize, CanvasStyle). Three things fall out of the subtyping and each was the reason for it:

  • The compiler picks by arity. A two-parameter implicit lambda is not potentially applicable to a three-parameter function type, and vice versa, so new Canvas(…) takes either form with no cast and neither is second class.
  • new Canvas(null) still resolves. Two unrelated interfaces would have made it ambiguous; a subtype makes the StyledPainter overload more specific, and the existing call sites that pass null compile untouched.
  • Nothing in paint changed. Box.painting still holds a Painter, BoxPainter still calls two arguments, Offscreen.paint(Painter) is unchanged, and the eight painters in the catalog that never wanted a style did not grow an unused parameter.

The alternative — making the three-parameter form the signature — was rejected for that last point. Ten call sites would have gained a parameter they ignore, and a signature that most of its callers do not use is a signature that is wrong for most of its callers.

CanvasStyle is a record, and it is a snapshot

public record CanvasStyle(Font font, int ink, double nowMillis, boolean reducedMotion)

Not ComputedStyle, as G11 proposed, and the difference matters twice.

It carries a Font, not a Typography. The thing a painter needs is the opened face at the resolved size, and only the renderer can produce one — it owns the Fonts book that makes “Inter 600 at 13px” a map lookup instead of a parse. ComputedStyle would have handed over the description of a font and left the caller to open it, which is the line the showcase already wrote.

It is read when the box is built, not when it is painted. This is not an optimisation; it is the only correct shape. Paints.Context answers per node by knowing which node is currently rendering — color("--gb-chart-1", …) resolves against a field the renderer sets just before each render call. A context held past that point answers for whichever node rendered last. So Canvas.render binds the painter to a snapshot:

var painting = painter instanceof StyledPainter styled ? styled.bound(context.canvasStyle(style)) : painter;

The same reasoning says what is not on CanvasStyle: the custom-property accessors. A painter cannot name in advance the tokens it will want, so they cannot be snapshotted, and a widget that needs them is a Paints and reads them in render — which is what every chart in the catalog does (ADR-0195).

Two things beyond the font, because a painter could not reach them either

nowMillis and reducedMotion are in the record. A canvas painter had no clock: Paints.Context.nowMillis() is the frame time read once per frame and shared, so two spinners tick together (ADR-0081), and an application animating a canvas had nothing but System.nanoTime() — which is a second clock, off by a frame, and immune to the virtual one every test drives. reducedMotion is the §1.7 question that has no declaration behind it and therefore has to be asked.

Frame.style() was the other option in the entry

Rejected. A Frame is the rasterizer’s surface and is one object for the whole window; a per-box CSS answer on it would be mutable state set and unset around each canvas, readable at the wrong moments by anything holding the frame, and a paint type with an opinion about the cascade. The parameter says the same thing and cannot be read where it does not apply.

Consequences

  • Nothing moved in any golden image. The catalog’s painters are unchanged and the showcase’s converted line draws text that is empty until a key is pressed.
  • The showcase stopped parsing a font file per frame. One line, and it is the line G11 was written about.
  • Canvas gained four constructors — the three it had, once more each for a StyledPainter, plus the canonical three-argument one, without which a method reference to a three-parameter painter has nothing to match at the three- argument call site.
  • An unbound StyledPainter paints with CanvasStyle.none() rather than failing. Handed straight to a Box or to Offscreen.paint, there is no node and therefore no cascade; the bundled UI face at the body size, opaque black and time zero are defaults and the javadoc says so, not the cascade’s answers. Failing mid-frame instead would turn a mislaid binding into a crash.
  • CanvasStyle.none() is one lazily made instance. Font.bundled parses the face out of the jar, which is not work to do in a class initializer or twice.
  • CanvasStyle is a record and will grow by gaining components, which is a source-breaking change for anyone constructing one directly. That is the trade for it being a snapshot: an interface could have grown silently and would have invited an implementation that answers live.

Alternatives considered

  • Painter.paint(Frame, LogicalSize, ComputedStyle) as the single method, as the entry proposed. Ten call sites gain an unused parameter, and the style it hands over still cannot open a font.
  • Frame.style() — see above.
  • A ScopedValue<CanvasStyle> bound around the painter call. Structurally correct and genuinely tidy, and still ambient: the value would be reachable from any code the painter calls, including code that is not a painter, and the binding would be invisible at the call site. A parameter is the same guarantee written down.
  • Putting the style on Box as a 26th component. It would have pushed the resolution into BoxPainter and made every new Box(…) in the toolkit carry a field that means something for one box in a hundred.

289. A composition is not an edit

Date: 2026-09-12

Status

Accepted. Closes docs/gaps.md G15. Opens G16.

Context

G15: “SDL_EVENT_TEXT_INPUT is bound and delivers committed text… The composition — the underlined string being assembled, with its own cursor — is SDL_EVENT_TEXT_EDITING, and it is not bound at all; neither is SDL_SetTextInputArea… So today a Japanese, Chinese or Korean user typing into a sticky sees nothing at all until they commit, and the candidate window opens wherever the compositor guesses.”

Two facts make this smaller than it looks and one makes it larger.

Smaller: ADR-0285 already built the caret, the selection, the wrapped-line geometry and the key map, and ADR-0281 already built the route from a platform event to a focused canvas. Committed text already arrives — an input method’s result has always worked.

Larger: the composition is not text. It is a proposal. にほんご becomes 日本語, and every character of what was typed is replaced when the user picks a candidate. A toolkit that treats it as typing is wrong in four places at once — the document, the undo history, anything observing the value, and the screen, which flickers as characters are inserted and withdrawn.

Decision

It is a third keyboard event, beside the other two

KeyEvent is a key. TextEvent is text the platform has finished translating. PreeditEvent is the string in between, and it is a separate type rather than a flag on TextEvent because the two have opposite obligations: one must be inserted and the other must not.

Handles.onPreedit is a default that does nothing, so every widget in the catalog is exactly as correct as it was — it receives committed text as before and simply does not draw the underline.

There is no capture phase, unlike onText. A container reads what was typed before its child does because a select filters on it and a menu navigates by it (ADR-0246); nothing can usefully do either with characters that are about to be replaced, and offering them would invite a widget to act on a guess.

The composition is displayed inside the text and stored outside it

Editor holds preedit beside edit, never in it. What changes is displayText() — the document with the composition spliced in at the caret — and that splice lives in one method. Everything downstream follows from it: paragraph() shapes the displayed text, so the words after the composition move along as they do in every native field; caret() is measured at edit.caret() + preeditCaret, so the caret sits inside the composition where the input method has it; and pointerAt maps the hit back out, because a click during a composition lands in a string the document does not contain.

Two things are deliberately different while composing:

  • No selection is drawn. A composition replaces the selection when it commits, and every platform’s input method collapses the highlight when one starts. Leaving it would highlight text that is about to go.
  • The converting clause is drawn like one. start/length is the clause the input method is currently working on; it is filled with Ink.selection and the whole composition is underlined in Ink.text. No new colours: a composition is the same three things a selection is, drawn to say “not yet”.

onText clears the composition before inserting, rather than waiting for the empty TEXT_EDITING that says it ended. The two are not ordered against each other on every platform, and waiting means an accepted candidate draws twice.

The offsets are converted once, in the backend

SDL reports the clause in UTF-8 bytes; everything above the backend counts in Java chars. Sdl3Backend converts, and the conversion is a span rather than a length — six bytes is two characters in Japanese and six in ASCII, so a length cannot be converted without knowing where it starts.

A composition is by definition not ASCII, so a layer that passed the bytes through would be wrong for every user this feature exists for. Out-of-range offsets are clamped rather than reported: a byte offset landing inside a character is not something a caller can act on, and the nearest boundary is the only useful answer.

The caret’s rectangle is published by the router, not by the application

SDL_SetTextInputArea tells the platform where the text being typed is, so the candidate window goes beside it rather than over it. Someone has to know where the caret is, and only the widget does — so Handles.caretArea() is a question the router asks, in the widget’s own content coordinates, after every event that could have moved a caret: a key, a character, a composition, a click, a focus change.

The router translates to window coordinates and hands the result to the window through onCaretAreaChange, which is onTextInputChange’s twin and exists for the same reason: placing a candidate window is a platform call, the router must not know about the platform, and the widget must not know about the window.

An unchanged rectangle is not republished, so a run of keystrokes inside one line is not a run of platform calls. Focus leaving publishes null, which clears the area — without it the platform keeps placing lists where a caret no longer is.

A CSS-transformed ancestor is not compensated. The hit-test region carries the inverse of the paint transform and not the forward one, so a caret inside a transformed subtree is reported where it was laid out. The candidate window is then in the wrong place and nothing else is wrong, which is a better trade than carrying a second affine through the hit test for a case an editable canvas does not have.

Consequences

  • One symbol and one struct were added — SDL_SetTextInputArea, SDL_TextEditingEvent — plus the SDL_EVENT_TEXT_EDITING constant. The layout harness caught the constant being missing from the C shim on the first run, which is exactly what it is for: a Java event number that disagrees with the linked SDL dispatches on an event nothing sends, and looks like a platform with no input method.
  • text-input does not compose yet, and that is now G16. Its editing model is TextInputState over TextEdit, and its caret and selection are absolutely positioned boxes rather than a painter — so the same feature is a different piece of work there, with a decision of its own about what a password field does with a composition. G15 asked for Editor, and Editor is what this closes; leaving the gap unnamed would have been the dishonest part.
  • The headless backend can drive one: composeText(text, start, length), inputText(accepted), endComposing() — so a test can be a Japanese user without an input method being installed.
  • What cannot be tested here is a person choosing a candidate. What is tested is everything else: the struct against the compiled library, the byte-to-char arithmetic including surrogate pairs, that the document and the undo history do not move while composing, that an abandoned composition leaves nothing behind, and that the caret’s rectangle reaches the window in window coordinates.
  • Editor.caret() moved — it is now measured against the displayed text. For everything that never composes, which is every existing caller, the displayed text is the text and nothing changed.

Alternatives considered

  • Inserting the composition and deleting it on the next event. What a toolkit does when it has no separate event. It is visible as flicker, it fills the undo history with keystrokes the user never chose, and it fires every Property bound to the value several times per character.
  • A flag on TextEvent. One type with two opposite obligations — insert this, do not insert this — which every consumer would have to branch on and one of them would forget.
  • Making the application call SDL_SetTextInputArea. Faithful to the entry, which proposed feeding it from TextGeometry.caretAt, and it would have meant every application that wants an IME remembering to do it after every keystroke. The router already asks the focused widget questions after every event; this is one more.
  • Carrying a forward affine through the hit test so a transformed caret is reported where it is painted. Real, and it costs a field on every region for a case that is a transformed editable canvas. Filed rather than built.

290. The pen belongs to the rasterizer

Date: 2026-09-12

Status

Accepted. Closes docs/gaps.md G14, and finishes ADR-0280.

Context

G14: “One method remains: public void Frame.drawGlyphs(double x, double baseline, BlendFont font, BlendGlyphBuffer glyphs, int argb). Font.draw is its only caller and nothing outside :core touches it. The other two leaks were values, and a value can be mirrored; these are handles, and the difficulty is ownership rather than transcription.”

The entry’s own instruction for enumerating the remainder was to delete one word from a module descriptor and read the compiler’s answer. Doing that — dropping transitive from :core’s requires io.github.digitalsmile.goldberry.natives — produced exactly two warnings, both on that one line. Font.shape and Paragraph.measureFunction had already been fixed by ADR-0282 and ADR-0279; the descriptor’s comment claiming eleven sites was stale.

So the remaining leak was one method, and the obstacle was ownership. Rasterizing a glyph needs a context, a font and a staged buffer. paint owned the first; text.font.Font owned the other two. Within one module Java offers nothing between package-private and public, so the join had to be public — and being public, it put two :natives types in the toolkit’s API and kept :natives readable by every application that requires :widgets.

Decision

The pen moves to paint, as the entry proposed

Two new types, both in io.github.digitalsmile.goldberry.paint:

public final class GlyphFace implements AutoCloseable {   // a typeface, to the rasterizer
    public static GlyphFace of(String name, byte[] data);
}

public final class GlyphPen implements AutoCloseable {    // that face at one size
    public static GlyphPen on(GlyphFace face, double size);
    public void draw(Frame frame, double x, double baseline, ShapedRun run, int from, int to, int argb);
    public double ascent(); public double descent(); public double lineHeight(); public double size();
}

Neither says anything native. One is made from a byte[], the other takes a ShapedRun — a value, in the face’s own design units, which ADR-0282 made. The BlendFontFace, BlendFont and BlendGlyphBuffer are private fields, and the handle passes between them through a package-private GlyphFace.handle() that only GlyphPen can call.

Frame.drawGlyphs is package-private now, with GlyphPen as its one caller.

text.font keeps what it is about

FontFace holds a GlyphFace where it held a BlendFontFace; Font holds a GlyphPen where it held a BlendFont and a BlendGlyphBuffer, and Font.draw is three lines of delegation. Everything that makes text.font interesting — shaping, the fallback chain, the paragraph cache, the metrics, the design-unit invariant — stayed exactly where it was.

The loop that copies a shaped run into the rasterizer’s staged buffer moved with the buffer. It belongs beside the thing it fills.

:core drops transitive, and Blend2D is sealed

requires io.github.digitalsmile.goldberry.natives;                      // :core
exports io.github.digitalsmile.goldberry.natives.blend2d to
        io.github.digitalsmile.goldberry.core;                          // :natives

Yoga and HarfBuzz were qualified by ADR-0280 and Blend2D was not, for exactly the reason above. All three are now, which is what that record said it was doing and could only do two thirds of.

The compiler is the enforcement, not the comment. -Xlint:exports under -Werror fails the build at the offending method the moment a :natives type reappears in an exported signature, and an application module naming a BlendPath does not compile. ExportedSurfaceTest asserts both halves: that none of the eight wrapped packages is exported unqualified, and that each is exported to :core and to nobody else — so an export silently deleted fails too.

Consequences

  • The foreign boundary is now the module graph, entirely. docs/ARCHITECTURE.md §3.1 said raw MemorySegment must never escape :natives; ADR-0280 added that no type of :natives may appear in a signature an application can read. That is true for the first time.
  • No golden image moved, and no behaviour changed. The glyph-copy loop runs where it always did, one call deeper. Font.draw’s signature, Paragraph’s painting and the metrics are untouched.
  • GlyphFace and GlyphPen are public, which is more surface than the leak they replaced. That is deliberate: a custom widget that wants to draw a shaped run at a size — a terminal, a music stave, a diff view with its own layout — now has a supported way to, and it is the way the toolkit’s own text stack draws. The alternative was package-private types plus a friend accessor, which is the same coupling with a worse name.
  • What is still exported unqualified is SDL’s wrappers, and that is right: an application legitimately names a BackendWindow, a tray and a cursor. What keeps those safe is the older check — they traffic in SdlWindowHandle, never in an address.
  • :core’s test fixtures still reach BlendImage. RendererRequirement probes whether the rasterizer loads at all, and compiles on the class path where module rules do not apply. It is a test-only path and not a hole in the shipped graph, but it is the one place the seal is a convention again.

Alternatives considered

  • A friend accessor, the JDK’s SharedSecrets pattern. paint publishes a registration point, text.font fills it in at class-init, and the handle crosses through an interface of Java types. It works, it is invisible in the API, and it replaces a compiler-checked boundary with an initialisation-order one. The leak was ownership; moving the owner is the fix for ownership.
  • Leaving drawGlyphs public and qualifying Blend2D anyway. The export would compile and the method would be uncallable — a public method in a public class that no application can name the parameters of. A worse state than the one it replaced, because it reads as supported.
  • Moving all of Font into paint. More than the leak needed. Shaping is not painting, and text is where the fallback chain, the cache and the design-unit invariant belong.

291. A URL scheme is packaging, and packaging is the application’s

Date: 2026-09-12

Status

Accepted. Closes docs/gaps.md G12 — as a decision, not as a feature. Goldberry will not own deep links, single-instance handoff or the OS keychain.

Context

G12 is the only entry in docs/gaps.md that asks a question rather than proposing an API:

Why it might be Goldberry’s. It is window-and-platform integration, which is the toolkit’s half of the world, and every desktop application that ships needs it. If Goldberry would rather not own it, brd will — but that is a decision to take deliberately rather than by default, which is what this entry is for. The OS keychain (plan auth/) is the same question and probably the same answer.

It has to be answered deliberately because the default answer is a slow yes: each piece looks small, each is “platform integration”, and by the third one the toolkit has a Win32 path, a Cocoa path and a freedesktop path in it.

brd://open/<token> is three separate problems wearing one name, and they have three different owners:

  1. Registration — telling the OS that this application handles brd://. A HKCU\Software\Classes\brd key on Windows, a CFBundleURLTypes array in Info.plist on macOS, a .desktop file with MimeType=x-scheme-handler/brd and a update-desktop-database run on Linux.
  2. Delivery — the URL reaching a running process. On macOS SDL already does this: handleURLEvent: becomes an SDL_EVENT_DROP_FILE with a NULL window. On Windows and Linux the URL is argv[1] of a new process.
  3. Handoff — that second process finding the first, giving it the URL, and exiting so one window is focused rather than two opened.

Decision

None of the three is Goldberry’s

Registration is packaging. It is written by an installer — an MSI, a .app bundle, a .deb, a Flatpak manifest — at install time, as the user, into places a running process should not be poking. Goldberry ships no installer, no bundler and no manifest; it is a library an application is built with. A toolkit that wrote a registry key at start-up would be an application’s installer running with the application’s luck, and it would be wrong on every platform that has a package manager, where the scheme belongs in the package metadata and gets uninstalled with it.

This is also where ADR-0003 and ADR-0041 already point. The backend SPI exists so that there is one platform-facing interface and it is SDL3’s; those records say in as many words that it “is not an invitation to grow hand-written Win32, Cocoa or Wayland backends”. Registration is exactly that invitation, in a shape that does not even need a window.

Delivery and handoff go with it, and that is the part worth being explicit about, because a narrower decision was available and was considered — see below. They are not platform code, but they are an application’s process model: what “already running” means, whether a second launch is an error or a message, whether two documents are two windows or two tabs, and what a URL is allowed to do to a window that has unsaved work in it. None of those has an answer a toolkit can pick, and the ones brd needs are brd’s own.

The keychain is the same answer. plan auth/’s credential storage is DPAPI, the macOS Keychain and libsecret — three platform APIs, no SDL coverage, and a security surface whose failure mode is a leaked token. The entry guessed this would be the same question and the same answer, and it was right.

What Goldberry does give an application that wants this

Nothing new, and that is the point — the pieces already exist:

  • The URL arrives as an argument. Application is handed the process’s own args; an application reads argv[1] and decides what a brd:// in it means.
  • On macOS it arrives as a drop. SDL translates handleURLEvent: into SDL_EVENT_DROP_FILE. Goldberry does not bind the drop events yet; when something needs drag-and-drop that binding lands for that reason, and a macOS deep link will fall out of it. Filed as such rather than built now.
  • The handoff is ordinary Java. A FileLock on a file under the application’s own data directory, and a java.net.UnixDomainSocketAddress channel beside it. No native call, no SDL, nothing a toolkit has to lend.
  • Getting onto the UI thread from that socket’s thread is solved: EventLoop.ui() is a UiExecutor, and it is the one sanctioned way in (ADR-0019). An application’s listener thread posts the URL and the loop wakes.

So an application that wants brd://open/<token> writes perhaps eighty lines and one line in its packaging. A toolkit that owned it would write three platform paths and still leave the packaging line to be written.

Consequences

  • G12 closes with no code, and docs/gaps.md stops carrying an open question. The entry becomes a record of the answer, with the sketch above, so that the next application does not re-open it.
  • The OS keychain will not be a gap either. Named here so that the same question does not arrive under a different heading.
  • brd owns the handoff and writes it once. It is the application that knows what a second launch means for a board with unsaved edits.
  • If a second application ever needs the same eighty lines, that is the moment to reconsider — two consumers is evidence and one is a guess (ADR-0019’s rule, which is why the popup, the clipboard and the tray each waited for one). Reconsidering means reopening this record, not quietly adding a method.
  • Drag-and-drop remains unbound, and the macOS deep-link path rides on it. That is the one piece of this that is genuinely the toolkit’s and is simply not needed yet: nothing in the catalog drops a file.

Alternatives considered

  • Delivery and handoff in the toolkit, registration left to the installer. The tempting middle, and the one this record spent the longest on. It is defensible: the handoff is platform-neutral Java and the delivery is an event. It was rejected because the resulting API is a Host.onOpen(URI) that does nothing on two platforms out of three until the application also does the packaging — a feature that looks supported and is inert, which is worse than one that is honestly absent. And the questions it would have to answer for the application — one window or two, what happens to unsaved work — are not questions a toolkit can answer.
  • Registration at start-up, on all three platforms. Gets brd a working brd:// with no installer work, and puts a registry writer, a plist editor and a .desktop generator inside a toolkit whose entire platform story is “SDL3, and one interface”. It is also wrong on any packaged install, where the scheme belongs to the package.
  • Binding SDL_OpenURL. The outbound half — opening a link in the user’s browser — which is one symbol and genuinely SDL’s. Not part of this decision and not blocked by it: when a widget needs a clickable link, that is its own small record.

292. A field composes, and a password does not

Date: 2026-09-12

Status

Accepted. Closes docs/gaps.md G16, and finishes what ADR-0289 started.

Context

G16 was opened by ADR-0289, which gave a canvas the ability to show what an input method is composing and left the toolkit’s own fields without it:

text-input has its own editing model (TextInputState over TextEdit), and its caret and selection are absolutely positioned boxes rather than a painter’s rectangles, so the same feature is a different piece of work there. … The one decision that is not mechanical is what a password does with a composition.

So this record has two jobs: carry ADR-0289’s rule — a composition is not an edit — into two more controls, and answer the password question.

text-area is here as well as text-input, and that was not optional. controls.css says of the two: “the two controls must not look like they were designed by different people, and the surest way to that is one set of rules rather than two that agree today.” Shipping composition in one and not the other is exactly that.

Decision

The composition is a span, because the string is already in the display

Both fields already draw something that is not their value. text-input has Mask, which turns hunter2 into ••••••• and carries two index tables so that a caret, a click and a selection mean the same place in both. A composition is the second instance of the same idea, and it needed no second mechanism: the state splices the composition into the string it hands its box, and adds one value saying which part of what you are drawing is not text yet.

record Composing(int start, int end, int clauseStart, int clauseEnd)   // display offsets

The caret goes inside it, at caret + preeditCaret, which is where every native field puts it — an input method walks a caret through the string it is assembling.

The highlight draws the clause, because there is no selection to draw

A composition replaces the selection when it commits, and every platform’s input method collapses the highlight when one starts. So the field’s existing text-selection part is free while a composition is open, and it draws the converting clause — which is what a clause is: a selection inside the composition’s own little document.

One new part, text-composition, is the rule under the whole composition. It is drawn after the glyphs where the highlight is drawn before them, because it is a mark on the text rather than a wash behind it. Its thickness is the widget’s and not a token, on text-caret’s width reasoning inverted: a caret’s width is a matter of taste and has --gb-caret-width, where an underline is a hairline on every platform that draws one, at every size.

text-area gets maxRows of them, exactly as it gets maxRows highlights and for the same reason: a composition can wrap, and a run of wrapped text is not a rectangle. The per-line rectangle arithmetic was already there for the selection; it is now spanRects and is called twice.

A password refuses to compose

This is the decision G16 said was not mechanical, and the answer is what the platforms do: Windows disables the IME for an ES_PASSWORD edit control, and macOS’s NSSecureTextField refuses marked text.

The reason is not squeamishness about bullets. A candidate window is a second, unmasked window showing what is being typed, drawn by the input method next to the field. A masked field that composed would put the password on screen beside itself, in a window the application does not own and cannot mask — and it would do so on a control whose entire purpose is that the text is not on screen.

So compose returns false, the event is left unconsumed, and the field draws nothing inline. Committed text still arrives, so the field still takes every character an input method produces; what a user loses is the inline preview, and that is the trade every platform has already made.

readOnly and disabled refuse on the same terms they refuse an edit.

The field answers where its caret is

Handles.caretArea() (ADR-0289) is answered from the state, because only it has the last frame’s shaped paragraph and the scroll offset — and the router asks after every event, not during a render.

The two controls answer differently, and deliberately:

  • text-input reports its whole content box. A single-line field is the line being typed on, and the rectangle’s job is to keep the candidate list clear of the text it would otherwise cover.
  • text-area reports the caret’s line. A candidate window kept clear of a ten-line control would be pushed a long way from the text it belongs to.

Consequences

  • No golden image moved. The new part renders an empty box when nothing is being composed, which is what text-caret and text-selection already do when they have nothing to cover.
  • text-area’s parts doubled, from maxRows + 2 to 2 * maxRows + 2, and TextAreaBox.render stopped indexing from the end (children.size() - 2) and started indexing from maxRows. The parity test that asserted “the same three parts” now asserts the same four and their positions, which is a better test: it says what the tree is rather than what its last two entries are.
  • A placeholder gives way to a composition. A field being composed into is not empty, however little of it is committed.
  • Losing focus ends a composition, and nothing else would: the empty TEXT_EDITING that normally ends one goes to whatever has focus, which by then is something else.
  • onChange never fires for a composition, which is the whole point restated: an application bound to a field hears the accepted candidate once, not one change per keystroke of a string that is about to be replaced.
  • Nothing about Mask changed. It was already the right shape, and a composition being a second instance of “what is drawn is not what is held” is the reason this was 250 lines rather than a rewrite.

Alternatives considered

  • Masking the composition in a password. Bullets inline, and the candidate window still showing the plaintext beside them — the leak untouched and the user misled about where their password is visible.
  • Letting a password compose unmasked. Honest and unusable: it would be the one control in the toolkit whose contents are on screen.
  • One underline in a text-area instead of maxRows. A composition almost never wraps, and “almost never” leaves a rule under the first line of one that did. The highlight already pays this cost and states why.
  • Extending Mask to carry the composition. Tempting — one splice instead of two — and wrong: a mask is per character and a composition is a span, and a password does not compose, so the one type that would have joined them is the one type where they never meet.

293. A button that reads as a link

Date: 2026-09-12

Status

Accepted. Adds button.link — the fifth button variant.

Context

The catalogue has four button variants: secondary (the default), primary, ghost and danger. All four are a fill plus the ink that fill carries. There is a fifth thing a real application needs and none of them is: an action that belongs in a sentence. “Take the tour.” “Read the licence.” “Show the six that were filtered out.” A ghost button is the closest and it is not close — it is a button-shaped hole with body-strong text in --gb-text, which reads as a control that has lost its fill rather than as a link.

docs/core-widgets.md §2 already specifies a link, and it is a different widget: text that navigates, a word inside a paragraph, href= or action=. That one is unbuilt and this record does not build it — see below, because the difference decides the one contentious question here.

Decision

button class="link" press="app.take-the-tour" "Take the tour"

Nothing new in Java. Button already carries no visual opinion — “the height, the padding, the colours and the four variants are all in controls.css” — and this is the fifth entry in that list. Semantically it is a button throughout: a Tab stop, a focus ring, Space and Enter, Role.BUTTON.

Three declarations distinguish it, and each one is doing work:

  • --gb-button-link-text, not --gb-accent. See below; this is the part that was measured rather than chosen.
  • --gb-weight-regular, where every other button is strong. §1.4 puts the strong weight on buttons because a button is chrome. This one is meant to sit in a sentence, and a bold word in a paragraph is emphasis, not a control.
  • 4px of horizontal padding, not 12. The 12 holds a fill off its label and there is no fill; a link indented like a button reads as a button somebody forgot to colour in.

The height stays --gb-button-height. §1.3’s ≥32 hit target is a floor whatever the thing looks like, and a 20-pixel-tall click target in a card is the failure that rule exists to prevent.

The ink is its own token, and the accent was rejected by measurement

--gb-accent was the obvious answer and it does not clear §1.2’s 4.5:1 as ink:

--gb-bg--gb-surface--gb-surface-2
dark, --gb-accent #88c0d06.245.034.31
light, --gb-accent #5c7ea83.644.203.45

The light theme fails on every surface, and that is not a flaw in the accent — it is ADR-0088’s point restated. A light theme’s accent is chosen as a fill that carries white text; asking it to be ink on a white surface is the opposite job, and the two ramps parted company for exactly this reason.

So there is a token per theme, with the numbers written beside it:

  • dark: #a3d0dd — the accent one step lighter, an invented value on --gb-checkbox-border’s terms (ADR-0258): 7.51 / 6.05 / 5.19.
  • light: var(--gb-accent-fill) — the two-steps-darker value ADR-0088 derived for button.primary, used here as ink rather than as a fill: 5.38 / 6.20 / 5.09. A fill dark enough to carry white text is dark enough to be read on white; the reuse is the Nord answer rather than a coincidence.

ContrastTest measures the ink on all three surfaces in both themes. It cannot measure the variant itself — a transparent fill has no contrast ratio, which is button.ghost’s situation and the reason that one is excluded from the sweep.

There is no underline, and that is the contentious part

Every link in every browser is underlined, and WCAG 1.4.1 is the reason: colour alone must not be the distinguishing feature.

That rule is about a link inside a block of text, where position says nothing and colour is all there is. button.link is not that. It is a standalone control in the tab order, with a focus ring, carrying three signals at once — no fill where its neighbours have one, a different colour, and a different weight. The widget that is a word inside a sentence is §2’s link, and for that one the underline is not optional.

Two further things point the same way:

  • §2.1 says hover changes the surface and never the text colour. So the hover affordance is ghost’s overlay wash, and there is no “underline on hover” to reach for without contradicting a rule one section up.
  • §8’s CSS subset has no text-decoration. Drawing one would mean either a new property threaded from ComputedStyle through Box.Text into the paragraph painter, or a child box in Button’s Java — and Button builds its content as boxes with no widget children, so the second means teaching Java which class it is wearing, which is the thing this widget is careful not to do.

So the underline is filed with §2’s link, where it is load-bearing, and text-decoration lands for that widget’s sake rather than for this one’s.

Consequences

  • Two golden images moved — button-variants-dark and button-variants-light — and they are now five buttons wide. The light one is where the two themes visibly disagree about this variant: the ink differs where every other variant’s fill does.
  • The showcase’s Basic screen has a buttons card, in basic.kdl, showing all five variants, both icon forms and a disabled one — nine buttons and not one line of Java, which is the argument for appearance being a class.
  • --gb-button-link-text is a new component token, so an application restyles every link button by overriding one name.
  • §2’s link is still unbuilt, and docs/design-system.md now says so on its own row rather than leaving two rows that look like the same feature.
  • The disabled link button is legible and unremarkable, because :disabled is a global rule on opacity rather than per variant.

Alternatives considered

  • --gb-accent as the ink. Rejected by the table above, on the light theme by a wide margin. Worth recording because it is the change somebody will propose as a simplification.
  • An underline drawn as a child box, the way tabs draws its indicator and text-input draws a composition’s rule (ADR-0292). It works and it puts attributes.classes().contains("link") in Button.render — a widget reading its own class to decide what to draw, which is precisely the line controls.css exists on the other side of.
  • text-decoration: underline in §8’s subset, now, for this. It is the right feature and the wrong reason: its consumer is §2’s link, and building a CSS property for a variant that does not need it would fix its shape around the wrong case.
  • A link widget instead of a button variant, satisfying §2 directly. A bigger piece of work with an unbuilt CSS property under it, and it would not have answered the thing that prompted this — an action, in a card, that should not look like a button.

294. A parser crosses the boundary once

Date: 2026-09-13

Status

Accepted. Opens docs/gaps.md G8’s Markdown half; departs from ADR-0190 in one measured way. ADR-0295 is the other half: what happens to the events once they are here.

Context

G8 asks for “goldberry-html: Markdown through md4c”, and names two consumers in brd: a note’s live preview, and GET /docs/{id}/body.html. The entry’s own argument for why it is the toolkit’s is that “a Markdown parser vendored into brd would be a second text stack”.

Two questions had to be answered before any of it could be written, and the second one is the interesting one.

Where does md4c’s C get compiled? ADR-0190’s rule is that a content module brings its own natives: its own CMake superbuild, its own classifier jars, its own THIRD-PARTY-NOTICES, quarantined so that goldberry-core stays lean and licence-flat. That rule was written with litehtml, PDFium and libVLC in view — engines measured in megabytes, some with attribution or copyleft obligations.

md4c is one C file. 7,000 lines, MIT, no dependencies, and the object code is tens of kilobytes beside libgoldberry’s twenty-odd megabytes. Standing up a second native artifact for it means a second superbuild, four classifier jars, four CI legs, a publishing story and a second library for NativeLibrary to find — before one line of Markdown renders.

How is a SAX parser bound? md4c calls back for every block, every span and every run of text. A thousand-word note is several thousand callbacks, each of which would be an FFM upcall, and six of its seven callbacks hand over a void* into one of seven MD_BLOCK_*_DETAIL structs — so the obvious binding costs five upcall stubs and puts seven struct layouts in the table ADR-0010 maintains. A wrong offset in MD_BLOCK_H_DETAIL is a heading at level 218.

ADR-0190’s own first rule is that the hot path never crosses FFM. It says so about litehtml’s thousands of draw calls. It is the same sentence about md4c’s thousands of events.

Decision

md4c is compiled into libgoldberry

One FetchContent_Declare pinned in gradle/libs.versions.toml like every other upstream (ADR-0030, ADR-0035), with SOURCE_SUBDIR pointing at a directory that does not exist — asmjit’s trick — so md4c’s own CMake project, which builds md4c-html, the md2html executable and two pkg-config files, never runs. What the build takes is two translation units, added to the shim’s target:

target_sources(goldberry PRIVATE
    "${md4c_SOURCE_DIR}/src/md4c.c"
    "${md4c_SOURCE_DIR}/src/entity.c")

This is a departure from ADR-0190 and it is deliberate. The quarantine exists for heavy natives and for licence obligations; md4c is neither. MIT is notice-only, which THIRD-PARTY-NOTICES.md already carries five of, and the payload does not move the artifact’s size. A second native artifact would cost more than the thing it isolates.

What is not relaxed is the dependency direction. :core and :widgets do not know Markdown exists; :natives exports md4c’s wrapper to io.github.digitalsmile.goldberry.html and to nobody else, which is ADR-0280’s seal with a second name on it:

exports io.github.digitalsmile.goldberry.natives.md4c to
        io.github.digitalsmile.goldberry.html;

litehtml, when it comes, still gets a library of its own. It is C++, it needs a native document_container because FFM cannot implement a virtual class, and it is megabytes rather than kilobytes — every clause of ADR-0190’s argument applies to it and none of them applies here.

The event stream is encoded natively and read once

goldberry_shim.c implements md4c’s five callbacks in C and encodes what they report into one growable buffer. Java makes one downcall per document:

void*    goldberry_md_parse(const char* text, uint32_t size, uint32_t flags);
const void* goldberry_md_data(void* handle);
uint32_t goldberry_md_size(void* handle);
void     goldberry_md_free(void* handle);
int      goldberry_md_entity(const char* name, uint32_t size, uint32_t* out);

Five symbols for a whole parser, and none of them is md4c’s own. Zero upcall stubs. Zero detail structs in the layout table — every one of them is read in C, by the compiler that built the library, and arrives in Java as three integers in a record that has no layout to get wrong.

The wire format is a 20-byte little-endian header per record and then its text, documented in two places that must agree: the comment above the encoder in goldberry_shim.c, and MarkdownStream, which is the decoder. Attributes — a link’s href, a fence’s info string — are emitted as their own records before the record they belong to, because md4c reports an attribute as a string broken into substrings with a text type each, and [a](x?p=1&amp;q=2) is exactly why that matters: flatten it first and an HTML renderer emits &amp;amp;.

goldberry_md_entity is the fifth symbol and the one that is not about the parse. md4c ships the HTML5 named-entity table — 2,125 names — and a copy of it in Java would be a copy that drifts, which is ADR-0010’s argument about struct offsets applied to data.

The enumerators are verified like every other library’s

Every MD_BLOCKTYPE, MD_SPANTYPE, MD_TEXTTYPE, MD_ALIGN and MD_FLAG_* the bindings hard-code is a GB_CONSTANT row in the layout table and a Md4cEnum on the Java side, so LayoutVerificationTest compares them against what the C compiler computed for the library actually loaded. md4c has inserted enumerators into the middle of two of these enums between releases, and a stream decoded against a shifted value does not crash: it renders a heading as a block quote.

The ABI version is 9.

Consequences

  • libgoldberry now contains a Markdown parser, which is a sentence worth being uncomfortable with. The mitigation is the module graph rather than the binary: no module except :html can name a type that reaches it, and ExportedSurfaceTest fails if that changes.
  • A parse is one crossing and one allocation, freed before Md4c.parse returns. Nothing in :html owns native memory, which is why a Document is an ordinary value that can be cached, compared and held across frames.
  • The wire format is a contract between two files in different languages. That is the cost of the decision, and it is paid by Md4cTest, which drives the real encoder and asserts on what the real decoder produced — a hand-written buffer would test the decoder against itself.
  • Two THIRD-PARTY-NOTICES entries rather than a second notices file: md4c and its entity table, both MIT, beside SDL3, Blend2D, Yoga and HarfBuzz.
  • A document larger than 4 GB cannot be parsed, since the buffer’s length crosses as a uint32_t. A note is not 4 GB.
  • ADR-0190’s rule is now “quarantine what is heavy or encumbered” rather than “quarantine everything optional”. A future goldberry-code will face the same question for Tree-sitter, which is small but carries grammars — and it should be answered the same way: by measuring, and writing down which clause applies.

Alternatives considered

  • :natives-html producing libgoldberry-html, as ADR-0190 prescribes. The faithful reading, and it was rejected on cost: four CI legs and a second loader for 7,000 lines of C. If md4c had been PDFium this would have been the answer, and for litehtml it still is.
  • Five upcall stubs, the ordinary binding. What every other library here gets. It is the shape ADR-0190’s own first rule forbids, and it would have added seven struct layouts to the table — the largest single addition since SDL’s event union — for a parser whose output is a stream of small values.
  • One upcall with a flattened signature, keeping the parse in C but reporting each event as it happens. Fewer struct layouts, same thousands of crossings, and it makes the Java side a state machine driven from C: harder to test than a list, and impossible to hand to a second consumer.
  • md4c’s own md4c-html.c, straight to HTML. It would have answered brd’s body.html with no model at all, and nothing else: no preview, no outline, no word count, and two renderers to keep in agreement. ADR-0295 is that argument.
  • A CommonMark parser in Java. No native dependency, no wire format, no ABI bump — and a second implementation of a specification with 600 test cases, maintained by this project for ever. G8 named md4c for a reason.

295. A document is a value, and a paragraph is a row of words

Date: 2026-09-13

Status

Accepted. The Java half of docs/gaps.md G8; ADR-0294 is the native half. Defers html-view, which G8 does not ask for and book/src/TODO.md says what waits on — and which ADR-0298 later built, with no litehtml under it either, by restating every decision below about a document rather than about Markdown.

Context

ADR-0294 gets md4c’s events into Java. What they should become is a separate question with three parts, and docs/content-widgets.md §1 had answered the third one already — “markdown-view = md4c parsing to HTML, rendered by the same litehtml pipeline” — at a time when litehtml was assumed to be arriving first.

It is not arriving first. book/src/TODO.md says why: litehtml’s native document_container draws through libgoldberry’s exported C symbols, and that surface has no gradients, no rounded geometry and — until canvas needed it — no nested state stack. “The first commit of goldberry-html is a widening of the toolkit’s own native surface”, which is real work, is shared with goldberry-vector and goldberry-terminal, and renders no Markdown.

Meanwhile the two things G8 exists for — a note’s preview and its HTML — need neither an engine nor a wider paint surface.

Decision

One module, two packages

goldberry-html stays one artifact, one THIRD-PARTY-NOTICES, one natives story. Inside it, Markdown gets a package of its own:

io.github.digitalsmile.goldberry.markdown          Markdown, MarkdownSyntax
io.github.digitalsmile.goldberry.markdown.model    the document, as records
io.github.digitalsmile.goldberry.markdown.html     MarkdownHtml
io.github.digitalsmile.goldberry.markdown.view     markdown-view

The …html package that will hold litehtml’s html-view is not written yet. Four reasons for the split, none of them taste:

  • Two upstreams with two licences and two lifetimes — md4c under MIT, litehtml under BSD-3 — which is the split ADR-0172 already made inside :natives: by what a thing is, not by which library it came from.
  • The dependency runs one way. Markdown produces HTML and never reads it.
  • The Markdown half needs no window, no rasterizer and no native paint surface, so it is testable — and shippable — while the other half is blocked.
  • It ships something brd can use before litehtml exists, which is the whole point of doing this now.

A document is a value

Markdown.parse returns a Document: a sealed hierarchy of records, Block and Inline, pattern-matched with a switch. Not a builder, not a visitor, not a stream of events — the events are ADR-0294’s and they stop at one package-private class, MarkdownParser, which is this module’s only mention of md4c.

A value, because a preview is not the only thing an application does with a note. A word count, an outline, a table of contents, the first paragraph as a summary, and the HTML a server hands out are all walks of the same tree, and a widget that hid the parse would make every one of them a second parse.

Sealed, so that a node added later is a compile error in every renderer that has not handled it. “Every renderer that forgot the new node” is otherwise a list nobody has.

The model is the toolkit’s vocabulary rather than md4c’s: CellAlignment.START rather than MD_ALIGN_LEFT, entities resolved on the way in, a tight list item’s inlines wrapped in a Paragraph so that every item has one shape. WikiLink is a node of its own rather than a Link with an odd href, because a wiki target names something in a collection the application owns — which is the reason a note-taking application turns the extension on.

HTML is a fold over the model, not md4c’s own renderer

md4c ships md4c-html.c and it is not used. An application that shows a note and serves it needs both halves to agree — the same dialect, the same entity resolution, the same decision about what a soft break means — and two renderers, one in C and one over the model, is how they stop agreeing. MarkdownHtml is a switch over the same records markdown-view renders, and the compiler is what keeps the pair honest.

A paragraph is a wrapping row of words

markdown-view builds column, row and text from the catalog, with classes that markdown.css styles. No engine, no second text stack, nothing a theme cannot restyle.

The load-bearing detail is inline marks. The text stack shapes one font per Paragraph, so a line holding both regular and semibold glyphs cannot be one shaped run. The alternatives were a rich-text layout engine in :core — which is what litehtml would bring, and is html-view’s job — or losing **bold** altogether. So an inline run is split at whitespace, each word is a text widget carrying the classes of the marks it is inside, and the row wraps: the line breaking goes back to CSS, which already had it.

A word is a token rather than a fragment, and the golden image is what said so. *emphasis*, and is an emphasised fragment followed by , and, so splitting per fragment put a space in front of the comma — “emphasis , and”. A token is everything between two spaces however many styles it spans, drawn as a row with no gap inside it.

Three things follow from having no engine, and all three are in the stylesheet and in TODO.md rather than hidden:

  • Emphasis is a faux oblique. The system ships two upright faces (§6.1), so there is no italic to set *a* in; transform: skewX(-10deg) leans the word without moving it. An application with an italic face replaces one rule.
  • Strikethrough is a colour, because §10’s CSS subset has no text-decoration.
  • A quotation’s bar and a task’s check box are widgets, because the subset’s border is uniform — there is no border-left — and U+2610 is in neither bundled face, so a typed check box renders as the missing-glyph box.

Nothing is clickable, and nothing fetches

A word does not hear a pointer, so a link is the accent colour and not a destination; an image is its alt text, because there is no img widget and fetching anything is the application’s (ADR-0190). Both are stated in the widget’s javadoc, in the stylesheet, and in TODO.md, rather than faked with a rule that suggests otherwise.

Consequences

  • G8’s two consumers are answered. MarkdownHtml.of(source) is GET /docs/{id}/body.html; MarkdownView.of(document) is the preview. Neither waits on litehtml.
  • A rendered document is ordinary widgets, so it inherits the cascade, the theme, the density, the text scale and the golden-image harness for free — and an application restyles a document with CSS rather than with a renderer subclass.
  • Widget count is the price. A 500-word note is roughly 500 text widgets plus their rows. That is fine for a preview pane and is not fine for a book, and the number is here rather than discovered later.
  • markdown-view is the first widget outside :widgets, which exercises ADR-0131’s promise: panels.kdl names the node, this module declares a WidgetCatalog, and the showcase never mentions either.
  • An application must add MarkdownStyles.stylesheet() beside Controls.stylesheets(theme). :widgets does not know Markdown exists, so it cannot add them, and a document with no rules renders as unstyled words.
  • html-view is still a gap, and G8 stays open in docs/gaps.md with the Markdown half struck through. What it waits on is unchanged: rounded geometry and gradients on the export list, and a native document_container.
  • No hard break inside a paragraph. A wrapping row has no widget meaning “start a new line here”, and a spacer with flex-grow — the obvious trick — would make the line before it look justified. Two trailing spaces therefore do nothing in the widget renderer, and do the right thing in the HTML one.

Alternatives considered

  • Wait for litehtml. The honest reading of content-widgets.md §1, and it means brd serves raw Markdown for another milestone while the work that unblocks it is a native paint surface shared with two modules that do not exist yet.
  • One package for HTML and Markdown. Fewer names, and it makes the cheap half hostage to the expensive one: everything in the module would import a package whose other half needs a rasterizer and a C++ container.
  • A String of HTML instead of a model. md4c to HTML in C, and a widget that renders HTML — which is litehtml again, or a second parser for the HTML this module just produced.
  • One text per paragraph, marks dropped. One widget instead of five hundred, and **bold** renders as bold nothing. The preview’s entire job is to show the marks.
  • Per-word widgets only where a run is styled, keeping unstyled prose as one text. It halves the widget count for plain paragraphs and makes the line breaking inconsistent: a wrapped plain run breaks anywhere, a styled one breaks at the run boundary. One rule is worth more than the nodes.

296. A preview is a binding, not a callback

Date: 2026-09-13

Status

Accepted. Extends ADR-0295; ADR-0297 is what building the showcase screen for it found in the catalog.

Context

ADR-0295 shipped markdown-view taking a Document. That is the right shape for a note somebody opened, and it is not a shape an editor can use: a live preview re-parses on every keystroke, and a widget that takes a finished document leaves the application to notice the keystroke, re-parse, and rebuild the subtree by hand.

docs/gaps.md G8 names that case explicitly — “the Note editor’s live preview” — so it is the case to get right rather than a nice-to-have.

The toolkit already has an answer for “this widget’s content comes from somewhere”: §9’s bind=, which ADR-0062 gave to text, badge, progress, select and every field. An element subscribes to whatever its widget returns from binding(), and a change marks it for rebuild.

Decision

markdown-view is bindable, and that is the whole of “live”

split-pane {
    text-area class="mono" bind="md.source" change="md.set-source" fill=#true
    scroll { markdown-view bind="md.source" }
}

Two nodes, one property, and nothing between them. The editor writes the property through an action; the preview’s element is subscribed to it; a keystroke marks that element for rebuild; the next frame is the parsed document. No controller, no listener in the application, no diffing.

In Java it is MarkdownView.following(property), and what is on screen right now is resolved() — read at build rather than captured at construction, which is the rule text already follows.

The dialect rides the widget

A bound view parses text the widget never saw at construction, so it has to know how: MarkdownSyntax is a component of MarkdownView, defaulting to gitHub(), and a document may name syntax="commonmark". Two words rather than a list of extensions — a document choosing bit by bit would be a document with opinions about md4c’s flags, and composing a dialect is Java’s job.

The parse is not cached

A keystroke re-parses the whole document. md4c reads a note in microseconds (ADR-0294) and the rebuild that follows costs far more than the parse — so a cache would be a lifetime to explain, an invalidation to get wrong, and nothing measurable to show for it. The number worth knowing is the widget count, which ADR-0295 already put at roughly one per word.

The showcase gains a screen, and it is two nodes of markup

The gallery had eight screens and now has nine. Markdown is a split-pane: the editor on the left, the same property rendered on the right. It is the screen that demonstrates an optional module — goldberry-html is not a dependency of the toolkit, so the application adds it, adds MarkdownStyles.stylesheet(), and gets a node it never registered (ADR-0131).

The sample it opens with is a resource rather than a Java text block, and covers every construct the parser reports: headings, marks, links, images, both kinds of list, tasks, quotations, fences, a table, entities and raw HTML. A text block would have eaten its backslashes, its backticks and the two trailing spaces that are a hard break.

Consequences

  • brd’s editor is two nodes, and its body.html is the same property through MarkdownHtml. The two cannot drift, because there is one source and one parse per frame.
  • An unbound markdown-view is unchanged. The document component is still there and is what a bound view falls back to before its property answers — which is what a lenient inflater produces for a path nothing resolves yet (ADR-0062).
  • A rebuild per keystroke. For a preview pane that is right; for a document of a hundred pages it is the widget count that bites first, and both numbers are in book/src/TODO.md rather than discovered later.
  • markdown-view cannot write. binding() is an Observable, so the preview reads and the editor reports through an action — ADR-0063’s rule, and the reason a document cannot quietly edit a model.
  • Nine screens, one digit left. Ctrl+1…Ctrl+0 still covers the gallery, and the tenth screen will be the one that has to argue for itself.

Alternatives considered

  • A controller, like toast’s. The editor holds one, the preview listens. That is the shape a toolkit without bind= would need; here it would be a second mechanism for what §9 already does, and every application that wanted a preview would write the same ten lines.
  • The application parses and passes a Document. It is what ADR-0295 shipped and it still works — but for the live case it means the application subscribing, parsing and calling setState, which is the widget’s own rebuild written out by hand.
  • Caching the parse by text identity. A WeakHashMap keyed on the string, or the last (text, document) pair on the widget. Widgets are values rebuilt every frame, so the cache would have to be static or on the state — and it would save microseconds while the rebuild beside it costs milliseconds.
  • A preview= attribute on text-area. The editor points at the view it drives. It reads well in markup and puts document rendering inside a form control, which is a dependency :widgets must not have.

297. An editor fills its pane, and a split knows its own width

Date: 2026-09-13

Status

Accepted. Two defects in :widgets, both found by building ADR-0296’s screen, and both older than it.

Context

The Markdown screen is a split-pane holding a text-area and a preview. Three frames of it were wrong, and none of the three was Markdown’s fault.

The divider was not where it was told to be. position=0.5 put it at 123 points of a 1200-point pane. SplitPaneState takes the pane’s measured length and assigns it to a field — deliberately not through setState, with a note saying “nothing drawn depends on it directly” and citing ADR-0119’s warning about a widget that rebuilds for ever from its own size.

That note stopped being true when the first pane started being sized in points from that number. Without a rebuild the view kept the -1 it had been built with, so the pane stayed on its first-frame proportional guess for ever — and a drag was the only thing that ever corrected it. Every test that passed, passed because it dragged.

The editor opened at the end of the document. TextEdit.of puts the caret at the end of the value it is given, which is right for a field somebody is about to type into, and TextAreaState.laidOut keeps the caret’s line in view from the first layout. A text-area holding a hundred-line note therefore opened on line one hundred, and the reader had to scroll up to find the beginning of their own document.

The editor could not fill its pane. §4’s text-area grows to fit its text between rows and max-rows, which is what a field in a form should do. An editor is the other thing a multi-line field is: it wants the height its container has, with the text scrolling inside it. rows=22 left a third of the pane empty and clipped the editor on a short window.

Decision

A measurement that changes the pane asks for a rebuild

private void measured(Extent bounds, Extent part) {
    var measured = widget().axis().isVertical() ? bounds.height() : bounds.width();
    if (Math.abs(measured - length) < 0.5) {
        return;
    }
    setState(() -> length = measured);
}

ADR-0119’s warning is still the rule this obeys rather than an argument against reacting at all. What is measured is the split pane’s own length; what the rebuild changes is its children’s. The value cannot feed itself, so the second frame is a fixed point — which the new tests assert by measuring twice and checking the second one asks for nothing.

The one arrangement where that is not true is a split pane inside a parent that sizes to its content, and controls.css rules that out for the default case by giving every split-pane flex-grow: 1.

The caret is only chased once somebody touches the control

TextAreaState keeps a caretMatters flag, false until a press, a key, an edit or the focus arrives. Until then laidOut leaves the scroll offset where it is, so an untouched area shows the top of its value — which is what every text box on the web does and what a reader handed a document expects.

TextEdit.of’s caret-at-the-end is untouched: it is right, and the moment the control is focused the content follows the caret exactly as it did before.

text-area fill=#true takes the height its container gives it

text-area class="mono" bind="md.source" change="md.set-source" fill=#true

The box grows (flex-grow: 1) instead of sizing itself from its line count, and the number of visible lines — which decides how far the text may scroll and how many selection highlights are built — comes from the measured height instead of from max-rows. One method, visibleRows(), is read by all three, because the three disagreeing is a selection that runs out half way down a pane.

rows and max-rows are then ignored, and that is stated rather than reconciled: a filling area’s height is the layout’s answer.

This is an addition to core-widgets.md §4, which describes the field and not the editor; recorded in docs/ARCHITECTURE.md §17.1 with the rest.

Consequences

  • Every gallery golden moved, and two of them moved for a reason worth reading: the Panels screen’s split pane now honours the position its document asked for, where before it landed wherever its two children’s content did.
  • A resize costs one extra build in a split pane and in a filling area — the frame that learns the new size. Measured already fires only on a change, and both guards ignore a sub-pixel difference, so a still window still rebuilds nothing.
  • A filling area in a container with no height is one line tall. That is flexbox being asked for something impossible rather than the control being subtle, and it is what the javadoc says.
  • text-input has the same caret-at-the-end behaviour horizontally: a single-line field holding a long value shows its end. It is one control, one flag and the same argument, and it is in book/src/TODO.md rather than done here — a field is not a document, and changing two controls on one screen’s evidence is how a fix becomes a regression somewhere nobody looked.
  • Neither fix is Markdown’s. Both are in :widgets and both are covered by :widgets tests, which is where the next screen that puts a document in a pane will find them already working.

Alternatives considered

  • Sizing the split’s first pane as a percentage. No measurement, no rebuild, correct on the first frame — and the divider’s six points cannot be taken out of a percentage, so the drag arithmetic (pixels to a fraction) and every point-exact assertion in SplitPaneTest would have had to change with it. The measurement is needed for dragging anyway.
  • A CSS height on the text-area. The stylesheet says how tall the editor is. It contradicts the note in TextAreaBox — auto-grow is a function of how many lines the text wrapped into, which no selector can ask — and it would make a theme able to stop a form’s fields growing.
  • Caret at 0 in TextEdit.of. Fixes the opening scroll for both controls in one line, and changes what happens when an application replaces a field’s value while somebody is typing, which is a behaviour four other controls lean on.
  • Leaving the split alone and giving the screen fixed pane widths. The screen would look right and the widget would still be wrong for everybody else.

298. HTML is a document, and not an engine

Date: 2026-09-13

Status

Accepted. Closes docs/gaps.md G17 — html-view — and answers the part of it that asked for litehtml with no, not for this.

Builds on ADR-0295, whose shape this copies exactly, and on ADR-0293, whose button.link is what an anchor becomes. Supersedes docs/content-widgets.md §1.1’s architecture for html-view and leaves §1.2’s — Markdown through litehtml — dead where ADR-0295 left it.

Context

G17 is the last entry on brd’s list and the only one nothing has asked for. It exists because content-widgets.md §1 specifies html-view beside markdown-view, and because G8 used to promise both — so the remainder should be visible rather than quietly dropped.

Its own text says what it waits on, and that has not changed since it was written:

litehtml’s document_container is a C++ virtual class, which FFM cannot implement, so it lives in a native library of its own and draws through libgoldberry’s exported C symbols — and that surface has no rounded geometry.

That is a real project. A second superbuild, a second native artifact with four classifier jars and four CI legs (ADR-0190), a C++ container implementing fifteen-odd callbacks against the toolkit’s text stack, and first a widening of the exported paint surface with rounded geometry and a nested state stack in it — work that is shared with goldberry-vector and goldberry-terminal and that renders no HTML on its own.

Meanwhile the thing G17 is actually for — authored content on screen: a help page, a changelog, the HTML half of an email, a note somebody kept as HTML instead of as Markdown — needs none of it. ADR-0295 had already proved that on the other half of this module: md4c’s events became a tree of records and a fold into column, row and text, and a Markdown document renders under the ordinary cascade with no engine anywhere.

The question this record answers is therefore not “how do we embed litehtml”. It is “is the engine what G17 wants, or is a document what G17 wants”.

Decision

A document, parsed in Java, folded like a note

html-view ships now, with no litehtml under it and no new native symbol:

io.github.digitalsmile.goldberry.html          Html.parse
io.github.digitalsmile.goldberry.html.model    the page, as records
io.github.digitalsmile.goldberry.html.view     html-view, HtmlStyles
var document = Html.parse(page.body());

var view  = HtmlView.of(document).onLink(app::navigate);
var links = document.find("a");
var words = document.text();
scroll { html-view bind="doc.source" link="doc.open" }

Every decision below is ADR-0295’s, restated because it turned out to be about documents rather than about Markdown:

  • A document is a value. HtmlNode is sealed over HtmlDocument, Element, HtmlText and Comment; a fold over it is an exhaustive switch that stops compiling when the model grows. A preview is not the only thing an application does with a page — an outline, a link check, a word count, an image prefetch — and a widget that hid the parse would make each of them a second parse.
  • A paragraph is a wrapping row of words, one text widget each, because the text stack shapes one font per run. The two views share the code that does it: content.inline.Words, in a package neither of them owns and nothing exports.
  • The appearance is a stylesheet. html.css in the TOOLKIT_BASE layer, added by the application beside Controls.stylesheets(theme). This is content-widgets.md §1.4’s “master stylesheet generated from the active theme”, written in var(--gb-*) so that there is nothing to regenerate on a theme switch.
  • A preview is a binding (ADR-0296). bind= re-parses the property on every build; the showcase’s HTML screen is the Markdown screen with one node name changed.

The tag is an open string, and the class on a widget is the tag

Element.tag() is a lower-cased String rather than an enum, and this is the one place the two halves of the module genuinely differ. Markdown has a closed vocabulary — md4c reports one of twenty block types — and HTML has not had one for a decade. So:

  • the node kinds are sealed and exhaustively matched, and
  • the tags are open: an element contributes the class html-<tag>, and html.css decides what that looks like.

There is therefore no table in Java mapping em to “emphasis”. html.css reads like a browser’s default sheet, <my-callout> renders as a block and is already styleable, and adding a rule for <figure> is a rule rather than a commit to the fold. Only the handful of tags whose structure differs — a list’s gutter, a quotation’s bar, a fence’s lines, a table’s rows, an anchor — are named in Java.

A document’s own class="callout" comes through as html-callout: the same namespace, so a page cannot be restyled by an application’s rule for its own .callout and an application styling its documents has one prefix to learn. An id is not forwarded at all, because an id is unique in a tree and a page’s are the author’s.

The one thing markdown-view cannot do, and the reason it cannot is worth stating: a Markdown link is four words, so following one needs hover and press on a run that the fold has already split. An HTML anchor is an element with a label, so the whole run is one widget — a Button carrying ADR-0293’s link variant, with the anchor’s text as its label.

That buys a Tab stop, :hover, Space and Enter, and an accessible name, for free and from the catalogue. What it costs is one rule that overrides a deliberate decision of the toolkit’s, written down in html.css beside the rule: ADR-0293 keeps the 32px button height because §1.3 makes it a floor for a control, and an inline link takes height: auto. A link inside a sentence is not chrome — it is a word, its target is the line it sits on, and a 32px-tall word would set every paragraph containing a link on triple-spaced lines. Every browser does the same. A block-level action in a page is still a button somebody wrote and still gets the floor.

Following is the application’s. onLink is handed the href and nothing else: no browser opens, no relative path resolves, nothing is fetched. That is ADR-0291’s division for URL schemes and ADR-0190’s for images, applied to the place a reader is most likely to expect otherwise.

There is no failure mode

Every string is a page. A stray </div>, an unclosed <p>, a < b in a sentence, a file truncated mid-comment — each has a recovery written beside the code that performs it, because a content renderer that threw on a page a browser draws is useless for the corpus it exists to read. The two that matter most:

  • A <p> is ended by any block tag and by no inline one, which is how paragraphs are actually written.
  • <li>First<li>Second is two items, not one inside the other. Without that table the second bullet is drawn indented under the first, which reads as a styling bug and is a parsing one.

And the list of what it is not, in the API’s own words

Html’s javadoc carries it, so that nobody has to discover it:

  • Not a browser. No scripting, no network, no navigation. The README’s promise was always “renders your HTML content, beautifully and offline”.
  • Not the HTML5 parsing algorithm. No implied html/head/body, no foster parenting, no adoption agency, no namespaces. A fragment stays a fragment.
  • Not a CSS engine. <style> and style= are kept in the model and applied by nothing; the cascade is the application’s stylesheets, which is what makes a page follow the theme instead of fighting it.
  • Not a resolver. An href and a src are strings.

litehtml stays open, and stays exactly where it was

This record does not delete the engine from the plan; it removes html-view from the list of things waiting on it. What an engine still buys, and what nothing here does:

  • Real inline layout — a line of mixed faces as one shaped run, with justification, hyphenation, and a selection a reader can drag across two faces.
  • Text selection, which follows from it.
  • The rest of CSS: floats, positioned elements, vertical-align, per-side borders, border-radius on a page’s own boxes.

When something asks for those, the work is what book/src/TODO.md already describes — the wider paint surface first, shared with two other modules — and the model above is what it would render, with HtmlWidgets becoming the second renderer rather than the only one.

Consequences

The :html module has two widget trees, and that broke the weaver. The catalog is written into “the longest package prefix every widget shares”, which for markdown.view.MarkdownView and html.view.HtmlView is io.github.digitalsmile.goldberry — a package :core owns. Two named modules containing one package is a LayerInstantiationException on the module path, so the first application to put both content widgets on its path would not have started, and no class-path test could have seen it. CatalogWeaver now descends from that prefix to a package the module actually has a class in. It is a latent bug this change exposed rather than one it introduced: any module with two widget trees had it.

Two stylesheets, not one. An application that renders notes and never pages adds MarkdownStyles.stylesheet() alone. The two sheets agree about sizes and gaps on purpose — controls.css says the controls “must not look like they were designed by different people”, and two kinds of document are no different — but neither names the other’s classes, and a test says so.

Words moved, from markdown.view to content.inline, and grew a Piece that can be a widget. That last part is what puts a link’s full stop against it: a button flushed as a widget of its own left the help . on the screen, which is the exact mistake ADR-0295 recorded about *emphasis*, and. Entities moved the same way and grew resolveAll, because md4c marks an entity for the Markdown side and an HTML tokenizer has to find its own.

The showcase has ten screens, which is exactly the ten digits a keyboard has. The eleventh is now a decision about which screen loses its accelerator rather than an addition.

No new native symbol, and no new native artifact. The one thing that crosses is md4c’s entity table, which was already there — 2125 names through one exported symbol rather than a copy that drifts (ADR-0010). A machine with no libgoldberry can parse HTML; it just cannot resolve &nbsp;.

A page is roughly one widget per word, as a note is (ADR-0295). The same bound applies and the same thing would fix it, which is the virtualization argument applied to a document and which nothing needs yet.

Alternatives considered

Build litehtml, as G17 and content-widgets.md §1.1 specify. Rejected for now, not on the merits: it is the only way to get real inline layout, and the first commit of it is a paint-surface widening that two other planned modules want. What settled it is the ordering — that work renders nothing until all of it is done, and this renders a help page today. The record above keeps it open.

Render HTML by translating it into the Markdown model. Rejected. It is lossy in the direction that matters — div, span, attributes, and every tag the author invented — and it points the module’s dependency the wrong way: ADR-0295’s split exists because Markdown produces HTML and never reads it.

A Tag enum. Rejected: it would make a fold exhaustive and <my-widget> unrepresentable, and authored HTML has been full of invented elements for a decade. The compromise above — sealed kinds, open tags — keeps the exhaustiveness where it catches something.

Forward a document’s class and id unprefixed, so a page’s own stylesheet vocabulary reaches the cascade directly. Rejected: a page’s class="card" would then be styled by the application’s rule for its own cards, which is a wrong picture with no visible cause. The html- prefix is one rule to explain and impossible to collide with.

Draw an <img>. Deferred, and it is not an engine’s problem: Image.decode and Frame.drawImage have existed since ADR-0283, and what is missing is an img widget in the catalogue and an answer about who fetches. Both are somebody else’s record. Until then an image is its alt text, exactly as in a Markdown note.

Make links inert, like markdown-view’s. Rejected: it is the one capability G17 promised over G8 that costs nothing here, and a help page whose links do not work is a help page nobody trusts.

299. A cache smaller than one frame is worse than no cache

Date: 2026-09-13

Status

Accepted. Makes [ParagraphCache]’s capacity a starting point rather than a ceiling, and fixes the benchmark that should have caught the defect and was measuring a screen that no longer exists.

Context

markdown-view and html-view build one text widget per word (ADR-0295, ADR-0298), which both records say out loud costs “roughly one widget per word” and treat as a widget-count problem to revisit if anything needs it.

It was not a widget-count problem. It was a cache problem, and it was 11× on the frame path:

screenelementsstyle pass, settledparagraphs shaped, settled
Basic (a wall of cards)2191.0 ms0
Markdown8669.5 ms287
HTML8606.2 ms313

Two hundred and eighty-seven paragraphs shaped on a frame where nothing had changed — no build, no style resolution, no invalidation, on a tree the previous frame had already drawn. Each one is 56 µs of HarfBuzz (ADR-0037) to arrive at glyphs the cache had held moments earlier.

The cause is arithmetic rather than a subtle interaction:

ParagraphCache.DEFAULT_CAPACITY is 256 — “a screenful of distinct strings, roughly”. A page of six hundred words asks for six hundred distinct paragraphs per frame. With least-recently-used eviction, each lookup evicts the entry the walk is about to reach, so the hit rate on the excess is not lower, it is zero — and the cache pays for the eviction on top of the shaping.

The comment was true when it was written: the workload it was sized for is a window of controls, where a screenful of distinct strings is forty. A document is a different shape of tree, and it arrived two hundred records later.

Nothing caught it, and that is the second half of this record. FrameBudgetTest exists precisely to catch “the showcase spent a month painting at 10–15 ms with nothing moving” (ADR-0142) — and every measurement in it named the screen "controls", which stopped being a screen when the gallery was reorganised into questions rather than widget families (ADR-0222). pickScreen set a property no tab matched, so the budgets were measured against a window with no screen selected. A benchmark measuring the wrong tree is the exact failure that file’s own comment describes, about FrameBenchmark measuring a synthetic 15-node tree.

Decision

The cache sizes itself to the frame it is drawing

DEFAULT_CAPACITY stays 256 as a starting point, and the cache grows to fit the working set it is actually asked for, bounded by MAX_CAPACITY = 8192:

  • During a frame, in the eviction hook. If this frame has already asked for as many paragraphs as the cache holds, then the least-recently-used entry is by definition one this frame touched — so the cache doubles rather than discard work that is about to be asked for again. This is what makes the first frame of a long document the cheap one rather than the one after it.
  • At the end of a frame, in frame(), which the renderer calls once per render: capacity rises to a quarter more than the frame asked for, so a document that gains a word does not re-tune.

It does not shrink. A window that showed a long document once can show it again, and giving the memory back would cost the next visit the shaping it just paid for; the whole cache is thrown away with the renderer anyway.

The ceiling is a real bound rather than a gesture: 8192 entries of six int[]s the length of a word is under two megabytes. A frame whose working set is larger thrashes exactly as every frame used to — which is the honest failure mode, and the alternative is a cache that grows until something else runs out.

The measured result

Same machine, same trees, after the change:

screenstyle pass, settledparagraphs shaped, settled
Markdown9.5 → 0.8 ms287 → 0
HTML6.2 → 0.6 ms313 → 0

A scrolling frame — a wheel event, a flush and a layout — is 1.1–2.3 ms with nothing rebuilt and nothing re-resolved, so what remains of a scroll is layout and raster.

Culling was considered and measured away. The obvious next move is to skip boxes outside the viewport’s clip, and the numbers say it would buy nothing here: the raster of the 860-box document screen (8.0 ms at 1280×900, one thread, no damage) is the same as the 219-box wall’s (8.3 ms), because rasterization is pixel-bound and Blend2D already rejects a clipped box cheaply.

The guard is a count, not a stopwatch

FrameBudgetTest now measures screens that exist — and treeFor refuses a name the gallery does not have, so this cannot silently recur — and it gained the assertion that would have caught this in the first place:

Render an unchanged tree twice; the second render shapes no paragraphs.

A count rather than a duration, so it holds on any machine and says exactly what is wrong when it fails. WidgetRenderer.paragraphs() is public for it, which is also what a diagnostic wants: “this window’s text working set is 692, the cache holds 865”.

Consequences

Documents are usable. This is the whole point: an eleven-times-cheaper style pass on the screens a reader spends time in, with no change to what is drawn.

Every application gets it, not only the content views. Any tree with more distinct strings than 256 — a long table, a big tree view, a list of file names — was paying the same toll silently.

Memory grows with the document, up to the ceiling, and a renderer that has shown a big page keeps that capacity for its lifetime. capacity() and highWaterMark() say so when somebody asks.

The identity that the retained render tree relies on is restored. ADR-0069’s measure callbacks are kept when a paragraph is the same instance frame to frame; under thrashing they were new objects every frame, so the layout pass was paying for rebinding as well.

FrameBudgetTest was measuring nothing for some months, and the fix is a guard rather than a correction: naming a screen that is not in the gallery now throws. That is the general lesson of this record — the benchmark and the thing it benchmarks drifted apart silently, which is the same failure as a cache and its workload drifting apart silently.

Alternatives considered

Raise DEFAULT_CAPACITY to a bigger constant. Rejected: it trades one wrong number for another. 2048 is wasteful for a window of controls and still too small for a long article, and nothing in the constant says which workload it was chosen for.

Let the application configure it. Rejected as the only answer: the renderer is built by the launcher, so an ordinary application has no reach to the knob — and a toolkit that needs tuning to draw a document at a sensible speed has the default wrong.

Make the views build fewer paragraphs — one text per unmarked run rather than per word. Rejected here, and it is not free: a coalesced run wraps inside its own box, so a paragraph holding one would have a different leading from the one below it and a mark in the middle of a line would break the wrapping. It is also unnecessary now that the cache fits: the shaping is paid once.

Cull boxes outside the clip. Measured and rejected — see above. Worth revisiting when something is bound by draw calls rather than pixels.

300. A document is read, and the application answers

Date: 2026-09-13

Status

Accepted. Makes both content views interactive — links a reader can press, images that are drawn, task boxes that tick — and draws the line in the same place every other record about the toolkit’s edges draws it.

One sentence below is superseded by ADR-0399: “toggleTask is a scan, not a parse-and-write”. The one-character edit it argues for is kept and is the part that matters; the scan is gone. The risk this record named in the next paragraph — “two counters must agree: md4c’s, walking the model, and a regex, walking the source” — is exactly the bug that arrived, and the answer was to stop having two.

Builds on ADR-0298, which put a button.link where an HTML anchor was and left the Markdown view’s links as colour; this finishes the job in both directions. Text selection is not here, and the last section says why and what it would take — ADR-0301 then built it to that design.

Context

Both views shipped as renderings a reader cannot touch. Three things in particular, and a fourth that is different in kind:

  1. A Markdown link is a colour. ADR-0295 explained why — “a word is a text widget and a widget’s pointer handling is per node, so a link that spans four words would be four hover targets and four handlers” — and ADR-0298’s Words.Piece quietly removed the obstacle: a piece can be a widget, so a link’s whole run is one node. The reason stopped being true and the code did not notice.
  2. An image is its alt text. ADR-0283 shipped Image.decode and Frame.drawImage in 2026; what was missing was a widget that draws one and an answer about who fetches. docs/gaps.md G17 said so out loud: “not built, and not the engine’s”.
  3. A task box is a picture of a check box. TaskMark’s own javadoc claimed the model carried a source offset to edit at. It does not — the claim was written for an Item.taskMarkOffset that was never built — so the feature was waiting on something that did not exist.
  4. Text cannot be selected. Which is the engine’s, and is treated separately below.

The common shape of the first three is worth naming, because it is what makes them one record: each needs an answer only the application has. What a link does, where a src is, what ticking a box means — none of these is the toolkit’s, and each of them was being answered with “nothing” because there was no way to ask.

Decision

MarkdownView.onLink(Consumer<String>) and onWikiLink(Consumer<String>); in markup, link= and wikilink=. An anchor already worked this way in html-view; this is the same code path through Words.Node, so the full stop after a link stays against it rather than becoming a word of its own.

Two handlers, not one. An href points somewhere and a [[wiki link]]’s target names something in the application’s own collection — which is the reason that extension exists at all (ADR-0295). A view that routed both to one consumer would be handing over two kinds of string and asking the application to guess.

A link with no text — one wrapping only an image, or an empty destination — is folded inline instead, because a button with nothing on it has nothing to click and nothing to read out (§13).

Following is still the application’s. Nothing here opens a browser, resolves a relative path or fetches anything — ADR-0291’s division, at the place a reader is most likely to expect otherwise.

An image is drawn when the application says where it is

ImageSource assets = src -> cache.get(src);          // the application's answer

var view = MarkdownView.following(model.source()).images(assets);
markdown-view bind="note.source" images="app.assets"
  • ImageSource is exported and is the only type in io.github.digitalsmile.goldberry.content: a src is a string whose meaning is the application’s — a path relative to something only it knows, a key in a store, a URL nothing here may fetch (ADR-0190).
  • Picture is a part, in a package that is not exported: a CSS type a stylesheet reaches and nothing constructs (ADR-0065).
  • A source that answers null draws the alt text, which is what every view did before and what a broken src should still show. There is no placeholder and no exception — a renderer that drew a broken-image icon would be inventing content.
  • In markup it is images=, which names an object through Wiring.handle — the third registry, for a thing that is neither a value nor a method (ADR-0130).

The size is arithmetic in Picture rather than a measure callback, because the toolkit’s measured leaves are text and icons and there is no general measure hook on Box. So: natural size, capped by max-width/max-height in proportion, and a percentage cap ignored rather than guessed at — resolving one needs the container’s width, which only a measure callback has. Both stylesheets write their caps in points and say so beside the rule.

This is also what makes it cheap: an Image is a value that owns no native handle (ADR-0283), so it can be held in a widget that is rebuilt every frame and cached in a map by the application that owns it.

A task box reports an ordinal, and the application rewrites one character

MarkdownView.following(model.source())
        .onTask(index -> notes.setSource(Markdown.toggleTask(notes.source(), index)));

The round trip: the view hands over which box — the nth task in document order — Markdown.toggleTask flips that one marker in the source, the application stores it, and the preview re-parses because it is bound to the property (ADR-0296). Nothing in the toolkit writes to anything.

An ordinal, because the model has no offsets. The alternatives were adding source offsets to the model — which is a change to a public value type for one feature, and md4c’s own offsets are byte offsets into a UTF-8 buffer the model no longer has — or handing back the Item and asking the application to find it. An index is the smallest thing that identifies a box, and it is the same number on both sides because both count tasks.

toggleTask is a scan, not a parse-and-write. A re-serialisation returns a document with the same meaning rather than the author’s file — it would reflow their tables, renumber their lists and normalise their line endings because somebody ticked a box. So one character changes and every other byte comes back as it was. A marker inside a fenced code block is skipped, because it is a program that contains - [ ].

The risk in that decision is that two counters must agree: md4c’s, walking the model, and a regex, walking the source. TasksTest holds them together — it toggles every task in a document and asserts the parser sees exactly that one box change.

A box nobody wired stays inert, is not focusable, and reports isDisabled() — a Tab stop that responds to nothing is a keyboard trap with extra steps (ADR-0281’s rule for canvas).

Text selection is not here, and this is what it needs

The honest statement of the gap, so the next person does not rediscover it:

A selection spanning a document needs three things this toolkit does not have arranged the right way. Hit-testing a point to a word and an offset in it: the views build hundreds of text widgets, and a widget cannot see its children’s laid-out rectangles — only its own, through Measured. Making every word report its rectangle would double the element count, which is the cost ADR-0299 has just finished paying down. Painting the highlight without rebuilding: a selected class per word means a rebuild of the document on every pointer move, which is exactly the shape of frame the last record removed. Character granularity: TextGeometry can answer inside one paragraph, and a selection across two faces is a different question.

The design that fits is a selection overlay: one node above the document that owns the anchor and focus, hit-tests against a geometry the layout pass hands it once per layout rather than per frame, and draws the highlight itself as a painter rather than as a class on six hundred words. That is a change to how a subtree reports its geometry, which is the toolkit’s business and not this module’s — which is why it is a separate piece of work and is recorded in book/src/TODO.md rather than half-built here.

ADR-0301 is that piece of work, and it landed: what the section above describes is what it built, with Located answering the first question, a painter reading mutable state answering the second and Paragraph.offsetAt answering the third.

Consequences

Both views are the same shape again. Every capability above exists in both, except tasks — GitHub’s task list is a Markdown extension and HTML has no equivalent, so html-view has nothing to offer there.

The showcase demonstrates the division rather than describing it. Both screens have a line under the preview that fills in with what the application was handed, and ticking a box in the Markdown preview rewrites the text in the editor beside it — which is the clearest possible statement that the toolkit reports and the application decides.

MarkdownView grew from four components to eight, and that is a real cost. The four-argument constructor is kept so existing callers compile, and the four new ones are @Nullable with “not wired” as the default everywhere.

TaskMark became a control. It is Handles and Semantics now — a Role.CHECKBOX that is disabled when inert — so it can take Space and Enter and appear to a screen reader as what it is.

Three claims in the documentation were wrong and are corrected, not quietly: ADR-0295’s “nothing is clickable”, TaskMark’s “the model says where”, and TODO.md’s image bullet. Each of them was true when written and had stopped being true for a different reason.

Alternatives considered

Make every word clickable. Rejected, and it is what ADR-0295 rejected: four hover targets and four handlers for one link, none of which knows it is part of a run. Words.Node makes the run one widget, which is the only version that hovers correctly.

A src= resolver inside the module — a file loader, a classpath resolver. Rejected: it decides what a relative path is relative to, which is the question Icons and the stylesheets each needed their own answer for, and it puts a file system under a widget that gets rebuilt on every keystroke.

An img widget in :widgets. Considered and deferred: a general image widget wants object-fit, intrinsic ratios in the layout engine and a measure hook, and none of that is needed to draw a picture in a document. Picture is a part in the module that has the requirement; when a second module wants one, that is the evidence for promoting it.

Source offsets in the Markdown model, so a task could be edited precisely. Rejected for now: it is a change to a public value type, md4c’s offsets are into a byte buffer the model does not keep, and an ordinal is exact for the one thing that needs it. If a second feature wants offsets — a click-to-edit editor, an inline comment anchor — that is the evidence, and the model can grow a component.

Half a selection: click-to-select a paragraph, or a “copy” button on each block. Rejected as inventing a UI: a reader who drags across text expects a selection, and an affordance that is not the one they reached for is worse than an honest absence.

301. A selection is geometry the frame already had

Date: 2026-09-13

Status

Accepted. Makes both content views selectable — drag, double-click, triple-click, Ctrl+A, Ctrl+C — and closes the last item on docs/gaps.md G17 that was not an engine’s.

Completes ADR-0300, whose “Text selection is not here, and this is what it needs” section is the design this carries out. Depends on ADR-0119’s Located and ADR-0299’s paragraph cache, and it could not have been built cheaply without either.

Context

Both views rendered documents a reader could not take a copy of, and ADR-0300 listed three obstacles rather than building it:

  1. Hit-testing a point to a word and an offset. build and render run before Yoga, so a word has no idea where it is (ADR-0080), and a widget can see its own size but not its children’s rectangles.
  2. Painting the highlight without a rebuild. A selected class per word means a rebuild of six hundred widgets per pointer move — the frame shape ADR-0299 had just removed.
  3. Character granularity. A selection that snapped to whole words is not a selection anybody uses twice.

Each of those had an answer already in the toolkit; what was missing was the observation that they fit together.

Decision

Every word says where it landed

Located — the third geometry facility, and the only one that carries a position — tells a widget where the last frame painted it and what clips it, in window coordinates, once a frame and only when it changes. That is exactly what a selection needs, and scrolled is the position it reports, which is what “where the reader sees the words” means.

So a document’s words are no longer text widgets from the catalog but content.select.Word:

  • it draws what text draws, through the same measured-leaf box and the same paragraph cache — one widget per word either way, which is the whole reason this is affordable after ADR-0299;
  • it is Located, so it reports its rectangle into a WordGeometry;
  • it hands its shaped Paragraph to that geometry, which is what turns an x into a character offset — Paragraph.offsetAt, the same arithmetic a caret in a text-input uses.

A wrapper node per word was the obvious alternative and is the one that costs: twice the elements for a document, which is precisely the cost the previous record paid down.

A drag repaints; it does not rebuild

The selection is mutable state — two carets — that the pointer handler writes and an overlay’s painter reads at paint time. A pointer move therefore costs one Host.repaint(): no build, no cascade, no layout. That is the decision the rest of the design hangs off, and it is why the highlight is a painter rather than a class.

The overlay is selection-layer, absolutely positioned and inset to nothing, so it fills the document’s padding box and contributes no layout. It is the first child, because paint order is document order: the wash goes behind the words rather than over them. It is Located too, which is how window rectangles become its own coordinates with no assumption about padding or borders.

Its colour is var(--gb-selection) from the cascade, so a theme decides what a selection looks like.

What a reader can do

Drag to select; double-click for a word; triple-click for a block — a paragraph, a heading, a cell, a line of a fence; Ctrl+A for the document; Escape to let it go; Ctrl+C to copy. Links and images are part of a selection: their rectangles are washed and a link’s label is in what gets copied, because a selection that skipped them would copy “Read first.” out of “Read the help first.”

The copied text carries the separators the document implies — a space between words, a newline between blocks — and that information is the fold’s, because a wrapping row draws its spaces as gaps between boxes rather than as characters. Each word therefore carries the separator that belongs in front of it, and WordMinter is where the fold says so. Without it a copy pastes Thequickbrownfox.

Two things it deliberately is not: an editor — no caret, nothing blinks, nothing can be typed — and a drag that starts on a link or a task box, because the router captures on press and that press belongs to the control.

A selection is dropped when the document changes under it

A preview re-parses on every keystroke. The geometry notices when a build registers different words from the last one and the selection is cleared, because keeping it would highlight whatever is now at those indices. A rebuild that changes nothing keeps it, which is the half that makes the rule worth having: a frame that re-parsed the same text must not take a reader’s selection away.

Consequences

The last non-engine item on G17 is closed. What litehtml would still buy is real inline layout — a line of mixed faces as one shaped run, and with it justification and hyphenation. Selection is no longer on that list.

A document’s words are a word CSS type rather than text. No stylesheet in the repository styled a bare text type, and both content sheets style the md-word / html-word classes, which are unchanged — so nothing moved visually, which the goldens say. An application that styled text inside a document would have to say word now.

Every word is Located, so the router notifies six hundred nodes a frame. It is a map write and a small record each, measured at no change to the document’s style (0.64 ms) or layout (0.82 ms) pass. Worth knowing it is there: the notify walk is now proportional to the words on screen rather than to the handful of widgets that used to ask.

The view is stateful now, through one SelectableDocument node between the view and the fold’s column. MarkdownView and HtmlView stay the same records they were — the state is in a node they build, which is also what lets both halves share every line of this.

A selection-host node wraps every rendered document. It hears the pointer and the keyboard, is focusable — Ctrl+C goes to whatever has the focus — and carries cursor: text. It draws nothing. One consequence is visible: a background on .markdown stops where the words do rather than filling the pane, because the pane’s child is now the host. The golden tests frame their documents on the host for that reason.

Auto-scroll while dragging past the edge is not implemented. Dragging to the bottom of a viewport stops selecting rather than scrolling on; the design for it is the same one a text-area wants and neither has it yet.

Alternatives considered

A selected class on each covered word. The obvious implementation, rejected on arithmetic: a class is part of a widget’s description, so every pointer move would rebuild the document and re-resolve every style — 600 elements at 60 Hz, to change a colour. The painter reads mutable state instead and the rebuild never happens.

Wrapping each word in a reporting node. Correct, and twice the elements: a document is already one widget per word, and ADR-0299 is the record about what that costs when it is multiplied.

Selection in the text stack, by making a paragraph of mixed faces one selectable run. That is the engine — it needs the inline layout ADR-0298 parked — and it would have to wait for it.

Word granularity only, snapping a selection to whole words. Cheaper, and wrong: the offsets come free from Paragraph.offsetAt, which the editor already uses for the same purpose.

Copying through the model instead of the rendered words — walk the document tree between two carets and serialise it. Rejected: the carets are positions in what was drawn, the mapping back to the model is a second thing to keep correct, and what a reader selected is what they can see. The words already carry their own separators.

302. A subpath is anchored where it was written

Date: 2026-09-13

Status

Accepted. Fixes a third of the bundled icon set, which had been drawing off the viewBox since :assets compiled its first table (ADR-0033).

Context

IconCompiler reduces every Lucide SVG to a single run of path data, because shipping 1544 XML documents would put a parser on the path that draws a checkbox. Each <path>, <line>, <circle> and the rest becomes a subpath, and the subpaths are joined with a space. Its own comment said why that was safe:

Every shape in the document becomes a subpath and they are concatenated in document order. That is safe because each one begins with a moveto — concatenating path data is only ever wrong when a fragment continues from wherever the previous one ended.

Both halves of that are true. The conclusion does not follow, because a moveto may be relative, and SVG has a rule that makes a relative one look absolute:

If a relative moveto (m) appears as the first element of the path, then it is treated as a pair of absolute coordinates.

— SVG 1.1 §8.3.2, and unchanged in SVG 2 §9.3.3

“Of the path” means of the d attribute it is written in. Lucide writes its icons as several <path> elements and lets each one open with m, which inside its own element is measured from the origin exactly as M would be. Joined behind another subpath, the same three characters mean something else entirely.

a-arrow-down is the whole bug in one line. It compiled to:

M3.5 13h6 m2 16 4.5-9 4.5 9 M18 7v9 m14 12 4 4 4-4

The first subpath leaves the pen at (9.5, 13). The m2 16 that follows was written to mean “start at (2, 16)” and was read as “start 2 right and 16 down from here” — (11.5, 29), which is off the bottom of a 24×24 viewBox. The arrow under the A had the same treatment and landed somewhere else again.

481 of the 1544 icons had at least one such subpath after the first. They did not fail to draw; they drew the wrong shape, somewhere else, at an offset that depended on where the previous shape happened to end — which is why nothing caught it. An icon set is checked by looking at it, and a third of a sheet of 24-pixel glyphs being subtly wrong reads as a rendering artefact.

A further 84 open with m and have no second subpath, where the rule really does make it absolute and the geometry was always right. The table text changes for those too — 565 lines in all — and nothing drawn changes.

There is a second trap inside the first, and it is the reason this record exists rather than a one-character fix. m2 16 4.5-9 4.5 9 is a moveto followed by two implicit linetos, and SVG says an implicit repeat inherits the case of the command that opened it: relative after m, absolute after M. Rewriting the letter alone moves the pen correctly and then draws the rest of the subpath to two absolute points nobody meant. That is an icon that is wrong in a different way, and worse than the original, because it looks deliberate.

Decision

Every subpath is anchored absolutely before it is joined, and the result is checked rather than assumed.

SvgPathData.absoluteStart rewrites an opening m x y to M x y, and moves any trailing coordinate pairs of that command to an explicit l so they keep the meaning they were written with. Everything after that is passed through byte for byte: the numbers are upstream’s, and re-emitting them would lose precision for nothing — the same reason IconCompiler copies a d attribute instead of parsing and printing it.

It is applied to every part, including the first, where it is a no-op in effect: the pen starts at the origin, so m and M agree there. Applying it uniformly is what makes “every subpath of the table opens with an absolute moveto” a property of the table rather than a property of its first line.

IconCompiler then refuses a compiled subpath that still does not open with M. That cannot happen for the seven elements it converts, which is the point: the check is for the eighth, whenever somebody adds one.

The two classes live in a new io.github.digitalsmile.goldberry.assets.svg package with SvgShapes, because reading SVG’s grammar and converting SVG’s basic shapes are the same subject and PrepareAssets is not.

Consequences

481 icons change shape, and 84 more change only in the table. Any golden image containing one of the 481 has to be re-recorded — it was recording the bug. As it happens the repository’s goldens are unaffected: the showcase binds palette and plus, and the widgets that draw their own icons use check, square, type and layout-dashboard, none of which is in the 481. That is luck rather than design, and the next golden that shows a different icon will need re-recording.

The table is a build output, so there is nothing to migrate: the next prepareAssets produces the corrected one. An application pinned to an older jar keeps the old table, which is the same thing as keeping the old bug.

SvgPath in :core is unchanged and stays that way. It treats a leading relative moveto as relative — correct for it, because it starts at the origin, so the two readings agree on the only input where it matters. Teaching it the first-element rule would be teaching it a rule about a document it never sees.

The check in IconCompiler will refuse an icon set that is not Lucide-shaped rather than emitting something almost right, which is the same bargain the element allow-list already makes.

Alternatives considered

Fix it in SvgPath, in :core. Rejected, and it is the tempting one: the reader could treat the first moveto of the data as absolute. That fixes nothing — by the time the reader sees the string, the subpaths have already been joined and there is exactly one “first” moveto, which was already right. The information about where one <path> ended and the next began is lost at concatenation, so the correction has to happen before it.

Join with an explicit M 0 0 between parts, or emit Z before each. Rejected: M 0 0 adds a stray point to the path that a round line cap will paint, and Z closes subpaths Lucide deliberately leaves open, turning every open stroke into a triangle.

Emit a fully parsed, normalised path — absolute commands throughout. Rejected: it makes the compiler an SVG path renderer, doubles the size of the table in decimal digits, and rounds every coordinate. :core already has the reader that does this properly, at draw time, from the numbers upstream wrote.

Re-order the shapes so a relative one never follows another. Rejected as nonsense on inspection — it changes paint order, and paint order is the only thing that makes an overlapping icon read correctly.

303. The router lets go of what the pointer was over

Date: 2026-09-13

Status

Corrected by ADR-0401. The sentence below calling this safe by construction stopped being true: a handler can now unmount the element under the pointer within the same dispatch, and State.setState on an unmounted state throws. The guard ADR-0401 adds is narrower than it looks — a disposed widget hears nothing, while the application’s own Attributes hook still finishes the pair it opened.

Accepted. Extends ADR-0180’s rule from the keyboard to the pointer, and closes the hole that left a tooltip open over content that no longer existed.

Context

ADR-0180 states one rule and gives it a hook: the router never holds an element that is not in the tree. refocus() enforces it for focused, runs once a frame from updateRegions, and its own record explains why it had to exist — Element.unmount tells the element tree and nothing else, so a router whose focused element was inside a closing dialog went on holding an unmounted element.

hovered had the identical hole. Nothing enforced the rule for it, because updateHover is the only thing that ever clears the field or tells onPointingChanged anything, and updateHover runs on pointer motion.

The frame hook did call into the hover state — restate(), added by ADR-0237 — and it is worth reading what that method says about itself, because it is exactly right and exactly not this:

No ENTERED or EXITED is emitted, deliberately. Nothing entered or exited anything: the pointer has not moved and the element under it is the one that was there.

Both clauses of that second sentence have to hold, and after a rebuild the second one does not. The element under the pointer is gone. So the sequence was:

  1. The pointer stops over a button. updateHover fires, hovered is the button, the launcher starts its tooltip timer, the tooltip opens.
  2. The user clicks. The button’s handler switches a tab, closes a dialog, deletes the row — anything that rebuilds.
  3. The tree flushes, the frame paints, updateRegions runs. refocus() puts the keyboard somewhere sensible. restate() re-asserts :hover on a chain of unmounted elements and says nothing to anybody. hovered still points at the dead button.
  4. Launcher.pointingChanged — the only caller of hideTooltip — is never reached, because nothing called notifyPointing.

The tooltip stayed up, anchored to a rectangle nothing paints any more, until the user moved the mouse. On a keyboard-driven step, or a pointer resting still while reading, that is indefinitely.

The launcher could have defended itself — showTooltip already checks isMounted before opening one — but a per-frame “is my anchor still there?” in Launcher would be the second copy of a question the router is the one that can answer, and the next thing to watch a hover would need a third.

Decision

rehover(), refocus()’s twin, called from the same frame hook.

When the hovered element is no longer mounted, the router re-resolves what the pointer is over against the regions of the frame just painted, and routes it through updateHover — so :hover moves, ENTERED and EXITED are emitted, and onPointingChanged listeners are told. A pointer that is not in the window at all (pointerX is NaN) drops the hover rather than hit-testing a position that means nothing.

restate() is untouched and keeps its rule. The two are answering different questions on the same frame: restate asks what a control should look like when the tree it is in stood still, and rehover asks who the pointer is over when it did not.

Only when the element is gone. The guard is one isMounted read on the frame path, and the hit test happens only in the frame where something really was unmounted.

Consequences

A tooltip closes when the thing it describes goes away, which is the user-visible fix and the reason this was found.

:hover is correct a frame after a rebuild rather than at the next mouse move. A button that replaced the one under the pointer no longer inherits the hover wash of its predecessor.

ENTERED and EXITED now arrive from a frame rather than only from an event. Any Handles widget that assumed a pointer event means the pointer moved is now wrong — though it was already wrong, because the same pair was already emitted by the next move after a rebuild. This makes it prompt, not new.

EXITED is delivered to widgets that have been unmounted. Also not new, for the same reason, and safe by construction: Element.markNeedsBuild is a no-op on an unmounted element, so a handler that calls setState in response does nothing.

The obvious next question is deliberately not answered here: should hover follow content that scrolls under a still pointer, where nothing unmounts at all? That is a real behaviour with its own costs — a hit test every frame, and ENTERED/EXITED pairs for a pointer that has not moved — and it is a separate decision. RehoverTest pins the current answer so that changing it is a choice somebody makes rather than a side effect.

Alternatives considered

Make restate() emit the events. Rejected, and it is the smallest diff: it would make the method’s own documented contract false for the case where it is currently right, which is the common one. Two questions, two methods.

Re-hit-test on every frame, unconditionally. Rejected for now — see above. It subsumes this fix and adds behaviour that has not been asked for, and mixing “stop holding a dead element” with “hover follows moving content” would land both under a bug report about a tooltip.

Have Launcher check its tooltip anchor each frame. Rejected: the launcher would be asking a question only the router can answer, and the router would still be holding an unmounted element for everything else that reads hovered() — openContextMenu(x, y) among them, which would open a menu for a dead node.

Have Element.unmount tell the router. Rejected for the reason ADR-0180 gave when it rejected the same idea for focus: the element tree does not know about the router, must not, and a frame-late answer is not a compromise — nothing can move the pointer between a tree flushing and the frame it produces.

304. A window has a floor, and the desktop enforces it

Date: 2026-09-13

Status

Accepted. Adds the one geometry constraint WindowSpec was missing, beside resizable, decorated and maximized (ADR-0221).

Context

A Goldberry window could be dragged to any size at all, including sizes at which nothing it contains means anything: a sidebar and a content pane at 200 logical pixels wide are not two things, a split with two panes each a few characters across is a layout nobody can read, and the toolkit will draw both without complaint. Yoga does the arithmetic it is given, and the arithmetic is fine — the result is simply not a user interface.

Every desktop toolkit has a floor for this, and every desktop window manager knows how to enforce one. SDL exposes it as SDL_SetWindowMinimumSize. Nothing in Goldberry asked for it.

The question that makes this a decision rather than a one-line binding is who enforces it. There are two places it could go:

  1. The application clamps. Watch BackendEvent.Resized, and when the size is below the floor, ask for a bigger one.
  2. The window manager refuses. Declare the floor once; the pointer stops at the edge of the drag.

The first is available today and is what an application would otherwise have to write. It is also visibly wrong: the resize has already happened by the time the event arrives, so a frame is laid out and painted at the too-small size, and the correction arrives as a second resize — the window shrinks and springs back under a pointer that is still dragging. On a compositor that resizes continuously, that is every frame of the drag.

Decision

The floor is declared, and the platform enforces it.

  • WindowSpec.minimumSize — a LogicalSize, defaulting to NO_MINIMUM (0×0), beside the other three window properties. WindowSpec.of gives no minimum.
  • BackendWindow.setMinimumSize / minimumSize() — SPI, defaulting to a no-op and NO_MINIMUM, so a backend with no window manager to ask is honest rather than pretending. Sdl3Window hands it to SDL_SetWindowMinimumSize; HeadlessWindow enforces it in resizeTo, which is the only way this rule can be tested at all.
  • Window.minimumSize(LogicalSize) — settable at runtime, because a window whose content changes shape has a different floor than the one it opened with.
  • Application.minimumSize() — one line to override, defaulting to no minimum.

A zero on an axis is “no minimum on that axis”, per axis, which is SDL’s own reading and the one a window that cares about its width alone wants: LogicalSize.of(480, 0) rather than a guessed height.

The default is no floor

The toolkit does not know what a window contains. A floor invented for it would be wrong for a colour-picker palette and wrong again for an editor, and a default that is wrong in both directions is worse than an absent one — the application that needed a floor still has to say so, and the one that did not now has to say so too. Showcase declares 640×480, which is what a default would have been an approximation of.

Contradictions are refused, except the one the command line makes

WindowSpec refuses a minimum larger than the opening size on either axis. Growing the window to its floor is not the size the application asked for, and opening below the floor gives a window the user can never drag back to the size it started at — the two ways out are opposite, so neither is a default. It also refuses a minimum on a window that is not resizable, for the reason it already refuses maximized on one: a window nobody can resize has no size to be stopped at, and a declaration that cannot do anything is a reader’s trap.

The exception is --size=, which exists so a screenshot or a golden run can pin the window’s geometry. Launcher demotes a floor that does not fit a command-line size and says so in the log, rather than refusing to start: an application declaring a 1024-wide minimum must not be able to make --size=800×600 fail. A floor is a promise to a user about what dragging an edge may do, and there is no user in a golden run.

Setting a floor does not resize the window

Window.minimumSize constrains what happens next; it does not grow a window that is already below it. Resizing a window out from under whoever is looking at it is a different action nobody asked for, and the open-time form — which refuses the contradiction — is where that case belongs.

Consequences

An application gets the desktop behaviour users expect for one overridden method, with no resize handler and no clamping.

WindowSpec gained a record component, so the canonical constructor’s arity changed. There was exactly one direct new WindowSpec(...) outside the record — in its own test — because of plus the withers is how it is built everywhere else, which is the property that made this cheap.

libgoldberry exports one more symbol. SDL_SetWindowMinimumSize is in exports/goldberry.symbols, so the native library must be rebuilt; a Java-only build against an older library fails at bind time, loudly, which is the failure mode ADR-0035 chose on purpose.

The floor is not read back from the platform. minimumSize() answers what the backend was told. SDL has SDL_GetWindowMinimumSize, and a second native call to read a number this process just wrote is a round trip for nothing — but it does mean that a window manager which quietly ignores the constraint will be reported as having one. That is the same bargain setTitle makes.

There is no maximum size, deliberately. SDL_SetWindowMaximumSize is the obvious symmetry and there is no use for it: a window that must not grow is resizable = false, and a ceiling that is not the display’s is a constraint users resent. It can be added the day something wants it.

Alternatives considered

Clamp in the resize handler. Rejected — see above. It corrects a frame late, which is a frame the user watched.

A minimum expressed as a widget’s intrinsic size, so the floor is whatever the content needs. Rejected as a much larger decision wearing this one’s clothes: it means running layout in an unbounded pass to find a minimum, once per build, and re-asking the window manager whenever the answer moves. The number an application would write down is also better: “below 640 the sidebar stops being a sidebar” is a judgement about the design, not a measurement of it.

Put it on Window only, not on WindowSpec. Rejected: the floor would be applied after the window is created and shown, so a window could briefly exist — and be dragged — below its own minimum. Having it in the spec also means the contradiction with the opening size is caught where both numbers are, rather than by a runtime surprise.

Default to something sensible, like 320×240. Rejected: see above. It is a number with no argument behind it, and it would silently constrain every application that never thought about the question.

305. A chip is a badge you can press

Date: 2026-09-13

Status

Accepted. Adds chip to docs/core-widgets.md §3 and a metrics row to design-system.md §3, which is the order design-system.md §5 requires — a spec and a §3 row and gallery coverage before code.

Context

badge has been in the catalog since ADR-0087, and §3 describes it as a “count/status chip”. select multiple grew a select-chip part with a remove affordance (ADR-0182). The word was doing three jobs and the catalog had no widget by that name.

What was actually missing is the thing every other toolkit calls a chip and neither of those two is: a small rounded label a user can choose and take away. A filter in a row of filters. A tag on a document. A token in a recipient field. badge cannot be any of them — it is a leaf with text, it is not focusable, it has no state and no keyboard — and select-chip is a part, which by ADR-0065 means nobody can build one.

The temptation was to grow badge a press and be done. That is the decision this record exists to refuse.

Decision

A separate widget, and the line between the two is what it reports.

A badge answers what is true — three unread, one build failing. A chip answers what you picked.

Everything else follows from that one sentence:

  • A chip is focusable, carries :checked, and has a keyboard. A badge has none of the three, and adding them would make every status badge in every application a Tab stop.
  • A chip takes press and dismiss. A badge takes neither.
  • A chip selects nothing itself: press raises and the application decides, which is ADR-0063’s loop and the same one radio-group and tabs run. bind= is the other half, so a row of filters is writable as a document.

Where they share, and where they part

They share the semantic hue tokens — --gb-badge-danger-bg and its four siblings — because §1.2’s “aurora hues only with semantic meaning” is one claim and two sets of five that have to be kept agreeing is how they stop agreeing.

They part on the rest fill, which is the one place the analogy fails. A badge takes --gb-badge-bg (--gb-surface-2), which is right for a plate you read beside something. A chip takes --gb-button-bg, because it is a control at rest and has to be a findable target.

Metrics: height 24 (a badge’s 20 is a plate, and §2.2’s 24 is a target), body rather than caption (a filter you choose is content, not metadata), radius full, flex-shrink: 0 — a chip that gave width back would ellipse the word a user is choosing between, and the answer to a narrow row is a wrapped one (ADR-0192).

The dot and the icon are one slot

§3’s row says “an optional leading dot or icon”, and the constructor refuses both. They are the same status at two resolutions — a dot is a status nobody has to recognise, an icon is one they do — so asking for each is a question with no answer, and a refusal beats a drawing decision nobody can predict.

A dot takes the foreground its own fill guarantees contrast against

One rule, and it is here because the obvious alternative is wrong in a way only an image shows. chip.success chip-dot { background: var(--gb-success) } reads correctly and puts a green dot on a green plate: the first golden of it came out with no dot at all. So a filled variant’s dot is that variant’s text token, and the .outlined forms — which have no fill — take the hue.

The useful pairing is the second: a quiet pill with a live status on it, which is what a dot is for.

Keyboard

Space/Enter press. Delete and Backspace dismiss — both, unlike tab’s one. A tab lives in a strip a user walks with the arrows, where Delete is the forward-facing key; a chip is commonly the last thing before a text field, where Backspace is what a hand reaches for. Binding one and not the other would make it a coin toss.

Consequences

The × is not a Tab stop, which is tab-close’s and select-chip-remove’s rule: a row of five filters is five stops, not ten. That is only defensible because the chip itself answers Delete, and it is why both delete keys are bound rather than one.

A read-only chip is not focusable either. A chip with no press and no dismiss is a badge with a different type name, and the check that says so is the one place the two widgets really are the same thing.

chip shares badge’s hue tokens, so a theme that re-tints one re-tints the other. That is the intent and it is also a constraint: a theme wanting a different chip palette has to add tokens rather than change these.

A default chip is flush with a card on the Nord dark theme, because --gb-button-bg and a card’s own fill are both --nord2. That is a property of the palette rather than of this widget — the default button and the bare badge have it too — and the showcase’s filter row uses .outlined and says so rather than papering over it here. A chip set that needs to read on a card asks for .outlined, and chip.outlined:checked exists so that such a set can still show which one is on.

Alternatives considered

Give badge a press and a selected. Rejected, and it was the cheap one. Every status badge in every application would become a Tab stop the moment the capability existed, because “it has a handler” is not something a stylesheet or a reviewer can see. Two names for two behaviours is the whole of the fix.

Make chip a button variant — button.chip. Rejected: a button does something and reports nothing about itself afterwards; a chip is a thing that is on or off and whose whole point is which. They would also have disagreed about :checked, which a button has no meaning for.

Let selected be supplied by a parent, as a tab’s is. Rejected, and it is the one place this widget deliberately differs from tabs: a strip exists to hold the invariant that exactly one is chosen, and a row of filter chips has none, two or all of them on. There is no parent to hold an invariant that does not exist, so selected is an ordinary attribute and bind= drives it.

A chip-group to own the selection. Rejected for the same reason and one more: the useful chip rows in real applications are not groups — a tag row is a list that shortens, a filter row is several independent booleans, and a recipient row is a text field with tokens in it. A group would fit none of the three.

306. The last crumb is where you are

Date: 2026-09-13

Status

Accepted. Builds docs/core-widgets.md §6’s breadcrumbs and opens the nav package, which §11’s table has named since v0.2 and which had nothing in it.

Context

nav was added to the package table as the tenth group, with one sentence justifying it:

breadcrumbs, steps and wizard all answer “where am I in a sequence”, which is neither a surface (panel) nor a control that reports a value (controls); folding them into either would have made that package’s name a lie.

breadcrumbs is the first of the three. §6 specifies it in four sentences and every one of them is a decision somebody could get wrong:

  1. crumb children with a label, an optional icon and an action.
  2. The last is the current page and is not a link.
  3. Overflow collapses the middle into a … that opens a menu of the hidden crumbs, rather than eliding characters.
  4. The separator is a chevron-right icon in --gb-text-muted, not a character, so it never joins the text run.

Decision

The trail decides which crumb is current, and a document cannot

The last one, written onto the crumb on every build — tabs telling a tab it is selected, exactly (ADR-0107). A document that could mark a middle crumb current, or none of them, would be able to describe a path that does not end anywhere, and a trail’s only invariant is that it ends where you are.

A current crumb is silently demoted: not focusable, and its handler does not run, whatever press= it was written with. That is deliberate rather than a refusal, and the reason is how trails are actually built — from a loop over a path, where every crumb gets the same handler and the last one is supposed to be inert. Refusing it would make the common case an error.

The enforcement is on Crumb and not on the trail’s wiring, so a crumb built by hand in a test behaves the way one built by a trail does.

Overflow keeps the first and the tail

Past collapseAfter crumbs the row shows the first, a …, and the last collapseAfter − 2 — so the row holds exactly collapseAfter things, counting the … as one. The first stays because “where does this tree start” is the question a deep path makes hardest; the tail stays because that is where you are.

collapseAfter defaults to §3’s 4 and is raised to 3 rather than refused if something asks for less: the number is a hint about width, and a hint that cannot be met should be met as closely as possible rather than stop a window opening.

Nothing is elided inside a name, which is §6’s own argument: a truncated folder name is worse than a hidden one because it still looks like a name.

The … is the one part in the catalog that takes the focus

tab-close, select-chip-remove and chip-dismiss are all deliberately not Tab stops, because each has a keyboard route through the control it sits in. This one has none — the crumbs behind it are not in the tree, so there is no node for the keyboard to reach and no key on the trail that could stand for “the fourth of the hidden ones”. A … only a pointer could open would put part of a navigation path out of a keyboard’s reach, which §13 does not allow.

It answers Space, Enter and Down, and reports Role.MENU_BUTTON.

The separator is a mark

[Box.Mark.Kind#CHEVRON_END], whose own documentation anticipated this use. A > typed between two labels is part of a paragraph: it shapes with the words, takes their colour, wraps with them and is read aloud. A node of its own does none of those.

The widget is stateful, and it is stateful for two facts

Where its … was painted, and which window it is in. Neither is describable: a widget is a value rebuilt every frame, and opening a popup needs a Host (ADR-0140). Everything else about a trail is a pure function of its crumbs.

breadcrumbs as a CSS type is therefore the node the stateful one builds, per ADR-0109: two breadcrumbs nodes nested in the cascade would take every rule twice.

Consequences

The nav package exists, and steps and wizard have somewhere to land that is not panel.

A trail does not wrap and does not shrink its crumbs. A path wrapped onto two lines puts where-you-are under where-you-started, which is the one arrangement it must not have — so the answer to a narrow window is the overflow menu, which is why there is one.

The semantics are incomplete and say so. §6 asks for “a navigation landmark containing links”; Role has no LINK and no landmark. The crumbs answer BUTTON and the row answers GROUP, which is honest — a role nothing can consume is a value written for a bridge that does not exist. book/src/TODO.md carries the rest until the AccessKit bridge.

The overflow menu is built at the moment of the click, not banked. It is a function of the crumbs and the crumbs are the application’s, so a menu held from build time would be the path as it was one frame ago.

A hidden crumb with no press still gets a menu row, disabled. It is part of the path, and hiding it would make the menu a different list from the trail.

Alternatives considered

Let current be an attribute. Rejected — see above. It is the whole invariant, and tabs already settled the same question the same way.

Refuse a press on the last crumb rather than ignoring it. Rejected: a trail is built from a loop, so this would make the ordinary way of writing one an error, and the workaround would be a conditional in every caller.

Elide long labels and keep every crumb. Rejected by §6 in the specification, and it is right: …/Ref…/Mid…/Shi… is four names a reader cannot identify, where Home … Hobbiton The Red Book is three they can plus a control for the rest.

Keep the last collapseAfter − 1 and drop the first. Rejected: the root is the crumb that tells you which tree you are in, and a trail that starts with … has thrown that away to save the same amount of width.

Anchor the menu by id rather than by a reported rectangle. Rejected: it would make an id mandatory on any trail that might overflow, and two trails in one window would need two ids to tell their overflows apart. [Located] already answers the question, and its answer is the painted rectangle, which is where the user is looking (ADR-0270).

307. The eleventh screen has no digit

Date: 2026-09-13

Status

Accepted. Adds the Icons screen to the showcase gallery, and settles the question ADR-0110 left open about what happens when there are more screens than digits.

Context

The gallery had ten screens and ten accelerators, and Screen.GALLERY’s comment called that “exactly the digits a keyboard has: Ctrl+0 is the tenth, and the eleventh would be a screen no key could reach”. ShowcaseShellTest asserted GALLERY.size() <= 10 and said in a comment that “the eleventh screen is a decision about which one loses its key”.

The eleventh screen turned up for a reason none of the ten shares. Every existing screen answers what does this widget do. This one answers a question a reader has while writing a document: icon="…" takes a name from a set of 1544, and until now the only way to find one was to read Lucide’s website. A sheet of them beside the gallery is the difference between a bundled asset and a usable one.

The prior art in this repository is on the other side: ADR-0110 cut twelve screens to ten because two of them had no key, and the bug it was written about was a Ctrl+8 that selected the ninth tab. So “more screens than digits” is the exact shape of a defect this gallery has already had.

Decision

Nothing loses its key. The eleventh screen is reached by the strip.

Ctrl+1…Ctrl+0 keep meaning exactly what they have always meant, and icons is reached three other ways: the strip itself, the arrow keys roving inside it, and Edit ▸ Go to.

The reason is what the two kinds of screen are for. The first ten are galleries a reader moves between — the comparison is the point, so the digit earns its keep. An icon sheet is a reference opened once and searched; the field inside it is where the reader’s hands go, not a shortcut. Re-pointing an accelerator somebody already knows, in order to give one to a screen that does not want it, would cost more than it bought.

The machinery already allowed it, and that is the interesting half

Showcase.screenShortcuts loops to Math.min(GALLERY.size(), digits.size()), and GalleryOrderTest has asserted “ten digits, however many screens there are” since the bug that produced it. Neither needed changing. What needed changing was a comment claiming a limit and one assertion encoding it — the limit was written down as a fact about the gallery when it was only ever a fact about keyboards.

ShowcaseShellTest now asserts the property that is actually load-bearing: the screens with keys are the first ten in strip order, and Ctrl+0 is canvas. Inserting a screen above the tenth would move every digit, and that is what a test should refuse.

The sheet virtualizes, over rows

1544 tiles is 1544 elements with three boxes and a paragraph each. So the sheet is a list with virtualized(ROW_HEIGHT) over runs of seven names rather than over icons, and only the rows in the viewport are built (ADR-0116).

Because it owns a viewport, the gallery does not wrap it in one — §2.4’s ban on nested same-axis scrollers, which is the Navigation and Markdown screens’ reason.

The icons are cached and built lazily: one Icon per name on the frame a row first needs it, kept for the life of the screen. Parsing all 1544 up front is 221 KiB of path data for a screen a reader may never open; parsing them per frame would be that much per frame. An icon is a value since ADR-0277, so the cache holds no native memory and needs no closing.

The application declares a widget, and that is worth showing

There is no icon widget in the catalog — an icon reaches the screen as a Box.icon inside whatever draws it. That is right for the toolkit and wrong for a sheet of a thousand cells, so the showcase writes IconTile itself: Widget.Leaf plus Styled plus Paints, three methods, no permission asked. The gallery has not shown that before.

Consequences

An eleventh screen is now possible in general, and a twelfth. What constrains the list is no longer the digits — it is that the first ten must stay the first ten, which is now asserted.

Two numbers have to agree, and the record exists partly to name them: IconsScreen.ROW_HEIGHT and #icon-sheet list-row’s height in showcase.css. A list-row is --gb-list-row-height — 32 — which is right for a list of names and half the height of a tile, so without the override every row of the sheet overlapped the one below it. That is what the first golden showed, and it is why the screen has a golden.

BundledAssets.iconNames() has no order, which this found: it is the key set of a Map.copyOf, so the first drawing of the sheet opened on book-lock, calendar-off, badge, list-start. The screen sorts, and the method’s contract is unchanged — it never promised an order and this is the first caller that needed one.

The sheet is a real test of the frame budget in a way the other screens are not: it is the only golden in the gallery of a virtualized tree.

Alternatives considered

Take a digit from canvas or html. Rejected: both are screens a reader moves between while comparing things, and a shortcut that changed meaning between releases is worse than a screen without one.

Bind Ctrl+I. Rejected, and it is the tempting near-miss. The gallery’s accelerators are positional — Ctrl+<n> is “the nth tab” — and a mnemonic key in the middle of that is two schemes in one strip. Ctrl+I is also italic in every editor a reader has used, and the showcase has a text-area.

Put the icon browser in a dialog off the Help menu. Rejected: it is a reference a reader wants beside the document they are writing, and a modal is the one shape that guarantees it cannot be. It is also the gallery’s own subject — the icons are a bundled asset of this toolkit, not a utility bolted onto it.

Show all 1544 without virtualizing and let the frame budget say. Rejected without measuring, which is unusual for this repository and is the honest call here: 1544 tiles is roughly 6000 elements and 1544 shaped paragraphs, against a FrameBudgetTest that already treats 866 elements as the large case (ADR-0299). The answer was not in doubt and the measurement would have been a formality.

308. A tooltip follows the focus ring

Date: 2026-09-13

Status

Accepted. The second half of ADR-0303, which fixed a tooltip that outlived the element it was anchored to and left this one standing: a tooltip that outlives the pointer.

Context

Click a button that has a tooltip, move the pointer off it, and the tooltip stays.

ADR-0303 looked like it had covered this. It had not, and the two are genuinely different: that one was about the anchor being unmounted and nothing telling the tooltip; this one is about a button that is still there, still mounted, and still reported as the tooltip’s target after the pointer has gone.

The cause is one line, and it is a rule that reads correctly:

private Element tooltipTarget() {
    var hovered = withTooltip(router.hovered());
    return hovered != null ? hovered : withTooltip(router.focused());
}

§7 asks for a tooltip “on hover and on keyboard focus”, so a fallback to the focused node is exactly right. What the code does not notice is that a click focuses things. So:

  1. The pointer rests on the button; hovered is the button; the tooltip opens.
  2. The user clicks. The router focuses the button — fromKeyboard = false.
  3. The pointer leaves. hovered goes null, so the fallback runs and answers the focused node, which is still the button.
  4. pointingChanged compares the target with tooltipOwner, finds them equal, and returns early. Nothing hides anything.

The tooltip then sits over the window until something else takes the focus. On a toolbar, where the natural gesture is click-then-move-on, that is most of the time.

Decision

The fallback asks for keyboard focus, not focus.

return router.focusedFromKeyboard() ? withTooltip(router.focused()) : null;

PointerRouter.focusedFromKeyboard() is new and exposes a field the router has always kept: the same one :focus-visible is mirrored from, and the same distinction ADR-0054 drew for the focus ring — focus that arrived by pointer is a side effect of the click rather than a statement about where the user is working. A control clicked with a mouse is focused and draws no ring; it should not hold a tooltip open either.

The one-sentence version, which is also what the code now says: a tooltip follows the focus ring.

It answers false when nothing is focused, so the call site needs no null check — “the keyboard is on this” is false when the keyboard is on nothing.

Consequences

A click no longer pins a tooltip. The reported behaviour, gone.

Keyboard focus still opens one, which is the half this fix is one wrong predicate away from destroying, and is why there is a second test for it: Tab to a control with nothing hovered, and the tooltip appears.

A tooltip does not reappear when the pointer leaves a mouse-focused control. Before this, moving the pointer off a clicked button and then off the window would leave the tooltip up; now it closes and stays closed until the pointer comes back or the keyboard arrives. That is a behaviour change beyond the bug — and it is the same rule, because both cases were the fallback answering for a focus the mouse had put there.

focusedFromKeyboard() is public API on the router. It is the third question about pointing the router answers — hovered(), focused(), and now how focus arrived — and anything else that wants to distinguish “the user is working here” from “the user clicked here” can ask it.

A press still does not dismiss a tooltip while the pointer stays on the control, which is showTooltip’s existing note and is unchanged: a press that closed it would close it in the same gesture that opened whatever was clicked.

Alternatives considered

Hide the tooltip on press. Rejected, and it is what most toolkits do. It would have fixed the report and broken the case showTooltip already documents — a click on a control whose tooltip is open, with the pointer staying put, should not flash the tooltip away and back. It also treats the symptom: the target would still be wrong, so openContextMenu and anything else reading the same fallback would still be answering for a mouse focus.

Clear the focus when the pointer leaves. Rejected outright: focus is not the pointer’s to give up, and a control would lose the keyboard because a mouse wandered off it.

Read :focus-visible off the element instead of asking the router. Rejected as a proxy for the fact rather than the fact. The pseudo-class is the cascade’s mirror of the router’s state, it is skipped for unmounted and disabled nodes, and a launcher that read a styling flag to decide behaviour would be one stylesheet change away from a bug nobody could find. The router already answers hovered() and focused(); how focus arrived is the same kind of question.

Compare tooltipOwner by more than identity in pointingChanged. Rejected: the early return is correct — the target really had not changed. The defect was in what “the target” meant.

309. A sheet of icons reflows, and pays for it

Date: 2026-09-13

Status

Accepted. Replaces the fixed-column virtualized list ADR-0307 built the Icons screen with, and records what that trade costs.

Context

The Icons screen chunked 1544 names into rows of a fixed seven and put them in a virtualized list. That was cheap and it was wrong in the one way a sheet of things can be wrong: it did not follow the window. A wide window left a band of empty space down the right; a narrow one had its last column clipped, because seven tiles of 152 do not fit in 720 points and a row does not care.

The fix is a reflowing grid, and §8’s subset has no grid — no column-count, no repeat(auto-fill, …). What it has is masonry, which is a count of equal-width columns and nothing else.

So the question is not “how do I reflow” but what the reflow costs, because a masonry cannot virtualize: placing a card under the shortest column is a decision about every card, so every card has to exist.

Decision

A masonry whose column count is as many tiles as fit, inside a scroll.

The count is floor((width + gap) / (tile + gap)), over a width the last frame reported through Measured. One frame of settling, which is Masonry’s own arrangement for heights and is invisible: the default is seven, which is what the window opens at.

Three things fell out of building it that are worth writing down.

A masonry of equal-height tiles reads across the row

Masonry’s own documentation warns that it reads down each column rather than across, and calls that its one real cost. That warning is about cards of differing heights. When every tile is the same height, “the shortest column, and the emptiest of the equally short” places them across the row — so an alphabetical sheet reads left to right, which is what a reader scanning for chevron-right expects. The property is asserted, because it is a consequence of a tiebreak rather than something the widget promises.

The scroll’s content must not grow

icon-sheet had flex-grow: 1, which is what a box that should fill its viewport looks like. It is a vertical scroll’s content, and content told to grow is exactly as tall as the viewport — so nothing overflowed, no thumb was drawn, and the sheet simply ran off the bottom of the window. A golden could not tell the difference: content running past the edge looks the same either way, and the thumb has faded by the time a picture is taken.

That is why the screen has a driven test as well as two goldens. It asserts the content is taller than the viewport, which is the difference between scrolling and overflowing.

The measurement, which contradicted the first draft of this record

elementsopens inbuildstylelayout
the whole sheet4709464 ms0.0 ms3.7 ms0.6 ms
after typing ar1085—0.0 ms0.9 ms0.2 ms
the wall, for scale~220—0.0 ms~0.3 ms~0.3 ms

This record’s first draft said the sheet was “expensive to open and ordinary to scroll”. The first half is an understatement and the second half is false. The style pass is O(elements) whatever is cached — ADR-0299’s cache stops the shaping, not the walk — so twenty times the wall’s elements is roughly twenty times its style cost. 4.3 ms of build, style and layout is a quarter of a 60 Hz frame before anything is rasterized.

FrameBudgetTest now measures both rows and asserts the whole sheet against a budget of its own — the measurement with room to move. A budget the screen fails would be a test nobody can leave green; a budget it cannot fail would be no test at all.

The filtered row is asserted as a ratio, and that too is a correction. The first version of the test asserted it against the wall’s 1.0 ms and it failed under a full build at 1.25 ms: 1085 elements is still five times the wall’s, and the measurement straddles the line depending on what else the machine is doing. The claim this screen actually makes is that searching takes most of the cost back — a third of the elements, less than half the style — and that is what is checked.

Consequences

The sheet follows the window, which is the point: seven columns at 1200, four at 720, and the last column a whole tile at both.

Opening the Icons tab takes about half a second. That is a real cost on a real gesture, and it is the one number here that would stop this being acceptable in an application rather than a gallery. It is paid on the tab switch, once per visit.

The search field is the performance story, not a convenience. Two letters take the sheet from 4709 elements to 1085 and roughly three quarters of the style cost with it — and looking for an icon is the only reason to be on this screen. The screen is defensible because of the field, which is an argument worth being explicit about rather than a happy accident. It does not make the sheet as cheap as a wall of cards, and saying it did is the over-claim this record had to take back.

IconsScreen.columnsFor and showcase.css share two numbers — the tile width and the gap. A widget cannot read what a stylesheet resolved for a node it is about to describe, so the arithmetic is stated twice and the CSS says so. Changing one without the other gives a sheet that over-fills its row, which the driven test catches by asserting no tile is narrower than a whole tile.

Virtualization is gone and could come back without losing the reflow: a virtualized list whose rows are columnsFor(width) tiles wide is the same picture with the same reflow and a bounded element count. It is not what is built here, and the reason is that it is not a masonry — it is a hand-rolled grid that would have to re-derive the row chunking on every resize. If the opening cost matters more than the simplicity, that is the change to make, and the numbers above are what would justify it.

Alternatives considered

Keep the virtualized list and make its column count dynamic. Rejected for this change and named above as the way back. It keeps the cost bounded and gives up the widget: chunking names into rows by a measured width is a grid written by hand in the screen, where a masonry is one that already exists and is tested.

Cap the sheet at the first N matches with a “keep typing” line. Rejected: the screen’s whole claim is that the bundled set is browsable, and a sheet that shows 300 of 1544 until you guess a word is a worse answer to “which icons are there” than the website it replaces.

Drop the caption element and draw the name as a second box on the tile. It would take about a third of the elements, which sounds like the fix and is not: 3 ms instead of 4 is the same order of magnitude, and the caption needs its own resolved style — muted, caption-ranked — which is what an element is. Rejected as a change that costs the styling and buys a number that is still over.

Leave the fixed seven columns and let the last one clip. Rejected — it is the defect this record exists to fix, and it is visible in the first narrow golden of the screen.

310. A shadow is a stack of rectangles

Date: 2026-09-14

Status

Accepted. Reverses the “no box-shadow” half of ADR-0164 and ADR-0166, both of which named adding it as an alternative and turned it down. The edge those records built stays — --gb-border-strong is still what tells a card apart — and it is now an edge and a shadow rather than an edge standing in for one.

Context

box-shadow has been in §8’s property list since the beginning and has been turned down three times. The reasons given were, in order:

  1. Box has no field for it (ADR-0164, ADR-0166).
  2. Nothing in the toolkit paints outside a box’s own rectangle, so a shadow would need a damage rectangle nobody had (ADR-0166).
  3. The rasterizer has no blur — ADR-0256’s line, and the only one of the three that was still true this week.

The first two stopped being true while other work went past them. The focus ring is drawn outside the border box, and RenderTree’s damage rectangle has grown by outline-offset + outline-width ever since. So “nothing paints outside the box” describes a toolkit two hundred ADRs ago.

The third is still true and is the interesting one. Blend2D’s image filters are not on the export list, and putting them there would not help much: a blur is an offscreen pass, and a box that wanted one every frame would pay for a buffer, two passes over it and a composite. The frost material’s 3-pass box blur (docs/design-system.md §1.5) is not the same thing — it runs over a backdrop, on a downsampled copy, cached while static, which is exactly the arrangement a shadow cast by a box in the middle of a paint walk cannot have.

What the rasterizer does have is a very fast rounded-rectangle fill.

Meanwhile the cost of not having the property kept showing up. A card is told apart by a 16%-alpha rim; a menu, a popover and a dialog float over the window with nothing under them; §1.5 pins two shadow recipes that nothing could draw; §1.7 describes affix animating “opacity on the elevation shadow” for a shadow that did not exist. Every one of those is the design system asking for a property and getting an apology.

Decision

box-shadow: <x> <y> <blur> [<spread>] <color>, drawn as a stack of nested rounded-rectangle fills.

One shadow per box, not a comma list. Three pieces:

  • css.value.Shadow — the value: four lengths, a colour, a parser, fade, mix, and the four asymmetric outsets that say how far past each edge it reaches. It rides on Decoration rather than as Box’s twenty-eighth component, on the sentence that class opens with: a drop shadow is drawn around a box and not in it, it is geometry derived from the corner radii, and nothing reads it without also reading them.
  • paint.shadow — a package of two halves, neither of which needs a Frame: ShadowRamp says how opaque each band is, ShadowGeometry says what shape it is.
  • paint.ShadowPainter — twelve lines, in paint because it is the only part that touches the pooled rasterizer path.

And --gb-elevation-1 / -2 / -3 in each theme, as whole box-shadow values rather than colours.

The alphas are solved for, not read off

This is the part that is easy to get wrong and invisible when it is. Nested fills composite over one another, so a point covered by the outer five bands does not end up at the fifth band’s alpha — it ends up at 1 - Π(1 - aᵢ). Bands whose alphas are read straight off the fade curve give a shadow far too heavy in the middle, with visible rings in it. So the curve is treated as the accumulated alpha and each band’s own alpha is solved:

Aₖ = T · C(uₖ)                 the alpha the fade wants after k bands
aₖ = 1 − (1 − Aₖ)/(1 − Aₖ₋₁)   the alpha this band must be painted at

C is smoothstep across the blur, which is not a Gaussian and has the two properties that matter: it is exactly 0.5 on the shape’s own edge, which is what a blur does, and flat at both ends, so the fade meets “nothing” and “solid” without a seam. Against a true Gaussian of σ = blur/2 it is a few percent light in the shoulders; at the alphas a shadow is painted at, a few percent of a few percent is under a bit of one channel.

One band per logical pixel of blur, between four and forty-eight. Per logical pixel, so a window at 150% paints the same bands at 1.5× the size rather than half again as many of them — which is what keeps a shadow inside ScaleInvariance.

The bands under an opaque box are never built

The painter tells the ramp whether the background will cover the box’s own rectangle. When it will, the bands lying entirely inside it are dropped — they are always a suffix, because the bands only shrink, so nothing earlier changes and the visible picture is identical. For 0 0 <blur>, which is what a glow is, that is half the fills. For the design system’s 0 2px 8px it is most of the inner half.

The alpha belongs to the theme, and the geometry does not

A shadow is black cast onto whatever is underneath, so what it costs in contrast depends entirely on how light that is. rgba(0, 0, 0, 0.16) is a clear soft edge on nord-light’s #eceff4 and very nearly nothing on nord-0. An application picking the number would pick one number and be wrong on one theme — which is precisely the mistake --gb-surface-2 cost three widgets (ADR-0245).

So the alpha differs between the two files, by roughly two and a half times. The geometry does not: an object 8px off the page throws the same shape whatever colour the page is, and §1.5’s 0 2px 8px and 0 8px 32px are what it is. ThemeTest asserts both halves — dark is heavier at every level, and the four numbers are identical.

Three levels where §1.5’s ladder names two shadowed ones: -1 is its raised, -2 its overlay, and -3 is above both, for a thing the pointer is dragging.

transition: box-shadow

Added to Transitions.Animatable, because a transition naming a property the engine resolves and cannot animate is the silent-nothing that enum’s own class note refuses. Every component interpolates, so a card lifting from -1 to -2 grows its blur and its offset as well as its alpha, which is what an object rising off a page does. Interpolating from none fades the arriving shape in at full size rather than ramping its geometry up from zero — CSS’s rule, and the reason for it is that the other way a card appears to inflate.

What this does not do

It does not knock the border box out of the shadow. CSS paints an outer shadow only outside the box that casts it; this paints the whole shape and relies on the box being drawn on top of it.

That is a deliberate limit and not an oversight. Cutting the hole needs either a path clip or a fill rule, and the binding has neither — and the obvious trick does not work: a reversed sub-path under Blend2D’s default non-zero winding fills the parts of itself the outer shape does not cover, so a band inside the border box (which every band of the inner half is) would paint a dark ring where it was supposed to erase one. Worse than the thing it fixes.

The difference is invisible under an opaque background, which is every shadowed surface the design system has. It shows under a fading one: a box mid-opacity transition fades its shadow by the same factor, so what is under it darkens it slightly rather than being hidden. ShadowPaintTest pins that, so the day a fill rule lands there is a test that says the deviation is gone. TODO.md carries it.

It does not put a shadow on any widget. The tokens exist and nothing in controls.css reads one yet. Elevating card, menu, popover and dialog is a visual change to the whole catalog and belongs in its own change, with its own goldens.

Alternatives considered

Export Blend2D’s blur and render each shadow offscreen. The faithful answer, and rejected on cost: a buffer, two passes and a composite per shadowed box per frame, for a picture the eye cannot tell from a ramp of fills. It is also the wrong shape — the blur would have to run at physical resolution while everything around it is described in logical pixels, which is where a shadow picks up a half-pixel seam at fractional scale.

A comma-separated list, as CSS takes. Rejected, and the list is read as its first entry with the rest logged. Every shadow §1.5 pins is one shadow and every one a theme ships is a single token, so a list has no author here. The idiom mostly exists to fake a blur profile out of two hard-ish shadows, and this is a ramp already. The first entry rather than a refusal for the reason border: 1px dashed red draws a solid line: drawing something is the more useful of the two wrong answers.

Make the token a colour — --gb-elevation-1: rgba(0,0,0,.44) — and let the widget write the geometry. Rejected, and it is the version this nearly was. It gets the theme-owns-the-alpha half right and leaves every widget restating 0 2px 8px, which is four numbers in eleven stylesheets that have to agree for the elevation ladder to mean anything. A whole-value token is the one where a rule writes box-shadow: var(--gb-elevation-2) and chooses nothing.

Keep the edge and add nothing. What ADR-0164 and ADR-0166 decided, twice, and it was right both times: the edge works, it needs no drawing outside the box, and it is still what a card wears. What changed is that the reason for it — “nothing paints outside a box” — stopped being a fact about the toolkit, so it was no longer a principle, only a limit.

Default a colourless shadow to black. Rejected. CSS’s default is currentColor and §8’s subset has no such thing; guessing black paints a hard black halo where an author meant a tinted one. The declaration is dropped and logged, which is what §8 does with a value it cannot honour.

inset shadows. Refused at the parser. An inner shadow is clipped to the border box rather than cast outside it — a different drawing that needs the clip this one was careful not to need — and no rule in the canon asks for one.

Consequences

§8’s list is two short. backdrop-filter and letter-spacing are what is left; box-shadow was the example in four different comments about properties the engine drops, and those comments now name backdrop-filter.

A shadowed box costs a run of fills. Eight for --gb-elevation-1’s 0 2px 8px, five of them once the bands under the box are dropped; thirty-one for -2’s 0 8px 32px, twenty-two after. Each is a rounded-rectangle fill, which is the primitive the rasterizer is fastest at, into one pooled native path. An unshadowed box costs one branch on hasShadow() and nothing else, which matters because that is every box in an ordinary window.

The damage rectangle is asymmetric now. It was one outset on four sides and it is max(ring, shadow.outsetX) per side, because 0 8px 32px reaches 24px below a box and 8px above it. Taking the larger for all four would repaint bands nothing drew in — and, the day a shadow is offset further than it is blurred, miss one, which leaves a smear that survives until something else repaints over it.

Decoration has a seventh component. Every wither in it grew an argument. That is the cost of not putting it on Box, and it is much the smaller of the two bills.

Three new test files and two goldens. The ramp arithmetic and the band geometry are tested without a rasterizer, because a golden can say the picture changed and cannot say that the alphas do not add. The goldens are one per theme, because the alpha is the half of an elevation token that belongs to the theme and one image could not say that.

311. Margin is room outside, and auto is the half that mattered

Date: 2026-09-14

Status

Accepted. Closes the margin entry in TODO.md, which since ADR-0244 had recorded the property as having no live consumer — and which was right about the case it was looking at and wrong about the one it was not.

Context

margin has been in §8’s layout list since the first day and in §10’s subset never. It is the third of the four properties a widget reached for and did not find — border-bottom, currentColor, margin, max-width — each written into a stylesheet, silently discarded, and found by looking at a picture (ADR-0215).

Nothing about it was hard. Yoga has had YGNodeStyleSetMargin since ADR-0029 bound the node API, and Yoga binds it with its auto call, which two other keyed length properties are deliberately bound without. What was missing was a component on Box, a component on ComputedStyle, and four lines in RenderObject.apply.

The TODO.md entry closed the case on the wrong evidence. tab-new wanted a margin to sit somewhere other than the top of its row; align-self answered that (ADR-0244), the entry recorded “no live consumer”, and the property stayed out. But align-self is the cross axis. On the main axis a box that wants to centre itself, or to sit at the far end of a row its container is not arranging for it, has no spelling at all:

  • justify-content is the container’s decision about all of its children at once, so one child cannot opt out of it.
  • A spacer box with flex-grow: 1 works and is a box in the tree that draws nothing, exists for the layout engine, and has to be remembered by whoever reads the document later.

That is what margin: 0 auto and margin-left: auto are for, and the binding already had the call.

Decision

margin, margin-top, margin-right, margin-bottom and margin-left, resolving into an Insets on ComputedStyle and a component on Box, applied per edge in RenderObject.apply.

CSS’s 1-4 value shorthand, the same one padding and inset take, over the same Insets and through the same helper.

auto is a value here. Length.AUTO on an edge reaches Yoga’s own YGNodeStyleSetMarginAuto and absorbs the free space on that side, which is what centres a box on the main axis and what pushes one to the end of a row.

Negative margins are allowed and not clamped, unlike a radius or a border width. A negative margin means something: it pulls a box over its neighbour, which is how a row of overlapping avatars is written and how a control escapes one edge of its container’s padding.

No margin collapsing, and that is not a shortcut. CSS collapses adjacent vertical margins in block layout and never in flex, and this is a flex engine — so 10 and 6 between two boxes is 16, which is both what Yoga does and what the specification says. It is asserted rather than assumed, because it is the first thing an author who learnt CSS on documents expects to be wrong.

A box with no margin costs one comparison. RenderObject skips the four foreign calls wholesale when a first apply sees Insets.ZERO, which is ADR-0181’s arrangement for limits and is worth more here: Yoga’s own default margin is zero, margin is rarer than padding in this catalog, and nothing in the catalog wears one today.

Two defects found on the way, both older than this change

Neither is about margin. Both were reachable before it and both are fixed here, because the property could not be correct without them.

padding: auto closed the window

Yoga’s setters come in pairs, a value one and an auto one, and four of them have no second half: there is no YGNodeStyleSetPaddingAuto and no YGNodeStyleSetMinWidthAuto. Yoga binds those without their auto call and refuses an auto by name rather than dropping it silently — which is the right choice for a binding and made padding: auto in a stylesheet an IllegalArgumentException thrown in the middle of a layout pass. A window closing over one typo, from a property the engine claims to support.

CssLength.parse reads auto for any length, so this was reachable from padding, padding-*, inset, top/right/bottom/left, gap and all four of min-/max-width/height. §8’s rule for a value the engine cannot honour is to drop the declaration and say so, and the only place that decision can be made is where the declaration is read. ComputedStyle has a fixed() beside its length() now, and the ten properties above go through it. width, height and margin do not: Yoga binds all three with their auto call.

The cascade returned its winners in hash order

Nothing between StyleResolver.resolve and ComputedStyle.apply re-orders, so the order properties come out in is the order they are applied in. A padding applied after a padding-left overwrites the edge the longhand set, which is right when the shorthand was written second and wrong when it was not.

cascade() collected its winners into a HashMap, so which way round any given pair came out was whichever way their property names’ buckets fell. padding and padding-left happened to come out the right way round. inset and left did not: inset: 8px; left: 20px resolved to 8px on all four edges, silently dropping the longhand, and had since inset arrived. No stylesheet here writes both — checked — so nothing was visibly broken; an application’s sheet would simply have got the wrong answer with nothing to blame.

It is a LinkedHashMap now, filled from the already-sorted match list, with a remove before each put — because LinkedHashMap keeps a re-put key at its first position and the position that matters is the winning declaration’s. Three rules naming padding-left, then padding, then padding-left again must end with the longhand last.

This is what the margin-left: 20px test failed on, which is the only reason it was found: margin is the first property added to the engine that has four longhands over a value a shorthand also sets.

Alternatives considered

Keep the entry closed and write a spacer box. What the toolkit does today, and it works. Rejected because it is a box in the tree that draws nothing and exists to be measured — a layout trick the document has to carry, where CSS has a declaration for it. A spacer also cannot centre: flex-grow: 1 on both sides centres only while neither side has anything else in it.

Refuse auto and take the lengths only. Rejected, and it would have made this change almost pointless. align-self already covers “sit somewhere else on the cross axis”, which is what the TODO.md entry measured the demand by; the main axis is the gap, and auto is the whole of the answer to it.

Clamp negative margins to zero, the way border-width and the corner radii are clamped. Rejected: those are clamped because a negative one is meaningless and arrives from arithmetic that went wrong. A negative margin is a technique.

Put the auto refusal in Yoga — drop it there instead of throwing. Rejected. A binding that silently ignores what it was told is the worse failure: the declaration would be gone with nothing reporting it, which is the exact condition StyleLint and SupportedPropertyTest exist to catch, and they can only catch it if the cascade is what refuses. Refusing by name is right where it is; what was wrong was letting the value get that far.

Fix the cascade order by sorting property names. Rejected as the wrong shape: the order that matters is the declarations’, not the alphabet’s. Preserving the sort that was already being computed costs nothing and is the order CSS specifies.

Consequences

§8’s layout list is complete except flex-basis, align-content and aspect-ratio. margin was the one on it with a binding already in place.

Three Insets on Box and on ComputedStyle. RecordWitherTest needed a third distinct value so a wither writing into the wrong one of the three cannot round-trip; that test is what verified all fifty-odd rewritten constructor calls across the two records, and it found nothing, which is the useful outcome.

inset: 8px; left: 20px now does what it says, which is a behaviour change to any stylesheet that wrote both. Nothing in this repository did — the toolkit’s own sheets are linted and would have failed — but an application’s might, and it would have been getting the wrong answer.

Ten properties now drop auto instead of crashing. A stylesheet that wrote min-width: auto — which is valid CSS, and what that property computes to on a flex item in a browser — took the window down. It is a dropped declaration with a warning now. That is still not CSS’s behaviour, and it is the honest one for an engine whose layout library has no such setting.

Nothing in the catalog uses a margin yet, exactly as nothing wears an elevation after ADR-0310. The property exists and controls.css reads it nowhere. Rewriting spacer boxes as margins is a change to the layout of real widgets and belongs in its own change, with its own goldens.

312. The catalog puts the two new properties on

Date: 2026-09-14

Status

Accepted. Spends what ADR-0310 and ADR-0311 built. Both of those ended with the same sentence — nothing in the catalog uses it yet — and both said the reason was that using it moves every golden that contains one of the affected widgets. This is that change.

Context

box-shadow and margin landed with tokens, tests and no consumers. That is the right way round — a property and its first use are two different risks — but a property with no consumer is also a property nobody has checked against a real widget, and the catalog was full of comments explaining what it would do if it existed:

  • card: “§5 says shadow tokens and §10’s subset has no box-shadow”.
  • dialog: “Elevation 2 is an edge and a scrim, not a shadow.”
  • dialog-actions: “The margin is padding-top on this node rather than a margin on the panel, because §8’s subset has no margin.”
  • affix:affixed: “§1.5’s elevation”, setting only a background.
  • tour-card: “§1.5’s dialog elevation”, setting only a radius.

Five rules describing a property they could not write. Each is a place the design system was already specific and the engine could not follow.

Decision

Elevation on the five surfaces §1.5 names, and margin in the two places the catalog was working around not having it.

The shadows

levelwhy
card1§1.5’s “raised: menus, cards, popovers”
card.interactive:hover1 → 2§5’s “hover-elevation optional via class”
dialog2§1.5 names dialogs for level 2 exactly
tour-card2a tour card is a dialog by another name, and its own comment said so
toast2below
affix:affixed > affix-content1§1.5 via §1.7’s motion table

toast is the one judgement here rather than a quotation. §1.5’s ladder has two shadowed levels: 1 for things raised off the page, 2 for overlays. A toast is in the window’s own overlay layer with the application’s content directly under it and no scrim between the two — it has left the page entirely, and level 1 over an arbitrary busy background does not say so.

card.interactive transitions box-shadow as well as border-color. Every component of the shadow interpolates, so the blur and the offset grow with the alpha. That is the difference between a card that rises and a stain that darkens under a card which has not moved, and it is the first thing in the toolkit to use ADR-0310’s transition: box-shadow. affix uses it too, which is §1.7’s “detach/attach: opacity on the elevation shadow, fast” — a line that has been in the motion table since before there was a shadow to put an opacity on.

The edges all stay. Not one of them was a placeholder. A shadow says “nearer” by darkening what is underneath, and a card sitting on another card is sitting on its own colour — where the shadow says almost nothing and the rim says it exactly. The Panels screen puts three surfaces side by side and a nested card inside a card, which is what that sentence looks like.

The margins

dialog-actions: padding-top: 24px → margin-top: 24px. §2 says “top margin 24” and the rule has carried a comment explaining the substitution ever since. The picture does not change — the row has no fill and nothing to clip — and the declaration now says what it means.

tour-card’s footer loses its Spacer. TourStop built [Skip][Spacer][Back?][Next]; it now builds [Skip][Back?][Next] with margin-right: auto on Skip. One widget fewer in the tree, and the picture is pixel-identical — checked, by regenerating the tour goldens with the spacer put back and comparing. It has to be: with n children and a gap g the spacer absorbs W − Σwidths − n·g and with one child fewer the auto margin absorbs W − Σwidths − (n−1)·g, which lands every button in the same place.

The margin goes on the trailing edge of the leading button, not the leading edge of the trailing one, because the trailing group is one button or two depending on whether there is a stop to go back to — and two auto margins split the free space between them and open a hole in the middle of the pair.

The showcase’s notice bar likewise: #clear-notices { margin-left: auto } replaces a Spacer in Notifications.bar.

What deliberately did not get a shadow

popover, menu and tooltip, which is exactly the list §1.5 names for level 1 alongside cards. They are drawn in popup windows created at the panel’s own measured size (ADR-0104). A shadow is drawn outside the box that casts it, so every pixel of one would fall outside the window and be clipped: the toolkit would pay for a run of fills and draw nothing.

The fix is a popup window sized to the panel plus the shadow’s reach with the extra transparent, which needs a compositor that honours a transparent popup on all three platforms — the same thing the rounded corners are already waiting on. Their comments said “the subset has no box-shadow”; they say the real reason now, which is a different and more durable one.

message, which is part of the column rather than over it, and hud, which is a diagnostic plate that §1.5 does not put on the ladder.

spacer is not deprecated. It is a §1 widget an application writes in markup, and a document has no stylesheet of its own to put a margin in. The showcase’s status bar keeps one on purpose, with the notice bar beside it as the other half of the comparison: same shape, done with a margin. What changed is that toolkit code, which does have a stylesheet, no longer reaches for a node to do a declaration’s job.

Alternatives considered

Give popover a shadow anyway and let it clip. Rejected on measurement rather than principle: a run of eight to thirty-one rounded-rectangle fills, per popup, per frame, every one of them outside the window. It would be invisible and not free.

Put --gb-elevation-3 on something. Rejected, and it stays unused. It is the “a thing the pointer is dragging” level and nothing in this catalog is dragged; a token used by nothing is ordinary for a theme — the semantic hues have ranks widgets do not use either — and inventing a consumer for it would be worse than leaving it.

Drop the edges now that there are shadows. Rejected, and it is the tempting one because the two look redundant against a page. They are not redundant against a card: the Panels screen has card.surface-demo inside card#surface-card, and there the shadow falls on the same colour it is cast by.

Leave dialog-actions as padding. Rejected as the whole point of ADR-0311. The picture is identical and the declaration was lying about what it meant, which is what the comment above it had been apologising for.

Consequences

Twenty-six goldens moved, across :widgets and :example — every screen in the gallery, because every screen is a wall of cards. They were reviewed rather than accepted blind; the three worth naming are card-hover.png (the lift, beside a card that did not), affix-pinned.png (the pinned header now casts onto the rows sliding under it, which is the whole argument for affix in one image) and gallery-panels.png (a card inside a card, where the shadow says nothing and the rim does).

RuleBucketTest caught a selector. tour-card > column > row .tour-skip has a rightmost compound that names no type, so the cascade would check it against every element of every kind (ADR-0152). It is button.tour-skip now, folded into the rule that was already there — two rules with one selector is its own small defect.

The catalog’s frame cost did not move. The style pass is what shadows would have touched if anything and it does not see them; the raster pays, and pays only where a shadow is. Two budget tests failed while :widgets:test and :example:test ran concurrently and pass in isolation, which is worth writing down because it will happen again: FrameBudgetTest measures wall-clock on a machine Gradle is also running another test JVM on.

Five comments stopped being wrong. That is most of the value here. Each one described a property that did not exist, and a reader had no way to tell which of them were still true.

313. A frame pays for what is on screen

Date: 2026-09-14

Status

Accepted. Builds on ADR-0114, which put the clip stack in Java, and on ADR-0069, which made the render tree survive a frame.

Context

ADR-0114 gave the painter a clip stack and said, of the one place it stops a walk:

Scrolled entirely out of sight. Nothing under here can produce a pixel, so the walk stops — which is the one place a clip saves the traversal as well as the rasterization.

That sentence is about a clipping box scrolled out of its parent. It is not about the ordinary case, which is a viewport with a thousand rows in it and forty of them on screen: each of those thousand rows is a box in a subtree whose clip is perfectly non-empty, and every one of them was handed to Blend2D to be clipped away. A fillRect outside the clip is cheap. A stroked icon path and a shaped glyph run are not, and neither is doing it 1504 times.

The screen that made this unignorable is the icon sheet (ADR-0309), which is 1544 tiles and 4709 elements because a masonry cannot virtualize. That record measured what the trade costs and published the numbers — build, style, layout — and measured no raster at all, which is where the cost actually was:

icon sheet, 1280×900, one Blend2D threadbefore
style3.5 ms
layout0.7 ms
raster18.0 ms

Eighteen milliseconds is the whole of a 60 Hz frame on a screen where forty tiles are visible. The user-facing report was “the icon view is really slow”, and it was right.

Decision

The painter skips a subtree whose ink cannot land inside the clip in force.

Three pieces, and the first two are arithmetic over values with no Frame in sight — a new paint.cull package, for paint.geom’s reason:

  • Ink — the rectangle a box, or a whole subtree of them, actually puts ink in. Four edges, like Clip, and its mirror image: a clip says what may be drawn and this says what would be. union, shiftedBy, mappedBy and one question, overlaps(Clip).
  • BoxInk — what one box draws, in its own coordinates. Its border box, grown by the three things that are outside it by design: the focus ring, the drop shadow’s four asymmetric outsets (ADR-0310), and an icon larger than the slot the painter centres it in (ADR-0143).
  • RenderObject.settle() — reads where Yoga put this subtree and unions its ink, bottom-up, once per layout pass.

The painter’s test is one line, at the top of the walk:

if (!parentClip.isNone()
        && !object.ink().shiftedBy(left, top).mappedBy(transform).overlaps(parentClip)) {
    boxesCulled++;
    return;
}

The subtree’s ink, not the box’s own

Culling on a box’s own rectangle would be wrong, and wrong in the way that loses content rather than the way that wastes time. A child may be drawn outside its parent: flexbox lets a box overflow, a transform moves one out from under its parent, and a focus ring is outside the border box by definition. So Ink is the union over the subtree, and a parent with nothing in its own rectangle stays alive because a grandchild is on screen.

What is deliberately not covered

Content that overflows its own box — a paragraph in a box a stylesheet gave a height too small for it. A measured leaf is sized by the text in it, so a text box fits its text by construction; a box whose own content spills past a pinned height is drawing over its siblings already, and the culler treats it the way the painter does.

Settled once, not measured twice

settle() also reads Yoga. That is not an extra cost, it is a cost moved and then removed: four separate walks want to know where every node is — the paint pass, the damage pass, the hit-test snapshot and the ink pass itself — and each of them was making four native downcalls per node for the same answer. Nothing between one update and the next can move a node, so layout() is read once and handed back afterwards.

And the pass caches. A subtree is re-measured only when something in it changed or its own rectangle moved; otherwise last frame’s ink still stands and the walk stops there. That is what makes scrolling free: a viewport moves by a transform on one box, so exactly one node is changed and the thousand under it are skipped at the first rectangle that held.

One rule about what is outside a box

RenderTree.bounds — the rectangle a promoted layer is allocated at — asked the same question for the opposite reason and answered it with its own copy of the ring-and-shadow arithmetic. It now calls BoxInk. Too small a rectangle there clips a focus ring off a promoted node; too small a one here drops a row that was on screen. One rule, one place.

The predicate had a hole, and a test found it

The cache reuses last frame’s ink when changed is false and the rectangle held. changed is the box diff over a subtree — and sameAppearance, which computes it, did not compare flex-wrap, align-self, the min/max limits, overflow or elevated.

RenderTreeTest.flexWrap failed immediately: a row that starts wrapping puts its third child on a second line without changing one field of that child’s box, so a subtree this comparison called unchanged had every rectangle in it move. The five properties are compared now. That makes damage and layer invalidation slightly more conservative and fixes two latent bugs of their own — a box that starts clipping, or starts painting over its siblings, produced no self-damage at all.

Alternatives considered

Virtualize the icon sheet. It is the direct fix for the screen that prompted this, and ADR-0309 already explained why it cannot be done: a masonry places each card under the shortest column, which is a decision about every card. It also fixes exactly one screen. Culling is the same win for list, table, tree, menu, the tab strip and every application viewport nobody has written yet.

Let Blend2D do it. It already does — that is what the 18 ms was. The rasterizer clips correctly and cheaply; what it cannot skip is the transform of a path, the shaping lookup for a glyph run and the downcall that submits them.

Cull each box against the clip and keep walking. Safer, and it saves the drawing but not the traversal. It also needs no subtree ink, which is the whole of the risk here. Rejected because the traversal is where the per-node layout() calls were, and because a subtree test costs the same arithmetic as a box test once the pass exists.

Compute ink lazily, during the paint walk. Appealing — nothing above a viewport would ever be measured. It does not work: deciding whether to descend into a node needs that node’s subtree ink, so the first container asked about walks everything under it anyway, and the memoization has to be invalidated by hand rather than by a layout pass that has just run.

Consequences

The measurement, same machine, same frame, one Blend2D thread:

icon sheet, 1280×900beforeafter
style3.5 ms3.6 ms
layout (now including settle)0.7 ms1.0 ms
raster18.0 ms4.4 ms
a settled frame22.2 ms9.0 ms

The wall of cards — the screen most of this application is — pays 0.03 ms for the ink pass and gets a slightly cheaper raster back. Nothing regressed anywhere.

A culler that stops working draws the same frame. No assertion on pixels can tell a correct cull from no cull at all, which is ADR-0081’s argument for layersRepainted arriving at a second optimization. So RenderTree counts boxesPainted and boxesCulled, and CullingTest asserts on both — the counts say the walk stopped, the pixels say it stopped in the right place.

Ten times the rows in the same viewport now costs the same to paint. That is the property an application can rely on, and it is asserted directly.

What is now expensive to get wrong. BoxInk is the list of things drawn outside a border box. A property added to Box that draws outside one — an outline on a second edge, a glow, a second shadow — and not added there is a subtree that disappears at a viewport’s edge and nowhere else. The same is true of sameAppearance: a new layout property not compared there is a stale ink rectangle. Both are one-line additions and neither announces itself.

The ink pass is O(nodes) on the frame something changes, and settle does not know how to be cheaper than that when a whole screen is rebuilt. On the icon sheet that is about 1 ms, against the 13 ms it takes off the raster. The obvious next step is to cache across the box-diff rather than re-walk, which is what the changed guard already does for the common case and what a finer flag could do for the rest.

314. A notch is three lines, and down is down

Date: 2026-09-14

Status

Accepted. Corrects two bugs against ADR-0115 and ADR-0116; does not change what either decided.

Context

Two reports, one sentence apart: “scrolling is slower than the system one”, and “two scrolls on the page work opposite to the wheel — e.g. in the tab with md”.

Both are true, and they are the same mistake made twice: a wheel event counts detents, and a viewport moves in lines, and the conversion between them was written down in prose and never in code.

Slower than the system

ScrollViewport.LINE has carried this paragraph since the widget was written:

What one wheel line moves, in logical pixels. Three of these is the conventional notch — around 60px, which is what every other application on the machine does.

And the handler below it read:

var moved = scrollBy(event.deltaX() * line, event.deltaY() * line, …);

One line per notch. A deltaY of ±1 is one detent of a real wheel, so the toolkit moved 20px where the desktop moves 60 — a third of the speed of every other window on the screen, which is not a number anybody reads off a stylesheet. It is a feel.

Its own test agreed with it, because the test asserted LINE and the code multiplied by LINE. Two copies of the same misreading is not two witnesses.

Opposite to the wheel

The Markdown screen is a split-pane: a text-area on the left, a scroll around the rendered preview on the right. They scrolled opposite ways, at wildly different speeds.

TextAreaBox is the one scrollable thing in the toolkit that is not a scroll — it holds its own offset because the text inside it is a paragraph rather than a subtree. So it handles the wheel itself, and the handler read:

if (editor.scrollBy(-event.deltaY())) {

Negated, and in pixels. The editor’s scrollOffset is “how far the content is scrolled up”, positive down the document, which is scroll’s own convention and deltaY’s; the minus sign inverted it. And the argument is a distance in logical pixels against a delta in lines, so one notch moved the document one pixel.

Four lines above it, unused, declared and never referenced:

/// How many lines a wheel notch moves. Three, which is what every scroll view
/// on every desktop does and what `scroll` itself uses.
static final int WHEEL_LINES = 3;

Which scroll did not, in fact, use — see above.

Decision

A wheel notch is three lines, everywhere, and the sign is deltaY’s.

  • ScrollViewport.LINES_PER_NOTCH = 3, applied to the wheel and to nothing else. --gb-scroll-line still says how far a line is (ADR-0251); three of them is a notch, whatever an author sets a line to.
  • AreaEditor.scrollBy(double dy) becomes scrollByLines(double lines). The unit is the one the event is in, and the conversion moves to the side that knows what a line of this control’s text is tall — a mono area at 13px and a body one at 15px move different distances for the same turn of the wheel, and both of them move a line at a time.
  • TextAreaBox calls editor.scrollByLines(event.deltaY() * WHEEL_LINES), with the sign left alone.

The keys are untouched

An arrow key means a line and ARROW is one; PageDown means a viewport. The notch is the only unit here that is a platform convention rather than a document’s own idea, which is why it is a separate number from the token an author can set. ScrollTest now asserts the relationship directly: one notch covers the same ground as three arrow presses, whatever --gb-scroll-line is set to.

A trackpad is unaffected in the way that matters

ADR-0115’s finding was that the fraction is what stops a trackpad moving in jerks, and the handler still reads deltaY rather than the accumulated ticksY. A trackpad’s eighths are multiplied by the same three, so the same gesture covers the same ground it would on any other application.

Alternatives considered

Set LINE to 60 and delete the multiplier. Fewer numbers, and it breaks the token: --gb-scroll-line: 50px would then mean “a notch is 50px” on the wheel and “an arrow moves 50px” on the keyboard, which are two different claims about one declaration. The arrow and the notch have to be able to differ.

Read the platform’s own scroll setting. GTK, Windows and macOS all expose a lines-per-notch preference, and honouring it is the genuinely correct answer. It needs an SPI call on three backends and belongs with the other platform settings nothing reads yet. Three is what all three default to.

Give text-area a real scroll inside it. It would delete the second copy of this convention, which is the root cause. It cannot be done as things stand: the text is a Paragraph painted by the box, not a subtree, so there is nothing for a viewport to translate. Worth revisiting if a text-area ever lays its lines out as elements.

Negate at the router instead. The router already flips SDL’s sign so that positive deltaY is down the document. Doing it twice in one direction and once in the other is how this happened; the fix is one convention stated once, which is what PointerEvent#deltaY documents.

Consequences

Scrolling is three times faster and matches the desktop. That is the whole user-visible change, and it is a change to a feel rather than to an API.

Four tests moved, and each of them for a reason worth reading:

  • ScrollTest asserted LINE per notch. It now asserts LINE * LINES_PER_NOTCH, and the harness’s parameter is called notches rather than lines — the distinction the handler used to collapse.
  • KnobChainingTest pre-scrolls its list off the top so a chained wheel has somewhere to go. Two notches used to be 40px and are now 120px, which carried the knob out of the viewport. Its own guard — “the knob was scrolled out of the viewport before the wheel” — is what caught it, which is what it was written for. One notch now.
  • AffixGoldenTest is a picture of a list scrolled exactly 140px. It asks for seven thirds of a notch, which is a fraction a trackpad sends all the time and the only way an event that counts detents can say “this far”.
  • TextAreaTest had no wheel test at all, which is why a control that scrolled backwards one pixel at a time survived. It has four now, and the one about distance asserts what is at the top of the pane rather than multiplying a line height out — the conversion is the thing that was wrong.

AreaEditor.scrollBy is gone. It is a package-private interface with one implementation, so the rename costs nothing outside widgets.form.textarea.

The number is still the toolkit’s and not the platform’s. An application on a desktop configured for five lines a notch gets three. That is the same trade the rest of the metrics make and it is now in one constant rather than in a paragraph that disagreed with the line under it.

315. A rebuild is not a restyle

Date: 2026-09-14

Status

Accepted. Narrows ADR-0070’s invalidation at the one caller that was throwing the cache away wholesale, and is what ADR-0313’s measurements were hiding.

Context

ADR-0313 measured a settled frame of the icon sheet — a frame where nothing had changed — and took the raster from 18 ms to 4.4. That was real and it was the wrong frame. The complaint was “the icon view is slow”, and an icon view is slow while somebody is scrolling it.

So: one wheel notch on the showcase’s icon sheet, every stage timed, 1280×900, one Blend2D thread.

wheel frame, icon sheetms
flush (widget rebuilds)2.0
style66.6
layout2.7
paint4.8
hit-test snapshot2.6
total78.6

Eighty milliseconds for a wheel notch, of which 85% is the cascade. A settled frame of the same screen styled in 3.5 ms, so a wheel event made the style pass eighteen times more expensive.

The cause is four lines that have been in Element.update since the element tree was written:

void update(Widget next) {
    var previous = widget;
    widget = next;
    invalidateStyle();      // this node's cached style AND its whole subtree's
    subscribeToBinding(previous);
    if (state != null) state.update(next);
    rebuild();
}

invalidateStyle recurses to every descendant and nulls its cached style. The comment above it says why, and then says why it is unconditional:

Invalidated wholesale rather than by comparing attributes: a rebuild is already the expensive path, and a comparison that missed a case would produce a node styled by a rule that no longer applies to it.

A rebuild is the expensive path when it rebuilds something. What a scroll rebuilds is two nodes — the viewport and the content box, whose transform changed — and the 4709 nodes underneath were being invalidated, re-described and re-cascaded for a translation none of them can see.

Decision

Two guards at the top of Element.update, and one question in the middle.

void update(Widget next) {
    var previous = widget;
    if (next == previous) {                 // (1)
        if (needsBuild) rebuild();
        return;
    }
    widget = next;
    if (matchesDiffer(previous, next)) {    // (2)
        invalidateStyle();                  //     the subtree
    } else if (RESTYLES.get(next.getClass())) {
        invalidateOwnStyle();               // (3) this node only
    }
    …
}

(1) The same description is not a description

A widget is a value. A parent that rebuilt for its own reason hands its children back the very objects it was holding — Scroll keeps its List<Widget> in a record field, so ScrollContent wraps the same instances and every tile under it arrives at update identical to the one already there. The same instance describes the same node with the same children: there is nothing to invalidate, nothing to re-describe and nothing below it to walk.

A rebuild this element’s own state asked for is still owed — markNeedsBuild put it in the tree’s dirty set and that is not the reason it is here.

This is Flutter’s child.widget == newWidget short-circuit in updateChild, arrived at from the same direction.

(2) A selector can ask three questions about a node

StyleElement is deliberately the smallest set of questions a selector can ask, and the list is short: type(), id(), classes(), parent() and hasState(). parent cannot change here; hasState lives on the element and survives a rebuild — that is what setPseudoClass is for.

So a re-description that leaves type, id and classes alone cannot change what matches anything below it, and the subtree keeps its styles. That is exactly the seam ADR-0149 opened — “the narrow half of invalidateStyle, for the caller that has asked whether the subtree can be affected and been told no” — arriving at the caller that needed it most.

The inherited half needs nothing added: a child’s cache is keyed on the instance its parent handed down, so a node whose own style really did change hands down a different one and its children re-resolve because of it (ADR-0142, ADR-0248).

(3) What is left is restyle, and almost nothing overrides it

If the selectors match the same, the cascade produced the same thing. The only remaining way a re-description can change a style is Styled.restyle, which runs after the cascade and reads the widget — §8’s seam for a number no selector can express, and the whole catalog overrides it twice.

RESTYLES is a ClassValue<Boolean>: reflection once per widget class, then a field read. For every other node — a text, an icon-tile, a row — the answer is false and the style survives the rebuild untouched.

Alternatives considered

Compare the widgets for equality. It is the obvious generalisation of (1) and it is quadratic: a Row’s equals walks its children, so comparing every node against its predecessor compares every subtree once per level.

Compare type/id/classes and always invalidateOwnStyle. This is (2) without (3), and it is what shipped first. It took the wheel frame from 78.6 ms to 15 ms and left the virtualized sheet re-cascading its whole window on every notch — 295 nodes at ~35 µs each, which is 10 ms of frame spent re-deriving styles that could not have moved. (3) is what made the difference between “much better” and “nothing to do”.

Teach the invalidation which rules could reach a descendant. The real answer, and real machinery: an index from a rule’s ancestor part to the nodes it could match. ADR-0070 called the subtree walk “conservative on purpose” and said the same thing. Still true, and (2) makes it much less urgent — the walk now happens only when a node’s own selector surface changed.

Consequences

The measurement, same machine, same notch:

wheel frame, icon sheetbeforeafter
flush2.0 ms0.1 ms
style66.6 ms4.2 ms
elements re-resolved15564

And it is not only the icon sheet. Every screen in the gallery re-cascaded everything under a scroll on every wheel event; the sheet is where it was big enough to see. Any widget that rebuilds for its own reason — a tabs changing its selection, a text-input blinking a caret — used to re-cascade everything beneath it.

sameAppearance grew five comparisons in ADR-0313 and they are load-bearing here too: flex-wrap, align-self, limits, overflow and elevated.

What is now expensive to get wrong. matchesDiffer is a list of what a selector can ask about a node. A selector added to §8’s subset that reads something else — :nth-child, a sibling combinator, an attribute selector — makes it incomplete, and the failure is a node keeping a style that no longer applies, which is a perfectly valid style. StyleElement’s own comment already says the subset stops where it does because “every one of them forces the matcher to know about ordering, and ordering is what makes invalidation expensive” — this is that sentence becoming load-bearing rather than explanatory.

The same is true of RESTYLES: it is a reflective question about one method, and a widget that computed a style somewhere other than restyle would keep a stale one. There is nowhere else to compute one, and Paints.render is documented as the wrong place for exactly this reason (ADR-0099).

Both are tested against the mechanism rather than against a colour. StyleCacheTest reads Element.cachedStyle directly — it is in the widget package so that it can — and the three new cases are: same classes keeps the subtree’s cache, a restyle-ing widget is still invalidated, and an identical widget is not a rebuild at all. Each fails without its guard.

316. A grid is a list of rows

Date: 2026-09-14

Status

Accepted. Reverses the layout half of ADR-0309 and keeps everything it decided about reflow. Builds on ADR-0213.

Context

ADR-0309 replaced the icon sheet’s virtualized list with a masonry, because a masonry is what reflows and the list did not — it chunked names into rows of a fixed seven, so a wide window left a band of empty space and a narrow one clipped the last column. It priced the trade honestly and published the numbers:

placing a card under the shortest column is a decision about every card. So all 1544 tiles are in the tree

4709 elements, and a settled frame that styled in 3.5 ms where every other screen in the gallery was under one.

Two records later the rest of the bill arrived. ADR-0313 found 18 ms of raster going into 1504 tiles nobody could see, and ADR-0315 found 66 ms of cascade going into the same tiles on every wheel notch. Both are fixed and both are toolkit-wide fixes that this screen merely found. What was left was the part that is genuinely about having 4709 elements: 5 ms of box-building, 2.5 of layout and 2.5 of hit-test snapshot, on every frame, whatever is on screen.

And ADR-0309 had already written down the sentence that undoes it:

A masonry of equal-height tiles is a reflowing grid in reading order

A grid of equal-height rows is a list of equal-height rows. list virtualizes. The masonry was never doing the reflowing — IconsScreen.columnsFor was, and still is, from a width the screen measures itself (ADR-0119). The masonry was doing the chunking, which is four lines.

Decision

The sheet is a virtualized list whose items are rows of columns names.

var grid = new ListView<>(rows(), IconRow::id, this::rowOf)
        .selection(Selection.NONE)
        .virtualized(ROW_PITCH)
        .id("icon-wall");
  • rows() chunks matching into slices of columns — 221 records where the masonry was handed 1544 widgets.
  • rowOf builds one row of tiles, padded to columns with empty cells: a tile grows over a 152pt basis so a full row divides the width evenly, and a part-full last row would divide the same width between fewer tiles and draw them half again as wide.
  • ROW_PITCH is 76 — a 68pt tile plus TILE_GAP — and showcase.css pins #icon-wall list-row to the same number. ListRow complains in the log if the two ever disagree, which is the guard ADR-0213 built for exactly this.

Reading order is left to right down the page. It always was; it is now true by construction rather than by an argument about which column is shortest.

The list’s furniture is taken back off

#icon-wall list-row drops the padding, the pointer cursor and the hover wash. This row is not a row of a list a reader picks from — nothing is selectable and nothing is pressable — and the hover highlight belongs to the tile under the pointer rather than to the whole row of seven. Selection.NONE is the model: §10’s own reading of it is “for a list that is a view — a set of things being browsed rather than picked from”, which is this sheet exactly.

Alternatives considered

Hand-roll the virtualization in the screen. Two spacers and a window is not much code, and it is code ListState already has — with the overscan, the Located window that settles in one frame, the pitch check and the focus that can reach a row outside the window. table composes a ListView rather than copying it (ADR-0214) and this is the same call.

Keep the masonry and make it virtualize. The masonry cannot, and ADR-0309 is right about why: a card’s column is a function of every card before it. That is true of a masonry and false of a grid, which is the whole of this record.

Leave it. The sheet was down from 78 ms a notch to 15 after ADR-0313 and ADR-0315, which is a screen that works. It is also a screen whose every frame costs 4709 elements for the 80 a reader can see, and the fix was four lines and a stylesheet rule.

Consequences

A settled frame, 1280×900, one Blend2D thread:

ADR-0309now
elements4709711
opens in414 ms106 ms
style3.5 ms0.72 ms
layout0.70 ms0.20 ms
raster18.0 ms4.2 ms

And a wheel notch, which is the frame a reader actually feels — the whole arc, across all three records:

wheel framebefore ADR-0313now
flush2.0 ms0.4 ms
style66.6 ms2.2 ms
layout2.7 ms8.4 ms
paint4.8 ms5.2 ms
hit-test2.6 ms0.5 ms
total78.6 ms16.8 ms

Layout went up, and that is the honest cost of virtualizing. A window that moves is a tree that changes: the sheet used to hold still and Yoga skipped it entirely, and now one row leaves at the top and one arrives at the bottom on every notch. It is 8.4 ms rather than the ~1 ms the change ought to cost, and the reason is known: RenderObject.reconcileChildren matches children by position, so a window that shifts by one row mismatches every row after it and closes and rebuilds the lot — a fresh YGNode and a fresh measure callback per text node, at the 11 µs apiece ADR-0037 measured.

Matching by Box.owner() instead — the element, which the element tree has already reconciled by key — fixes it and was measured at 9.9 ms → 3.7 ms. It is not in this record because it moved a card’s bottom border by one pixel on the Panels screen, deterministically, and a one-pixel layout change in the render tree that nobody can account for is not a thing to ship on a performance argument. The measurement and the artefact are written down here so the next person starts from where this stopped rather than from the beginning.

The search field is a convenience again. ADR-0309 made it the performance story — “two letters take about three quarters of the cost back” — and that was true of a wall that built every tile. A virtualized list builds its window, so a filtered sheet and a whole one are the same tree and the same frame. FrameBudgetTest asserts the new property in place of the old one: filtering changes the model by a factor of ten and changes the cost by nothing.

The sheet has no budget of its own any more. It had 8 ms of style and 4 of layout where every other screen had 1, because it did not meet the wall’s and pretending otherwise would have been a test that fails or a test that checks nothing. It is 2 ms and 2 ms now — three times the measurement, which is this file’s own doctrine — and the raster is on the wall’s own number.

What the picture cost. The two icon goldens moved by 4 points: a masonry divides its width into n columns and writes each one’s width inline, where a flex row of n growing tiles divides the same width by flexing. The row fills to the content edge and the masonry stopped 5 points short of it. Both are correct arrangements of the same tiles; the new one is the one with no slack in it.

And the element count is asserted, not only the time. IconsScreenTest checks that fewer than a tenth of the 1544 tiles exist and that the sheet is nonetheless as tall as all of them — the spacers adding up to the model is what keeps the thumb still while a reader scrolls, and it is the property that fails first if this screen ever stops virtualizing.

317. A router does not talk to the dead

Date: 2026-09-15

Status

Accepted. Closes docs/gaps.md G31. Completes ADR-0303, which made the router let go of what the pointer was over and left the same hole one field along.

Context

Closing a surface that had the keyboard — a palette, a sheet, a panel with a field in it — took the window down on the next frame:

java.lang.IllegalStateException: setState() on a state that is not mounted.
  at ...widget.State.setState(State.java:77)
  at ...form.textinput.TextInputState.focusChanged(TextInputState.java:504)
  at ...form.textinput.TextField.onFocusChanged(TextField.java:151)
  at ...input.PointerRouter.notifyFocus(PointerRouter.java:953)
  at ...input.PointerRouter.focus(PointerRouter.java:932)
  at ...input.PointerRouter.refocus(PointerRouter.java:299)
  at ...input.PointerRouter.updateRegions(PointerRouter.java:151)
  at ...Launcher.paint(Launcher.java:502)

Every party in that stack is behaving correctly, which is what made it worth an ADR rather than a patch.

refocus is doing what its own javadoc promises — “the router never holds an element that is not in the tree” — and the check it makes is the right one:

if (focused == null || focused.isMounted()) {
    return;
}
…
focus(null, false);            // or focus(restoreTo, …)

Two lines later it hands that same element, which it has just established is not mounted, to focus, which tells it so:

if (lost != null && lost != focused) {
    notifyFocus(lost, false, fromKeyboard);
}

focus is right to notify lost: that is its contract for every ordinary focus change, and a control that was not told it lost the keyboard would keep its caret blinking. What it cannot know is that this particular caller is reporting a death rather than a move.

And State.setState is right to throw. Its javadoc says an unmounted setState means “a callback outlived the widget that registered it, which is a leak worth hearing about”, and that is exactly the class of bug it catches everywhere else.

It is not overlay-specific and not application-specific. Any tree where a focused control disappears reaches it, and refocus’s own javadoc names three: a tab that switched, a list that shortened, a dialog with a field in it that closes on its own button.

Decision

One clause, in the place that already knows.

// PointerRouter.focus(Element, boolean)
if (lost != null && lost != focused && lost.isMounted()) {
    notifyFocus(lost, false, fromKeyboard);
}

and the same guard in notifyFocusWithin, which walks the same two chains:

for (var element : left) {
    if (!shared.contains(element) && element.isMounted() && element.widget() instanceof Handles handles) {
        handles.onFocusWithin(false, fromKeyboard);
    }
}

Per element, not per notification. The :focus-within chain from a dead node runs up through its dead containers and then into ancestors that are still there — a window whose sheet just closed really has lost focus-within, and it is told. Only the elements that went away are skipped.

mark — the pseudo-class half of the same method — has had element.isMounted() in it from the beginning, for the same reason and without anyone writing it down. This makes the notification half agree with it.

Consequences

An unmounted element has already been disposed: its state’s dispose has run, its bindings are closed and its subtree is gone. There is nobody left to tell, so nothing is lost by not telling them — which is the whole argument that this is a fix and not a suppression. The one thing a control could have wanted from a final onFocusChanged(false) is to release something, and dispose is where that belongs and already runs.

State.setState’s complaint keeps its meaning. It was the messenger here, and a router that stops creating the one legitimate case makes every remaining one a real leak again.

Alternatives considered

A flag on focus, so refocus can say “this is a death”. An extra boolean through a method four other routes call, to describe a condition the callee can observe for itself. The guard is cheaper and cannot be passed wrongly.

Make State.setState tolerate it. This was the tempting one, because it would have fixed every caller at once. It would also have thrown away the assertion that catches real leaks — the javadoc says so — and an application’s own stale callback would then fail silently instead of loudly.

Let the application avoid the position. What brd did meanwhile: move the keyboard back to the content before closing an overlay, so refocus finds a mounted element and returns at the first check. It is good behaviour on its own terms and it is not a fix: an application cannot intercept a focus change it never sees, and PointerRouter is :core’s and installed by the launcher.

318. A line starts where the paint says it does

Date: 2026-09-15

Status

Accepted. Closes docs/gaps.md G30. Finishes ADR-0256, which gave the paint an alignment and left everything that measures the same text without one.

Context

ADR-0256 put text-align in the one place that had both numbers it needs — a line’s own width and the width of the box — and that place is Paragraph.paint. The indent was a private static method four lines long:

private static double indentOf(double width, double maxWidth, double fraction) {
    if (fraction == 0 || !Double.isFinite(maxWidth)) {
        return 0;
    }
    return Math.max(0, maxWidth - width) * fraction;
}

text.edit.TextGeometry is the other half of the same subject — where is the caret, what did the click land on, what does Up mean — and it measured every x from the paragraph’s origin:

var x = paragraph.widthBetween(line.start(), Math.max(line.start(), offset));

So the painter drew each line indented by its share of the box’s slack and the caret was measured as though no line ever moved. The two parted company the moment the text was not left-aligned: the caret drifted from the glyphs by half the line’s slack under center and by all of it under end, and the drift grew as the line shortened, which on a wrapped paragraph means every line is wrong by a different amount.

An application hit it first, because a board’s default shape is a centred sticky, and it did the only thing it could: wrote the indent rule out a second time and added it to every x it computed and subtracted it from every x it was handed. That works, and it is exactly the duplication docs/gaps.md exists to stop — if Paragraph.paint’s indent ever changes (a justified alignment, an RTL line), the copy is silently wrong and only a round-trip test says so.

It was never only an application’s problem. A text-area whose stylesheet centred it would drift the same way the day anything wires ComputedStyle.textFlow() into the box that draws its value.

Decision

The rule moves to the property, and the geometry is told the alignment.

TextAlign.indentOf(lineWidth, available) is now the one implementation:

public double indentOf(double lineWidth, double available) {
    var fraction = fractionOfSlack();
    if (fraction == 0 || !Double.isFinite(available)) {
        return 0;
    }
    return Math.max(0, available - lineWidth) * fraction;
}

Paragraph.paint calls it. So does every method of TextGeometry, each of which gained a form that takes the width the text was drawn in and the alignment it was drawn with:

TextGeometry.caretAt(paragraph, layout, offset, wrapWidth, TextAlign.CENTER)
TextGeometry.offsetAt(paragraph, layout, x, y, wrapWidth, TextAlign.CENTER)
TextGeometry.moveLine(paragraph, layout, offset, lines, desiredX, wrapWidth, TextAlign.CENTER)
TextGeometry.selectionRects(paragraph, layout, start, end, wrapWidth, TextAlign.CENTER)

The existing shorter forms stay and mean START, which is what every caller written before this assumed.

selectionRects is in the list although the gap did not ask for it. A highlight drifts exactly as a caret does and for the same reason — a selection is geometry the frame already had (ADR-0301) — and three of four corrected would have been a fourth bug waiting.

Which indent comes off which x is the one subtlety. caretAt adds the indent of the line the offset is on. offsetAt subtracts the indent of the line the y lands on, because the x it is given is where the user pressed. moveLine subtracts the target line’s indent from desiredX, not the source line’s, because desiredX is an x a caller read off a Caret and is therefore already in the painted space — that is what makes a run of Down through lines of different lengths keep the column it looks like it is keeping.

Editor carries a textAlign of its own and hands it to all four, so the canvas editor is correct end to end: the paint, the caret, the hit test, Up/Down and the selection move together. Setting it invalidates no layout — alignment changes where a line starts, not where it breaks, which is the whole reason it can be a late decision.

Consequences

An application that had written the rule out a second time deletes it and passes two more arguments. It cannot drift again, because there is no second copy to drift from.

The text-area and text-input controls are unchanged and still ignore text-align altogether: both measure their own carets against their own origin, and both draw their value through Box.text(paragraph, argb) with the default flow, so neither indents its glyphs either. They are consistent today and they are ready — the day one of them passes style.textFlow() down, TextGeometry has the form it needs and the caret follows. Wiring it is not this ADR: those two controls hold the most delicate geometry in the catalog, and a change there deserves its own measurements.

Alternatives considered

Leave the rule in Paragraph and expose it. A public Paragraph.indentOf would be one implementation too, and it would put a property of text-align on the class that happens to paint. TextAlign already owned fractionOfSlack; the distance is the same question one step further on.

Have TextGeometry take a TextFlow rather than a TextAlign. A flow also carries white-space and text-overflow, and neither means anything to a caret: the layout it is handed has already broken the lines, and a truncated line has no caret in the part that was cut. Taking the one value it uses keeps it impossible to pass a flow that disagrees with the layout.

Take an origin instead, and let the caller do the arithmetic. That is what the application’s stopgap was, moved inside the signature — the caller would still need the rule to compute the origin, per line, which is the thing being centralised.

319. A panel is not a menu

Date: 2026-09-15

Status

Accepted. Closes docs/gaps.md G29. Narrows ADR-0104’s forwarding rule, and finishes what ADR-0185 started with takesFocus(false).

Context

ADR-0104 says the owner window forwards keys to whatever popup is open, and the Launcher’s watcher spells out why:

while a menu is open the keyboard belongs to it, whether or not the platform moved focus there — otherwise an arrow would move the selection in the window underneath the menu

That is right for a menu, which is up for as long as the user is choosing from it and goes away as soon as they have. A panel is the other shape: a bar of buttons floating over a canvas, positioned by number because it points at a selection, and open the whole time something is selected — over a canvas somebody is typing into. Every key it takes is a key the canvas did not get, and Popup.takesFocus defaults to true, so it focused its own first control on opening and a focused Button consumed Enter. Pressing Enter pressed a swatch instead of breaking a line in the text underneath.

ADR-0185 had already met half of this, from the other side: a suggestion list under a combobox must not focus its first row, because the field is what is being typed into. takesFocus(false) is that fix, and it settles only the opening. It stops focusFirst and nothing else, so:

  • the owner still forwards every key to the popup, and
  • a press inside the popup still focuses what it landed on, through the popup router’s own focusFromPress — so clicking a swatch and then pressing Enter pressed that swatch again.

An application cannot patch around it. The forwarding happens in the launcher’s watcher, Window.inputWatcher is package-private, there is one per window, and an application cannot decline a key on a popup’s behalf because it never sees the key.

Decision

One flag, because “does this thing want keys at all” is one question.

host.attachedPopup(content, anchor, placement, minimumWidth, fit).keyboard(false);

Popup.keyboard(false) does three things, and it is the set of them that makes it a panel rather than a menu:

  1. Nothing is focused when it opens — focusFirst returns, as under takesFocus(false).
  2. A press inside it focuses nothing. The popup tells its own router pressFocuses(false), and PointerRouter.focusFromPress returns early. This is the half takesFocus could not reach, because the press arrives at the popup’s window and never passes through the Popup object.
  3. The owner does not forward keys to it. Popup.handleKey declines immediately, and the launcher looks for the topmost popup that wants the keyboard rather than the topmost popup.

topmostKeyboardPopup() is a second question rather than a change to the first, and the difference is deliberate: a press outside dismisses whatever is topmost, panel or not, while a key belongs to the topmost thing that asked for one. A panel floating over a menu therefore leaves the menu operable by arrows — which is what “a panel is not in the keyboard’s way” has to mean if it means anything.

Escape is not this flag’s business. A panel that may be dismissed by input still is: that is lightDismiss, and a panel that must survive a keystroke says so there — in which case the launcher’s Escape branch finds nothing to dismiss and the key reaches the window. Declining keys and refusing to close are different promises, and one flag for both would make the wrong pair inexpressible.

PointerRouter.pressFocuses is the press and nothing else. Traversal, focus and focusById still do what they are told: a caller that asked for focus by name has said what it wants, and a router that quietly refused would be a second rule to discover.

Consequences

takesFocus(false) keeps its meaning and its consumer. A suggestion list does want keys — the arrows move through it while the field keeps the text — so the two flags are not a ladder with one useful rung: takesFocus is about the opening and keyboard is about the whole lifetime.

A panel that the platform gives real keyboard focus to is still a theoretical hole: the key arrives at the popup’s own window, and a window cannot hand a key back to its owner. In practice attachedPopup opens NOT_FOCUSABLE windows (ADR-0186), which is precisely the kind a window manager will not focus, and the panel’s router now dispatches nothing on a press either.

Alternatives considered

Two flags — one for the forwarding, one for the press. What the gap asked not to have, in as many words: “one flag rather than two, since ‘does this thing want keys at all’ is one question”. Two would also make it possible to set half of it, and half of it is the bug.

Let an application install its own input watcher. It would mean exporting Window.inputWatcher, which is one slot per window that the launcher owns; a second installer would silently win or silently lose depending on order.

Give the popup kind the answer — a PopupKind.PANEL beside MENU, ATTACHED and TOOLTIP. The kind is the window the platform makes, and it already decides focusability at that level. Keyboard forwarding is a toolkit rule above it, and tying them together would mean a panel could not be a tooltip-kind window or vice versa.

320. A popup reports where it is in the window that owns it

Date: 2026-09-15

Status

Accepted. Closes docs/gaps.md G28. Generalises the correction ADR-0113 made for Popup.anchor, one layer down and for everyone.

Context

color-picker in a floating options bar opened its saturation/value plane in the top-left corner of the window, however far across the screen the bar was.

PickerField is Located, so the router tells it where the frame put it, and the state hands that rectangle to Host.attachedPopup. The two are in different coordinate spaces whenever the control is inside a popup:

  • Located is notified by the PointerRouter that painted the node, and a Popup has its own router — so a swatch 8 points from the bar’s left edge is told it is at x=8.
  • Host.attachedPopup places in the owner window’s logical coordinates.

So the plane opened at (8, 30) of the main window. date-picker, time-picker and select are the same shape and were the same bug: anything with a popover was unusable inside a popup.

The application cannot correct it, because it never sees the rectangle — it passes between two pieces of toolkit inside a widget the application does not own.

The toolkit already knew this class of problem exists and had solved it once. Popup.anchor(String) says so in as many words:

Host.anchor answers from the main window’s geometry and knows nothing about what is in a popup … the answer is translated by this popup’s own offset

but that route is for a menu opening a submenu, and a picker has no way to reach it.

Decision

The translation goes where the mismatch is: the router.

PointerRouter gains an origin — where its window sits in the coordinates Located reports in — and Popup sets it from the backend offset on every frame, before handing over the regions:

router.locationOrigin(backend.offset());
router.updateRegions(regions);

Both rectangles a Located widget is handed are translated: the painted rectangle and the clip. The clip has to move with it, because the two are documented as comparable — an affix subtracts one from the other — and a clip in one space beside a rectangle in another is worse than either.

Every frame rather than once, because a popup moves: a popover following a scrolling anchor is moved rather than closed and reopened — Popup.move exists for exactly that — so move now also asks for a repaint. The tree did not change and the pixels did not either, but every rectangle those widgets were told is in the wrong place, and a frame is the thing that re-reports them.

Nothing else is translated. Hit testing, hovering, capture and the cursor are all answered against the window the pointer is actually in, and translating those would be translating them twice. Popup.anchor reads the captured regions directly rather than through the router, so it is unchanged and does not double-count.

For a window, the origin is (0, 0) and the translation is skipped by an if: a window’s own space is the space its popups are placed in.

Consequences

Four controls became correct inside a popup without being touched — color-picker, date-picker, time-picker and select — and so does the fifth, whatever it turns out to be. That is the whole argument for fixing it here: the alternative fixes the four that happen to have popovers today.

Located’s contract is now “the window’s logical coordinates, and for a widget inside a popup, the coordinates of the window that owns it”, which is a sentence in its javadoc rather than a caveat at four call sites.

A widget that wants to know where it is inside its popup cannot ask Located any more. Nothing does, and the honest place for that question is the popup, which knows its own size.

Alternatives considered

Have BuildContext carry the popup a build is happening inside, so a picker asks it for the anchor rather than the Host. This was the gap’s second sketch and it is a bigger surface: a new thing on every build context, a second anchoring route for widgets to choose between, and the four controls each needing to know which one applies. The gap itself said which it would bet on, and for the reason above.

Translate in Host.attachedPopup by asking whether the anchor came from a popup. It cannot: a rectangle is four numbers and carries no provenance.

Make the picker translate it. It would need the popup’s offset, which is the thing it has no route to, and every Located widget would have to do the same.

321. A rule under text belongs to the face

Date: 2026-09-15

Status

Accepted. Closes the second half of docs/gaps.md G27 — text decorations. The first half, an italic face, is still open and is ADR-0066’s question rather than this one’s.

Context

G27 asks for two of the three controls every board and document tool puts beside bold, and says either would be enough on its own:

  • BundledFont.UI_ITALIC (and a Style beside Weight), or the variable-axis answer ADR-0066 deferred.
  • TextFlow.decoration(Decoration.UNDERLINE | Decoration.LINE_THROUGH), drawn by Paragraph.paint from the face’s own metrics.

Italic is a face, and a face is a file: ADR-0066 settled that a second weight is a second file here, because instancing wght at runtime needs symbols bound in both HarfBuzz and Blend2D. Italic is the same decision one step further on, and it adds a megabyte of outlines, a licence line and a golden-image sweep to a change that has nothing else in common with the one below. It stays open.

Underline and strikethrough are a text-layer feature, and the gap says exactly why an application cannot have them:

a decoration needs the line’s extents and the font’s underlinePosition and underlineThickness, which are in the face and not reachable from Paragraph. Drawing a rectangle under the text in TextPainter would get the thickness wrong at every size and the position wrong at every family.

The numbers turned out to be one layer nearer than expected. BLFontMetrics has sixteen floats, the layout table has verified all sixteen against the compiled library since ADR-0010, and BlendFontMetrics carried six of them — with a note saying why the other ten were not surfaced:

an accessor for a number nothing uses is a promise to keep it working.

Four of those ten are the decoration metrics. Something uses them now.

Decision

text-decoration-line, from the face’s own numbers, drawn by the paint.

Four layers, none of them new machinery:

  1. BlendFontMetrics carries ten fields instead of six — underlinePosition/Thickness and strikethroughPosition/Thickness, read out of the struct the call was already filling. No new native symbol and no relink: bl_font_get_metrics was writing all sixteen and this end was reading six.
  2. Font.decorations() answers a Font.Decorations record — the four together, because they are only ever read together.
  3. TextDecoration (UNDERLINE, LINE_THROUGH) joins text.flow, and TextFlow carries a Set of them beside white-space, text-overflow and text-align.
  4. Paragraph.paint draws a rectangle per line per decoration, in the text’s own colour, at the face’s position and thickness.

The sign convention is Blend2D’s and it is the useful one. Both positions are y-down offsets from the baseline to the top of the rule: an underline is positive (below the text) and a strikethrough negative (through it). So the paint is baseline + position with no arithmetic to get wrong — unlike ascent and descent, which are compared against a box and are therefore both positive.

A face that says nothing gets conventional numbers. A post table too short to carry the pair is legal, and a thickness of zero is a real answer; Decorations.orElse(size, ascent) substitutes a rule a fourteenth of the em, an underline a tenth of the em below the baseline and a strikethrough a third of the ascent above it. “Do not draw the underline the stylesheet asked for” is the one response that is certainly wrong.

The rule follows the glyphs, not the box. It is as long as the line, indented with it under text-align (TextAlign.indentOf, one implementation — ADR-0318), and absent from a blank line, which has no glyphs to mark. An ellipsised line is ruled across its mark, because the mark is part of the line: a rule that stopped short of its own … reads as two words, one of them underlined.

The face’s metrics are read once per paint, not once per line: it is a downcall, and it is the same answer for every line of one font. A paragraph with no decorations reads them not at all.

The cascade

text-decoration and text-decoration-line both resolve, into a Set<TextDecoration> component of ComputedStyle, and it inherits.

CSS does not inherit it — it propagates to in-flow descendants, which is a different mechanism with nearly the same effect — and inheritance is how that reads here for text-align’s reason (ADR-0256): a control’s text is very often an anonymous child box, so button.link { text-decoration: underline } has to reach the label inside the button or it decorates nothing at all. inheritsSameAs learned about it in the same commit, because a property that starts inheriting and is not in the style cache’s key makes the cache go stale rather than merely cold.

CSS’s shorthand also carries a colour and a style (wavy, dotted), and a declaration naming either is dropped whole with the usual warning. Half-applying it would be a property that lies: a rule that asked for a wavy red underline and got a straight one in the text’s colour is worse than a rule that did nothing. overline is absent for the ordinary reason — the subset in docs/core-widgets.md §8 says which properties exist, and nothing has asked.

Consequences

An application setting a shape’s text to struck-through writes one field, one codec line and one button — the gap says so — and a stylesheet can underline a link without the widget knowing. :html’s <u>, <s>, <del> and <ins> become styleable the same way, since every element already contributes an html-<tag> type; html.css does not use it yet and this ADR does not add it.

The two form controls that draw text through Value still pass the default flow, so text-input and text-area ignore text-decoration exactly as they ignore text-align. That is consistent rather than half-done: the day one of them passes style.textFlow() down it gets both, and the caret geometry it would need is ready.

BlendFontMetrics’s note now covers six unsurfaced fields rather than ten, and the promise it describes is one this ADR takes on for the four: something uses them, so they are kept working.

Alternatives considered

Draw the rule in the widget layer, from a Box field. It is where a border lives, and a decoration is not a border: it belongs to the line, and a wrapped paragraph has as many rules as it has lines, each as long as its own line. A box knows none of that.

A colour of its own. CSS has text-decoration-color, and the honest use for it is a spell-checker’s squiggle, which also wants wavy. Both together are a feature; either alone is a property that mostly disagrees with the text.

An int bit mask, which is how the gap sketched it. A Set of an enum is what the rest of the toolkit uses for a small closed set, it prints legibly in a TextFlow’s toString, and it cannot be |-ed with a value from another enum by accident.

322. The desktop says light or dark, or says nothing

Date: 2026-09-15

Status

Accepted. Closes docs/gaps.md G26.

Context

An application that ships two themes has one question it cannot answer for itself: which one is the user’s desktop set to, and when does that change. The second half is the one that matters: on every platform with a sunset schedule it changes once a day, while the application is running, so asking at start-up answers the first question and none of the later ones.

Every way of asking is a platform call. SDL_GetSystemTheme and SDL_EVENT_SYSTEM_THEME_CHANGED cover all three platforms in one; the alternatives are AppleInterfaceStyle through NSUserDefaults, the AppsUseLightTheme registry value, and the XDG settings portal over D-Bus. Goldberry already owns the window, the event loop and the SDL layer, and ADR-0174 says an application may not hold a MemorySegment at all — so an application reaching for any of the three is the thing :natives is sealed to prevent.

Decision

Two methods on Host, and the second one is the point.

Optional<SystemTheme> systemTheme();                        // LIGHT | DARK
void onSystemThemeChanged(Consumer<SystemTheme> listener);

SystemTheme is :core’s own word for it, in a new io.github.digitalsmile.goldberry.render.desktop package — the SDL enum stays inside :natives, like every other platform vocabulary.

The Optional is the design, not a nicety

SDL answers SDL_SYSTEM_THEME_UNKNOWN on a desktop that has no such setting, and an application needs to tell “the desktop says light” from “the desktop does not say”: the first is a theme and the second is a default. SystemTheme therefore has two constants and the third answer is an empty Optional, which no caller can ignore by accident — a third enum constant is exactly the thing a switch forgets.

Three different situations produce empty, and they are deliberately one answer: a desktop with no such setting, a video driver that cannot ask, and a libgoldberry built before the export existed. What a caller does about them is identical.

The path down

  • SDL_GetSystemTheme is on the export list, bound as an optional symbol — SdlVideo.systemTheme() answers UNKNOWN rather than failing to link, which is the same treatment the display-mode pair gets and for the same reason: a library built before the symbol existed must keep opening windows.
  • SdlSystemTheme’s three ordinals and SDL_EVENT_SYSTEM_THEME_CHANGED’s number are in the layout probe’s registry, so the C preprocessor checks them (ADR-0010). A wrong event number does nothing at all and a wrong ordinal starts the application in the wrong theme; neither reports an error anywhere, which is what that probe exists for.
  • Backend.systemTheme() is a default method returning empty, so the headless backend and every test double are correct without being touched. The headless one overrides it, with a setter that also posts the event — a desktop in a test.

The event carries a window, and the setting does not

SDL delivers the change with no window id, because it concerns the session. It is translated into one SystemThemeChanged per open window, which is exactly what QUIT already does with CloseRequested, and for the same reason: a Host is per window, so that is where an application is listening. A two-window application gets two events with the same theme in them, and each window’s listeners hear it once.

Window gains systemTheme() and onSystemThemeChanged(…) too, because a bare window with no Application over it is a supported shape and this is a window-level fact in the same sense a scale change is. No repaint is scheduled for it, for onMove’s reason: nothing inside the window changed, and what to do about a new setting is the application’s decision — the frame comes from whatever it changes.

No Subscription comes back

Unlike the router’s listeners, onSystemThemeChanged hands back no handle. The caller is the application, its listener lives as long as the window it registered against, and that is the lifetime of the application itself. A widget that wants to follow the desktop should be told by the application, through whatever it already rebuilds from — a widget subscribing to a window-lifetime listener is the leak the absence of a handle makes obvious.

The launcher notifies over a copy of its list, so a listener that reacts by adding another one — a settings screen that appears because the theme changed — is not a ConcurrentModificationException. A listener registered during a notification hears the next change.

Consequences

The toolkit still does not choose a theme. Goldberry ships nord-light and nord-dark; which one an application uses, whether it follows the desktop at all, and whether it offers a three-way choice of its own are the application’s. This is one input to that decision and nothing more — an application that ships a single theme ignores it and nothing changes.

Nothing is cached on the Java side. SDL keeps the answer and updates it from the same platform notification that produces the event, so a copy here would be a second thing to keep right.

The event is now the fourth kind that is not really about a window, after QUIT, the dialog answers and the frame heartbeat. BackendEvent.window() still holds for every case, which is what keeps the runtime’s exhaustive switch honest.

Alternatives considered

A SystemTheme.UNKNOWN constant. Smaller signature, and it moves the mistake into every switch that forgets the third arm. The gap asked for the Optional in as many words, and the reason it gave is the reason it is here.

Resolve it in the toolkit — ship a Theme.SYSTEM that picks a stylesheet. It would decide, for every application, that following the desktop is the right default and that “unknown” means light. Both are the application’s calls, and one of them is the difference between a document tool and a drawing tool.

A poll instead of an event. Reading the setting once a frame would be a platform call per frame for a value that changes once a day, and would still have no answer for “when”.

An EventWatch, the way the interactive-resize path works (ADR-0060). The theme change does not arrive inside a blocking platform call, so there is nothing for a watch to rescue — the ordinary queue is where it already is.

323. An italic is a face, and the matrix closes

Date: 2026-09-15

Status

Accepted. Closes the other half of docs/gaps.md G27, which ADR-0321 left open. Extends ADR-0066 by one step, and takes the same trade it took.

Context

G27 asked for the three controls every board and document tool puts beside bold: italic, underline, strikethrough. ADR-0321 built the last two and said why the first was a different question:

Italic is a face, and a face is a file … it adds a megabyte of outlines, a licence line and a golden-image sweep to a change that has nothing else in common with the one below.

That is the cost, not an argument against paying it. The argument for paying it is that nothing else can. The entry is explicit:

brd cannot add a face to a bundle it does not own, and the alternative available to an application — shearing the glyphs in the painter — is a synthetic oblique, which is a decision about type design rather than a workaround.

Inter’s italic is drawn: a single-storey a, an f with a descending tail, different advances. Shearing the upright is a different typeface from the one the designer made, and the toolkit is the layer that gets to decide which typeface it ships.

Decision

Two more files, so the matrix closes.

uprightitalic
400UIUI_ITALIC
600UI_STRONGUI_STRONG_ITALIC

extras/ttf/Inter-Italic.ttf and extras/ttf/Inter-SemiBoldItalic.ttf, from the release the manifest already pins and the archive already in the cache — the same SHA-256, verified on the way in, so no new trust and no new network fact.

Two rather than one, and that is the decision inside the decision. A single italic would leave font-weight: 600; font-style: italic resolving to “the nearest of the three we shipped”, and a design system acquiring a weight nobody chose is exactly what ADR-0066 and §1.4’s two-weight rule exist to prevent. 830 KB buys a matrix with no wrong corner.

BundledFont.Style is the third thing that names a file, beside the family and the weight, and Typography carries it — because an italic is a face and a text-decoration is a mark, so one resolves where the font is chosen and the other where the paragraph is drawn.

Matching is CSS’s order: family, then style, then weight

BundledFont.of(family, weight, style). A family that has the style at another weight uses it — the slant is what a reader was told to look for — and a family with no such style falls back to the weight it was asked for, upright. JetBrains Mono ships one face, so italic code stays upright code.

oblique is refused, and visibly

CSS’s oblique asks for a slant. Answering it with Inter’s italic would answer a different question; answering it with a shear would be a type-design decision taken by a stylesheet. So font-style: oblique is dropped with the usual warning, which is how a stylesheet learns that this toolkit does not synthesize one.

Consequences

The jar grows by 830 KB, to about four megabytes of fonts. docs/ARCHITECTURE.md §6.1 already states the trade embedding is: identical rendering everywhere, paid for in bytes. This is the same trade at the same exchange rate.

Nothing renders differently until a stylesheet says font-style: italic. The faces are opened lazily like every other — an application that never writes it never parses one — so the cost of the two files is bytes on disk and nothing at runtime.

§2’s link widget is now unblocked twice over: ADR-0321 gave it the underline and this gives emphasis in running text somewhere to go. It is still unwritten.

What is not here: a font-synthesis switch, and any of the other six weights in the archive. Both are additions a specification would have to ask for first, which is Principle 3’s rule and the reason there are two weights rather than nine.

Alternatives considered

Instance the wght/slnt axes at runtime. The general answer, and ADR-0066 costed it: symbols in both HarfBuzz and Blend2D, a new struct layout, three export branches and a four-target CI run to prove them. It is the right change the day an intermediate weight is specified, and it buys nothing today that two files do not.

One italic face at 400 only. Half the bytes and a matrix with a wrong corner, where semibold italic silently renders at 400 — or, worse, upright at 600. A stylesheet cannot tell which it got.

Shear the upright glyphs. Free, available, and a different typeface. The gap names this one and refuses it in the same sentence.

InterVariable-Italic.ttf instead of two statics. One file of 910 KB against two of 415 — barely a saving, and it is a variable file whose wght axis nothing can instance, so it would render at a single weight anyway. The statics are what the upright side already ships.

324. A field draws the text its stylesheet resolved

Date: 2026-09-15

Status

Accepted. Finishes what ADR-0318 and ADR-0321 left in text-input and text-area, and closes the caveat both of them recorded.

Context

ADR-0318 gave TextGeometry the alignment and said plainly what it had not touched:

The text-area and text-input controls are unchanged and still ignore text-align altogether: both measure their own carets against their own origin, and both draw their value through Box.text(paragraph, argb) with the default flow, so neither indents its glyphs either. They are consistent today and they are ready.

Consistent, and useless in both directions. slider-value has been text-align: end since ADR-0256 because a column of numbers lines up on its units column — and a field a user types numbers into could not do the same thing. Neither could a centred note in a text-area, and neither could underline a value.

The reason it was deferred rather than done is worth keeping: these two controls hold the most delicate geometry in the catalog. A caret, a highlight, a composition’s underline and a hit test are four readings of one number, and the glyphs are drawn by a different object — Value, a part whose paragraph the painter indents. Wiring the paint alone would have been strictly worse than leaving both alone: the glyphs would move and the caret would not.

Decision

One part passes the flow, and each control places its own geometry from the same alignment.

Value.render hands the box style.textFlow() rather than only style.color(), so the glyphs get text-align and text-decoration — and both properties inherit, so a rule on the field reaches the anonymous label inside it.

The two controls then have to agree with that paint, and they agree in two different ways because their value boxes are shaped differently:

text-input: the box hugs its text, so the box moves

A single-line field’s value is an absolutely positioned child sized by its content, so Paragraph.paint sees no slack and indents by nothing. What moves is the child: laidOut computes align.indentOf(textWidth, room) and returns one number — the scroll, less the indent — which every part is inset by and which the hit test adds back.

One number is safe because the two can never both be non-zero: indentOf clamps the slack at zero, so a value too long to fit scrolls and is never indented, and one that fits does not scroll. That invariant is asserted rather than assumed.

text-area: the box has a definite width, so the paint indents each line

A multi-line field gives its value box contentWidth(), so the painter does the per-line indent itself — and the control adds the same line’s indent to the caret, the highlight, the composition’s rule, the hit test and the column a run of Up/Down keeps. Per line, because two lines of different lengths do not start in the same place, and a single “where does the text start” number cannot describe a centred paragraph.

Both go through TextAlign.indentOf, which is the rule ADR-0318 moved to the property precisely so that a fourth and fifth reader could not disagree with the first three.

The alignment travels down with the paragraph, through laidOut, for the reason the wrap width already did: render is the only place a widget is handed anything that can measure text, and the resolved style for the frame being described is what the paint will use.

Consequences

text-input { text-align: end } and text-area { text-align: center } work, and a field can be underlined. A numeric column in a form lines up on its units column the way slider-value does.

The round trip is what the tests assert, in both controls: ask where the caret is drawn, press exactly there, get the same offset back. Under center that fails by half the line’s slack if either half of the change is missing, and on a text-area’s second line it fails by a different amount — which is why the per-line case has a test of its own.

caretArea for a text-area now starts at the line’s indent, so an input method’s candidate window lands under the glyphs rather than under the paragraph’s origin. caretOffset stays relative to that rectangle, which is what the seam already promised.

white-space and text-overflow ride along with the flow and mean what they say. No shipped rule sets either on a field: a wrapped text-area is the text-area’s own business and it passes a definite width down, so nothing in the catalog moved and no golden image changed.

Alternatives considered

Leave the controls alone, as ADR-0318 did. Defensible while nothing asked; docs/gaps.md G30 asked, and the property has been in the subset since ADR-0256 with two controls unable to use it.

Pass the alignment to Value and let the paint do everything. It is half the change and it is the bug: the glyphs would be centred and the caret would not.

Give the controls a TextFlow rather than a TextAlign. They read one of its four fields. white-space is the text-area’s own decision (it passes a width down), text-overflow belongs to a single-line label, and a decoration is drawn by the paint with no geometry for the control to place — so taking the whole value would be taking three things nothing here reads.

Use TextGeometry’s new aligned forms in both controls. The honest long-term answer, and a bigger change than this one: both controls do their own line walking for reasons that predate TextGeometry (a masked display, a preedit splice, a scrolled window, a row cap). Replacing that is a refactor with no new behaviour, and this ADR is the behaviour. The indent they add is the same method TextGeometry calls, so the duplication is one call and not one rule.

325. A build says what it can ask the desktop

Date: 2026-09-15

Status

Accepted. Closes docs/gaps.md G32.

Context

ADR-0322 bound SDL_GetSystemTheme and gave applications Host.systemTheme(). On a GNOME/Wayland desktop set to dark, it answered empty.

The desktop was not the problem. Measured on the machine that reported it:

$ gsettings get org.gnome.desktop.interface color-scheme
'prefer-dark'

$ gdbus call --session --dest org.freedesktop.portal.Desktop \
      --object-path /org/freedesktop/portal/desktop \
      --method org.freedesktop.portal.Settings.Read org.freedesktop.appearance color-scheme
(<<uint32 1>>,)                       # 1 = prefer dark

# and, calling the toolkit's own library directly:
after SDL_Init: SDL_GetSystemTheme() = 0        # 0 = UNKNOWN

The desktop answers. The portal answers. SDL does not, and the reason is in the library rather than in the session. On Linux, SDL’s theme detection is entirely the D-Bus portal — src/core/linux/SDL_system_theme.c reads org.freedesktop.appearance color-scheme from org.freedesktop.portal.Settings and subscribes to its SettingChanged signal, and there is no second path. That file is behind SDL_USE_LIBDBUS, which is #defined from HAVE_DBUS_DBUS_H, which SDL’s CMake sets from a build-time probe:

dep_option(SDL_DBUS "Enable D-Bus support" ON "${UNIX_SYS}" OFF)
...
if(SDL_DBUS)
  pkg_search_module(DBUS dbus-1 dbus)          # headers, not the library

SDL dlopens libdbus-1.so at run time, but only if the headers were present when it was compiled. On the machines that built Goldberry’s SDL they were not:

$ grep DBUS .../build_config/SDL_build_config.h
/* #undef HAVE_DBUS_DBUS_H */

so SDL_GetSystemTheme() was a compile-time constant UNKNOWN in every copy of libgoldberry.so built that way, on every Linux desktop, however it was set.

It was not only the theme, and it was not only one machine. The same probe gates the portal file dialog and the screensaver inhibit; a second gates the input method on X11; a third, device hotplug. And the absence was structural rather than accidental: LinuxDependencies — the table checkToolchain reads — listed dbus-1 as OPTIONAL under the purpose “SDL3 desktop integration”, which was an honest description until ADR-0322 bound the call; linux.yml, which builds the published artifact inside a manylinux container, installed neither dbus-devel, systemd-devel nor ibus-devel; and LinuxDependenciesTest’s CI drift guard checked that workflow for HARD_STOP rows only, because hard stops are what CI had previously been burned by.

So four things were true at once: a -dev package was missing, no local check minded, CI did not install it, and the guard that exists to catch exactly this disagreement did not look. Nothing failed. Every build was green, and a client shipped a settings screen that told its user their desktop had no light-or-dark setting.

Decision

A build that cannot ask the desktop something stops; one that is told to build anyway says so, in the binary, for ever after.

Three parts, and the third is the one that generalises.

1. The headers are a declared dependency

dbus-1 becomes NEEDED in LinuxDependencies — the necessity that already means “SDL drops this silently, so fail here because SDL will not”, the same reading libdecor-0 and the Wayland spec get. ibus-1.0 joins the table as a new row. Each desktop-integration row also names the Capability constants a library loses without it, which is what ties the build table to what an application can read back at run time.

linux.yml installs dbus-devel, systemd-devel and ibus-devel — all three verified present in the manylinux_2_28 container — and example.yml and showcase.yml gain libibus-1.0-dev. LinuxDependenciesTest now asserts that the workflow building the published artifact installs the package behind every capability, not just the hard stops.

2. The superbuild stops rather than degrading

The CMake superbuild probes for the same pkg-config modules SDL probes for (dbus-1/dbus, ibus-1.0, libudev), sets SDL_DBUS, SDL_IBUS and SDL_LIBUDEV explicitly rather than inheriting their defaults, and raises a FATAL_ERROR naming the package — on both package managers — when D-Bus is absent. It then cross-checks its own prediction against SDL’s answer, by reading the SDL_build_config.h SDL just generated: headers present and HAVE_DBUS_DBUS_H still undefined is a build that would report a capability it does not have, which is the one outcome this record rules out.

-DGOLDBERRY_REQUIRE_PLATFORM_INTEGRATION=OFF, or -Pgoldberry.allowDegradedPlatform=true through Gradle, builds without them on purpose — one flag reaching both the toolchain check and CMake, so the two halves cannot disagree about what was asked for.

3. The library reports what it is

Set<Capability> Goldberry.capabilities();
// SYSTEM_THEME, INPUT_METHOD, DEVICE_HOTPLUG, FILE_DIALOG, SCREENSAVER_INHIBIT

The superbuild passes what it found to the shim as compile definitions; goldberry_platform_capabilities() returns them as a word; NativeCapability decodes it and :core’s Capability is what an application sees — two enums, because natives.* does not leave :natives (ADR-0174), and a switch the compiler checks between them. The five bit values are rows in the layout probe’s registry, like every other constant the bindings hard-code (ADR-0010).

The bits describe the library, not the session it is loaded into. A build that can ask reports SYSTEM_THEME even on a desktop that has no such setting, because “could not ask” and “asked and was told nothing” are different facts and only the first one is fixable. The second is what an empty Host.systemTheme() means, and the two together are what let an application say something true to its user.

And the sdl3 backend warns once at start-up when a capability it ships an API for is missing — naming the package — because a log line on the machine where it is wrong is worth more than a paragraph in a document.

Consequences

The ABI version goes to 10. A new export changes the shape of the surface, so an old libgoldberry beside new Java fails at load time with the message that already exists for it. The pair is built together everywhere it matters.

A contributor without libdbus-1-dev now has to install it, or pass a flag. That is the intended cost and it is small: one package, named in the failure, on both package managers. The alternative is the state this record exists to end, where that contributor builds a lesser library and cannot tell.

INPUT_METHOD is a Linux half-truth, deliberately. On Wayland SDL drives zwp_text_input_v3 from the compositor and needs neither IBus nor Fcitx, so a build without the bit composes text perfectly well there and not at all under X11. A build-time bit cannot express “depends on the session”, so it reports what it is — whether an X11 session would have an input method — and the backend only warns when the driver actually is X11. The javadoc says so in as many words.

FILE_DIALOG is about the portal specifically. SDL has a second path on Linux — it shells out to zenity — so a library without the bit may still open a dialog on a machine that happens to have that binary. The bit reports the half the build decides.

Empty means two things and that is accepted. Goldberry.capabilities() is empty both for a library that can do none of this and for a run with no native library at all — a Java-only test, the headless backend. Both mean do not expect these to work here, which is what a caller acts on.

This does not fix libdecor. The same audit found linux.yml installs no libdecor-devel or xkeyboard-config either, and neither package exists in the manylinux_2_28 repositories — so the published Linux artifact may have a Wayland window with no titlebar, for the same class of reason and with a harder answer. That is a separate entry, not this one, and the capability mechanism above is what it should be reported through when it is written.

Alternatives considered

Read the portal from :core. It is what SDL does, and doing it ourselves would mean Goldberry talking D-Bus to the desktop — platform integration living above the backend SPI, and a second answer to a question Host.systemTheme() already answers. Worse than none.

A run-time probe instead of a build-time constant. Asking the session whether D-Bus is reachable answers a different question: a library compiled without the support cannot use a D-Bus that is right there. What an application needs to know is what its library can do, and that is decided once, at build time.

Warn instead of stopping. A warning in a configure that prints several hundred lines is a warning nobody reads — this one was effectively printed for months by SDL itself, in the form of a #undef in a generated header. The default is a stop precisely because the failure mode is silence.

One Capability.DESKTOP_INTEGRATION. Fewer constants, and it would collapse three independently-gated features into one bit that is false when any of them is, telling an application less than it needs to say anything useful.

326. A value you cannot type into opens at its beginning

Date: 2026-09-16

Status

Accepted. Closes docs/gaps.md G34.

Context

TextInput.readOnly(true) is the control for a value somebody has to take off the screen: a peer invite, a key, an identifier. It takes focus, selects with the pointer and with Ctrl+A, copies with Ctrl+C, and refuses every edit. All of that was already right, and the entry that raised this said so.

What was wrong was where it opened. TextEdit.of(text) puts the caret at text.length(), and a field scrolls to keep its caret in view — so a value wider than the box showed its tail. A hundred-and-twenty-character invite read …fiahiyvvqd where a reader wanted endpointabrq….

For an editable field that is right and is not in question: you type at the end of what is there, and a field that opened at the start would put the caret in front of the value somebody is about to correct. The case that had never been looked at is the one where nobody is going to type at all.

Decision

A read-only field starts its caret at offset zero.

TextEdit.atStart(String) joins TextEdit.of(String) as its mirror — same value, other end — and TextInputState.opening(String) picks between them from widget().readOnly(). It is used in two places, which is the whole change: initState, and follow() when the application replaces the value later.

Why not the API the entry proposed first

The gap offered two shapes:

public TextInput caret(int offset);   // where the caret starts; the view follows it

or, in its own words, “without new API and probably better: a read-only field starts its caret at 0”. The second is what landed, and the reason is that the first is a call site that can be forgotten. Every read-only field in every application wants the same answer; a parameter makes that answer something each one has to remember to ask for, and the one that forgets looks exactly like the bug being fixed here.

caret(int) remains available to build if something ever wants a caret somewhere else. Nothing does, and a method with no caller is a method with no test.

What it deliberately does not do

A field that becomes read-only after it is mounted keeps its caret. The rule is about where a value arrives, not a continuous invariant — a control that yanked the caret to the start because a flag flipped would be moving a selection the user may have made.

Consequences

  • One behaviour change, in one direction, for a case where the old behaviour had no defender. ReadOnlyCaretTest pins both halves: read-only opens at the head, editable still opens at the tail.
  • TextEdit gains a public factory. It is of’s mirror and documents which end it puts the caret at and why, so the pair reads as a choice rather than as one method and an exception.
  • TextInputState.scrolledBy() is now package-private rather than absent, so “the box shows the head of the value” is a number in a test rather than a picture. TextAreaState already had the same accessor for the same reason.
  • text-area needs nothing: it has caretMatters, which already keeps an untouched area at the top of its value whatever the caret says.

327. A hover is a node property, not a menu’s

Date: 2026-09-16

Status

Accepted. Closes docs/gaps.md G33.

Context

The router has derived PointerEvent.Kind.ENTERED and EXITED since it was written. They are synthetic — computed from pointer flow, in the same walk that moves :hover along the ancestor chain — and exactly two widgets could hear them: menu.MenuTitle and menu.Item, each of which takes an onHovered because a menu bar opens on hover.

Nothing else could ask. An application that wanted a hover-hold preview on a search result had two ways out, and both are wrong:

  • Make the row a menu.Item, which is choosing a widget for its event hook. An Item brings a tick column, an accelerator and a chevron that a search result has no use for, and its role is menuitem, which a screen reader will announce.
  • Write a HoverRegion widget in the application — a Widget.Leaf implementing Handles whose whole body is a three-arm switch over event.kind(). It reimplements nothing, because the events are the toolkit’s own, but it is a widget an application owns for an input concern, which is the shape this project’s no-reimplementation rule exists to delete.

Nothing was broken. The hook had simply only ever been needed by menus, which is why it was only on menus.

Decision

Two Runnables on Attributes, run by the router beside a widget’s own handler.

// io.github.digitalsmile.goldberry.widget.attr.Attributes
public Attributes onPointerEnter(Runnable action);
public Attributes onPointerExit(Runnable action);

with the chainable pair on Attributed, so they compose with any widget:

new Row(new Button("Open", this::open), new Spacer(), new Text(name))
    .onPointerEnter(this::armPeek)
    .onPointerExit(this::cancelPeek)

On Attributes, not as a widget

That is where every other cross-cutting node property already lives — tooltip, name, contextMenu — and the argument is the same one ADR-0105 made for a tooltip: “attaches to any widget” is what this means, and a catalog where each control had to carry its own would have thirty chances to forget. A HoverRegion container would also have to sit in the tree, take part in layout, and be explained to anyone reading the markup; an attribute is invisible to all three.

It is about the subtree

The hooks are run from PointerRouter.emit, which is called from updateHover — the same walk that moves :hover. :hover applies to a node and every ancestor of it, because .card:hover .title has to work, and so does this. A hook on a row fires once when the pointer arrives anywhere inside it and once when it leaves altogether; moving between the row’s own children raises nothing.

That is the behaviour a hover-hold wants and is awkward to build from the outside: a per-leaf hook would have to be debounced against the gaps between siblings.

It consumes nothing, and cannot

The event these derive from is synthetic and delivered to one element rather than down a capture/bubble chain, so there is nothing here a hook could swallow. A press that lands inside still belongs to whatever is inside. This is the concrete difference from the container widget the stopgap was: that one sat in the tree and had to be trusted to pass events through.

The hook runs after the widget’s own Handles.onPointer, so a control that already acts on hover — a menu-title opening its menu — has done its work before an application’s hook sees the same arrival.

There is no markup form

Attributes.of(KdlNode) parses id, class, tooltip, context-menu and name, and it cannot parse this: a Runnable is not a KDL value, and the registry that turns press="app.save" into one is the inflater’s Wiring, which Attributes.of does not see. Widening Attributes.of to take a Wiring would push the action registry into :core’s widget contract for two attributes nothing has asked for in markup.

So this is Java-only, and said so out loud in the javadoc rather than left as a hole somebody rediscovers. §11’s parity invariant is about widgets built two ways producing the same value; an attribute markup cannot express is not two values, it is one form.

Consequences

  • Attributes grows from six components to eight. The six-argument constructor is kept, exactly as the three- and five-argument ones were (ADR-0105, ADR-0260), so no existing call site moves.
  • menu.MenuTitle and menu.Item keep their own onHovered. They are not the same thing: a menu’s hover drives the menu’s state machine — intent delays, submenu opening, sibling collapse — and is wired by Menus on every open rather than written by an author. Replacing it with this would be a rewrite of the menu for no gain.
  • Two Runnable fields on a value every widget carries. They are null for effectively every node, and PointerRouter.hook returns after one instanceof when they are.
  • A node unmounted under the pointer still hears its exit. The router lets go of an element that has left the tree and re-hit-tests against the frame just painted (ADR-0303), and that is the same walk these are raised from — so the hook fires on the frame the router notices. What is still not guaranteed is a teardown with no frame after it: a window closing takes its tree with it and nobody is told, so a caller holding a timer cancels it on dispose as well.

328. A dot’s colour is data

Date: 2026-09-16

Status

Accepted. Closes docs/gaps.md G36.

Context

chip draws docs/ux-design.md §10’s pill: a rounded label with a pressed state and, with withDot(true), a leading 6-point dot. The dot takes its colour from background on the chip-dot part, so chip.danger chip-dot { background: … } is how a status hue is set — and that is the right mechanism for a status, which is a closed set somebody can write rules for.

It is the wrong mechanism for a Project. A Project has a colour the way it has a name and a due date: it is a row in a database, chosen by whoever made the Project, and six of them in a menu are told apart by hue long before they are read. A stylesheet cannot have a rule per Project, because the Projects do not exist when the stylesheet is written.

So an application could store a colour per Project and not draw it. The pill said which Project by name, the menu’s rows were distinguished by name, and the hue was a column with no pixel.

Decision

withDot(int argb) beside withDot(boolean), and a dotColor component carrying it.

public Chip withDot(boolean value);   // as before: the stylesheet's colour
public Chip withDot(int argb);        // this colour, and the dot shown
chip dot-colour="#bf616a" "Goldberry"

A colour, not a class name

Because the value is data. This is the same line ADR-0195 drew for chart series colours and ADR-0251 for component metrics: the cascade is for things a rule can name, and a widget that draws something the property set has no declaration for still has to be able to take the answer from somewhere.

int argb and not an Rgba: 0xAARRGGBB is the toolkit’s own currency at the paint boundary — Box.background, frame.fillRect, ComputedStyle.color — so this keeps a colour type out of the widget API entirely.

0 means “the stylesheet decides”, which is the sentinel Wiring.colour already uses for every other document-supplied colour in the catalog.

The colour turns the dot on

withDot(0xFFBF616A) shows a dot. A chip carrying a dot colour and no dot is a value saying two contradictory things, so the constructor refuses the pair rather than picking one — the same refusal, and the same reasoning, as the existing “a dot or an icon, not both”.

withDot(false) clears the colour with it. Turning something off and leaving its colour behind is state waiting to reappear.

Markup takes both spellings

Wiring.colour(node) already accepted colour= and color=, because CSS spells it one way and this repository’s prose spells it the other. A widget with more than one colour needs more than one name, so that method gains an overload taking the pair of names, and chip uses dot-colour/dot-color. A colour alone turns the dot on, which is what keeps the markup and the Java form building the same value from the same number of attributes.

Consequences

  • Chip grows from nine components to ten. The canonical constructor has no callers outside the class, so the change is contained; every wither carries the new component and ChipDotColourTest asserts that they do.
  • ChipDot gains a component and paints the colour over whatever the cascade resolved for its background. The stylesheet keeps everything else about the dot — its size, radius, margin and border — which is what makes this an override of one property rather than an opt-out of theming.
  • The same argument will apply to a chip used for a Ticket’s status dot, which is the second caller and is why this is a parameter rather than a widget in an application.
  • A colour an application supplies is not contrast-checked. ThemeAudit reasons about tokens, and a hue from a database is not one. A 6-point dot beside a label is not carrying the meaning on its own — the label is — so this is a decoration rather than an accessibility hole.

329. Two more codecs: one fetched, one written

Date: 2026-09-16

Status

Accepted. Closes docs/gaps.md G35a.

Context

Image.decode handled PNG, JPEG and QOI, and handled them well: the format comes from the bytes rather than from a name, premultiplied BGRA comes out, and the decoder’s handle is destroyed before it returns so an Image is a value that can go in a cache (ADR-0283).

It handled those three because Blend2D is compiled with those three codecs and no others. So a WebP — which is what most screenshot tools now write — and a GIF both threw ImageDecodeException.

That mattered more than a missing format usually does, because it was the one place a desktop client and a web service had to disagree about what a valid asset is: a browser decodes all four, so a node written against browsers accepts all four, and a client that could not had to narrow its intake set and refuse a WebP with a sentence naming the format. An honest answer, and not the right one.

Decision

Sniff the bytes, and route the two the rasterizer does not know to two new decoders — one linked, one written.

ImageFormat.of(bytes)  ->  GIF   -> GifDecoder   (Java, in :core)
                           WEBP  -> Webp         (libwebp, in :natives)
                           else  -> the rasterizer, as before

Image.decode is unchanged for callers. The sniff reads at most twelve bytes, decodes nothing, and returns UNKNOWN for anything it does not recognise — which routes to the rasterizer, so a format Blend2D gains later needs no entry here. It decides routing, not validity.

Why the two are answered differently

Because they are not the same size of problem.

WebP is VP8, a video codec: intra prediction, a bool-coder entropy stage, a DCT-like transform and a loop filter, plus a second lossless format sharing the container. There is no version of writing that in Java that is a good idea. So the superbuild fetches libwebp — BSD, pinned in the version catalog like every other upstream (ADR-0035) — and builds its webpdecoder target only: the encoder, the muxer, the animation demuxer and all nine command-line tools are switched off, because Goldberry reads one still frame and writes PNG.

Three symbols are bound, with no C glue, which is §3.1’s rule and what the export list exists for: WebPGetInfo, WebPDecodeRGBA, WebPFree. Unlike Blend2D’s and HarfBuzz’s, libwebp’s headers mark the API visibility("default") on GCC and Clang even in a static build, so it needs no entry in the visibility fix-up ADR-0031 added.

GIF is nine pages. A palette, a handful of length-prefixed blocks, and LZW. Taking a second native dependency for that would cost more than owning it — and :core already owns a format outright: image.png.PngEncoder. So image.gif.GifDecoder is Java, and sits beside it as the same kind of thing.

The GIF decoder reads the first frame

An animated GIF is a still image with more frames after it, and this reads the first one. That is a stop rather than an oversight: what an application does with a GIF here is put a picture on a board, and animation is a scheduler, a frame-disposal model and a clock, none of which belongs in a decoder. A GIF with one frame — which is most of them — decodes exactly.

The frame is composited onto the logical screen the file declares, so an image whose first frame is smaller than the canvas comes back the size the file says it is with transparent pixels around it. An optimizer writes a partial first frame whenever only part of the picture changed, and cropping would hand back an image of a size the file never claimed.

Interlacing is honoured. A decoder that ignored it produces a picture that is recognisably the right one with its rows shuffled, which is exactly the kind of wrong that passes a size check — so there is a fixture for it.

Both hand back 0xAARRGGBB

Neither decoder premultiplies. Image.ofArgb does that once, for every caller, and has since ADR-0283 — so the two new paths join the existing one at exactly the place where “not premultiplied” stops being true, rather than each getting it right separately.

The clipboard’s offered set moved with them

Image.CLIPBOARD_MIMES gains image/webp and image/gif. That set is a statement about what can be decoded, so it moves when that does; a codec linked in and unreachable from the one place a picture usually arrives would be half a change. ClipboardDataTest’s “a type this toolkit cannot decode” case, which used to be WebP, is now TIFF.

Consequences

  • The ABI version goes to 11. Three new exports change the shape of the surface, so a libgoldberry.so built before this refuses to load against this Java rather than failing later at the first WebPDecodeRGBA. ADR-0330 lands in the same bump.
  • The published library grows by libwebp’s decoder. webpdecoder is the three decode-side object libraries and nothing else, which is a few hundred kilobytes — the encoder, which is the larger half, is never built.
  • :natives gains a webp package, sealed to :core like Blend2D’s and Yoga’s wrappers: an application calls Image.decode and names no type of that module. The MemorySegment never leaves the call, let alone the module.
  • A GIF decoder is now this project’s to maintain. That is the cost of not taking a dependency, and it is bounded: the format has not changed since 1989, and the test asserts the decoder against a PNG of the same picture rather than against a table somebody transcribed.
  • An animated WebP does not decode. WebPDecodeRGBA answers null for one, which becomes an ImageDecodeException naming that as the usual reason. Reading the first frame of one needs webpdemux, which is a second library and is not built.

330. A dropped file arrives somewhere

Date: 2026-09-16

Status

Accepted. Closes docs/gaps.md G35b.

Context

There was no drop event anywhere in the toolkit. SDL3 has five — SDL_EVENT_DROP_BEGIN, DROP_POSITION, DROP_FILE, DROP_TEXT, DROP_COMPLETE — and none was surfaced, so dragging a PNG from a file manager onto a window did nothing at all.

Nothing was broken; this is surface that had not been built, and it was found by needing it rather than by reading the API. The routes that did exist — a file dialog, and Ctrl+V for a clipboard picture — cover the common cases, which is why it is a gap and not a defect.

Decision

One event per gesture, carrying the files and the point they landed on.

// io.github.digitalsmile.goldberry.Window
public Subscription onFileDrop(Consumer<FileDrop> listener);

// io.github.digitalsmile.goldberry.input.drop
public record FileDrop(List<Path> paths, LogicalPoint at) { … }

The position is half the feature

A board needs to know where something was dropped, not merely that it was: the whole gesture is “put this picture here”. SDL carries the coordinates already, so a drop event without them would make the toolkit the reason an application cannot use one.

They are LogicalPoint, window-relative, in the same space every pointer event and every layout is in — so a drop can be hit-tested against the last painted frame exactly as a click is.

The gesture is assembled in the toolkit, not in the backend

The SPI keeps the platform’s shape: BackendEvent.FileDropped is one file, and FileDropCompleted ends the run. Window collects them and raises one FileDrop.

That split is deliberate. Reassembling “a beginning, a moving position, one event per file and an end” into “these files, there” is arithmetic every application would otherwise get slightly differently, and it is arithmetic that can be tested without a desktop — FileDropTest drives it through the headless backend and never touches SDL. What is left in Sdl3Backend is one switch arm per event type and a remembered position, and the numbers and offsets in it are checked against the compiled SDL by the layout probe (ADR-0010).

The backend keeps the last position because which events carry one is a platform’s business: DROP_BEGIN carries none by SDL’s own documentation, and the others may or may not. The last non-zero answer wins, so a drop always knows where it was.

A Subscription, not a setter

Every other handler on Window is a setter returning Window, and those answer a question about the window: how big it is, where it is, whether it may close. There is one answer to each, so a second caller replacing the first is right.

A drop is aimed at whatever is under the pointer, which in one window is any number of things. A board and a settings panel in the same window both have a use for one, and neither should silently overwrite the other. So the listeners are a list and unsubscribing is closing the Subscription — the type :core’s bind package already has for exactly this.

Nothing is read and nothing is checked

The paths are names the platform handed over. Whether they exist, can be opened, or are what they claim to be are questions for whoever accepted the drop. A name this file system will not accept at all — a NUL byte — is skipped with a log rather than taking the whole gesture down: a drop shortened by one bad name is better than a drop that failed.

The path crosses the SPI as a String and becomes a Path in Window, for the reason a keycode crosses as an int: turning a platform’s name into something Java’s file system agrees with is a conversion with a failure mode, and a backend is not where that should be decided.

onTextDrop is not here

SDL_EVENT_DROP_TEXT is the same shape and nothing has asked for it. A second event with no caller is a second event with no test.

Consequences

  • The ABI version goes to 11, with ADR-0329: the SDL_DropEvent layout joins the probe registry, and four event numbers join the constants it checks. A wrong event number does nothing at all — the drop simply never arrives — which is precisely the failure that table exists for.
  • BackendEvent gains two cases. It is sealed, so every exhaustive switch over it stopped compiling until it said what it does with them, which is the property ADR-0004 chose the shape for.
  • A drag that crosses a window and leaves raises nothing: the completion arrives with no files behind it and clears the gesture silently.
  • The HeadlessBackend produces no drops. It has no desktop to drag from; the gesture is still fully testable through Window, which is where the logic is.

331. A gutter numbers hard lines at soft positions

Date: 2026-09-16

Status

Accepted. Closes docs/gaps.md G37.

Context

text-area is already an editor pane in everything but one respect: soft wrap at the control’s width, fill(true) so it takes the height a container gives it rather than growing to fit, the bundled monospace behind class="mono", and an editing model — selection, clipboard, undo, word operations, Up/Down by visual line — that is text-input’s and needed no second copy.

What it did not have is line numbers, and they cannot be composed from outside. That is the whole entry: the numbers have to line up with hard lines drawn at soft-wrapped positions, and only the thing that laid the text out knows where those fell. A Column of numbers beside the pane is correct until the first line that wraps, and then every number below it is wrong — which is worse than having none, because it looks like it works.

Decision

One flag on the widget that has the layout.

public TextArea gutter(boolean on);
text-area class="mono" gutter=#true fill=#true bind="note.body" change="note.type"

A boolean and not a renderer. What a line number looks like is the stylesheet’s, and an application that wanted to draw something else in that column is asking for a different widget rather than a parameter.

The numbers are one paragraph, not one node per line

This is the part worth recording, because the obvious design does not work.

A widget’s children are described in children(), which runs before anything is laid out — render() is where Paragraph.layout happens. So a column of text-area-line-number nodes could only be built from the previous frame’s wrap, and would therefore be a frame behind the text on every keystroke that changed the line structure. Worse, nothing would request the correcting frame: the rebuild a keystroke causes is the frame that gets it wrong.

So TextAreaBox draws the numbers itself, in render(), as one text box whose content is a number per hard line and an empty line per wrap:

"1\n2\n\n\n3"     lines one, two — which wrapped into three — and three

Drawn in the control’s own font at the control’s own line height and inset by the control’s own scroll offset, so the numbers cannot drift from the text by construction rather than by two pieces of arithmetic that have to be kept agreeing. Scrolling is one inset for both, which is why they cannot shear.

The strip is still a node

TextAreaGutter — text-area-gutter — is a real child and carries the column’s fill. It is pinned top and bottom so it runs the height of the control rather than stopping where the text does, and pulled out to the border on the left so the control’s own padding sits inside it.

The numbers’ ink is --gb-gutter-color, read through Paints.Context.color, and the room on each side is --gb-gutter-gap through Paints.Context.length. That is exactly what those two accessors exist for (ADR-0195, ADR-0251): a widget drawing something the property set has no declaration for still has to be themeable. Both are declared on text-area in controls.css, muted and 8px.

There is no rule down the gutter’s edge, and that is §8’s subset rather than an oversight: border is a shorthand over four edges with no per-edge longhands (ADR-0107), so border-right is not a declaration that exists. One step of surface tells the column from the text, which is how table’s header strip is told from its body.

The width comes off the text

The column is measured from the document’s last line number rather than from the one on screen, so the text does not slide left and right as a long note is scrolled, and at least two digits wide, so a note does not visibly shift the first time it passes line nine. It is measured in the node’s own font — a mono editor and a body one have different digits — which is why it is computed in render and handed down rather than guessed at either end.

AreaEditor.laidOut gains a gutter parameter, and everything that measures against the content moves with it: the wrap (contentWidth), the caret, the selection rectangles and where a click lands. It is a separate number from the padding rather than folded into it, because the two are not used the same way — the padding is on both sides and comes off the wrap twice, the gutter is on one and comes off once.

Consequences

  • TextArea grows two components in this record and two more in ADR-0332. An eleven-argument constructor is kept for the call sites that build one positionally.
  • A text-area with no gutter is byte-for-byte the control it was: gutterWidth is 0, no TextAreaGutter is built, and every inset is what it was before.
  • text-area-gutter is the only new CSS type. There is deliberately no text-area-line-number node, and the javadoc on both classes says why — so the next person to reach for one finds the reason rather than the absence.
  • An application cannot change what a line number says. Relative numbering, a fold marker or a diff gutter would all be different widgets. That is the boundary a boolean draws, and it is the one the entry asked for.

332. An editor is handed the caret

Date: 2026-09-16

Status

Accepted. Closes docs/gaps.md G38.

Context

text-area reports the new whole value through change=, which is exactly right for a form field and is what every other control in the catalog does.

It is not enough for an editor. Ctrl+B around a selection, - and Enter continuing a list, Tab indenting one — every Markdown shortcut needs to know where the caret is and what is selected, and a String says neither.

The value that holds exactly that already exists and is already what the widget drives itself from: text.edit.TextEdit, a record of (text, anchor, caret) with every motion and every deletion as a pure function. This was a request to let it out, not to invent it.

Why the caret cannot be inferred

It can, for typing, and that is the trap. An application can compute where a change happened by diffing two versions of the string, and after a keystroke the caret is at the splice’s end. That inference is correct for typing and wrong for a selection, a click, and every caret move that changes no text at all — three of the four things a shortcut needs to know about.

An inference that is right often enough to be trusted and wrong exactly when a shortcut fires is worse than no inference.

Decision

The change event in the richer currency, and a way to apply one back.

public TextArea onEdit(Consumer<TextEdit> listener);   // text, anchor and caret
public TextArea edit(TextEdit next);                   // an edit the application computed

change= stays as it is — a form does not want a caret — and these are beside it, for the callers that are editors rather than fields. The two are independent: a form may listen to one, an editor to the other, a screen that is both to each.

onEdit fires on every change, including ones that changed no text

That is the whole point. It is raised after a keystroke, a click, a drag, an arrow key, a select-all and an undo. change= still fires only when the text differs, which is what a form wants and what every existing listener already assumes.

edit(TextEdit) is half the request, not a convenience

Wrapping a selection in ** is an edit and a caret move. An application that could compute one but only push back a String would leave the caret wherever the widget decided, which for a shortcut is the difference between working and not.

It is offered, not imposed, and works exactly as value= does: the control adopts it when it changes and ignores it on every rebuild in between. A constant edit adopted on every build would reset the caret after every keystroke. The state keeps lastPushed beside the lastOffered that already does this for the value, and for the same reason.

It is applied after bind= in the same build, so an area that is both bound and pushed to in one frame takes its caret from here rather than from the clamp a new value would leave behind — which is the case a Markdown shortcut always is: the model’s text changed and the caret moved, in one action.

A pushed edit is recorded but not echoed

It goes into the undo history, so Ctrl+Z undoes a shortcut the way it undoes a keystroke — an application’s ** is an edit and belongs in the same stack.

It is not reported back through onEdit, and change= is not raised for it either. The caller already knows what it pushed, so a report would be an echo; and it would be an echo raised from inside build, where a setState in reply is a rebuild during a rebuild. lastOffered is deliberately left alone, so follow() keeps comparing against what the model last said rather than against text the application pushed without updating its own model.

There is no markup form

change= names a method that takes a value. A TextEdit is not a value a KDL document can write or an action registry can resolve, and an editor’s shortcuts are Java. Stated in the javadoc on inflate rather than left as a hole.

Consequences

  • TextArea grows two components here and two in ADR-0331, reaching fourteen. The eleven-argument constructor every existing call site uses is kept.
  • onEdit fires more often than change= — on caret moves, which happen on every arrow key. It is null for every field that does not ask for it, and the call is one null check.
  • An application can now put the caret anywhere, including somewhere absurd. TextEdit’s constructor clamps to the text’s length and snap moves an offset to the nearest legal grapheme boundary, so “absurd” is bounded rather than corrupting.
  • text-input does not get this. A single-line field is a form control; the one thing an editor needs that it does not have is exactly this seam, and adding it there would be API with no caller.

333. A version is a year and a count

Date: 2026-09-17

Status

Accepted. Replaces goldberryVersion=0.1.0-SNAPSHOT and the “0.1 release” that docs/ARCHITECTURE.md §16 named at M5.

Context

Nothing had been published, so the version had never had to mean anything: gradle.properties said 0.1.0-SNAPSHOT, every module read it verbatim, and the suffix was a string somebody would have had to remember to delete on release day and put back the day after.

Publishing (ADR-0334) makes the version permanent. A release on Maven Central cannot be withdrawn or replaced, so the three places a release names itself — the tag, gradle.properties and the POM — have to agree, and the moment they could disagree is the one moment nobody is looking at them.

Semantic versioning was the default and fits a toolkit poorly. Goldberry breaks API on a schedule set by milestones rather than by individual changes, a 1.0 would be a statement about stability the project is not ready to make, and a 0.x that runs for years says nothing at all. What a user of a UI toolkit wants from the number is how old is this, which is what JetBrains’ 2026.1 / 2026.2 answers.

Decision

Versions are YEAR.RELEASE[.PATCH] — 2026.1, 2026.2, 2026.2.1 — where RELEASE counts the releases of that year from one and starts again in January. One spelling per version: no .0 patch, no leading zeros. CalendarVersion parses and orders them.

gradle.properties declares the release line being worked towards, never a snapshot. goldberryVersion=2026.1. The conventions plugin resolves the actual version through BuildVersion:

InputsVersion
nothing2026.1-SNAPSHOT
-Pgoldberry.release=true -Pgoldberry.releaseTag=v2026.12026.1
-Pgoldberry.release=true -Pgoldberry.releaseTag=v2026.2build fails, naming both
-Pgoldberry.release=true without a tagbuild fails
goldberryVersion=2026.1-SNAPSHOTbuild fails: the suffix has one author

So the suffix is never typed. The release workflow is the only thing that passes the flag, and it passes the tag it was triggered by, so a tag pushed on the wrong commit fails at configuration — before a jar exists — rather than at Central.

./gradlew -q :core:printVersion prints the resolved version, which is what the workflows write into a run’s summary.

After tagging v2026.1, master’s gradle.properties moves to 2026.2. A patch is cut from a release/2026.1 branch that declares 2026.1.1. docs/releasing.md is the checklist.

Alternatives considered

  • Derive the version from git tags (axion-release, jgitver, git describe). No file to bump, but a snapshot’s version becomes a function of the nearest tag and the distance from it, which needs the full history in every CI checkout (fetch-depth: 0), differs between a shallow clone and a real one, and makes “what version is master building” a question with a computed answer. A property is readable in a diff.
  • Keep -SNAPSHOT in gradle.properties and strip it at release. The status quo, and the failure it invites is the one this record exists for: the strip is a step, and a skipped step publishes -SNAPSHOT to a release repository or a stale number to Central.
  • Semantic versioning. See Context: the number would claim a compatibility discipline the project does not practise yet, and say nothing about age.
  • A Gradle plugin for CalVer. The logic is forty lines with a test each; a dependency for it would be larger than the thing it replaces.

Consequences

  • A release is a tag on the commit that declares it, and nothing else. There is no version-bump commit in the release, only the one after it.
  • Someone has to bump gradle.properties after every release, or master keeps publishing snapshots of a version that is already out — which Maven orders below the release, so a consumer on the snapshot silently goes backwards. The runbook says so; nothing enforces it yet.
  • The year in a version is the year of the line, not of the tag: 2026.3 tagged on 2 January 2027 is still 2026.3. CalendarVersion.nextRelease(Year) starts a new year’s count only when asked.
  • :assets and :weaver do not apply the conventions and still read the property directly, so their unpublished jars are named 2026.1 without a suffix. They are build-time tools and never leave the build.
  • BuildVersionTest reads gradle.properties, so a hand-written -SNAPSHOT there fails build-logic’s tests as well as the build.

334. Central is fed once per run

Date: 2026-09-17

Status

Accepted. Builds the publishing half docs/ARCHITECTURE.md §15 and ADR-0009 promised and book/src/TODO.md recorded as missing. Never yet run against Central.

Context

§15 says goldberry-common, -natives, -core, -widgets, -html and -gpu go to Maven Central under io.github.digitalsmile, with the natives as four classifier jars. Until now the artifact half existed — release.yml reused the three per-OS workflows, gathered four libraries and ran :natives:nativeJars — and the publishing half did not: no module applied maven-publish, so there was no POM, no javadoc jar, no signature, and release.yml ended at upload-artifact.

Two further asks shaped this: every push to master should publish a -SNAPSHOT automatically, and it should come out of the Linux, macOS and Windows workflows.

The last one runs into how Maven snapshots work. A snapshot upload writes maven-metadata.xml for its version, and that file’s <snapshotVersions> lists the artifacts of that upload — which classifier resolves to which timestamped file. If the Linux runner published goldberry-natives:2026.1-SNAPSHOT with its two classifiers and the Windows runner then published it with its one, the metadata would name only windows-x64, and a consumer asking for linux-x64 would get a 404. The Java modules would be published three times over, racing. The per-OS workflows cannot each publish; they can each build for a publish.

Decision

One reusable workflow, publish.yml, is the only thing that uploads to Central. It calls linux.yml, macos.yml and windows.yml, waits for all three, downloads the four libraries they uploaded, and runs one Gradle invocation:

./gradlew publishToMavenCentral -Pgoldberry.skipNative=true \
    -Pgoldberry.artifactsDir=artifacts [-Pgoldberry.release=true -Pgoldberry.releaseTag=v…]

Two thin callers:

  • snapshot.yml — every push to master, release: false. Publishes to https://central.sonatype.com/repository/maven-snapshots/. Queued, never cancelled, so an upload is not stopped halfway.
  • release.yml — every v* tag, release: true. A workflow_dispatch run is a rehearsal: the same chain as a snapshot, into mavenLocal, uploading nothing.

The per-OS workflows lose their push trigger. On master they run as jobs of snapshot.yml, so each library is built once per commit and the one that passed verify is the one published. Pull requests still run them directly, as before. This is how “Linux, macOS and Windows publish snapshots” is met without four publishers.

In Gradle, a precompiled goldberry.publish plugin applies com.vanniktech.maven.publish 0.37.0, which speaks the Central Portal API (OSSRH was retired in 2025) for both releases and snapshots and signs from an in-memory key:

  • Coordinates from PublishedModules, which is the list of what ships; applying the plugin to anything else fails configuration.
  • Sources and javadoc jars; javadoc with doclint off (see Consequences).
  • :core’s test fixtures are skipped from the component, including their sources variant, so no consumer’s POM names them.
  • Signing only when the version is not a snapshot.
  • A release stops in the Portal for a person to press Publish, unless -Pgoldberry.centralAutoRelease=true — which publish.yml passes from the repository variable CENTRAL_AUTO_RELEASE.
  • :natives attaches the four nativeJar* classifier jars to its publication only when -Pgoldberry.artifactsDir is given, and each jar already refuses to build without its library.

Credentials and key come from secrets as ORG_GRADLE_PROJECT_*. Without MAVEN_CENTRAL_USERNAME a snapshot run rehearses into mavenLocal and says so in the summary, the way qodana.yml waits for its token; a release without it fails.

PublishWorkflowsTest holds the shape: no push trigger on the per-OS workflows, both callers going through publish.yml with secrets: inherit, and no other workflow mentioning publishToMavenCentral. PublishedModulesTest holds each module’s build script to the list.

Alternatives considered

  • Each per-OS workflow publishes its own classifier. The literal reading of the request, and broken by the metadata argument above. Separate artifact ids per platform (goldberry-natives-linux-x64) would make it work, at the price of changing §15’s coordinates, giving every platform its own POM and snapshot timeline, and still leaving three runners racing to publish the Java modules.
  • Keep push on the per-OS workflows and add snapshot.yml beside them. Every commit would run the twenty-minute superbuild on every platform twice.
  • workflow_run after the per-OS workflows. It fires once per completed workflow and has no “after all three” form; joining them means polling.
  • JReleaser. Capable, and a second configuration language for what one Gradle plugin already does; its strength is GitHub releases, changelogs and announcements, none of which is asked for.
  • Plain maven-publish + signing + the Portal’s upload API by hand. The Portal takes a bundle zip through a REST call with polling for validation; that is exactly the code the vanniktech plugin maintains.
  • Fix the 120 javadoc errors first. Right eventually, and not a reason to hold publishing: none of them stops the pages rendering.

Consequences

  • Snapshots appear only after Linux, macOS and Windows are all green. One broken platform stops every snapshot, which is the point — a snapshot missing a platform is broken for that platform’s users — and also means a Windows-only flake holds up Linux consumers.
  • The README’s per-OS badges now report pull-request runs; master’s health is the Snapshot badge.
  • Central’s side has to be set up by a person and cannot be tested from here: the io.github.digitalsmile namespace verified, snapshots enabled for the namespace in the Portal (they are off by default), a user token, and a GPG key published to a keyserver. docs/releasing.md lists them.
  • -Xdoclint:none on the published javadoc means a broken {@link} ships quietly. The 120 findings are in book/src/TODO.md.
  • The natives classifier jars are extra artifacts on the Maven publication and are not described in Gradle Module Metadata. A Gradle consumer asks for them by classifier, as a Maven consumer does.
  • publish.yml runs the per-OS workflows three levels deep (snapshot → publish → linux); GitHub allows four.
  • The first real run is untested. Every step up to the upload is exercised by the rehearsal (release.yml’s dispatch, or any snapshot run before the secrets exist), and was run locally with stand-in libraries into a throwaway repository.

335. The showcase is a package, and example.yml is folded in

Date: 2026-09-17

Status

Accepted, then superseded the same day by ADR-0340: the GitHub Packages publication and the jlink image are gone, and the example’s tests run on linux.yml’s verify leg. What survives of this record is that example.yml stays retired. Extends ADR-0048; retires the workflow ADR-0021 and ADR-0023 describe.

Context

showcase.yml builds a runnable image on each desktop platform — a jlink’d JDK, the modules, libgoldberry and a launcher (ADR-0048) — runs it until it has presented three frames, and uploads it as a run artifact. Run artifacts expire after ninety days, need a GitHub login to download, and have no stable address: there was no link to “the current showcase”.

example.yml predates the image. It builds libgoldberry on an Ubuntu runner, runs ./gradlew :example:build — the example’s goldens and screen tests, which need a real rasterizer and skip in linux.yml’s java job — and then ./gradlew :example:run under Xvfb until three frames are presented. Its header gives two reasons to exist: the module path, and the reflective binding exercised as a real module (ADR-0155).

Both reasons are now met more strictly by showcase.yml’s Linux leg: the image is a module-path launch, with nothing on it jlink did not keep, and the same three-frame assertion. The one thing example.yml did that nothing else did is run the example’s tests against a built library.

Decision

The images are published to GitHub Packages as a Maven artifact, io.github.digitalsmile:goldberry-showcase:<version>, one classifier per target:

ClassifierFile
linux-x64.tar.gz
macos-aarch64.tar.gz
windows-x64.zip

A final publish job in showcase.yml runs on pushes only — master publishes a snapshot, a v* tag the release, with the version resolved as ADR-0333 says — downloads the three archives and uploads them in one Gradle call, :example:publishShowcasePublicationToGithubPackagesRepository, with the job’s GITHUB_TOKEN and packages: write. Once, not per leg, for ADR-0334’s metadata reason. ShowcasePackage owns the target list and the tar-versus-zip rule, and its test holds the workflow’s matrix and file names to it.

example/build.gradle declares the publication only when -Pgoldberry.showcaseDir is given, and no longer disables PublishToMavenRepository — that blanket switch-off would have made the upload report SKIPPED in a green run.

example.yml is deleted. Its :example:build step moves into showcase.yml’s Linux leg, after the library is built. LinuxDependenciesTest now guards showcase.yml alone, and PublishWorkflowsTest fails if example.yml returns.

Alternatives considered

  • GitHub Releases for the images. Public, unauthenticated downloads, which Packages is not — but only for tags. A master build has no release to attach to short of a rolling nightly release rewritten on every push. Worth adding for tagged releases later; it does not replace a snapshot address.
  • An OCI artifact on ghcr.io through ORAS. Anonymous pulls for public packages, but a user needs oras to fetch a zip, and nothing else in this build speaks OCI.
  • Maven Central. An application image with a bundled JDK is not a library, and Central’s validation expects javadoc and sources for every artifact.
  • Keep example.yml. It would duplicate the Linux leg’s native build — the slowest step in either workflow — to run a launch the image already runs more strictly.

Consequences

  • GitHub Packages’ Maven registry needs authentication to download, even from a public repository: a personal access token with read:packages. The images are an address for people with a GitHub account, not for everyone.
  • Snapshot uploads accumulate: every push to master adds three images, each a trimmed JDK. GitHub does not prune Maven snapshots on its own; old files have to be deleted, by hand or by a scheduled actions/delete-package-versions, which is not written.
  • The release images go to Packages from showcase.yml, not from release.yml — a tag triggers both, independently, so a release can reach Central while its showcase failed, or the reverse.
  • The example’s tests still run on Linux only, as they did.
  • The README loses its Example badge.

336. One dependency to start from, and a BOM to line up the rest

Date: 2026-09-17

Status

Accepted. Extends ADR-0334’s published set; applies ADR-0190’s “none of them a dependency of -core or -widgets” to the POM.

Context

ADR-0334 publishes six libraries: goldberry-common, -natives, -core, -widgets, -html and -gpu. An application that wants the toolkit has to know that list, name four of them, and repeat one version four times. An application that also wants Markdown adds a fifth and has to keep that version in step by hand. Every content module ADR-0190 describes — PDF, code, terminal and the rest — makes both of those worse.

The content modules and :gpu are optional by design, and the dependency cannot point the other way: :html depends on :widgets, so goldberry-widgets naming goldberry-html, even as <optional>, would be a cycle.

Decision

Two more artifacts, both generated from PublishedModules.

  • goldberry-bom (project :bom, a java-platform): a constraint on every library and on the umbrella, at the build’s version. It adds nothing to a graph; it decides the version of whatever the application does add.
  • goldberry (project :toolkit, no code): the umbrella. Its POM lists
    • goldberry-common, -natives, -core, -widgets as ordinary compile dependencies, and
    • goldberry-html, -gpu as <optional>true</optional>.
implementation platform('io.github.digitalsmile:goldberry-bom:2026.1')
implementation 'io.github.digitalsmile:goldberry'
implementation 'io.github.digitalsmile:goldberry-html'     // opting in; no version

PublishedModule is a sealed interface — Library(project, Inclusion), Bom, Umbrella — and Inclusion is REQUIRED or OPTIONAL. The BOM’s constraints and the umbrella’s dependencies are loops over it, so a new content module is one new Library("pdf", Inclusion.OPTIONAL) and nothing else. goldberry.publish configures a JavaPlatform or a JavaLibrary by which kind the project is, and refuses a project whose plugins do not match.

Version and group moved out of goldberry.java-conventions into goldberry.versioning, because a java-platform cannot also be a java-library and the BOM still needs both.

The umbrella publishes no Gradle Module Metadata. Gradle’s only way to say optional in a .module file is a feature variant, which needs a source set and publishes an empty jar per feature. Without the file Gradle reads the POM, where it treats an <optional> dependency as a version constraint rather than a dependency — which is the meaning wanted. Checked with a consumer build against a local repository: goldberry alone resolves the four required modules and not goldberry-html; adding goldberry-html without a version resolves it at the BOM’s.

Alternatives considered

  • Optional dependencies on goldberry-widgets. A cycle; see Context.
  • Gradle feature variants on the umbrella (registerFeature('html')). Proper Gradle semantics — requireCapability — at the cost of an empty classifier jar per feature on Central, a source set per feature, and capabilities that have to be named so they cannot collide with the real goldberry-html. The POM already says the same thing to both build tools.
  • BOM only. Aligns versions and leaves “which four do I need” to the reader.
  • Umbrella only. An optional dependency is versioned in the umbrella’s POM, but Maven does not import versions from a dependency’s POM, so a Maven user adding goldberry-html would still have to write its version.
  • A pom-packaged umbrella. No empty jar, but a Maven consumer then has to write <type>pom</type>, which nobody remembers.

Consequences

  • Two more artifacts per release, both small: the BOM is a POM, the umbrella an empty jar with a sources and javadoc jar beside it because Central requires them for jar packaging.
  • The umbrella’s jar is on an application’s module path. It has no module descriptor and exports nothing; its manifest names it io.github.digitalsmile.goldberry.toolkit so the automatic module name derived from goldberry-2026.1.jar cannot collide with anything.
  • The natives’ platform jars are still the application’s to add (goldberry-natives:<v>:linux-x64). A POM cannot choose a classifier by the consumer’s platform. The BOM does line up their version.
  • A Gradle consumer of the umbrella reads a POM rather than module metadata, so it loses variant-aware resolution for that one artifact. It has no variants.
  • The root blessGoldens skips :bom, which has no tests.

337. The native showcase is built on every platform

Date: 2026-09-17

Status

Accepted. Puts book/src/native.md’s “No CI job” into CI; extends ADR-0335. Amended by ADR-0340: the image is built on a v* tag or by hand and attached to the GitHub Release, not published to GitHub Packages on every push.

Context

:example:nativeImage has existed since ADR-0127/ADR-0156: a GraalVM native image of the showcase, one executable with libgoldberry carried inside it (ADR-0159). It was built and run by hand on linux-x64 and never anywhere else, because no CI machine had a GraalVM. The traced reachability metadata it builds from was recorded on that one machine.

The runtime images already go to GitHub Packages from showcase.yml (ADR-0335). The native images should go beside them, for the same three platforms.

Decision

Each showcase.yml leg builds the native image after the runtime image, on the same runner:

  1. graalvm/setup-graalvm with version: '25.3' and distribution: graalvm-community — GraalVM CE 25.3, today 25.3.4.1 on JDK 25.0.4.1 — which sets GRAALVM_HOME, where :example:nativeImage already looks, and on Windows the MSVC environment native-image links with.
  2. macOS and Windows only: :example:nativeImageMetadata, a fresh headless trace on that platform. Linux builds from the checked-in, reviewed trace.
  3. :example:nativeImage.
  4. The binary is run for three frames, as the runtime image is — under Xvfb on Linux.
  5. Packaged as goldberry-showcase-native-<target>.tar.gz on unix, for the executable bit, and as goldberry-showcase-native-windows-x64.exe on Windows, where it is one file with no bit to lose.

On a push, a second publish job uploads the three as io.github.digitalsmile:goldberry-showcase-native:<version> to GitHub Packages. The runtime and native images are separate artifacts published by separate jobs, and both jobs run unless the workflow was cancelled, so a native build that fails on one platform does not stop the runtime images publishing. A partial publication cannot happen: each Gradle publication names all three files, and a missing one fails the upload. ShowcasePackage gained a Kind — RUNTIME_IMAGE, NATIVE_IMAGE — carrying the artifact id, publication, property and file format, and its test holds showcase.yml to all four.

The pin is by GraalVM version, not Java version. Since 25.1 GraalVM CE is released as graal-25.x and versions apart from its JDK. setup-graalvm resolves java-version: '25' alone through the older jdk-25.* tags, whose newest is jdk-25.0.2 from January — two feature releases behind — which is what this workflow first said. GraalVmRelease.CI_LINE holds 25.3; a test holds showcase.yml to it, and :example:nativeImage reads the GraalVM home’s release file and warns when a local build is on another line.

--no-fallback is gone. On 25.3 it is “deprecated … No effect, no replacement”: there are no fallback images left to refuse, and the flag was both of the build’s two warnings.

:example’s GraalVM lookup now tries .exe and then .cmd on Windows. It asked for java.cmd, which GraalVM for Windows does not have — the trace step would have failed on its first run there.

Alternatives considered

  • A separate native job matrix. Cleaner logs, and a second full superbuild per platform — the slowest step in the workflow, three times over, on the runners that cost the most.
  • Trace on every platform, Linux included. Uniform, but it replaces the one trace that is reviewed as source (ADR-0156) with an unreviewed one on the platform where the reviewed one exists.
  • Trace nowhere; build every platform from the Linux trace. Possibly enough: the bindings’ descriptors are not chosen per platform today. But the trace is of what a run did, and a call, a resource or a reflective lookup only macOS’s or Windows’ code path reaches is absent from a Linux run — and a foreign descriptor registered nowhere is a MissingForeignRegistrationError at its first call. Whether the traced sets actually differ is unmeasured; the first CI run’s metadata, diffed against the checked-in file, answers it.
  • Oracle GraalVM. Profile-guided optimisation and the G1 collector, under the GraalVM Free Terms rather than an open-source licence; Community is what native.md was written against and is enough for a showcase.
  • Publish the binaries to the GitHub Release of a tag. Unauthenticated downloads, and no address for master’s snapshots. The same trade as ADR-0335.

Consequences

  • Every showcase leg gets longer by a native-image build. Measured on 25.3.4.1, linux-x64, 8 threads: 1 min 23 s, peak RSS 2.27 GiB, a 49 MiB binary. macos-14 has 7 GB and 3 cores, so memory should hold and time will not; if the build is killed there, -Pgraalvm.args=-J-Xmx… is the first lever.
  • Moving to a new GraalVM line is a deliberate edit of CI_LINE and the workflow together, followed by a local build — the way this one was found to need a fresh trace and a dead flag removed.
  • The macOS and Windows images are built from traces nobody reviewed. They are as complete as a 120-frame headless run makes them. A screen the run never reaches can be missing a registration, which fails when that screen is opened — not in the three-frame check.
  • A leg can now go red for a native-image reason while its runtime image is fine and published. The job summary and the separate showcase-native-* artifact say which.
  • The native images are built against the library each runner builds, not the manylinux one (ADR-0012), exactly as the runtime images are. They are showcases, not the distribution.
  • Snapshot pruning is now six files a push rather than three (ADR-0335).
  • The first local run proved the point of the job. The Linux leg’s commands, run by hand, built an image that failed on its first frame: the checked-in trace was from 30 August and nothing had registered the clipboard upcall added after it. Nothing had built an image since, so nothing had noticed. The trace was refreshed alongside this record — traced again on 25.3.4.1 from clean, with the same result — and from now on a stale one turns the Linux leg red on the next push rather than whenever somebody next builds by hand.
  • None of this has run in CI yet.

338. A red run says why, in public

Date: 2026-09-17

Status

Accepted. Repairs CI after a month red; amends ADR-0334’s per-OS jobs, testing.md §3’s coverage gates and §4’s nightly lane. Amended the same day with the four causes the runners’ own logs named, read through the GitHub MCP server once it was reachable.

Context

No CI workflow had passed since 2026-08-16. linux.yml, macos.yml and windows.yml all failed at the java job, the nightly at “Build libgoldberry”, and after ADR-0334 snapshot.yml failed at the same places through publish.yml.

Nobody could see why from outside. A job log cannot be read without signing in, so every failure said “Process completed with exit code 1” and nothing else. The showcase workflow’s comments show the cost: its Windows image “has failed twice … so the only diagnosis available was inference”. A check run’s annotations, on the other hand, are served by the public API.

Rebuilding the java job from a fresh clone, with no build cache and no native library, turned up five separate causes:

  1. TestFont.get() (:widgets) and TestFonts.get() (:html) stored the font book before the probe parse that throws. The first caller skipped and every later one got a book that could not open a face. About 280 widget tests failed instead of skipping. Locally a library was always built, so this never showed.
  2. Tests that reach libgoldberry without asking first: MaximizedStateTest (a headless window still paints into Blend2D), MarkdownHtmlTest, whose @BeforeAll touched nothing native, and one ShowcaseDocumentsTest case that parses Markdown. MarkdownPropertyTest is jqwik, and jqwik reports an abort from @BeforeContainer as an error rather than a skip.
  3. The coverage floors (2026-08-30) were measured with the library loaded. Under -Pgoldberry.skipNative every test that shapes, paints or parses skips, and :core/:widgets/:html fall to 56/39/57% of lines. The java job could never pass them, and nothing else ran them.
  4. GoldberryTest still asked for a semver x.y.z after ADR-0333 made the version 2026.1-SNAPSHOT. This broke the java job and every verify job.
  5. nightly.yml installed no system packages before :natives:cmakeBuild. Since ADR-0325, checkToolchain refuses to continue without D-Bus, IBus and udev, and the job never had X11 or Wayland headers either.

Decision

Failures are written as annotations. A new build.ci package in build-logic:

  • WorkflowCommand renders ::error title=…::message with the runner’s escaping rules (%, CR, LF; plus : and , in a property) and caps the body.
  • TestFailureAnnotations, a TestListener that goldberry.java-conventions adds to every Test task, writes one annotation per failed test: the task path, the class, the test name, then the cause chain and the top frames.
  • BuildFailureAnnotation, a flow action registered once per build, writes “What went wrong” for failures no test reports: jlink, native-image, CMake.

All of them run only where GITHUB_ACTIONS=true. The flow action is registered from the conventions plugin, guarded by a flag on gradle, and not by a plugin on the root project. Applying build-logic at the root puts its classpath under every module, and :core’s versioned me.champeau.jmh request then fails to resolve.

Test fixtures skip only after the probe succeeds, and every test that reaches the library asks first: RendererRequirement in :core and :example, MarkdownRequirement in :html. For jqwik, MarkdownAvailable is a SkipExecutionHook, which is jqwik’s own way to skip.

Coverage floors run only when the library is part of the build (onlyIf { !skipNative }), and the linux-x64 verify leg runs them against the manylinux library it just verified. One leg is enough, because coverage does not vary by CPU. The nightly coverage job stays a report.

nightly.yml installs the same apt list as showcase.yml, and LinuxDependenciesTest now holds both files to the dependency table.

The four the runners named

Read from the job logs of run 35188823461 (Snapshot), 35188822932 (Showcase) and 35076016742 (Nightly), on 2026-09-17, once the GitHub MCP server made them readable. None of them can run on the machine this is written on.

  1. Windows :natives:test: seven structural tests, one path. ExportedSurfaceTest and HolderShapeTest each found the module’s classes with Path.of(codeSource.getLocation().getPath()). On Windows that location is file:/D:/a/goldberry/.../classes/java/test/, getPath() keeps the leading slash, and Path.of refuses /D:/... with Illegal char <:> at index 3. Both now go through CompiledClasses, one helper in the test tree, which converts the URI — the form that knows about drive letters — and is tested on its own.
  2. Windows :build-logic:test: CRLF. GraalVmReleaseTest cut showcase.yml at its first blank line, indexOf("\n\n"); a Windows runner checks out with core.autocrlf=true, no such sequence exists, and the test died of a StringIndexOutOfBoundsException rather than a message. Repository.read normalises what it hands out to LF, tested, and a .gitattributes keeps every working tree at LF — the drift guards are about content, and endings are not content.
  3. The Windows showcase built libgoldberry with MinGW. :natives:cmakeBuild hands CMake the Ninja generator and no compiler, and the first cc on a windows-2022 runner’s PATH is C:\mingw64\bin\cc.exe: cl is only on the PATH inside a Developer Command Prompt. GNU 14.2.0 configured without a word, linked libgoldberry.dll — GNU naming, and a libstdc++-6.dll dependency — and showcaseImage failed looking for the goldberry.dll that MSVC writes and goldberry-natives ships. windows.yml never met this because it drives CMake itself with the Visual Studio generator. Now WindowsToolchain in build-logic names the compiler: checkToolchain resolves cl the way it resolves cmake and refuses without it, naming the compiler CMake would have taken instead; cmakeConfigure passes the path it found as CMAKE_C_COMPILER and CMAKE_CXX_COMPILER; and showcase.yml runs ilammy/msvc-dev-cmd@v1 before the build, held there by a test.
  4. The macOS trace aborted inside CoreGraphics. The jlink image ran to three frames on the same runner, under Cocoa. nativeImageMetadata ran the same showcase under videoDriver=dummy and died with Assertion failed: (CGAtomicGet(&is_initialized)), function CGSConnectionByID and no Java frame. SDL’s macOS tray is Cocoa’s status bar, and SDL_CreateTray calls [NSStatusBar systemStatusBar] before [NSApplication sharedApplication]; under the Cocoa driver SDL_Init had already created the application, under dummy nothing had, and the status bar’s first call into the window server is the assertion. Two changes, because one would not do: TrayAvailability in the sdl3 backend declines a tray on macOS under dummy so the process is never aborted for asking; and the macOS trace runs under cocoa (-Pgoldberry.trace.videoDriver, TraceVideoDriver in build-logic), because a trace recorded with the guard in force would hold no tray call and the image built from it would meet its first one on a user’s desktop. The runner has a window server, as the jlink run proved.

The five behind those

The push carrying 6–9 (fd36169a) turned the Showcase green on all three legs and every native build, verify and Linux job in the Snapshot. Its own annotations — the first this record produced — named the layer the first four had hidden:

  1. Two timers overdue at one wake-up fired in creation order. EventLoop collected what was due and ran it in list order; a macOS runner’s pump overslept past both a 5 ms and a 30 ms timer and ran the 30 ms one first. The test’s own name said the rule. fireDueTimers sorts by due time, and a new test sleeps past both timers before running the loop so the case no longer needs a slow machine to appear. The only change to toolkit behaviour in this record.
  2. WaylandDecorations split a symlink target on /. The /proc reader is Linux-only in use, but its unit test makes the link in a temp directory, and on Windows a relative target prints with backslashes. It reads the target’s path elements now.
  3. FileChoiceTest compared against /a.png as text, which Windows prints as \a.png. The expectation goes through Path too.
  4. HtmlStylesTest and MarkdownStylesTest carried the same getPath() conversion as 6, on the html module’s descriptor. Through the URI now.
  5. The catalog sweeps found no catalog. AnimationSweepTest and SemanticsSweepTest turned a relative source path into a binary name with replace('/', '.'), which on Windows changes nothing, so Class.forName found no class and both sweeps reported an empty tree. SourceTree, one helper in the arch test package, joins the path’s elements instead and is tested on its own.

Consequences

  • Reproduced locally in a fresh clone, with no build cache and no library: the java job’s three steps pass. Then, against a local libgoldberry: :natives:test and :core/:widgets/:html:test pass with all three coverage floors.
  • Diagnosed from the logs, fixed blind, and then confirmed by the run: 6–9 passed on their platforms at fd36169a — the MSVC + Ninja configure of the whole superbuild included, which windows.yml had only ever done through the Visual Studio generator. 10–14 passed the same way at d478ecfe: the Snapshot green on all twelve jobs, the Showcase green on three legs and two uploads — the first green push since 2026-08-16, and the first time publish.yml reached its upload.
  • The Showcase’s two publish jobs failed only for want of the macOS and Windows artifacts, and need nothing of their own.
  • Every test in this record that reached a path did so through a string. The rule that falls out: a code source is a URI, a relative source path is a list of elements, and a path in an expected string is a Path first.
  • A PR’s java job no longer enforces the coverage floors; the verify job does. A drop in coverage now turns the verify job red, not the java job.
  • The runner keeps ten error annotations per step and fifty per job. A step with hundreds of failing tests reports only the first ten, which is enough to name the cause.

339. A foreign call is registered because it exists, not because a run reached it

Date: 2026-09-17

Status

Accepted. Amends ADR-0156, whose trace still supplies the reflection and resource metadata but no longer the foreign calls, and corrects native.md’s claim that the descriptors were never run-dependent. Applies ADR-0160’s rule — a module ships its own metadata — to the FFM registrations.

Context

The first green Showcase (ADR-0338) built and ran the native image on all three platforms, three frames each. Run on Windows by hand, the image opened, painted, and died on the first click into the html, canvas or Markdown screen with MissingForeignRegistrationError.

GraalVM has to know every FunctionDescriptor a Linker.downcallHandle or upcallStub will be asked for while the image is being built. ADR-0156 got that list from GraalVM’s tracing agent watching the showcase run for 120 headless frames, and native.md asserted the list was complete anyway, because “the holders of a library are all reached when its …Calls record binds, which happens on any JVM start”. That was wrong. A …Calls record binds when the wrapper that owns it is first used: MarkdownCalls when the first document is parsed, PathCalls’ arc and cubic holders when a canvas first draws one. A run that opens no Markdown initialises no MarkdownCalls$Parse, links no descriptor, and the agent records none. The checked-in trace held 61 downcall shapes out of the holders’ 216 handles, and the difference was every screen the run never opened.

The macOS and Windows legs re-trace headlessly before building, for “a call only that platform’s code path reaches” (ADR-0337). They reach the same 120 frames, so they fix nothing here, and they cannot be reviewed.

Decision

The foreign registrations are generated from the bindings, at build time, and ship in the goldberry-natives jar. No trace has to reach a screen for its calls to be registered, because the registration comes from the holder existing.

  • Downcalls.link, which every holder’s FD_… initialiser already calls, records the descriptor it was handed. Downcalls.linked() is the downcall half of the surface.
  • Upcalls.describe is the new choke point for the other half. The five classes that make a stub — SdlEventWatch, SdlFileDialogs, SdlTray, MeasureCallback, SdlClipboard — declare their DESCRIPTOR through it, so the shape is recorded when the class initialises rather than when the first tray or dialog exists.
  • ForeignSurface, in a new natives.metadata package, lists the module’s classes through its ModuleReader (or its code source, off the module path), initialises every class in a …calls package and every upcall owner, and returns both lists. Initialising a holder needs no libgoldberry, which is ADR-0173’s point: an unbound handle is linked from a descriptor and names no address.
  • ForeignMetadata writes them as the foreign section of a reachability-metadata.json, in the agent’s own spelling: jint, void*, struct(jfloat,jfloat), padding(n), sequence(n, …), union(…).
  • :natives:foreignMetadata is a JavaExec on the module path that runs it, and jar copies the result under META-INF/native-image/io.github.digitalsmile/goldberry-natives/. native-image reads that path from every jar on its module path, so an application building its own image gets the registrations without knowing they exist — ADR-0160’s arrangement, again.

The trace stays for what it is good at. Reflection, services and resources are still what the run saw, and ADR-0156’s warning still applies to them. Its foreign section is now a subset of the generated one and is harmless where it overlaps.

The tests hold the surface to the tree. ForeignSurfaceTest checks that every holder’s handle shape is among the reported descriptors, that the Markdown parser’s is there without any Markdown having been parsed, and that the list of upcall owners equals the set of source files that call upcallStub. ForeignMetadataTest checks the spelling against the agent’s: every descriptor the checked-in trace ever recorded must appear among the generated ones, which is the one comparison against the reader that matters.

Consequences

  • An image built from this commit registers all of the holders’ distinct descriptors and six upcall shapes. Verified by hand on 2026-09-17, from a manual Showcase run: the native image opens the html, canvas and Markdown screens on Linux, macOS and Windows. The canvas screen needed one more fix on the way, a resource rather than a call (ADR-0160’s amendment).
  • The registrations are exact rather than derived from a MethodHandle’s type: a struct passed by value keeps its layout, which a MethodType would have flattened to MemorySegment.
  • A holder added tomorrow is registered tomorrow, because it lives in a …calls package. A new upcall owner is not, until it is added to UPCALL_OWNERS — and the test that scans the sources fails the build until it is.
  • Downcalls and the holder packages are initialised at image build time (ADR-0161), so the registry list is in the image heap: a few hundred references, nothing more.

340. The showcase is a release artifact, not a package

Date: 2026-09-17

Status

Accepted. Retires ADR-0335’s GitHub Packages publication and ADR-0048’s jlink image; keeps ADR-0337’s native image and moves it to the release.

Context

showcase.yml built the showcase twice on every push to master — a jlink runtime image and a GraalVM native image, on three runners each — and published both to GitHub Packages as goldberry-showcase and goldberry-showcase-native, one classifier per platform. Two things were wrong with that once it ran.

A GitHub Packages Maven registry needs a token to download from, even on a public repository, so the images were never a link anyone could follow; and nothing pruned them, so every push added six archives that stayed. TODO.md had both written down. And the six-runner build on every push was buying a check the Snapshot already makes: the native image is exercised for three headless frames, which is a smoke test of the packaging and nothing else, while the example’s own tests — the part of that workflow that found real bugs — ran on the Linux leg alone.

The jlink image was the older of the two packagings (ADR-0048), kept when the native one arrived. Two artefacts of one program, from one workflow, with two launchers and two upload shapes, is one more than a demonstration needs; the native image is the one that is a single file (ADR-0159) and the one the toolkit is designed around (ADR-0127).

Decision

The showcase is built as a native image only, on a v* tag or by hand, and a tagged build is attached to the tag’s GitHub Release.

  • showcase.yml runs on push: tags: ['v*'] and workflow_dispatch. The image job builds libgoldberry, traces where a platform needs to (ADR-0338), builds the native image, runs it for three frames and uploads it as a run artifact. A new release job, on a tag only, downloads the three and runs gh release upload, creating the release as a draft when it does not exist — so it is published by hand at the same moment as the Central Portal deployment, which is what docs/releasing.md’s checklist already asked for.
  • The publish job, the maven-publish plugin on :example, its two publications and the githubPackages repository are gone, and so is ShowcasePackage in build-logic with its test.
  • The jlink tasks (jlinkImage, showcaseImage) and their launcher scripts are gone from example/build.gradle.
  • The example’s tests move to linux.yml’s linux-x64 verify leg, beside the :core/:widgets/:html suites and the coverage floors, against the same downloaded library. That is the step of the old workflow that ran on every push and found bugs, and it keeps running on every push.
  • PublishWorkflowsTest holds the workflows to this: a tag or a dispatch and nothing else starts the showcase, nothing writes packages, no jlink task exists, and the Linux verify leg runs :example:build.

Consequences

  • A push to master runs one fewer workflow, three fewer runners and no native build. A regression in the native packaging is now found on the next release build or manual run rather than on the next push; native-image reads the generated foreign metadata (ADR-0339) and the declared resources (ADR-0160), and both are unit-tested on every push, which is where the two real image failures of this week would have been caught.
  • The release checklist gains a step: publish the draft GitHub Release beside the Central deployment. A manual run on 2026-09-17 built the three images, and they were checked by hand on all three platforms; the upload to the release is the one step that has not run, because no tag has been pushed yet.
  • The README’s “self-contained image” section now describes the native image. Anyone who wanted the jlink form builds it from the ADR-0048 commit’s recipe; nothing in the toolkit depended on it.

ADR-0341: CodeQL findings are fixed where they are real and answered here where they are not

  • Status: Accepted
  • Date: 2026-09-17
  • Relates to: docs/testing.md §2

Context

codeql.yml (ADR-0338’s advisory half) ran its first scheduled analysis on 2026-09-14 over commit 558e01d0, with the security-and-quality suite on CodeQL 2.27.0. The dashboard filled with 184 findings in tracked files. Read one at a time they are four kinds of thing, and the kinds need different answers:

KindCountWhat they are
Security: comparison-with-wider-type (severity 8.1)4An int loop counter run against a long or double bound
Correctness: index bound, null path, inherited-call, NumberFormatException22Twelve real, ten in code whose input is validated first or where a crash is the answer
Quality: never-read locals and unused parameters14261 pattern bindings named ignored; 81 parameters, of which 71 are contract signatures
Test smells: empty container, unused container, useless null check, new String4Two vacuous assertions, one GC anchor, one deliberate non-identical string
False positives: representation exposure, missing switch case12Compact record constructors that already List.copyOf; multi-label case A, B -> arms the query reads as one label

The alerts themselves are behind the code-scanning API, which needs a token even on a public repository. Nothing in the job log lists them. The way to see them without an account is to run the same CLI and suite locally, which is what was done, and what §2 now records.

Two things made this a decision rather than a cleanup. The workflow’s own comment says the wider suite was chosen because “a false positive costs nothing but a read” — so silencing rules to make the dashboard green would reverse a choice already made. And 142 of the findings are one idiom: a binding the code does not use, spelled ignored because the language had no other spelling when they were written. JDK 22 gave it one (JEP 456), and the toolchain is JDK 25.

Decision

Every finding is answered, and the answer is in code wherever the code can carry it. The four security findings and twelve correctness findings are fixed. The ignored bindings become unnamed variables, _, which is what they were saying. Dead parameters that were merely dead are removed. What remains on the dashboard is listed below with its reason, and the reason is the whole of the answer: no rule is excluded from the suite and no path is excluded from the scan.

What was fixed:

  • Loop counters are as wide as their bounds. ScaleInvariance.resample hoists its Math.ceil bounds to ints before the loops; SdlClipboard counts MIME types with a long; TimeTicks names the quantity it was comparing — how many labels a rung produces, as an int — instead of comparing a double ratio with an int budget.
  • Path’s coordinate stride is a switch over the six verbs, not an array indexed by a masked byte. The array’s bound was true and unprovable; the switch has no bound and refuses an unknown verb the way its callers already do.
  • Transform.mix takes each side from its own list or grows it from the other’s identity in one expression per side, so neither is ever null and no reader has to work out that length is the longer list’s.
  • ParagraphCache’s eviction hook says super.size(). The enclosing cache has a size() too, and an unqualified call inside the map read as either.
  • A NumberFormatException is turned into a refusal that names what was asked where the text came from somebody: the launcher’s --frames= and --size= flags, an SVG points list at build time (which icon, which token), a document’s action argument (which action, what it was given), and the showcase’s md.toggle-task.
  • Two test helpers lose a parameter nothing read, SdlTray.surfaceOf loses an arena it never allocated from, and the try that opened it goes with it.
  • Two tests now assert what their names claim. ImmutabilityTest’s “handlers defeat equality” asserted other != null; it asserts two handlers are two sliders. SelectPopupTest’s “selected row is marked” asserted against a list nothing wrote to; it asserts that choosing the current value is a request the handler hears.

What stays on the dashboard, and why:

FindingWhereWhy it stays
local-variable-is-never-read on _61The binding has no name now, which is the language’s own spelling of “never read”. CodeQL 2.27.0 — and, as of this record, its main branch — still models an unnamed pattern variable as a local with no reads, so the count does not move until the query learns JEP 456. Reverting to ignored would be the same count with a worse spelling
unused-parameter on _ -> lambdas7The same limitation: an unnamed lambda parameter is reported as <anonymous parameter>
unused-parameter on inflate(node, children, wiring)62 widgetsThe factory signature is the markup contract (@Markup, ADR-0130); a text has no children and a spacer no wiring, and a parameter cannot be _
unused-parameter on upcall targetsSdlTray, SdlEventWatch, MeasureCallbackThe signature is the C function pointer’s; userdata and node are what SDL and Yoga pass, not what Java asked for
unused-parameter on interface methods with a documented parameterHandles.onFocusWithin, Input.caretOffsetIn, Input.onFocusChanged, Measure.measure, CodeEditor.focusChanged, AreaEditor.focusChanged, TextEditor.locatedAn implementation that ignores fromKeyboard or clip is one that has nothing to draw differently; the parameter is there for the one that does
internal-representation-exposure9 record compact constructorsEvery one already assigns List.copyOf(...) or Set.copyOf(...) in the compact constructor; the query does not follow the reassignment to the implicit field store. ImmutabilityTest is the real check
missing-case-in-switchMarkdownParser ×2, MarkdownWidgetsThe “missing” constants are the second and third labels of a case A, B, C -> arm; the query reads one label per case. Error Prone’s MissingCasesInEnumSwitch is the check that would fire if a case were missing, and it is blocking
uncaught-number-format-exceptionCssTokenizer ×2, CssColor ×2The digits are validated one character at a time before they are parsed, and parseDouble has no overflow; there is no input that throws
uncaught-number-format-exception in tests6A test that parses what it wrote should fail loudly if the text is not a number; a catch would hide the failure
unused-containerBindingSchemeBenchmark.aliveThe list exists to be held, not read: it keeps models reachable so the population a benchmark line reports is exact
inefficient-string-constructorPropertyTestnew String("frost") is the point: equal, not identical, is still unchanged

Added by the second triage, 2026-09-30 (docs/static-analysis-plan.md, ADR-0498). The same kinds in code written since, then kinds this table did not have:

FindingWhereWhy it stays
unused-parameter on upcall targetsGlibLog ×2, SdlLog, IoCallbacks ×2, AudioToolboxDecoder ×2, VideoToolboxDecoder ×3The C callback’s signature, as for SdlTray above: GLib’s user_data, FFmpeg’s opaque, and the refCons and duration VideoToolbox and AudioToolbox pass whether or not Java wants them
uncaught-number-format-exceptionimage.qr.Segment ×2, GraalVmReleaseThe text is matched first: a numeric segment is digits by construction and cut at three at a time, and a GraalVM version must match its pattern before any part is parsed
internal-representation-exposure18 record and class constructors, among them MediaInfo, MediaError.UnsupportedCodec, Source, ShaderCode, TextureSpec, VertexBufferLayout, UriList, TextDropAs above: each copies with List.copyOf, Map.copyOf or clone() before it stores, including the explicit canonical constructors ADR-0497 introduced
missing-case-in-switchPlayback ×2Multi-label case A, B -> arms, as for MarkdownParser
random-used-onceDrawTest, GpuApiTest, SdlGpuDeviceTest ×4A seeded Random made for one fixture is the point: the same pixels every run, and a new seed per test so no test depends on another’s draws
potentially-weak-cryptographic-algorithmmedia test Fixtures.md5A frame’s fingerprint in a test, compared with the same function’s output. Nothing is secured by it
constant-comparisonMemoryIO.awaitReleasereleases == entered is re-read after every wait(), which the query does not model as a write by another thread
empty-zip-file-entryCatalogCompilerTestThe entry is a directory, which is what the test’s archive needs to have

Alternatives considered

  • A query-filters: block excluding java/unused-parameter. It would remove 78 findings and the three that were real with them, and it reverses the workflow’s own reasoning about breadth. If the noise is ever the problem the fix is a filter scoped to the rule, and it is one line; it is not taken here because nothing has been read yet that the noise hid.
  • Dismissing the false positives in the GitHub UI. Right for what they are, and the account holder can; a dismissal is not in the repository, so this record is what says why, and a re-scan on a new default branch would bring them back without it.
  • Renaming contract parameters to unused/ignored. The query does not read names, so the finding stays and the signature reads worse.
  • Splitting case A, B -> arms to satisfy the switch query. Duplicates the body or adds an empty arm to work around a query limitation; the compiler and Error Prone already have the exhaustiveness question.
  • paths-ignore for src/test. Removes eleven findings and the two that found vacuous assertions. Tests are code.

Consequences

  • Updated 2026-09-30: a local re-run after the second triage has 308, all of them kinds in the two tables. 151 are _, and 102 are unused parameters, 72 of them inflate’s. The count grew with the code, not with new kinds of finding. The original record follows.
  • The dashboard settles at 163 findings — the local re-run says so — and every one of them is in the table above; 68 of them are the _ spelling CodeQL does not read yet, and go the day it does. A new finding is therefore something to read, which is what an advisory dashboard is for.
  • _ is the spelling for a binding nothing reads, in type patterns (case Close _ ->), record patterns (Link(var _, var _, var text)), lambda parameters (_ -> revalidate()) and side-effect-only locals (var _ = calls.showCursor().call()). One cost, found the first time: palantir 2.97 drops a bare _ inside a record pattern and fails its own lint, so the record-pattern form is var _. A future formatter may allow the bare form; until then the convention is var _ inside parentheses and _ elsewhere.
  • Four refusals have messages that name a flag, an icon’s point list, an action or a document ordinal. Each is a small contract and each has a test.
  • The Path stride is a switch on every replay, where it was an array read. It compiles to a jump table over six constants; FrameBudgetTest is the place a difference would show, and it was not re-run for this — it is a nightly number, not a gate.
  • Reproducing the scan locally is a documented, tokenless path (§2), which is what makes the next triage a morning rather than a request for access.

342. A window is resized from outside, and the run says what it cost

Date: 2026-09-17

Status

Accepted. Closes the frame-evidence half of M5 in status.md; the number M1’s claim was waiting on is now a line in a log and a ceiling in showcase.yml.

Context

M1 claims a paragraph resized at 60 fps, and nothing could measure it. Three things were missing, in the order the work fell:

  • Nothing could resize a window but a hand. SDL_SetWindowSize was bound in :natives and used for popups, and BackendWindow never exposed it for a window — so an application could not size its own window after opening it, and a CI run could not drive one. The load that matters is a drag, which is a resize event per pointer motion, each a pixel or two from the last: it is what found the damage-clamp bug (ADR-0072) and what a frame loop has to be measured under. A jump from one size to another is one reallocation and says nothing.
  • Nothing said what a run cost. FrameRing keeps the last sixty frames, because that is what a HUD wants to watch (ADR-0146, ADR-0153); a run that exits after three hundred wants every frame it painted and every refresh it missed (ADR-0271), and the ring had forgotten most of both by the end.
  • Nothing failed. showcase.yml opened a window on three runners and asserted three frames were drawn — a smoke test of the packaging.

Building the first found a fourth. Launcher.run registered its own window.onResize and window.onMove after Application.start, into the one handler slot a window has. An application’s handler was replaced without a word. The showcase’s resized to … line, written in start, had never once fired; the comment eleven lines further down, on onSystemThemeChanged, explains exactly this hazard for the theme slot and takes it first for that reason.

And the second draft of the walk found a fifth. Asking for the resize from inside the painter worked on X11, where the window manager answers a request later, and failed everywhere SDL_SetWindowSize takes effect on the spot — the headless backend’s first draft, the dummy driver, and, by SDL’s documentation, Windows and macOS: the size changed under the frame being painted, the platform refused the frame, and every frame of the run was counted late. Fifty-nine of sixty, on the first headless run.

Decision

A resize is a request on the SPI, the walk steps between frames, the ring keeps totals, and the showcase runs under the load and fails over budget.

  • BackendWindow.resize(LogicalSize), defaulting to nothing, is what BackendPopup.resize already was, made available to a window — the popup’s declaration now overrides it. Sdl3Window hands it to SDL_SetWindowSize, rounded, and reports nothing itself: the compositor answers with a Resized and size() reads what it decided. HeadlessWindow plays the window manager the way HeadlessPopup already did — clamps to the floor, posts the event, and applies the size as the event is delivered, so a caller that measured straight after the call would be as wrong here as on two of the three desktops. The mechanism moved up from the popup; HeadlessBackend delivers it for either. Window.resize is the public face, ignored on a closed window.
  • --resize=WxH walks the window a pixel a frame on each axis from its opening size to WxH and back, for as long as the run lasts. ResizeWalk, in a new drive package, is told the window’s current size on every step, so a manager that clamped, rounded or lagged the last request is walked from its answer rather than from the ask. The step is scheduled on a zero-delay timer, which runs on the next pump after the frame has been presented.
  • FrameRing keeps three totals beside the window — late refreshes, paint time and the worst frame — and FrameStats.summary() hands them out as a FrameSummary. The launcher logs frames: 300 frame(s) painted, 2 late; paint mean 1.31 ms, worst 8.90 ms; display 60.0 Hz after the window has closed, in Locale.ROOT, because a workflow greps it.
  • --late-budget=N makes that a verdict: past N late refreshes the launcher throws FrameBudgetException after shutdown, so the process exits non-zero with the summary in its message and nothing left open.
  • showcase.yml runs the native image on each platform for 300 frames with --resize=1580x1100 --late-budget=30, greps the three-hundredth frame, and writes the summary line into the step summary. The X server is 1920×1200 so the walk has room.
  • The launcher’s resize and move hooks are its own. Window gained package-private launcherOnResize and launcherOnMove, run before the application’s handler; onResize and onMove are the application’s alone.

The caveat, written down with the numbers

GitHub’s runners are GPU-less virtual machines. On Linux the image paints into Xvfb, which reports no refresh rate, so the pacer does not pace and no refresh can be missed — a Linux run can only be late by refusing frames. macOS and Windows have a compositor and a rate. Measuring there is real evidence about three platforms’ drivers, and far better than one VirtualBox VM, but it is not a claim about hardware: a run over budget has regressed, and a run well under it has not proved 60 fps on a desktop. The budget is a tenth of the frames, chosen as a ceiling and not a target, and the summary line is the number.

The walk’s only run so far is headless on this machine, in a Gradle-launched JVM: 60 frames, 0 late, paint mean 22 ms with a worst of 446 ms — the JIT warming up, and the reason the mean is not a claim either.

Consequences

  • An application can size its own window: host.window().resize(size).
  • An application’s onResize and onMove handlers survive start. Anything that relied on them not firing was relying on a bug.
  • --frames, --size, --resize and --late-budget are the launcher’s four flags; everything else on the command line is the application’s.
  • A frame the platform refuses because the window changed under it is still a late frame, and a driver that changes the size synchronously will show it. Anything that resizes a window from inside a painter is doing it wrong.
  • The Showcase workflow can go red for a reason a diff did not cause. That is the point of a ceiling, and the summary line says by how much.

343. The published javadoc is linted

Date: 2026-09-17

Status

Accepted. Closes TODO.md’s “the published javadoc is built with doclint off”.

Context

goldberry.publish turned doclint off for the javadoc jar Central requires, with a comment counting ~120 errors and naming two causes. With the lint on and every published module built, the count was exact — 120 — and the causes were the two the comment named, in different proportions than it guessed:

KindCountWhere
invalid use of @param / @return100 (the cap; 425 tag lines in fact):natives’ …Calls holders
reference not found20:core, :widgets, :html, :natives

The first is one idiom. Every foreign function in :natives is a nested public static final class with one call method, and the class’s /// comment carried the @param and @return lines for that method. Doclint reads a @param on a class as a type parameter that does not exist. The second is twenty [Foo] links to a type in another package — resolvable in an IDE, which follows imports the way a reader does, and not by javadoc, which needs the qualified name or an import the file did not have. Three of them were also wrong: a method renamed (repeat() for isRepeat(), model() for models()), a signature that had grown a parameter, and a class that does not exist (ItemCheck, for what is ItemLead).

Beside the errors were 412 warnings, all in doclint’s missing group: no @param for this parameter, no @return, no comment on this member.

Decision

The errors are fixed at the source and the lint stays on, less missing.

  • The 425 tag lines moved from the holder class to its call method, under a one-line summary naming the C function — mechanically, one script over thirty files, checked by the lint that would have refused a name that no longer matched a parameter.
  • The twenty references are qualified, corrected, or — where the type is in a module this one cannot see, Backend from :natives or ListView from :core — turned into a code span, which is what they always were.
  • goldberry.publish passes -Xdoclint:all,-missing. missing is left out on purpose: it wants a @param x the x for every parameter, and this codebase documents in prose. A /// sentence that says what a method does is the documentation, and four hundred tag lines restating parameter names would be noise that hides the sentence. The three other groups — accessibility, html, reference, syntax — are what catch a link that rots or a tag on the wrong thing, and those are on.

Two things worth knowing for next time. Palantir’s formatter wraps a /// line over 120 characters by breaking it, and the continuation is not a comment — the compiler error is <identifier> expected on the line after. A qualified name pushed three lines over, each a build failure until rewrapped by hand. And a 2>&1 > file redirect sends javadoc’s errors to the terminal and nothing to the file; the order is > file 2>&1.

Consequences

  • ./gradlew javadoc fails on a broken [link] or a misplaced tag, on every module that publishes. The release’s last step cannot be the first to find one.
  • A new …Calls holder documents its call method, not its class.
  • A link to a type in another package is spelled in full; a link to a type in another module is a code span.

344. A list of steps writes where each one stands, and a wizard moves nothing

Date: 2026-09-17

Status

Accepted. Builds docs/core-widgets.md §6’s steps and wizard, the two nav widgets that status.md listed as “not started” after breadcrumbs. The connector’s colour fill is superseded by ADR-0356: the subset did have a transform-origin.

Context

§6 specifies three widgets over one model — “an ordered list of steps, a current index, and which of them are reachable” — and breadcrumbs was the first (ADR-0306). Its one hard-won rule was that the trail decides which crumb is current and a document cannot: an invariant written on every build rather than hoped for. steps has the same invariant with one more word in it, and wizard is steps plus a page plus a bar with, as §6 puts it, “no validation, no navigation policy and no data”.

Two sentences in §6 needed a decision rather than a transcription. “The widget never decides reachability itself, because only the application knows whether step 3 is valid yet” — so a press has to be gated twice, once by the list and once by the step. And “advancing moves focus to the new step’s first control, because a keyboard user who pressed Next and stayed on the button has not moved” — which is a thing a widget cannot describe and has to ask for.

Decision

The list writes index, count and state onto every step; a step keeps error and reachable for itself; a press needs both the list and the step to allow it; and a wizard reports Back, Next and Finish and moves nothing.

  • steps is a Widget.Stateless composition building a StepList (the CSS type, per ADR-0109). On every build it hands each Step its position, the total, and a StepState — DONE before the index, CURRENT at it, UPCOMING after — unless the step says error, which overrides all three: a step that failed is neither done nor merely upcoming, wherever the index is. The current step is :checked and every state is also a class, because a stylesheet wants to colour four and :checked names one. A StepConnector between each pair is filled behind a done step, so the line is drawn from where you have been and stops where you are.
  • A step is pressable only when the list is clickable and the step is reachable, and then reports its index through change, as a number. Otherwise it is a picture: it takes no focus, because a row of Tab stops that do nothing is worse than none. current is read through bind or written by the application (ADR-0063); the list moves nothing.
  • The marker carries the state without colour. A done step has a tick, a failed one a cross, the other two their number — §6’s “colour alone cannot carry error”, and the accessible name says it too: Payment, step 2 of 4, current. The number is a child of the disc rather than its own text, so the stylesheet’s centring applies to it — the first golden had every number in the disc’s top-left corner. The label is the same shape for the same reason: a cell as tall as the disc with its text centred, because a padding that assumed the line-height was wrong by two pixels at the theme’s 18px and would have been wrong again at every density.
  • wizard is Widget.Stateful with one fact in its state: the page it last showed. It makes one Step per WizardPage and hands them to the standalone Steps — “a wizard’s indicator is the standalone one and cannot drift from it” — builds the current page’s children into a WizardContent and nothing for the others, and writes Back then Next-or-Finish into a WizardActions bar. Back is disabled rather than absent on the first page, so the bar does not change shape; a wizard given no back= has no Back at all. The bar is dialog-actions under another name: canonical order, affirmative-right, and a Windows theme reverses it with one declaration.
  • Advancing asks for focus. When the current index changes under a mounted wizard, the state asks the host to focus the content area on a zero-delay timer, exactly as a dialog does on opening (ADR-0176); the content node takes no focus itself, so Host#focus resolves to the first control inside. Not on the first build — a window opening on page one must not take the keyboard — and the timer is cancelled on unmount. The content’s id is the wizard’s own with -content after it, or a name of the state’s own for a wizard the document did not name, because a focus request needs an id.
  • A wizard’s indicator is a picture unless goTo is wired, in which case a reachable step forwards its index — the one way to move backwards two pages at once, and still the application’s move to make.

Consequences

  • steps, step, wizard and page are markup; step-marker, step-body, step-label, step-description, step-connector, wizard-content and wizard-actions are parts, CSS-selectable and not constructible.
  • §3’s “connector fill transform: scaleX base” is a colour transition instead: a line that grows from one end needs a transform origin the subset does not express. book/src/TODO.md has it.
  • Role has no list; the container answers GROUP and the items ROW, as breadcrumbs does, until the AccessKit bridge.
  • The showcase’s Navigation screen has a card with both, on one index.

345. A timeline is a list whose line goes on

Date: 2026-09-17

Status

Accepted. Builds docs/core-widgets.md §10’s timeline. A badge as the marker, left unbuilt here, is ADR-0356’s marker slot.

Context

§10 specifies timeline in three sentences and one of them is the whole widget: “pending=#true renders a trailing unfilled marker for ‘and then what happens next’, which is what distinguishes a timeline from a list with dots.” The rest — a marker, a label, a timestamp, a body, direction, align — is what a list row would carry too. The axis is the widget.

Two things had to be worked out on the way. A line between markers has to run the full height of whatever is beside it, which a gap on the list would break; and an alternating timeline has to keep its axis in one place whatever is on either side of it, which a two-part row cannot do and §8’s subset has no flex-basis to do it with.

Decision

A timeline is a column of entries, each a rail beside a side; the rail stretches, the line after the last marker is drawn only when the story goes on, and an alternating timeline gives every entry both sides at half width.

  • Timeline is Widget.Stateless, building a TimelineList (ADR-0109) that carries horizontal and alternate as classes. On every build it writes a Placement onto each Entry: its index, the direction, which side it sits on, whether the list is two-sided, and whether the line continues past it — true for every entry but the last, and for the last too when pending. A pending timeline gets one more entry with no words, whose marker is a ring.
  • An entry is a row of the rail and a side, and the gap between entries is the body’s bottom padding rather than a gap on the list, so the rail’s line runs from marker to marker unbroken. The rail is a column of the marker and, when the line continues, a TimelineLine that grows to the entry’s height.
  • Alternating is three columns. Every entry gets a side, the rail and a side, with the words in one and the other empty; both sides are width: 50% with an equal shrink, which is the arithmetic flex-basis: 0; flex-grow: 1 would have done. The first cut gave only the crossed-over entries a second side, and the axis wandered.
  • The marker sits in a cell one line tall. timeline-marker-cell is min-height: var(--gb-line-body) with the dot centred, so the dot is on the head’s centre whatever the token is; the first cut padded the rail by four pixels for a line-height of twenty that was eighteen.
  • The marker is a dot or an icon. A dot takes the entry’s colour or the stylesheet’s, which is chip’s rule for a dot (ADR-0328); an icon sits in a larger disc. A badge as the marker is not built: a marker that is a widget needs a slot markup can name, and nothing else in the catalog has one yet.
  • The line is a drawing. Each entry answers ROW and is named by its label and its time; the rail, the marker and the line carry no semantics, which is §10’s “the connecting line is a drawing and is not announced”.

Consequences

  • timeline and entry are markup; timeline-rail, timeline-marker, timeline-line, timeline-side, timeline-body, timeline-head, timeline-label, timeline-time and timeline-content are parts.
  • A record with a children component and a Widget.Leaf override of children() hands the parts out through the accessor the component was meant to have. Entry calls its content body; the parts call theirs content. Worth knowing before the next widget does it.
  • The showcase’s Collections screen has a timeline with a pending marker and a coloured dot.

346. A link is a word, and the desktop opens the rest

Date: 2026-09-17

Status

Accepted. Builds docs/core-widgets.md §2’s link, and binds SDL_OpenURL to open its href through.

Context

§2’s link was unblocked when text-decoration landed (ADR-0321) and stayed unwritten. It is “text that does something”: action= for in-app navigation, href= for an external target “opened through the platform”, visited as the application’s word, focusable and in the Tab order, Enter activates, an underline on hover, and — the sentence with the decision in it — “external links carry a trailing 12px external-link icon and their accessible name says so, because ‘opens outside this window’ is not something a colour can convey”.

Two things stood in the way. Nothing in the toolkit could open a URL: the deep-link direction is not Goldberry’s (ADR-0291), but the outbound one is one SDL call on every platform and was not bound. And an Icon is a value a widget must not own (ADR-0043) — every icon in the catalog is the application’s — while this widget has to make one of its own.

Decision

link is a stateful word; its href goes to the desktop through Host#openExternal, which is SDL_OpenURL; and the icon is the state’s.

  • SDL_OpenURL joins the export list as an optional symbol, like the theme call (ADR-0322): a libgoldberry built before it must keep opening windows, and “the platform would not” is an answer a link already has to handle. Backend.openUrl defaults to false; the SDL backend hands the string over; the headless one records it and answers true. Host.openExternal is the door, with a default of false so the test hosts keep compiling. It is a request, not a result: SDL returns once the desktop has been asked, and on Linux it hands even an empty string to xdg-open and says yes — so there is no “refuses nonsense” test, because it would open nonsense.
  • Link is Widget.Stateful building a LinkText (the CSS type, ADR-0109). The state holds the host and, for an external link, the external-link icon at 12px, made on the first build and closed on dispose — the one lifetime an icon can have inside the toolkit. A press runs the action, opens the target, or both; a link with neither is a word in the link ink that takes no focus. A refused open is logged, not thrown: a link that silently did nothing is the one failure a user cannot tell from a missed click.
  • Enter activates and Space does not, which is §2’s word and every browser’s: Space scrolls a page. visited and external are classes; the underline on hover is the stylesheet’s, through text-decoration.
  • It is block-level. A row of a word and, sometimes, an icon, so the icon sits beside the word in the same ink without joining its text run. A link mid-sentence is text’s span class="link", as §2 says.
  • Role.BUTTON, for crumb’s reason: Role has no link, a role nothing can consume is a value written for a bridge that does not exist, and “something you press to make it happen” is true of a link. The accessible name of an external one ends with “opens outside this window”.

Consequences

  • link is in Primitives.builtInTypes(), beside text, and the parity and immutability tests hand both a word, since neither exists without one.
  • An application can open a URL: host.openExternal("https://…").
  • libgoldberry has to be rebuilt to export the symbol; a stale one answers false and the link logs it.
  • The showcase’s Basic screen has three links, one of them external.

347. An icon-only button is a circle, and float is a place

Date: 2026-09-17

Status

Accepted. Builds the four button options §3 listed as not built after ADR-0293: outlined, square, circle and float.

Context

§3 gives button two shape classes and an outline, and says of the shape that “a button given an icon and no label is a circle by default — an icon-only button is a disc everywhere else in the canon, and requiring class="circle" to get the obvious result is the kind of improvisation Principle 3 exists to prevent; class="square" overrides it”. It gives float=#true as the floating action button, “a class of placement, not of appearance”, pinned to a window corner at the window margin with elevation 1.

Three of the four are stylesheet rules. The default circle is one line of logic. float is the one that needed a design: a widget is a value inside a tree, and a floating button is not in that tree — it is in the window’s overlay layer (ADR-0100), which only a Host can reach.

Decision

The shapes and the outline are classes; an icon-only button adds circle unless told square or circle; and float is a stateful wrapper that puts the button in the overlay layer and builds nothing in place.

  • button.outlined is a transparent fill, a 1px --gb-border and the text ink, and it composes: button.outlined.danger changes the border and the ink together, button.outlined.primary takes the accent. button.square is radius 0; button.circle is a full radius on a box as wide as it is tall. Button#classes() adds circle when the label is empty, there is an icon, and neither shape was written.
  • Floated(button, corner) is Widget.Stateful. Its state attaches the button — with float added to its classes — through Host#overlay on the first build and removes it on unmount; button float=#true corner=… inflates to one, and Widget.nothing() is what stands in the tree. The handler is read at the press, not at the attach: a lambda is a new object on every build, so an equality that included it would take the button down and put it back every frame, losing its hover and focus each time. The overlay is re-attached only when the word, the icon, the disablement, the attributes or the corner change; the press forwards to the latest description’s handler.
  • button.float’s elevation is an edge, as on every floating surface in the sheet; a shadow here would be the first. The 0.9→1 scale on the way in is not built: the overlay layer has no entering state for a transition to run from. book/src/TODO.md has it.

Consequences

  • Button.SQUARE, CIRCLE, OUTLINED and FLOAT name the classes.
  • The button-icon golden changed: its icon-only button is a disc now, which is the rule taking effect on the one picture that had it wrong.
  • The base stylesheet’s comments may not contain a # — ButtonTest reads one as a colour literal — so float=true is spelled without KDL’s hash there.
  • A floating button described in a card is drawn in the window’s corner; the headless gallery, which has no host, draws it nowhere, so the showcase golden shows the card and not the button.
  • The showcase’s Basic screen has a card with all four.

348. A canvas asks for its next frame with what it was painted with

Date: 2026-09-17

Status

Accepted. Closes docs/gaps.md G41. Extends ADR-0081 and ADR-0228.

Context

§1.7’s frame loop is idle when nothing moves. A widget that draws from the clock keeps it turning through Paints.isAnimating(), which is how a spinner stays a spinner (ADR-0081). Canvas did not override it, so a canvas painted from CanvasStyle.nowMillis() was painted once. An application’s tile floor that settles, with each tile dropping in on a stagger, showed only its first frame.

An application cannot close this on its own. Implementing Paints itself means writing a second Canvas. A timer that calls host.repaint() repaints the whole window on a clock the frame pacer cannot see, so ADR-0271’s lateness measurement would count every one of those frames as late.

The question also cannot be a boolean on the description. A settle ends by itself when the last tile lands, and “has it landed” depends on the time. A boolean would make the application rebuild the widget to turn it off.

Decision

Canvas.animating(Predicate<CanvasStyle>), asked by the renderer once per frame, straight after render, with the CanvasStyle the painter was given.

  • Paints gains isAnimating(ComputedStyle, Context), defaulting to the no-argument form. Every existing widget keeps its answer untouched, and the renderer calls only the new form.
  • The renderer asks it inside the same window render runs in, before currentElement is cleared, so a context read by the answer is still this node’s. That is the rule ADR-0288 set for binding a painter, applied to a second reader.
  • It is still asked after render, which is ADR-0228’s rule. The frame that draws the last tile landing answers false, and the loop goes quiet on the next frame rather than one frame later.
  • The canvas snapshots context.canvasStyle(style) again for the predicate instead of keeping the painter’s copy. It is four values in a record, and keeping the copy would mean state on a record that has none.
  • Reduced motion is the painter’s to honour. The predicate is handed reducedMotion(), and style -> !style.reducedMotion() is a loop that stops for a user who asked it to. The canvas does not guess, because a still drawing that needs no animation and one that loops are both legitimate for such a user.

Consequences

  • Canvas is a four-component record. The three-component constructor stays, so every call site written before this compiles unchanged.
  • new Canvas(floor::paint).animating(style -> style.nowMillis() - mounted < 2000) is the whole of an animated canvas. The showcase’s Motion screen is one (ADR-0354).
  • Markup still names no painter, so animating has no markup form, for the reason the painter has none.

349. A face an application ships is found after the bundled ones

Date: 2026-09-17

Status

Accepted. Closes docs/gaps.md G39. Extends ADR-0066 and ADR-0323.

Context

An application’s landing page sets display text in Forum and running text in Golos Text. Its desktop client should use the same two faces. A stylesheet could name only the faces BundledFont enumerates, so font-family: Forum resolved to nothing and the title was drawn in Inter.

A canvas could open the bytes with Font.of(byte[], size) and draw a title itself. That title would not select, wrap or follow font-size like every other label, which makes it a second text stack. A face has to reach four places together: the cascade, Paragraph layout, the text-input caret and the glyph cache. All four already go through one Fonts book, so the book is where a face has to be added.

Decision

FontSource describes a shipped face. Application.fonts() lists them, and Fonts.bundled(List<FontSource>) searches them after the bundled faces.

  • assets.Face is a sealed interface over BundledFont and FontSource: a family, one of the two weights, and upright or italic. Face.match is the matching rule BundledFont.of already had (family, then style, then weight, then the family’s upright regular), moved so that both kinds share it. With two copies, a shipped family would fall back differently from Inter the first time one of them changed.
  • The matrix stays closed. A shipped face is one of the two weights and one of the two styles. §1.4 ships two weights, and Principle 3 applies to an application’s faces too.
  • Bundled first. Fonts.of(Typography) asks BundledFont.of and only then the shipped list, so a file an application calls Inter is never drawn. It is logged when the book is opened, rather than refused, because the stylesheet still gets the face it named. Two sources for the same corner are refused, since one of them could never be drawn and nothing could say which.
  • Lazy, and forgiving at draw time. The bytes are a Supplier<byte[]>, read the first time the face is asked for. FontSource.resource(…) reads a resource beside a class, the way Stylesheet.resource does. A source that cannot be read or parsed is logged once, remembered as unusable and drawn in the UI face. That happens inside a render pass, where a thrown exception would mean a window with no text.
  • Read once. The launcher opens its book with application.fonts() before start, like the stylesheets. Offscreen.fonts(List<FontSource>) does the same for a render, so a render test paints what the window paints.

Consequences

  • font-family: Forum works everywhere a bundled family does: in labels, fields, the caret, canvas painters (through CanvasStyle.font()), markdown-view and offscreen renders.
  • BundledFont.of keeps its signature and its answers. BundledFontTest, ItalicFaceTest and FontsTest pass unchanged.
  • There is no markup form. Faces belong to the application, like its stylesheets.
  • Font licences are the application’s to ship. The toolkit’s THIRD-PARTY-NOTICES.md covers only what the toolkit bundles.

350. A gutter strip is outside the clip, and its numbers are inside it

Date: 2026-09-17

Status

Accepted. Closes docs/gaps.md G43, a defect in ADR-0331. Relies on ADR-0272’s ContainingBlock. text-input’s matching arithmetic is fixed by ADR-0355.

Context

An application reported two things about text-area gutter=#true.

The strip did not reach the padding. With padding: 12px 16px, the filled column started 12px down and 16px in, and a band of the field’s own background showed above it and to its left. The insets were right: -padding on three edges, which ContainingBlock shifts back to the border box. The clip cut the strip. RenderTree.clipFor clips a box’s children to the box less its padding, which is the content box, and a field is overflow: hidden so that scrolled text stops at the content box. The strip was a child like the text, so the same clip cut it by exactly the padding.

Changing the clip for every box is not the fix. Borders here are drawn inside the padding rather than laid out, so “inside the border” is not an edge the render tree knows, and every scroll view, field and golden is built on the clip as it is.

The numbers were said to drift after a wrap. The report was that with --gb-gutter-gap other than 8 and a non-zero left padding, numbers sat one visual line too high after the first soft wrap. That could not be reproduced against this checkout. It was rendered with each combination the report named, and the numbers and the text wrap at one width in every case. What the reproduction did find is a real width error beside it: TextAreaState.contentWidth() subtracted 2 * leftPadding. That is right for every stylesheet the toolkit ships and wrong for padding: 12px 16px 12px 4px, where the text wrapped 12px wider than its room and ran under the right padding. visibleRows() did the same with 2 * topPadding.

Decision

The field draws two layers. A content layer is pinned to the content box and clips, and the strip is its sibling under a field that does not clip. The wrap subtracts each padding edge once.

  • The content layer is an absolute box with a zero inset on all four edges. ContainingBlock shifts each edge by the field’s padding, which lands the box on the content box. It has no padding of its own, so every part inside it (the washes, the text, the caret, the composition’s rules and the numbers) is placed in exactly the coordinates it was before. It is overflow: hidden, which is the clip the field used to have.
  • The strip sits beside that layer and is inset from the border’s inner edge: each inset is borderWidth - padding. The field no longer clips it, and it would otherwise paint over the field’s 1px edge. Its two leading corners take the field’s radius less the border width, so a rounded field keeps its curve. Those corners override any radius a stylesheet gives text-area-gutter, which has no shipped rule for one.
  • AreaPadding carries all four edges from render to the state, replacing the two-edge record. The wrap subtracts left + right and the visible height subtracts top + bottom.

Consequences

  • text-area-gutter { background: … } fills the column from border to border, and the stopgap background: transparent in the reporting application can go.
  • A field with asymmetric padding wraps inside its room. TextAreaGutterStripTest checks the right padding for ink across five padding and gap combinations, and checks that scrolled text still stops at the top padding.
  • gallery-forms and gallery-forms-light changed where the numbered area’s strip now fills the padding, and nowhere else.
  • The box tree under a text-area is one level deeper. Tests that walk it look in the last child, as TextAreaGutterTest does now.
  • text-input still computes its room as width - 2 * leftPadding. Its padding is symmetric in every shipped stylesheet, and the same asymmetry would scroll the text 12px late rather than wrap it wrong. It is filed in TODO.md rather than widened into this change.
  • If the drift in the report came from a published snapshot older than this checkout, this ADR does not close it. The reporter’s own rendering test is the check.

351. A window icon is several sizes, and the backend picks the base

Date: 2026-09-17

Status

Accepted. Closes docs/gaps.md G40. Binds two more SDL symbols, optional in the way ADR-0346’s SDL_OpenURL is.

Context

A window run from a jar or gradlew run showed the platform’s generic icon in the taskbar, the dock and the window switcher. Installer packaging puts an icon in the launcher, but a running window has only what the toolkit sets on it, and the toolkit set nothing.

Setting one is SDL_SetWindowIcon, a platform call behind the backend (ADR-0004). SDL’s model for sizes decides the shape of the API. The surface passed is the 100% display scale picture, and other sizes are hung off it with SDL_AddSurfaceAlternateImage for high-DPI displays. The pinned SDL source shows that the platforms do not read these the same way:

  • X11 copies the base surface alone into _NET_WM_ICON.
  • Wayland sends every size through xdg-toplevel-icon, and compositors without the protocol refuse.
  • Windows builds an HICON from the base and the alternates.
  • macOS shows the bundle’s icon in the dock whatever the window says.

And SDL_PIXELFORMAT_ARGB8888 is straight alpha. The toolkit paints in premultiplied BGRA, so passing its buffers through would dim every anti-aliased edge of a mark by its own coverage.

Decision

Application.icon() returns several sizes of one picture as Images. The launcher sets them before start, and the SDL backend chooses the base.

  • Image, not a path. Image.decode already exists, and an application decodes from its own resources.
  • A list, not one image. Windows wants 16 and 32, a dock 48 or more, and a scaled-down PNG is the blur a hand-drawn icon set exists to avoid.
  • render.window.IconImage is one size in straight-alpha, native-order ARGB in a direct buffer, read through Image.argb, which unpremultiplies. Window.icon(List<Image>) converts, and BackendWindow.setIcon(List<IconImage>) receives. It defaults to false, the headless backend records the sizes, and the SDL backend hands them over.
  • The base is the smallest size at least 48 pixels wide, or the largest when none is. X11 reads only the base, and a dock scales it, so 48 is the smallest base that a dock does not blur. On Windows and Wayland the alternates cover the other scales. This rule is the backend’s because the reason for it is SDL’s, and a caller passes sizes in any order.
  • SDL_SetWindowIcon and SDL_AddSurfaceAlternateImage are optional symbols. A library built before them keeps opening windows, and the answer is false. Every surface is a view over Java’s buffer and is destroyed before SdlVideo.setWindowIcon returns. SDL converts the base and its alternates into its own copy first, and the pinned source confirms that SDL_ConvertSurface carries the alternates.
  • False is an answer. A Wayland compositor without the protocol refuses, and so does macOS. The launcher logs it at debug level and does not warn, because the window works and the desktop file or the bundle provides the icon.

Consequences

  • libgoldberry has to be rebuilt to export the two symbols. A stale library shows the generic icon.
  • The showcase has an icon, example.brand.ShowcaseIcon: four tiles on a rounded plate, computed at 16, 32, 48 and 256 pixels, so the example ships no binary assets to demonstrate this.
  • There is no Host method to change the icon at runtime, for example to show an unread badge. Window.icon exists, so an application can reach it through host.window(), and a Host method is one line when something asks for it.

352. An element enters from its starting style

Date: 2026-09-17

Status

Accepted. Extends ADR-0067. Builds design-system.md §3.1’s button[float] entrance, which ADR-0347 left unbuilt. The way out it left unbuilt is built by ADR-0355.

Context

ADR-0067 decided that an element’s first frame starts no transition. There is no previous style to move from, and a window that faded every control in from black as it opened would be wrong. That rule is still right for nearly everything.

It also left no way to say “this particular element arrives”. §3.1 specifies button[float] as “in: opacity + scale 0.9→1, base”, and ADR-0347 could not build it because the overlay layer mounts a widget already at rest. The same gap kept check-mark and radio-dot mounted and invisible in every unchecked control. A mark that came into existence checked would have snapped instead of growing in.

CSS has an answer to this exact question, and it is not a new lifecycle API: @starting-style. Rules inside it describe the style an element has before its first style, so the transitions the element already declares run from there.

Decision

@starting-style { rules } is in the subset. On an element’s first styled frame, if a starting rule matches it, the renderer observes the starting style first and the element’s real style second, so its declared transitions run from the one to the other.

  • Parser. The block form at the top level of a sheet, with no prelude. Its rules are ordinary StyleRules with starting = true, numbered in source order among the other rules. A nested at-rule or a prelude is refused.
  • Cascade. StyleResolver puts starting rules in buckets of their own. They are never part of resolve, so a starting rule can never leak into the style an element has. resolveStarting(element) returns null unless a starting rule matches. Otherwise it runs the ordinary cascade with the matching starting rules added in their source positions. That is CSS’s “before-change style”: a starting rule that says only opacity: 0 leaves every other property where the element’s rules put it. var() is substituted against the element’s own custom properties, and not against any a starting rule declares, which would otherwise be cached as the element’s for every later frame.
  • Renderer. Element.firstStyled() answers true exactly once per element. On that frame, if the element’s style declares transitions and a starting style resolves, Animations.observe(starting) runs before observe(style). The widget’s restyle is applied to the starting style too, so an inline value such as a segmented indicator’s position does not appear to move.
  • No transition, no entrance, as in CSS. A starting style only chooses where transitions start from.
  • Reduced motion reduces the starting style’s transitions as it reduces every other style’s, so the element arrives at once.
  • Once. A rebuild keeps the element, so the element does not enter again. A keyed element that moves keeps its element. An element that is unmounted and mounted again is a new element, and it enters again.

Consequences

  • button.float enters from opacity: 0; transform: scale(0.9) over --gb-motion-base. Its press still snaps because of a button.float:active rule, since the float rule would otherwise out-rank button:active by source order. FloatEntranceTest pins the frames.
  • A sheet with no starting rules pays one boolean per element per frame. hasStartingStyles() keeps the second cascade off the first frame entirely.
  • The way out is still not built. An overlay that is removed has no frame left to transition in, and that is §1.7’s closing phase rather than a stylesheet’s.
  • check-mark and radio-dot could now be built only while checked and enter from a starting style. They are left as they are, because nothing about them is wrong and a golden would move for no visible gain.

353. A stylesheet may name keyframes

Date: 2026-09-17

Status

Accepted. Reverses one sentence of ADR-0081 (“§8’s CSS subset has no @keyframes and is not going to grow one”) and keeps the rest of it. Extends ADR-0067.

Context

ADR-0081 turned down @keyframes for progress and spinner, and its argument was about those two. A perpetual loop is a function of the clock. Stored state per element leaves two spinners permanently out of phase, and phaseAt(now) cannot be. That argument still holds, and both controls keep their functions.

What it did not cover is motion that is authored: a sequence of more than two states, a stagger, or a choreography an application’s designer wrote down in CSS terms. Without keyframes, the only way to write one was a widget of its own or a canvas. That means Java for what is a stylesheet’s business, and every such widget had to reimplement easing, direction, fill and reduced motion.

Decision

@keyframes name { from/to/percentages { declarations } } and animation with its seven longhands are in the subset. A keyframe animation is a second layer of the per-node overlay, beneath transitions, and it is subject to the same whitelist.

  • Parser. A Keyframes block is a name and frames sorted by offset. from, 50% becomes two frames. An offset outside 0–100%, a word that is not from or to, none as a name, and !important inside a keyframe are all refused. Stylesheet gains a keyframes list, and its two-component constructor stays.
  • Cascade. A later block with the same name replaces an earlier one whole, across layers too, which is CSS’s rule. Keyframes do not merge. resolveKeyframe(element, frame) substitutes var() for the element the animation runs on, so a keyframe of var(--gb-chart-1) follows the theme.
  • ComputedStyle.animations is KeyframeAnimations: seven lists, kept apart until entries() combines them the way CSS does, with names deciding the count and shorter lists repeating. That is what lets a later rule change only animation-delay, which is how a stagger is written. The shorthand takes its parts in any order, with the first time as the duration and the second as the delay. The curves are §1.7’s three keywords, with ease-enter as the default, which is transition’s default. A bad value drops the declaration and names it.
  • Timing. KeyframeTrack.progress implements CSS’s model: a delay, which may be negative and is filled only under backwards or both; iterations, each played backwards per animation-direction; and an end that is held only under forwards or both. The easing applies between each pair of keyframes.
  • Start. An animation starts on the frame its name first appears in the element’s style, and keeps that start for as long as the name stays. This is the stored state ADR-0081 avoided for loops, and it is right here: an animation that arrives with an element has to start when the element does. Anything that must be phase-locked across elements stays a function of the clock.
  • Values. A keyframe’s declarations are applied on top of the element’s resolved style (ComputedStyle.applied), and only the declared properties are read back. The implicit 0% and 100% are the element’s own values. A property outside Transitions.Animatable is dropped with one warning per block and property. Animatables holds the read, write, compare and interpolate switches for both layers, so a colour moves through OKLCH in both.
  • Layering. Keyframes are applied first and in-flight transitions over them, which is CSS’s order.
  • Frames. Animations.settle and isAnimating include keyframe animations that are waiting or running, and exclude one that has ended and is only holding its last frame. So a forwards animation keeps its value on a loop that has gone idle.
  • Reduced motion drops every keyframe animation. A transition has an end state the cascade already resolved, so reducing it means snapping to that state. A loop never arrives, and a settle’s last keyframe need not be the element’s style, so the honest reduction is none at all. KeyframeAnimations.reduced() is that.
  • Unknown names draw the element as it is and are logged once per renderer.

Consequences

  • §1.7’s rules 4 and 5 (“nothing loops”, “motion is meaning”) still bind the toolkit’s own stylesheets. The mechanism is now available to an application, and ToolkitLoopsTest checks that no toolkit sheet declares an infinite animation.
  • progress, spinner and skeleton keep ADR-0081’s clock functions.
  • The showcase’s Motion screen breathes five swatches on a stagger, turns a mark, and cycles a plate through three tokens, all in showcase.css (ADR-0354).
  • @keyframes inside @media, animation-play-state and animation-composition are not in the subset.

354. A choreography is a function of time, and a timer wakes it

Date: 2026-09-17

Status

Accepted. Uses ADR-0348 (G41) and sits beside ADR-0352 and ADR-0353. The floor starts on its first render rather than its first paint, and the screen has a golden, since ADR-0355.

Context

The application that filed G41 described a floor of glazed tiles that settles. Each tile drops in from 20px above, turned a few degrees, staggered by its distance from a focus. After that, one tile every 1.3 seconds takes the glaze of a neighbouring band. G41 gave a canvas the means. What remained was to show a choreography built the right way, because there are two wrong ways that both work:

  • Ask for frames for ever. animating(style -> true) keeps the settle and the fades smooth. It also repaints a still floor sixty times a second for the 900ms between swaps, which is most of the time.
  • Keep state per frame. Advance each tile’s position a little on every paint. The floor then runs at a different speed on a 144Hz panel, and a dropped frame becomes a slower settle, which §1.7’s frame clock exists to prevent.

Decision

The showcase’s Motion screen (example.ui.MotionScreen) draws the floor as a function of the frame time. The canvas asks for frames only while something moves, and a host timer starts each swap.

  • example.motion.Settle is the arithmetic: a pose (offset, turn, opacity) as a function of time since the floor started, a tile’s delay and its turn. It uses §1.7’s ease-enter, and opacity reaches 1 a third of the way down, so a tile is solid by the time it lands. 850ms, 20px, 4°, 45ms per place of distance.
  • example.motion.TileFloor holds what is state: the glazes, the focus, the swap counter, and the two start times. Both start times are set by the paint that first sees them unset. A replay or a swap has no frame time of its own (a press arrives between frames, and a timer fires on the wall clock), so each leaves the time unset and the next frame fills it in.
  • Who asks for frames. Canvas.animating(style -> floor.isMoving(now, reduced)) is true while a tile has not landed or a glaze is within its 400ms fade, and it is also true while a start time is unset, because a frame is what sets it. Between swaps it is false, and the loop idles. host.after(1.3s) calls swap() and setState. The rebuild brings one frame, the frame sets the fade’s start, and the predicate keeps frames coming for 400ms.
  • Turned tiles are rotated paths (example.motion.Rotated), not a frame transform. Frame.transform states the whole matrix, and inside a canvas that would discard the translation that puts the canvas on screen.
  • Reduced motion. The floor is drawn at rest, a swap is a cut, and isMoving is always false. The glaze still changes, because the change carries meaning (§1.7 rule 5) and only the movement is dropped.
  • Deterministic. Which tile turns which way, and which tile each swap visits, come from the tile index and the swap counter, so the sequence is the same on every run and in every test.

The same screen has a card for each of the other two mechanisms: five swatches breathing on a stagger with @keyframes, and entries added by a button that enter from an @starting-style.

Consequences

  • A canvas choreography in this toolkit has three parts: a pure function of time, a predicate over it, and a timer for wake-ups. TileFloorTest checks that the floor asks for frames exactly until the last tile lands and exactly for the length of a fade.
  • An offscreen render paints once, so the floor card is empty in one: the first paint is the settle’s start. That is why the screen has no golden. The tests assert the moments that matter as numbers.
  • Screen.GALLERY gains motion, last, so no digit shortcut moves. Every gallery golden changed by the one new tab in the strip.

355. A floating button leaves, a field’s room is both paddings, and a floor starts on its first frame

Date: 2026-09-17

Status

Accepted. Closes the three items ADR-0350, ADR-0352 and ADR-0354 left open. Changes one consequence of each of the last two.

Context

The batch that closed docs/gaps.md G39 to G43 and built three kinds of motion left three things written down as not done:

  1. text-input computed its room as width - 2 × left padding, the same arithmetic that wrapped text-area wrong in G43. A field does not wrap, so under padding: 0 16px 0 4px the error was a caret scrolled into view 12px late, with the end of the value under the right padding where the clip cut it.
  2. A floating button had no way out. §3.1 says “out: reverse, fast”, and Overlay.remove() took the button down on the frame it was called, so it vanished.
  3. The Motion screen’s floor was blank in any offscreen render, so the screen had no golden. The settle started on the canvas’s first paint. An offscreen render renders twice to measure and paints once, so it photographed the settle’s first instant, with every tile still invisible above its place.

Decision

Each edge of a field’s padding comes off once. A floating button leaves by class and is removed by a timer. A floor starts on the first frame that renders it.

  • TextEditor.laidOut takes the left and right padding separately, as AreaEditor does since ADR-0350. caretArea uses both as well.
  • FloatSlot is what Floated now puts in the overlay layer: the button, and a Property<Boolean> the slot is bound to. An overlay’s widget is fixed once made, so the slot cannot be swapped out, but a bound property can rebuild it. FloatedState.detach() sets the property, which puts leaving on the button, and button.float.leaving in controls.css is the exit: opacity: 0 and scale(0.9) on --gb-motion-fast with ease-exit. §1.7’s rule 2 holds, because the exit is faster than the 160ms entrance and on the exit curve. The overlay is removed by host.after(--gb-motion-fast), read through BuildContext.duration. The state lets go at once, so a rebuild that attaches a new button while the old one leaves shows the two crossing, as a toast queue does.
  • A press on a leaving button does nothing. §1.7 says input is disabled the instant closing starts. The press asks its own slot’s switch rather than the state’s current slot, which by then may belong to the next button.
  • TileFloor.at(now) starts a settle or a fade that is waiting for a frame time. The canvas’s animating predicate calls it, and the renderer asks that predicate on every render, painted or not. paint still calls it too, so a painter used without the predicate behaves as before.
  • gallery-motion is a golden of the screen at the offscreen renderer’s 200ms: the floor part way through its ripple, the swatches part way through a breath, the mark part way round. All three are functions of a virtual clock.

Consequences

  • ADR-0352’s “the way out is not built” and ADR-0354’s “the screen has no golden” are no longer true, and both records say so in their status.
  • A way out for overlays in general is still not built. Toasts have their own Phase, and a hud or a tour goes when removed. FloatSlot is the pattern another overlay would follow, and nothing else has asked for one.
  • The box tree under a floating button gains a composition node. The Basic-screen golden is unchanged, because a stateless node draws nothing.

356. A connector grows from where you were, and an entry has a marker slot

Date: 2026-09-17

Status

Accepted. Closes two of the three items ADR-0344 and ADR-0345 left in book/src/TODO.md, and answers the third, ADR-0176’s “isModal has one consumer”, without building it.

Context

After the catalog’s last four widgets were built, book/src/TODO.md listed three small things under “What the last four widgets left behind” and §4:

  1. A step’s connector filled by colour, not by scaleX. §3.1 asks for the fill to grow along the line. ADR-0344 said the subset has no transform-origin, so a scaleX would grow from the middle.
  2. A badge could not be a timeline’s marker. §10 lists “dot, icon or badge”. ADR-0345 built the first two and said a widget marker needs a slot markup can name.
  3. isModal has one consumer, dialog. That entry named a wizard step and a sheet as plausible second consumers.

The first reason was wrong. transform-origin has parsed, cascaded and painted since ADR-0068, with TransformTest covering its keywords and TransformPaintTest its device-pixel resolution. The sentence in ADR-0344 was copied from an older note and never checked.

Decision

The connector is a track with a fill in it, and the fill is scaled about its start edge. An entry takes a widget marker from a marker child. A wizard is not modal.

The connector

  • step-connector keeps its size and its track colour, and gains one child part, step-connector-fill, that covers it. The fill is present in every state. A node built already done has no previous style to move from and would snap, which is check-mark’s reason (ADR-0067).
  • controls.css gives the fill transform: scaleX(0) about left center, and scaleX(1) under step-connector.done, with transition: transform on --gb-motion-base. A vertical list uses scaleY about center top, so the line grows down.
  • The fill carries no class. step-connector.done step-connector-fill reaches it, so the one word the list writes is written once.
  • Settled, the pixels are what the colour version drew, so both steps goldens and both wizard goldens are unchanged. What differs is the frames between the two states.

The marker slot

  • EntryMarker is @Markup("marker") and holds exactly one widget. It is a description, the way page is to wizard and tab to tabs: Entry.inflate lifts its content out of the children and everything else stays the body.
  • Named rather than inferred. “The first badge in the body” would move a badge that a document wrote as content onto the axis. Two marker children, or a marker with zero or two widgets, are refused.
  • Entry gains a marker component and withMarker(Widget). The seven-argument constructor is kept. A widget marker wins over an icon, and the pending ring never holds one.
  • TimelineMarker becomes a bare holder with the class widget. It takes the widget’s size and paints nothing of its own, so a badge draws as a badge, in its own colours.
  • The rail keeps its 20px. A wider marker overhangs it evenly, and a short badge’s overhang lands in the body’s 8px of left padding. Widening the rail per entry would move the axis for one event. A timeline whose markers are all wide widens timeline-rail in its own stylesheet.
  • The marker names nothing. The entry is still announced by its label and time, and §10’s “the connecting line is a drawing” covers the axis.

isModal

A wizard is not its second consumer. §6 makes the wizard’s content area a focus-scope: Next moves focus to the new page, and nothing is trapped. A wizard is written inline in a window, and isModal traps the keyboard and, since ADR-0232, the pointer, so an inline wizard declaring it would stop the rest of the window taking input. A wizard shown inside a dialog is already trapped by the dialog. sheet is not built. The entry stays open and now names sheet alone.

Consequences

  • ADR-0344’s and ADR-0345’s “not built” lines are corrected in TODO.md, docs/core-widgets.md §6 and §10, and controls.css.
  • The box tree under every step connector gains one node. A list of N steps paints N−1 more boxes, each a 2px line.
  • marker is a markup name. It is only meaningful inside entry, and elsewhere it inflates to a description that draws nothing, which is page’s behaviour outside a wizard.
  • The showcase’s Chronicle gives Rivendell an IX badge marker, and gallery-collections is 1040 tall so the card is photographed whole.
  • timeline-badges-dark.png is a new golden: a success v2, a one-digit 3 that is exactly the rail’s width, and a dot after them.

357. A test that paints asks for the library, and a download asks again

Date: 2026-09-17

Status

Accepted. Repairs Snapshot run 12 (commit f716adec). Applies ADR-0338’s rule to one more test and adds retries to :assets’ downloads.

Context

Snapshot run 11 was green. Run 12 was red on four jobs, for two unrelated reasons, both read off the runners’ check-run annotations and job logs:

  1. publish / {linux,macos,windows} / Java — :core:test, 2267 tests, 1 failed: WindowResizeTest > what the manager decided arrives through onResize, and a frame follows, with UnsatisfiedLinkError: libgoldberry not found. Those jobs build with -Pgoldberry.skipNative=true, so there is no library by design. The test came in with ADR-0342. It is the only one of the five that runs the frame loop, and a frame rasterizes. It never called RendererRequirement.enforce(), which is the fifth instance of the defect ADR-0338 fixed in four tests.
  2. publish / linux / Verify layouts (linux-aarch64) — :core:prepareAssets failed with Server returned HTTP response code: 500 for github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip. The other three verify legs downloaded the same archive in the same minute. It was GitHub’s failure, and AssetCache made one attempt.

Because the Java job stops at the first failing task, :widgets, :html, :example and :natives never ran there. A local run of every module’s tests with -Dgoldberry.native.library=/nonexistent/libgoldberry.so found no other test in that state: 0 failures across 5263 tests.

Decision

The painting test skips without a library. A download retries a failure that can pass on its own, and only that.

  • WindowResizeTest.arrivesThroughTheHandler calls RendererRequirement.enforce(). The other four tests there only move a headless window and keep running without the library.
  • io.github.digitalsmile.goldberry.assets.download.Downloader makes the request through java.net.http with redirects followed. It tries four times, waiting 2, 4 and 8 seconds, on HTTP 408, 429, any 5xx or an IOException from the connection. Any other status fails on the first attempt: a 404 is a pin that names nothing. Each failed attempt is printed to stderr, so a slow asset step says why.
  • AssetCache.fetch and fetchText go through it. fetchText is now an instance method, so the licence download shares the cache’s downloader.
  • The checksum is not retried. An archive that arrives whole and hashes wrong is a changed upstream, and AssetCache’s message says so.

Consequences

  • A GitHub outage that lasts longer than about 14 seconds still fails the asset step, and should: a longer wait makes a stuck run look like a slow one.
  • The transport, the sleeper and the attempt count are constructor arguments, so DownloaderTest covers the retry policy with no network and no waiting.
  • Any new :core test that opens a window and runs Goldberry.run() with a paint handler needs the same guard. The no-library run above is how to check: ./gradlew test --continue -Dgoldberry.native.library=/nonexistent/libgoldberry.so.

358. An image loads off the frame, and is its own size

Date: 2026-09-17

Status

Accepted. Builds docs/core-widgets.md §1’s image, and answers book/src/TODO.md’s “There is no img widget”.

Context

§1 specifies image: sources of “path, classpath, bytes, or async supplier (placeholder fill until loaded)”, fit modes contain | cover | fill | none, DPI-aware raster selection, and “image with alt text (required attribute for non-decorative use)”. Until now a canvas was how an application drew a picture, and the TODO entry named what a widget would need: object-fit, a natural size that takes part in layout, loading and error states, and a decode off the UI thread.

Three facts decide the shape:

  • Image.decode is synchronous, and a large JPEG is tens of milliseconds. A build runs every frame.
  • Box has no measure hook for an image and no aspect-ratio. content.image.Picture in :html already sizes a picture by arithmetic against its style.
  • Frame.drawImage takes a crop in image pixels and a destination in logical units, so any fit can be drawn without a clip.

Decision

ImageView is stateful, and loads through an ImageLoader that answers a future completing on the UI thread. Its styled part sizes itself from the picture unless the stylesheet sizes it. Fit is a crop and a rectangle.

Sources and loading

  • ImageSource is sealed: File, Resource (an anchor class, or a class loader for markup’s classpath:), Bytes (copied, keyed by SHA-256), Decoded (an Image in hand) and Supplied (an application’s own work under its own key).
  • ImageLoader.shared() is one process-wide ImageCache. It holds futures, so two views of one file in one frame share one decode. It forgets a failure, so a file that appears later is read again. It is bounded at 256 MiB of pixels, least recently used first, counted in bytes because a count treats an icon and a photograph alike.
  • A load runs on a virtual thread through Goldberry.async when there is a UI thread to come back to, and in the caller when there is not: a test, or an offscreen render, where a picture that arrived later would never be photographed. A Decoded source never leaves the caller.
  • ImageState reads a future that is already done during the build, so a cached or decoded picture is drawn on the first frame with no placeholder. Each request carries a generation, and an answer to a question the view no longer asks is dropped. That covers a changed src and a window moved to a display of another scale.

Variants

Variant(scale, source); srcset="logo.png 1x, logo@2x.png 2x" in markup. The view draws the smallest variant at least as dense as the window’s scale, or the densest there is. The natural size is the pixels over the variant’s scale, so logo@2x.png is drawn at the size of logo.png.

Size

In ImagePaint, per axis:

  • neither axis given: the natural size, capped in proportion by a max-width or max-height in points;
  • one axis in points: the other follows the picture’s shape;
  • a percentage width with an auto height: the height follows the width the last frame laid out, through Measured, one frame late;
  • both given: the stylesheet’s box.

Fit

Fit.place returns a crop in image pixels and a rectangle in the box. cover and none crop the source to a centred region rather than drawing outside the box, so neither needs a clip or overflow: hidden, and neither can paint over a neighbour. Crops are whole pixels and at least one.

States and semantics

  • image.loading and image.error take --gb-surface-2, the skeleton’s placeholder fill. An error shows Lucide’s image-off and the alt text, as a browser does.
  • Alt text is required unless decorative=#true. A meaningful image builds an ImageFigure (Role.FIGURE, named by its alt); a decorative one builds an ImageBox, which does not implement Semantics at all. The role set has no image of its own, and canvas answers FIGURE too.

Consequences

  • SVG is not decoded. §1 routes it to goldberry-vector, which does not exist, so an SVG shows the error state.
  • An image with no size of its own has nothing to fill while it loads. A list of known shape should size its images.
  • Image.decode stays synchronous and uncached for a canvas that calls it by hand. ImageLoader.shared() is public, and is the seam a painter can use.
  • The Canvas screen gains an “image widget” card over the existing canvas-sample.jpg, and gallery-canvas is re-blessed. image-dark and image-light are new goldens.

359. A select is as wide as its widest option

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “A select’s field is as wide as its current value”, and records the items docs/widgets-finishing.md answers rather than builds.

Context

docs/core-widgets.md §3 asks for a select whose closed field does not move when its value changes. The field tracked its value: “Dark” made a narrow control and “High contrast” a wide one. The TODO entry had already found that its own reason had expired: Paints.Context.paragraph(style, text) shapes against the node’s resolved style during render, and a shaped paragraph’s width is one call. What remained was a decision about a shipped drawing, since every select golden changes.

Decision

The value cell’s preferred width is the natural width of the widest label it could show: every option’s and the placeholder’s.

  • SelectState hands SelectField the labels, and SelectValue shapes each against its own style and sets width to the widest, rounded up to a point.
  • Preferred, not minimum. The cell keeps flex-shrink: 1, so a stylesheet that sizes the select (select { width: 90px }) still wins and the value ellipsizes, as before.
  • Not for tree=, whose labels are nodes that may not be loaded, and not for multiple or autocomplete, which draw chips or an editor instead of a value.
  • It is not the Measured trap: what is measured is text, a function of the model and the style, not last frame’s geometry.

Consequences

  • select-dark, select-light, select-disabled, select-on-surface and select-placeholder are re-blessed. Each field is now as wide as “Choose a theme”.
  • Shaping every label is paid on every render of a closed select. It is a cache hit after the first frame (ADR-0037), and a select with thousands of options is autocomplete’s case, which is excluded.

360. An affix stays inside its container

Date: 2026-09-17

Status

Accepted. Amends ADR-0119. Closes book/src/TODO.md’s “A pinned affix is not pushed out by the next one” and the sticky-header half of “A table has no column resizing and no sticky header”.

Context

An affix pinned itself to its viewport’s edge once it would have scrolled past, and stayed pinned for as long as its hole was above the edge. Two sections with a header each overlapped: the first header stayed pinned while the second arrived under it. The TODO entry said doing better “needs an affix to know about its sibling, which is a relationship nothing in the widget tree expresses”.

CSS answered this without siblings. A position: sticky element is confined to its containing block, so a section’s header leaves with its section and the next header takes over by the same rule. What an affix lacked was the rectangle of the box it is in.

The table’s header was blocked on this. It sits above the table’s list, so a table inside a page that scrolls lost its column names, and an affix there would have pinned them over whatever came after the table.

Decision

Located also reports the painted rectangle of the nearest ancestor with a box, and an affix never travels past that box’s far side.

  • Located.located(self, clip, container) is a default method that calls the two-argument form. PointerRouter.notifyLocated finds the container by walking up from the element to the first ancestor that has a hit-test region, because a composition node has no box. With none it reports the window. The router only notifies again when the container moves.
  • AffixState limits its shift to the room between the hole’s far side and the container’s: for top, the container’s bottom minus the hole’s bottom; for bottom, the hole’s top minus the container’s top; and likewise for the horizontal edges. :affixed still comes on as soon as the affix lifts.
  • An affix directly in the scrolled column has the whole document as its container, so it pins exactly as before.
  • Table wraps its head and rule in an affix. At rest the header is where it was; on a scrolling page it pins while the table is in view and leaves with the table.

Consequences

  • A sticky header per section is two columns, each holding an affix and its rows, and needs no code.
  • The table’s box tree gains affix and affix-content above table-head. The table goldens are unchanged.
  • TableHead’s hit testing goes through the affix’s translate, which the router already inverts (ADR-0068).

361. A column is resized by asking

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “A table has no column resizing”.

Context

docs/design-system.md §3.1’s table row says “column resize: 1:1, like split-pane’s drag”. The TODO entry proposed a split-pane between the headers, but a split divides one box between two panes and a table has any number of columns, some fixed and some weighted.

Table is a stateless leaf, and everything about its rows is the application’s: the order, the selection, and the widths through Column.fixed and Column.weight. Sorting already works by asking: a header click reports what the sort would become, and the application hands back rows in that order.

A 1:1 drag has to be measured from the width the column had when the drag started. For a weighted column only the layout knows that.

Decision

A resizable column’s header carries a grip, and a drag on it asks the application for a width in pixels, anchored at the width the header last came out as.

  • Column.resizable(true) and Table.resized((key, width) -> …). The application answers by making the column fixed(width); a weighted column that is dragged becomes a fixed one.
  • A resizable header is built inside TableHeaderCell, a stateful composition node with no CSS type, which keeps the header’s last laid-out width through Measured. The header answers gestureAnchor() with it.
  • table-grip is a 6px part over the header’s trailing edge, positioned right: -12px because an absolute box here is placed against the content edge and §3’s cell padding is 12. It handles the drag: on each move it asks for anchor + dragX, with a floor of 32px so a column cannot be dragged shut. The router asks the pressed chain deepest-first for an anchor, so the grip reads the header’s width. It consumes the press, release and click, so a drag never sorts.
  • table-header loses overflow: hidden. That clips at the content box and hid the half of the grip over the padding; the label still clips itself.

Consequences

  • The table goldens are unchanged: a column that is not resizable builds no cell node and no grip.
  • A resize is a rebuild per pointer move, the same cost as a split-pane drag.
  • There is no keyboard resize. A header is focusable only when it sorts, and a second meaning for its arrow keys is a decision for when something asks.
  • The showcase’s Name column is resizable, with its width kept in the card’s state; gallery-collections is re-blessed for the caption that says so.

362. A text area draws scroll’s bar

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “A text-area has no visible scrollbar”, and amends ADR-0117: ScrollBar is public.

Context

§4 asks text-area for “optional auto-grow between min/max rows, scrollbar beyond”. The control scrolled with the wheel and to keep the caret in view, and drew no bar. The TODO entry named two choices and liked neither: put the text in a scroll, which would fight auto-grow because both want to decide the height, or write a second bar.

Neither is needed. ScrollBar is three numbers and two callbacks: a viewport length, a content length, an offset, where to report a new offset, and when a drag starts and ends. The thumb floor, track paging and dragging all live there. A text area knows all three numbers.

Decision

ScrollBar is public, and text-area builds one over its own offset while its text is taller than what it shows.

  • TextAreaState.scrollbar(): the viewport is the content box’s height (the rows for an ordinary area, the measured height less padding for one that fills), the content is the line count times the line height, and the offset is the state’s. Null while the text fits, so an area that fits has no part.
  • The bar reports through scrollTo, clamped to the maximum, and dragBar holds .dragging for the thumb, exactly as ScrollState does.
  • TextAreaBox puts it last among the control’s children, beside the clipped content layer rather than inside it, positioned down the content box’s height and against the inside of the right border (a negative padding inset, as the gutter strip already does). Its part over the padding is drawn and can be pressed.
  • A render that moves the text asks for one rebuild. The bar is built before render, and laidOut can move the offset to follow the caret. When it does, the state marks itself dirty; the next build puts the thumb where the text is, and the render after that lays out the same offset and asks for nothing.
  • text-area:hover widens the thumb and shows the track, as scroll:hover does.

Consequences

  • The bar overlays the last few pixels of the text’s width, as scroll’s overlay bar overlays its content. The reserved gutter is a separate item.
  • gallery-html and gallery-markdown are re-blessed: their source editors overflow and now show a thumb.
  • TextAreaGutterTest finds the content layer by its clip rather than by being last.

363. A programmatic scroll glides, and the offset is already there

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “A revealed row lands rather than glides”.

Context

docs/design-system.md §3.1 gives scroll “wheel/drag: direct · scrollIntoView / programmatic: overlay duration”. A reveal jumped: the offset is ScrollState’s, and nothing interpolated it. The TODO entry called it “a transition on a value the cascade cannot see”.

Two constraints shape it:

  • Direct input must not wait. §1.7’s first rule: drags and the wheel track the pointer 1:1.
  • Only render has a clock. ScrollFade already found this: a wake knows something happened and has no time, and render has a time and does not know what happened.

Decision

ScrollController.scrollBy and reveal move the offset to the target at once, and the viewport draws the content on its way there for --gb-motion-overlay (240ms) on ease-enter.

  • ScrollGlide holds where the drawing started, where it ends, and when it started. It is stamped in ScrollViewport.render, which replaces the content box’s translation with the in-between one, and isAnimating() keeps frames coming until it lands.
  • The state’s offset is the target. Clamps, the scrollbar, the wheel and any later reveal all measure from where the view is going. A glide started during another starts from where the first had got to.
  • Wheel, keys, thumb drags and track pages go through moveTo, which cancels the glide: the pointer takes over from wherever the drawing was.
  • Reduced motion jumps, as §1.7’s rule 6 says programmatic scroll does.
  • A reveal measures where its rectangle will be. A Located rectangle is painted mid-glide while the offset is already at the end, so ScrollController.reveal first moves the rectangle by the glide’s remaining travel. Without that, a reveal asked again on the next frame (which is how a widget that reveals itself from located works) scrolled a second time and overshot.

Consequences

  • Hit testing, Located and affix all see the painted, in-between position during a glide, which is where the content is on screen.
  • The thumb jumps to the target while the content glides. The bar is built from the offset, and the offset is the target.
  • ScrollControllerTest and the showcase’s ScrollingScreenTest run on a virtual clock and let the glide land before they measure.

364. Always-shown scroll bars are a token sheet

Date: 2026-09-17

Status

Accepted. Closes both of book/src/TODO.md’s gutter entries: “The ‘always show scroll bars’ reserved gutter is not built” and “… has nothing to switch it”.

Context

docs/design-system.md §2.4: “‘Always show scroll bars’ app/user setting swaps to a classic reserved 12px gutter — components must survive the gutter appearing (layout, not overlay)”, and §4 lists it among the accessibility switches. The overlay bar was built; the gutter was not, and the TODO said it waited on “a settings mechanism that does not exist for reduced motion or density either”.

Density did not wait for one. It is a token stylesheet in the THEME layer that an application passes to Controls.stylesheets from its own preferences (ADR-0074). The same shape serves here.

Decision

Scrollbars.ALWAYS is a token stylesheet. The overlay bar’s metrics become tokens, and a viewport that finds a non-zero --gb-scrollbar-gutter pads its content by it and stops fading its bars.

  • controls.css declares --gb-scrollbar-gutter: 0px, --gb-scrollbar-size: 10px, --gb-scrollbar-track: transparent, --gb-scroll-thumb-size: 6px and --gb-scroll-thumb-hover-size: 10px, which are the numbers the rules held, so every existing golden is unchanged. The scrollbar and scroll-thumb rules read them.
  • scrollbars-always.css sets a 12px gutter, a 12px bar, the --gb-surface-2 track, and an 8px thumb that does not widen.
  • Scrollbars { OVERLAY, ALWAYS } in :widgets, with stylesheets() and source() shaped like Density’s, and Controls.stylesheets(theme, density, scrollbars).
  • ScrollViewport reads the gutter token in render and banks it in ScrollState, as it already banks --gb-scroll-line. ScrollContent adds the gutter to its padding on the bar’s side (right for a vertical axis, bottom for a horizontal one), so the content is narrower rather than under the bar. A viewport with a gutter draws its bars at full opacity, because §2.4’s classic bar does not fade.

Consequences

  • The bar keeps its absolute position. The gutter is made by the content’s padding, so the overflow arithmetic, the thumb and the wheel are unchanged.
  • The gutter arrives one frame after the stylesheet changes, like the line token, because children() has no context to read it from.
  • text-area’s bar reads the same tokens, but a text area reserves no gutter: its text width is its own arithmetic (ADR-0331), and a change there is a change to wrapping.
  • The showcase’s File menu has “Always show scroll bars”.

365. An overflowing tab strip pages from its ends

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “A tab strip scrolls, and has no chevrons at either end”. Adds ScrollController.position() and onChange, and a CHEVRON_START mark.

Context

A tab strip’s headers sit in a horizontal viewport (ADR-0118), so a strip wider than its window can be reached by the wheel or by dragging the thumb. A tab bar conventionally also has a chevron at each end. The TODO entry named what was missing: a chevron “needs to know it is at an edge, which is scrollIntoView’s missing question again”. A ScrollController could move a viewport but could not say where it was.

Decision

A controller reports its viewport’s position and says when it changes, and a tab list that overflows puts a tab-pager at each end that pages through it.

  • ScrollController.Position holds the offsets, the overflows and the viewport’s size, with overflowsX(), canScrollLeft() and canScrollRight(). position() answers NONE with nothing attached. onChange(Runnable) sets a single listener, because a controller has one owner. ScrollState notifies it when the offset moves (by any route) and when its measured extents change.
  • TabsState listens and keeps the last position. TabList builds [rule, pager, viewport, pager] while the headers overflow and [rule, viewport] otherwise.
  • A pager pages by 80% of the viewport’s width, never less than 40px, through the controller, so the move glides (ADR-0363). It is :disabled at the edge it would page past, is a BUTTON named “Earlier tabs” or “Later tabs”, and is not a Tab stop: the strip is one stop, and a selected tab already reveals itself.
  • Box.Mark.Kind.CHEVRON_START is CHEVRON_END mirrored, a kind rather than a transform for CHEVRON_UP’s reason.

Consequences

  • The pagers take width from the viewport, so a strip near the threshold has two stable states: overflowing with pagers, or fitting without. Which one it is in depends on the side it was approached from, and it does not oscillate.
  • gallery-basic-narrow and gallery-icons-narrow are re-blessed: their strips overflow and now show pagers.

366. A kept tab is hidden, not removed

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “A tab’s content is rebuilt when it is selected again”. Adds Styled.isHidden() to :core.

Context

§5 asks tabs for “lazy content instantiation”, and a strip builds only the selected tab’s content. The TODO entry said that is right, and that it means a scroll position, a caret or a half-typed form in a background tab is gone, with nowhere to put it but the application’s model.

Keeping content alive needs a subtree that stays mounted, with its elements and their state, while it takes no layout, paint, input or focus. Widget.nothing() removes a subtree’s content, and :disabled keeps a subtree painted. The toolkit had nothing in between.

Decision

Styled.isHidden() keeps a node and its subtree mounted and unused. tabs with keep-alive wraps each tab it has shown in a keyed tab-page that is hidden unless selected.

  • Styled.isHidden() defaults to false. WidgetRenderer.render returns no box for a hidden node and does not render under it, so it has no layout, no paint, no hit-test region, and no Measured or Located notifications. PointerRouter.isFocusable refuses anything under a hidden ancestor, walking up as isDisabled does, and refocus lets go of a focus that has become hidden.
  • Tabs.keepAlive(boolean) and keep-alive=#true in markup. TabsState keeps the values it has shown and still has, in first-shown order. The panel then holds one TabPage(value, content, selected) per kept tab, keyed by value so the reconciler matches a page to the same elements whichever tab is selected.
  • A tab never selected is still not built. A closed tab is dropped from the kept set, and its page unmounts.
  • Off by default, which is §5’s default.

Consequences

  • Hidden content still costs its elements and their state, and a rebuild of a hidden subtree still runs when its widgets change; only rendering is skipped.
  • isHidden is not a stylesheet feature, so §8’s subset still has no display: none.
  • The showcase’s chapter strip keeps its tabs alive, and each chapter has an unbound note field to show it; gallery-navigation is re-blessed.

367. A document places a list it cannot describe

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “A list is Java, like canvas and like autocomplete” and the markup half of “Autocomplete is Java-only”. Answers markup for tour and toast without building it.

Context

list, table and tree had no markup. The TODO entry said why: “an item-factory is a function and §8’s documents have no way to write one”. A table’s cell factory and a tree’s child suppliers are the same wall. It proposed a named item-factory.

Autocomplete had the same shape. §4 says the widget raises the query and the application supplies the list, and in Java the list arrives by rebuilding the field. A document has no rebuild, so a document’s field offered nothing.

A named factory would still leave a document writing a list’s items, its identity, its selection and its handlers, which are the application’s. What a document can do is name a value that changes, and bind= exists for that.

Decision

list, table and tree inflate to Bound, which draws the widget a bind= value holds. text-input suggestions= and select options= inflate to Suggested, which describes the field again with whatever a bound list holds.

  • Bound(source, type, attributes) in widgets.markup is a stateless composition node whose binding is the source. It draws the value when it is a type, with the document’s id winning and its classes added to the widget’s own, and draws nothing otherwise. The element already subscribes to a widget’s binding, so a model that replaces the ListView rebuilds the node, and the list under it reconciles by key.
  • ListView, Table and Tree carry @Markup and an inflate returning a Bound over their own class.
  • Suggested(source, field) in controls.option rebuilds its field whenever the bound collection changes. It offers Options as they are and anything else as an option of its string. TextInput.inflate wraps with field::suggesting; Select.inflate wraps with the new Select.withOptions, which replaces the written options and keeps any other children.
  • tour and toast get no markup. Starting a tour needs a Host, which a document node does not have (ADR-0121). A toast is raised through a ToastController, not placed. Both are imperative by design.

Consequences

  • The application model holds widgets for these three: a @Bind field of type ListView<Person> that it replaces when its data changes. That is where the factory, identity and handlers already live in Java.
  • A document mid-edit, with nothing bound yet, shows nothing rather than failing.
  • The catalog’s chaining, immutability and parity tests cover the three new names through Bound.

368. A focus by name reaches the popup it came from

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “A select tree=#true has no typeahead”.

Context

The TODO entry said a flat select’s open list reads letters on the capture phase (ADR-0246) and a tree in the same panel did not. It framed the gap as a design question: whether a prefix search descends into collapsed branches, and what it means to match a node nobody can see.

tree had already answered that when its keyboard was finished: its typeahead matches visible rows only and moves the focus without choosing (ADR-0209). The select’s panel did not stand in the way either. SelectList has no list-level typeahead for a tree, so a letter reaches the focused tree row.

Reproducing it found the cause somewhere else. A tree moves its typeahead by asking host.focus("tree-" + id). A popup’s element tree is built with the window’s Launcher as its host, and Launcher.focus asked only the window’s router, which has no such row. The letter arrived, the tree found its match, and the focus request landed in the wrong window and did nothing. Anything in a popup that focuses by name had the same defect.

A second, smaller one: a tree select opened on its first row whatever it held, because chosenId only knew option rows.

Decision

Host.focus(id) tries the open popups, topmost first, before the window, and a tree select opens on its chosen row.

  • Popup.focusById asks the popup’s own router. Launcher.focus walks the open popups from the topmost down and returns at the first that finds the id, then asks the window. Topmost wins for the reason a key goes to it.
  • SelectState.chosenTreeRow names tree-<value> when the value is a root and so is on screen when the list opens; otherwise the popup focuses its first row as before.

Consequences

  • A select’s tree list takes a typeahead over its visible rows, which is the same behaviour a standalone tree has.
  • A value inside a collapsed branch still opens the list on the first row; expanding a path to reveal it is a separate decision.
  • An id present in both a popup and the window resolves to the popup. Ids are meant to be unique per window, and a popup is a window.

369. A knob turns round its dial from its own value

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “The circular drag is not built, and §3 offers it”. Amends ADR-0089.

Context

§3’s knob: “Rotary: vertical-drag primary (circular-drag optional)”. The vertical drag shipped. The TODO entry held the circular one back: a circular drag has to decide what happens when the pointer crosses the 90° gap at the bottom, “and every answer is either a jump or a wrap that depends on which way round the user went, which needs the accumulated angle, a second piece of gesture state”.

The accumulated angle is only needed to recognise a jump, and a jump can be recognised without it. A knob always knows its current value, and the pointer’s angle round the dial is already computed for a click on the ring.

Decision

Knob.circular(true) and drag="circular" make a drag follow the pointer’s angle round knob-dial, and a move is judged against the knob’s current value.

  • circularFraction(angle, current): in the 90° gap the value stays at the end nearer current; on the travel it is the angle’s fraction, unless that is more than half the travel from current, which is a jump across the gap and is held at the nearer end too.
  • So pushing past the top holds the knob at the top, and coming round through the gap to the bottom does not flip it; turning back along the travel resumes.
  • Detents apply as they do to the vertical drag. The wheel, the keys and a click on the ring are unchanged. The vertical drag remains the default.

Consequences

  • A user who deliberately wants to go from the top to the bottom turns back along the travel, which is what a physical knob with a stop needs as well.
  • The knob holds no new state; the record gains one flag.

370. A reveal can keep to one axis

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “A reveal moves both axes at once” as an option, with the default unchanged.

Context

ScrollController.reveal scrolls the least it can on each axis independently. ADR-0120 argued that is right, and the TODO entry agreed. Its one complaint was that it “occasionally moves a view further than a person would have”: a wide table asked to show a cell slides sideways as well as down. Nobody had asked for an axis to take priority.

Decision

reveal(self, clip, axes) moves only along axes; the two-argument reveal is reveal(self, clip, ScrollAxis.BOTH).

  • The distances are computed as before, and the one for an axis axes does not include is dropped before scrollBy. ScrollAxis already names the three choices, so no new type is needed.
  • The measurement ahead of a glide (ADR-0363) applies to both axes before the filter.

Consequences

  • A caller that only means “bring this row into view” passes VERTICAL, and the horizontal position the user chose is left alone.
  • ScrollControllerTest’s harness takes an optional stylesheet, so a grid can be wider than its viewport.

371. An affix pins to one edge per axis

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “An affix pins on one axis”.

Context

An affix took one edge=. The TODO entry described the case for two: “a header that is both sticky at the top and held against the left of a horizontally scrolling table”, which “needs two shifts and a rule about which wins”.

There is nothing for either to win. A vertical edge’s shift is a translateY and a horizontal edge’s is a translateX, and each is computed from the same three rectangles on its own axis: the hole, the clip and the container (ADR-0360).

Decision

An affix has an edge and an optional cross edge on the other axis, and each axis’s shift is computed independently.

  • Affix.alsoPinnedTo(Edge) and edge="top left" in markup. A second edge on the same axis is refused in Java; in markup it is ignored, as an unknown edge word already is.
  • AffixState.shiftFor(edge, offset, self, clip, container) is the per-edge arithmetic, lifted out unchanged. The state keeps a shift per axis, and :affixed is on when either is non-zero.
  • AffixSlot and AffixContent carry shiftX and shiftY instead of an edge and a shift, and the content translates by both.

Consequences

  • One offset applies to both edges.
  • The table’s own sticky header (ADR-0360) is unchanged; a table in a horizontally scrolling viewport that wants its header held on the left too says alsoPinnedTo(Edge.LEFT) on an affix of its own.

372. A tab is dragged, and the strip asks where

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “Nothing reorders tabs”, as an option.

Context

The TODO entry: “a reorder would need a different animation from an arrival: a tab that moves has two positions and nothing to interpolate between them, which is ADR-0097’s missing geometry again. §5 does not ask for drag-to-reorder; the model shape would take it without a change, since the strip draws the list it is given.”

Two things make a drag possible without that geometry. The dragged tab is the only one that needs to move smoothly, and it follows the pointer 1:1, which §1.7 asks of every drag and which needs no interpolation. And tabs already report their painted rectangles through Located (ADR-0120), so the strip can tell where among the other tabs a drop landed.

Decision

Tabs.onReorder((value, index) -> …) makes a strip’s tabs draggable. A drag draws the tab at the pointer, and a drop asks the application to move the tab to an index among the others.

  • TabDrag is a composition node with no CSS type around each header of a reorderable strip. It hears the pointer after the Tab, which consumes only the click. Past 4 points of travel along the row a press is a drag: each move reports the travel and the pointer, and the release reports the drop.
  • TabsState keeps every header’s painted rectangle while the strip is reorderable, in strip order, dropping rectangles for tabs that have gone. The drop index is the number of other tabs whose centre is before the pointer. Nothing is asked when that is where the tab already was.
  • While dragged, the Tab carries a dragOffset that its restyle turns into a translateX, so the tab is drawn where the pointer took it and its slot in the row stays put.
  • The strip does not reorder anything. On the drop the tab returns to its slot, and the application’s new list puts it in its new one on the next build.
  • A press and release that did not travel still select.

Consequences

  • The other tabs jump to their new places when the application’s list changes. Animating them is still ADR-0097’s missing geometry.
  • There is no markup: two values do not fit a valued action. The showcase’s chapter strip, which is Java for the list’s sake already, is reorderable.
  • gallery-navigation is re-blessed for the caption that says so.

373. A column starts from nothing and grows

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “flex-basis is one of two layout properties §8 names and nothing resolves”.

Context

§8 has listed flex-grow, flex-shrink and flex-basis from the beginning. The first two are resolved; the third was implemented for segmented and taken back out, because flex-basis: 0 on a track inside an unconstrained row makes Yoga compute that row’s content size as zero and the bar collapsed.

Two widgets have wanted it since and written around it:

  • masonry needs 1/n of a row where n is a count no selector can make. It wrote an inline width: 100/n % instead — which is 1/n of the row before its gaps, so three columns and two 12px gaps overflowed by 24px.
  • timeline.alternate wants half a row per side and writes width: 50% with an equal shrink, which is the same arithmetic and the same overflow.

The thing that collapsed was never the property: it was flex-basis: 0 on a box whose parent had no definite main size, which is CSS’s behaviour too.

Decision

flex-basis is resolved, as a Length on ComputedStyle and on Box, and masonry is its first consumer.

  • auto is the initial value — Yoga’s and CSS’s — so a box that never mentions it lays out exactly as before. It is a value rather than a missing one: it undoes a more general rule, which is align-self: auto’s argument (ADR-0244).
  • Box.basis(Length) is the builder, and RenderObject sets it on the node the same way width is set: only when it differs from the previous frame.
  • masonry-column is flex-basis: 0; flex-grow: 1 in controls.css, and MasonryColumn no longer takes a count or writes a restyle. The columns are now a share of what the gaps left rather than of the whole row.

Consequences

  • The collapse that took this out is still real and is now the author’s to avoid: flex-basis: 0 in a row with no definite main size sizes that row to nothing. The segmented bar keeps the grid ADR-0099 gave it.
  • RecordWitherTest covers the new component on both records for free.
  • timeline.alternate is left as it is: its percentage is of a row it does not share with a gap, so the arithmetic is the same and the change would only move goldens.

374. Wrapped lines share a cross axis

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “align-content is still absent”.

Context

Align has nine constants and three of them — SPACE_BETWEEN, SPACE_AROUND and SPACE_EVENLY — are align-content only. align-content was not resolved, so those three were values the toolkit’s own enum advertised and no stylesheet could reach; worse, align-items: space-between parsed, because the keyword parser reads the whole enum, and then meant whatever Yoga does with a value CSS does not allow there.

The entry’s own argument for waiting was that nothing in the catalog wraps inside a box with a fixed height. That is still true, and it is an argument about a default rather than about a property: a wrapping container has a rule for its lines whether or not anybody wrote one.

Decision

align-content is resolved over the same [Align] value space, defaulting to stretch.

  • stretch is Yoga’s default under useWebDefaults and CSS’s normal for a flex container, so every box in the catalog lays out exactly as it did.
  • It reaches Yoga through YGNodeStyleSetAlignContent, which was bound on the first day and had no caller.
  • Box.alignContent(Align) is the builder, beside alignItems and alignSelf.

Consequences

  • The three constants that no property accepted are reachable, and reachable only from the property that means them.
  • align-items: space-between is still admitted by the keyword parser and is still Yoga’s business what to do with — refusing per-property keyword subsets is a table this does not build.
  • A wrapped row inside a fixed-height box can now pack its lines, which is what a future chip row inside a sized panel will want.

375. A box that does not fit says so

Date: 2026-09-17

Status

Accepted. Closes the open half of book/src/TODO.md’s “Nothing has a minimum size, so overflow is silent”.

Context

flex-shrink: 0 stops a control being squashed and does not stop it being clipped: a window narrower than its content overflows, which is CSS’s behaviour and not a bug. The entry’s two proposed answers — a scroll view and an ellipsis — both shipped, and what was left is that nothing says it happened. A button pushed off the right edge of a window looks exactly like a button that was never built, and the toolkit knew and did not mention it: Yoga has recorded hadOverflow per container since the first day, and nothing read it.

Decision

The layout pass asks the root whether its line overran, and when it did, a walk names what is off the edge — once.

  • RenderTree.update calls YGNodeLayoutGetHadOverflow on the root after calculateLayout. That is one foreign call on a frame where everything fits, which is nearly every frame.
  • OverflowWatch (in paint.tree, where the tree is) walks only then, and reports each child laid out past its container’s own edge.
  • paint.overflow holds the rest: Overrun is the value — two names and two distances — and OverflowLog is the dedupe and the sentence. Keyed by shape, container > child, not by distance, so dragging a window narrower says it once rather than once a pixel. The same argument, and the same cap, as a dropped declaration (ComputedStyle.REPORTED).
  • Not reported: a container whose overflow is not visible, because that is a scroll viewport working; and a child whose position is absolute, because it was placed rather than flowed — a tab’s underline, a popover’s arrow.
  • OverflowLog.reported() hands the list back, so a test asserts on what was said without a logging backend and an application can put it on a HUD.

Consequences

  • The warning names the two boxes and says what the three answers are — a scroll, an ellipsis, or a min-width — and picks none of them, which is the author’s decision and was the entry’s own conclusion.
  • On a window that is already too small the walk runs every frame. That is the deliberate trade: the frame budget’s difficult case is a window that fits, and the reporting is bounded however often the walk is not.
  • A box built by composition has no type, so it is reported as “a box”. That is the same anonymity that makes it invisible to a type selector.

376. One key map, three editors

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “Two key maps” — which was three by the time it was read again.

Context

text.edit.Editor, text-input’s TextField and text-area’s TextAreaBox each held their own onKey, and each mapped the same keys to the same intents. They agreed because each was written by reading the last: agreement by inheritance rather than by construction, and nothing to keep it. A toolkit whose two editors disagree about Ctrl+Shift+Z has a bug in one of them and no test that can see it.

The entry’s own answer was to converge the editors — the controls holding an Editor instead of their own state machines — and its own objection was that this is a rewrite of two controls with a hundred golden images and a full interaction suite behind them. Both halves are right, and neither is a reason to keep three tables: what was duplicated is the keyboard, and the keyboard is the part that has no text model in it.

Decision

text.edit.keys holds the map. Each editor keeps its own text and answers commands.

  • EditKeys.of(event, surface) is the one table. It returns an EditCommand — Move, MoveLine, Delete, Type, or one of six Simple accelerators — or null for a key no editor may take. Tab, Escape and a field’s Enter are null, which is the half of the contract that keeps focus moving and dialogs closing.
  • EditSurface is everything the map needs to know about the editor asking, and it is two questions: are Up and Down lines here, and is Enter a newline. FIELD answers no and no, WRAPPED yes and no, DOCUMENT yes and yes.
  • EditCommand is sealed, so each editor’s switch is exhaustive: a command added later fails to compile in the three places that have to answer it, which is what the three hand-copied tables could never do.
  • EditCommand.Simple.isEdit() says which commands a read-only editor must refuse. That list was the other thing being kept in three places.
  • A page stays the caller’s: MoveLine(lines, byPage, extend) says “a page” and the editor multiplies by its own — rows for a text-area, ten for a canvas editor that has no viewport to measure.

Consequences

  • EditKeysTest asserts the table once, including the two redo spellings and that Ctrl+Alt+V is AltGr typing a character rather than a paste — which no test could state before, because there was no table to state it about.
  • The three controls’ own suites pass unchanged, which is the claim that this moved no behaviour.
  • Converging the editors themselves is still open and is still a rewrite. It is now a smaller one: what is left to share is the text model, not the keyboard.
  • An application writing its own editor on a canvas can read the same map, so a third-party editor agrees with the toolkit’s by construction.

377. An underline travels by being let go of

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “A tabs indicator still cannot travel, though segmented’s does”, and the half of ADR-0097 it left open.

Context

docs/design-system.md §3.1 gives tabs and segmented one selection indicator with one effect. segmented‘s pill travels: its cells are a grid, so the distance to cell k is k times the pill’s own width and a percentage in a transform says it with no measurement at all (ADR-0099). tabs cannot do that, because its headers are as wide as their labels — and ADR-0097 deferred the travelling version for want of “a fact about two boxes’ laid-out geometry”, which no widget could reach.

It can now. Every header reports where it was painted through Located, which is how a drag knows which headers it was dropped between (ADR-0372), and the strip keeps the rectangles.

The obvious design — one indicator for the strip, positioned where the selected header is — was built and taken back out. It makes the underline a function of geometry that only arrives through the router, so any paint without one draws no underline at all; half the golden images in the catalog are a BoxPainter.paint of a rendered tree, and every one of them lost it.

Decision

The underline stays inside its own tab and travels by being displaced onto the old one and then released.

  • Tab.Travel(dx, scale, id, arrived) is a displacement, not a position: how far back and how much wider the underline still is, relative to where it belongs. The strip computes it from the two headers’ painted rectangles.
  • The frame a selection changes, the newly selected tab’s indicator carries the whole displacement — translateX and scaleX in one transform about the top-left corner — and is therefore drawn exactly over the header being left. The frame after, the strip hands over zero and the transition in controls.css slides it home on --gb-motion-base, which is the clock segmented’s pill is on.
  • id is the indicator’s key. A displaced underline is a newly built element, whose first frame starts nothing (ADR-0065) — otherwise the displacement would itself animate and the underline would glide backwards before gliding forwards. Letting go of the displacement keeps the same id, so that half is a change to the same element and therefore a transition.
  • arrived is called from the indicator’s render, which is where the frame that drew the displacement exists. It marks the strip for a rebuild, exactly as a finished departure does (ADR-0052, ADR-0109).
  • A strip whose headers nothing has measured hands over null, and the underline appears where it belongs with no journey — which is what every golden image of a tab strip photographs, and why they are all unchanged.

Consequences

  • The underline is still each tab’s own box and still pinned across its own header, so the resting picture, the colour a tab carries into it, and the rule it sits on are all exactly as they were.
  • The travel is a transform and nothing else. transition takes the compositor-cheap set only: a left or a width that animated would run Yoga on every frame of the journey.
  • TabTravelTest drives a real router so the rectangles exist, and asserts the painted left edge rather than a matrix entry — the transform is applied about the box’s own place, so the matrix alone reads as an offset of something else.
  • The other tabs still jump to their new places when the application reorders its list. That was ADR-0372’s consequence and it stays one: a reorder moves several boxes at once, and this displaces one.

378. The desktop’s own modifier has a name

Date: 2026-09-17

Status

Accepted. Closes docs/ARCHITECTURE.md §17.1’s “The platform primary modifier”, which had been recorded as a disagreement between the design system and the code since the shortcut table was built.

Context

docs/design-system.md §2.3 asks for accelerators expressed against a platform primary modifier — Cmd on macOS, Ctrl elsewhere — “via one Shortcut abstraction”. Shortcut refused to do it, and wrote down why: a toolkit that silently turns Ctrl into Cmd on macOS makes Ctrl+C mean two different things depending on where it runs, and a terminal emulator or an editor with Emacs bindings is broken by exactly that translation.

§17.1 recorded both as defensible and incompatible, with the design system winning by default. They are not incompatible. The design system wants a way to say “whatever this desktop uses”; the code was refusing to guess it. The disagreement was that the toolkit had no word for the thing.

Decision

Primary is a modifier you can name, and nothing is translated.

  • PrimaryModifier.current() is Mod.META on macOS and Mod.CTRL everywhere else, read once from os.name and overridable with -Dgoldberry.input.primary=ctrl|meta. resolve(osName, override) is the testable half, which is NativePlatform’s rule for the same problem.
  • Shortcut.of("Primary+S") and Shortcut.primary(Key.S) build the accelerator this desktop would have written. Mod and CmdOrCtrl parse as the same thing, because those are the names the same idea goes by elsewhere.
  • Ctrl is still Ctrl and Cmd is still Cmd. An application that means the control key gets the control key, on every platform.
  • The toolkit’s own editing accelerators move to it: select-all, copy, cut, paste, undo and redo are on the primary modifier in EditKeys, so a text-input on macOS answers Cmd+C rather than Ctrl+C. On Linux and Windows nothing changes, because the primary modifier is Ctrl there.
  • Shortcut.toString() prints Cmd+ where the desktop calls it that, so a menu row reads as that platform’s menus read.

Consequences

  • Word-wise movement stays on Ctrl, and Home/End stay where they are. The rest of macOS’s editing conventions — Alt+Left for a word, Cmd+Left for the line, Ctrl+A for its start — are a second key map and are their own entry on the TODO; this is the accelerator modifier, not the whole platform.
  • design-system.md §2.3 is satisfied by the abstraction it asked for, and the counter-argument it was up against is preserved rather than overruled.

379. A disabled container reaches the cascade

Date: 2026-09-17

Status

Accepted. Closes docs/ARCHITECTURE.md §17.1’s “A disabled container disabling its descendants”, and completes ADR-0077.

Context

core-widgets.md’s widget contract says the disabled state propagates down the tree for input and semantics. ADR-0077 built the input half: PointerRouter walks up from an element and refuses a click, a focus or a hover to anything with a disabled ancestor, so a button inside a disabled form is unavailable without having to know it.

The style half was deliberately left out, and the reason was real: the fade is opacity: 0.45, opacity multiplies down a subtree, and a control that faded itself inside a container that had already faded it landed at 0.2 — which reads as broken rather than as unavailable. ADR-0077 concluded that :disabled should stay on the node that declared it, and deleted a radio-group:disabled radio:disabled { opacity: 1 } undo as evidence that the mechanism was wrong.

What that leaves is a cascade that cannot see the state at all. A stylesheet can say nothing about a disabled subtree — no cursor, no muted label colour, no rule of an application’s own — because nothing inside one matches :disabled. And a container with no fade of its own, which is what form and group-box will be, disables its contents invisibly.

Decision

:disabled propagates, and one rule says the fade belongs to the outermost of them.

  • WidgetRenderer carries “an ancestor is disabled” down the render walk — the direction styles already resolve in — and mirrors :disabled onto every element under a disabled one. The router keeps its own walk up: input is routed before a frame is rendered, and the two must not depend on each other’s timing.
  • controls.css gains :disabled :disabled { opacity: 1 }. This is the general form of the undo ADR-0077 deleted, and it is a different rule: that one undid a mechanism for one widget, this one states what the mechanism means for every widget — a disabled thing inside a disabled thing is not faded twice.
  • The state is recomputed every render, so a container that is enabled again takes it back from everything under it.

Consequences

  • Every golden image is unchanged, segmented-disabled included: the fade lands in exactly the same place, on the outermost disabled node.
  • An application can now write panel:disabled text { color: … } and have it apply, which was the half of §17.1’s entry that was simply missing.
  • :disabled :disabled is the ninth untyped selector in the toolkit’s own sheets, and RuleBucketTest records why it cannot name a type: it is true of every control in the catalog and of every container that can hold one.
  • The semantics half of the contract is still owed, and is still owed by something that does not exist: there is no semantics tree, so nothing yet reports a role as unavailable. That is M5’s AccessKit bridge, and it will read the same propagated flag.

380. The tooltip row is what ships

Date: 2026-09-17

Status

Accepted. Closes docs/ARCHITECTURE.md §17.1’s “A tooltip’s radius and its type rank”.

Context

design-system.md §3’s row for tooltip asks for radius 4 and caption. The stylesheet wrote 8 and body, each with an argument beside it:

  • The radius. §1.5 groups radii as 4 (inputs, small controls), 8 (buttons, cards) and 12 (dialogs, popovers, frost panels), and names no tooltip in any of them. The nearest named thing is a popover at 12, and a tooltip is a small one — so 8 is a reading and 4 is a reading, and neither follows.
  • The type rank. §1.4 gives caption to secondary text under a control, where the reader has the control itself for context. A tooltip is the only text on screen at the moment it is read, and 11px of it at arm’s length is a squint.

ADR-0263 found three of that row’s four numbers had departed and pinned what ships in TooltipMetricsTest, so a fourth departure is a failing test. §17.1 recorded the two as decisions nobody had taken.

Decision

The code follows the design system: radius 4 and caption.

The arguments for 8 and body are good and are not the point. The design documents are the authority — ARCHITECTURE.md §17 says so, and says departures are recorded rather than quietly made — and a toolkit whose own stylesheet departs from its own specification in two places has a specification nobody can read a screen against. Where the reading is genuinely open, as §1.5 leaves the radius, the tie goes to the row that wrote a number down.

The counter-argument for body stays in controls.css beside the declaration, because it is an argument about §1.4 and §1.4 is where it has to be answered.

Consequences

  • Five tooltip goldens are re-blessed: the plate is tighter and the text is 11px. That is the change being made, and the images are the review of it.
  • TooltipMetricsTest now pins agreement rather than a departure, and the row it guards is four numbers with no exceptions in it.
  • §3’s row loses its “not what ships” caveat, and design-system.md says where the body argument went.

381. A rank has two spellings and one meaning

Date: 2026-09-17

Status

Accepted. Closes docs/ARCHITECTURE.md §17.1’s “text style="body"” and book/src/TODO.md’s “text has no style="body" attribute”.

Context

core-widgets.md §2 gives text a style= attribute for §1.4’s token styles — text style="title". What shipped is class="title": the same thing spelled the way CSS already spells it, resolved by rules in controls.css that a theme can move all at once.

§17.1 recorded it as a spelling disagreement and noted that a second spelling “may still earn its keep”. Two things say it does. The design documents are the authority, and this is a sentence in one of them that the code simply does not implement. And the two spellings are not equivalent in one respect that matters: a class is an open vocabulary — an application writes its own — while a rank is a closed one, so class="titel" is a rule that has not been written yet and style="titel" is a mistake.

Decision

Both spellings, one mechanism: style= names a [TextRank] and resolves to the class.

  • TextRank is §1.4’s scale as an enum, and cssClass() is its name as CSS spells it — BODY_STRONG is body-strong.
  • text style="title" adds title to the element’s classes at inflate time, so a rule written text.title, an application’s own .title override and the theme’s own tokens all apply to it unchanged. A rank the widget carried privately would be a second mechanism that looked like the first.
  • Classes written beside it survive: text style="title" class="muted" is both.
  • An unknown rank is refused where it is written, with the six named in the message. That is the difference between the two vocabularies, stated.
  • Text.style(TextRank) is the same thing from Java.

Consequences

  • §2’s sentence is implemented rather than recorded as a departure, and §17.1 loses an entry.
  • The rank is still not a size: what heading is worth stays in controls.css, which is what lets a large-text theme move every rank without a widget hearing about it.
  • TextRankTest asserts that the seven constants are the seven rules the stylesheet has, so a rank added to one has to be added to the other.

382. A GIF has the frames after the first

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “One frame only” for GIF; the animated WebP half of that entry stays open and is narrower now.

Context

ADR-0329 wrote a GIF decoder in Java and stopped at the first frame, on purpose: “an APNG, an animated GIF or an animated WebP is a sequence and a clock, and would be its own record: a disposal model, a delay per frame and something to drive it.”

That sentence is the design. The reason to build it now is that all three pieces are cheap where they belong and expensive where they do not: the disposal model is thirty lines inside the decoder and impossible outside it, the delays are in the file, and the clock is the caller’s — which is exactly how every transition in this toolkit already works.

The disposal model is the part that cannot be skipped. A GIF frame is usually a patch, and what is under it depends on what the frame before it asked for when it left: keep the canvas, clear the patch to transparent, or put back what the patch covered. A decoder that ignores it draws every frame at once, which is the classic way an animated GIF renders wrong.

Decision

GifDecoder.decodeAll reads the sequence; Animation says which frame is showing; nothing here holds a clock.

  • GifDecoder.Sequence is frames, each already composited onto what the frame before it left, each with its delay, plus the loop count from the NETSCAPE extension — 0 for ever, which is that extension’s own spelling.
  • All three disposal methods are implemented. “Restore to previous” captures the canvas before the frame is drawn, because that is what previous means.
  • A delay below 20ms becomes 100ms, which is what Firefox and Chromium both do with a file that asks to run as fast as possible.
  • image.anim.Animation is the value: at(elapsedMillis) is the only question it answers, and the elapsed time belongs to whoever is drawing — a canvas painter reading the frame clock, an offscreen render stepping a virtual one, a test asking for exactly 240ms.
  • Every image is an animation: Animation.still(image), and Image.decodeAnimation answers one for a PNG as readily as for a GIF, so a caller drawing either needs no branch.
  • A finite animation stops on its last frame rather than vanishing or restarting, which is what a GIF that has played its three loops looks like.

Consequences

  • Image.decode is unchanged: it still reads the first frame, which is what a picture on a board wants and what most GIFs contain.
  • The image widget still shows that first frame. Playing one is a widget decision — §1’s row for image asks for fits, DPI variants and states, and says nothing about animation — and it now has something to play. Today an application animates one on a canvas in two lines.
  • An animated WebP is still one frame, and the reason is unchanged: the frames live in a webpdemux the superbuild does not build (ADR-0329). The entry is narrower rather than closed.
  • The decoder is bigger and is still one file with no dependencies. The sequence path and the single-frame path share the patch model, so a disposal bug cannot hide in one of them.

383. The desktop is asked whether to move less

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “Reduced motion is obeyed but not detected”; the density half of that entry is answered separately below.

Context

§13 names four accessibility switches and the toolkit obeys all four. Three are the application’s to set and one is not: reduce motion is a desktop setting, and renderer.reducedMotion(true) was the only way in — so every application had to find the answer for itself, on three platforms, which is exactly the work a toolkit exists to have done once.

ADR-0322 answered the other desktop preference with one SDL call and was glad not to write three platform integrations for a boolean. There is no SDL_GetReducedMotion, so this is those three integrations — taken because the alternative is every application writing them.

Decision

Ask each platform directly, through FFM, against a library the process already has; cache the answer; obey it.

askedof
Linuxorg.freedesktop.portal.Settings.Readthe XDG portal, over libdbus
WindowsSystemParametersInfoW(SPI_GETCLIENTAREAANIMATION)user32.dll
macOSNSWorkspace.accessibilityDisplayShouldReduceMotionlibobjc
  • No native build. Nothing is compiled, nothing is added to the superbuild and nothing new ships: each library is dlopened by name at run time, which is what SDL itself does with D-Bus.
  • Linux asks the specified key first — org.freedesktop.appearance’s reduced-motion, a uint32 whose 0 means “no preference” — and falls back to GNOME’s org.gnome.desktop.interface/enable-animations, which is the same question the other way round.
  • dbus_message_append_args is variadic and is not used: the iterator API says the same thing with fixed signatures. The reply is a variant wrapping a variant on every portal implementation tried, so the reader unwraps until it finds a value.
  • Three answers, not two. UNKNOWN is what a missing library, a missing portal, a missing key, a refused call and an unexpected type all produce, and it is not an instruction: a renderer built from it animates normally.
  • Backend.reducedMotion(), Window.reducedMotion() and Host.reducedMotion() carry it, and Launcher applies it — unlike the theme, which the toolkit deliberately does not act on. A colour scheme is a matter of taste; an accessibility preference is not.
  • -Dgoldberry.motion.reduced=reduce|full overrides it, for a test, a screenshot, and a desktop whose answer is wrong.
  • The holders live in a package that is not exported, which is ADR-0173’s rule and what ExportedSurfaceTest enforces: what leaves :natives is an enum with three constants in it.

Consequences

  • Asked once per process, and a change mid-session is not obeyed until the application restarts. Listening means a D-Bus main loop on Linux and an observer on each of the others — a much larger thing than one 200ms read, and worth its own decision if anybody asks for it.
  • The Linux path is verified against a real portal on this machine, in all four of its outcomes: a uint32 preference, a uint32 zero, a GNOME boolean and a namespace nobody serves. The Windows and macOS paths are written from their documented APIs and are exercised by CI on those runners; each of them fails to UNKNOWN, which is the behaviour the toolkit had before this existed.
  • Density is answered, not built. The TODO entry pairs the two, and they are not alike: no desktop has a density setting to read — not GNOME, not Windows, not macOS — and §1.3’s compact mode is an application’s own choice about its screens. There is nothing to detect.

384. The emoji face is an artifact an application opts into

Date: 2026-09-17

Status

Accepted. Closes docs/ARCHITECTURE.md §17.1’s “goldberry-emoji is not a module, and core carries its attribution” — which that section called the only obligation in the toolkit an application cannot discharge with a file.

Context

content-widgets.md’s table has always quarantined OpenMoji in an optional artifact, and given the reason: CC BY-SA asks for attribution where the work is seen. An about box, a credits screen, a help page — not a line in a notice file inside a jar.

goldberry-core shipped the font anyway, because the emoji slot is part of §6.1’s font chain and the chain lives in the text stack. So every application that depended on Goldberry inherited a share-alike attribution obligation whether or not it ever drew an emoji, and had no way to tell that it had.

§17.1 recorded the two as defensible and incompatible. They are not: the chain is a text-stack decision and the face is a packaging one, and nothing required them to live in the same artifact.

Decision

The face ships as goldberry-emoji; :core keeps the slot and loads the face through a service.

  • assets.EmojiFont is a service interface in :core, which uses it. goldberry-emoji provides it with OpenMojiFont, and declares the provider in META-INF/services as well, so it is found on the class path and on the module path alike.
  • A resource, not a service, was the obvious alternative and does not work: a resource in a named module is encapsulated, and opening a package to read one file is a wider hole than a provider.
  • BundledAssets.font(EMOJI) with no provider throws MissingEmojiFontException, whose message names the artifact, the licence and what to do about it. BundledAssets.hasEmojiFont() is the cheaper question for an application that draws emoji when it can.
  • OpenMojiFont.CREDIT is the sentence to display, as a constant, so an application meets the obligation without transcribing it.
  • :assets prepares a selection now — --only=inter,jetbrains-mono,lucide for :core, --only=openmoji for :emoji — and each asset task empties its directory first, because an incremental build otherwise keeps shipping a face the module has stopped fetching.
  • PublishedModules gains one line and the BOM, the umbrella and the POMs follow; the artifact is optional, like goldberry-html and goldberry-gpu.

Consequences

  • An application that drew emoji before must now add a dependency. That is the change, and it is the point: the obligation is visible at the moment it is taken on. The failure says so in a sentence rather than as a missing resource.
  • :core is 1.4 MB smaller, which is a side effect and not a reason.
  • The shaping test moved with the font: “emoji shape through the emoji face” belongs to the artifact that ships the face, and :core asserts the absence — that hasEmojiFont() is false and that the message names the artifact.
  • NOTICE and THIRD-PARTY-NOTICES.md say which jar the font is in, and that an application adding it owes the credit. licenses/openmoji.txt stays where it is: the licence text is still disclosed by the repository whether or not a given build ships the font.
  • §6.2’s re-themed COLRv0 variant is unaffected and still not bundled. If it ever is, it is a derivative work and the statement of changes goes in the same two files.

385. WebP is written, and animated

Date: 2026-09-17

Status

Accepted. Closes book/src/TODO.md’s “PNG is the only format written” for WebP, and the animated-WebP half of “One frame only” that ADR-0382 left open.

Context

ADR-0329 linked libwebp’s webpdecoder target — the decoder alone — and said why: “Goldberry reads one still frame and writes PNG, so the encoder would be several hundred kilobytes of code nothing can reach.” The encoder, the muxer and the animation demuxer were all switched off.

Two things have happened since. Offscreen renders a picture an application wants to write — a thumbnail, a preview, a screenshot of a canvas — and PNG is the only thing it can write. And ADR-0382 built animated GIF, leaving animated WebP as the whole of what remained of “one frame only”.

Both live in targets upstream already builds. webp is the decoder plus the encoder; webpdemux is the animation reader over it. Neither is new code.

Decision

Link webp and webpdemux; export the encoder and the animation decoder.

  • Image.encodeWebp() is lossless, and that is the default on purpose: VP8’s transform is worst at flat colour and hard edges, which is exactly what a user interface is made of. encodeWebp(quality) is the lossy path, for a photograph. The tests say both halves — lossless round-trips a drawn picture exactly and beats its PNG; lossy at 80 is a long way off on random noise.
  • Image.decodeAnimation reads an animated WebP the same way it reads a GIF, and the work is upstream’s: WebPAnimDecoderGetNext hands back a fully composited canvas, so the disposal model ADR-0382 had to write in Java is libwebp’s here.
  • libwebp reports the moment a frame stops being shown; the binding differences those into durations, because a duration is what a caller can use.
  • WebPAnimDecoderOptionsInit and WebPAnimDecoderNew are static inlines in demux.h, so what is exported is the …Internal pair they forward to, with the ABI version passed from Java and checked by the library — which is what the inline does.
  • The muxer stays off: nothing here assembles an animation, and writing one is a different decision with a different API.
  • ImageEncodeException is new, for one case: WebP holds 16383 pixels on a side at most, so a very tall screenshot is a refusal rather than a bug.

Consequences

  • libgoldberry grows by the encoder and the demuxer. That is the cost ADR-0329 declined to pay for nothing; it is paid now for two features.
  • An animated WebP no longer decodes to its first frame — it decodes to all of them, and a still one is still a still.
  • JPEG encoding is still not built, and is now the only entry left on that line. Blend2D ships a JPEG decoder and no encoder, so writing one means a third codec library or a written one, and neither is worth it while a lossless WebP is a third of a PNG and reads everywhere.
  • The animated fixture is three 4×4 frames at three different delays, written by a script rather than found: what is being asserted is the timing model, and a photograph of a cat would assert it no better.

386. A sheet of emoji is the font’s own contents

Date: 2026-09-17

Status

Accepted. Adds the showcase’s thirteenth screen, and the reader-facing half of ADR-0384.

Context

ADR-0384 moved the emoji face out of goldberry-core and into an artifact an application opts into, because CC BY-SA asks for attribution where the work is seen. That left two things unsaid. Nothing in the showcase drew an emoji, so the toolkit shipped a font nobody could look at; and nothing anywhere showed what taking the obligation on looks like, which is the part an application author actually has to copy.

The Icons screen is the shape of the answer — a searchable sheet of every bundled icon — and it is deliberately not the same screen. An icon is a path the application holds and hands to a box. An emoji is text: a code point drawn through whichever face the cascade picked, which is §6.1’s font chain and one font-family declaration.

Decision

A second sheet, beside the first, built from the face’s own cmap.

  • FaceCoverage.codePoints(bytes) reads a TrueType cmap — formats 4 and 12 — and answers which characters a face has. Java rather than hb_face_collect_unicodes, for GifDecoder’s reason: the table is two arrays of ranges, and binding a set object and its iterator across FFM for a question asked once per face costs more than owning the format. Unreadable bytes are an empty answer, because the caller is asking what is in a face.
  • The sheet is filtered to Character.isEmojiPresentation, and not to Character.isEmoji: the second is true of 0, # and ↔, so a sheet built on it opens on the ASCII digits. 1205 of OpenMoji’s 1845 characters are pictures on their own.
  • The names are Character.getName — Unicode’s own, out of the JDK, so a search for “cat” works and no second asset is fetched. The hex matches too, because the other way somebody looks for an emoji is with a U+231B from a bug report.
  • The screen carries the credit: OpenMoji, openmoji.org, CC BY-SA 4.0, in the note under the title. That is what an About box would do, and it is the demonstration ADR-0384 owed.
  • Everything else is the Icons screen’s and is shared rather than copied: the reflow arithmetic (IconsScreen.columnsFor), the row pitch, the measured sheet (IconSheet) and the virtualized list of padded rows.
  • The gallery golden for it uses a font book, alone among the gallery images: the others are taken with the one-font renderer, which ignores font-family — and a picture of an emoji sheet drawn in Inter is a picture of 1205 .notdef boxes.
  • Fonts now falls back when the emoji face is absent rather than throwing: a stylesheet naming font-family: OpenMoji in an application without the artifact draws in the UI face and says so once in the log. The cascade runs inside a render pass, and a missing optional artifact must not be able to turn a window into no text at all.

Consequences

  • Thirteen screens, and no digit moved: Ctrl+1…Ctrl+0 still name the first ten, and the two sheets are reached by the strip, the arrows and Edit ▸ Go to — which is exactly what ADR-0307 decided when the eleventh screen arrived.
  • Every gallery golden is re-blessed for one more tab in the strip. The change is 547 pixels wide and is the tab.
  • :example depends on :emoji, which is the first application in this repository to opt into an artifact with an obligation attached — and EmojiScreenTest asserts that the credit is on the screen, not merely that the face loaded.
  • FaceCoverage is public and general: an emoji picker is the first caller, and “does this face cover this script” is the same question.

387. A resource directory is a package

Date: 2026-09-18

Status

Accepted. Fixes the start-up failure ADR-0384 shipped, and adds the check that would have caught it.

Context

goldberry-emoji put its face where the asset step had always put faces:

io/github/digitalsmile/goldberry/assets/fonts/OpenMoji-black.ttf

goldberry-core still ships four faces and an icon table in that same directory. A resource directory is a package to the module system, exactly as a directory of classes is — so two modules contained io.github.digitalsmile.goldberry.assets.fonts, and the showcase died before its first frame:

java.lang.LayerInstantiationException: Package io.github.digitalsmile.goldberry.assets.fonts
    in both module io.github.digitalsmile.goldberry.emoji and module …core

Every test passed, and that is the part worth writing down. Tests run on a class path, where a split package is two directories and nobody objects; ./gradlew :example:run puts the modules on a module path, which is where the rule lives. A green suite and an application that will not open is the exact shape of failure this repository has tests for — and the module graph, which docs/ARCHITECTURE.md §3.1 calls load-bearing, had no test at all.

Decision

A module’s resources live under that module’s own package, and a test walks the artifacts to say so.

  • PrepareAssets takes --root=, so a build script says where inside the jar its assets land. :core keeps io/github/digitalsmile/goldberry/assets; :emoji writes io/github/digitalsmile/goldberry/emoji, and its face is …/emoji/fonts/OpenMoji-black.ttf.
  • SplitPackageTest is :example’s, because :example is the one module that depends on every other. It walks each Goldberry artifact on the class path — directory or jar — collects the directories that hold files, and fails when a package is in two modules.
  • It asks the question the JVM asks, rather than reading ModuleDescriptor.packages(): that set comes from the ModulePackages attribute javac wrote about the classes it compiled, and what the runtime objected to came from the jar’s contents.

Consequences

  • The showcase runs again, and the run is part of what was verified rather than assumed: ./gradlew :example:run painting three frames on the dummy driver.
  • The test is discovery-based, so a module added later is covered without anybody remembering to add it — and it refuses to pass if it finds almost nothing, which is how a discovery test says it has stopped discovering.
  • The rule now has a name to cite: assets belong under the module that ships them. :html already obeyed it by accident, having no resources outside its own package.

388. A note is shaped a line at a time, and drawn a screenful at a time

Date: 2026-09-18

Status

Accepted. Closes docs/gaps.md G44. Changes what ADR-0331 numbers — the rows in view rather than the document — without changing how. Leaves ADR-0299’s paragraph cache as it is and adds one counter to it.

Context

The entry arrived as a table from a note editor downstream: one text-area in edit mode, gutter on, class="mono", one character typed per frame, and a style span that grew about linearly with the note while the tree stayed the same size. It named three suspects — the cascade, per-run shaping, the gutter’s numbers — and said it was guessing.

It reproduces here. TextAreaFrameBenchmark (:widgets) is that editor: one filling text-area in a 640×600 window, a character per frame, each stage of the frame timed separately in the launcher’s order. Against this branch’s base, 5d70609b, on linux-x64, medians in ms:

notebuildstylelayoutrasterframe
2 kB0.120.730.403.124.63
50 kB0.526.380.7212.5320.47
500 kB5.0992.266.30104.98207.39

The first two rows are two hundred frames. The third is sixteen, because two hundred is not reachable: a 500 kB note dies with an OutOfMemoryError after about thirty keystrokes in Gradle’s 512 MB test JVM. That is the first finding and it is not a detail — see below.

What it is not. Restyling a text-area whose text did not change was already flat: 0.11 ms at 2 kB and 0.38 ms at 500 kB, on the same checkout. The tree is seventy-five elements either way, and nothing walks it twice. ADR-0315 holds; a text-area does not defeat it. The cascade was never the term.

What it is. TextAreaBox.render handed the whole note to Paints.Context.paragraph. A Paragraph is shaped whole and keeps two prefix sums over it, an int per character each, over a run of six more int[] — for half a million characters that is about 17 MB and half a megabyte of HarfBuzz. Every keystroke makes a different string, so every keystroke paid all of it. Counted rather than timed, one keystroke on the old code shaped:

notecharacters shapedcharacters drawn
2 kB2 0622 127
50 kB50 00552 060
500 kB500 005524 600

And it always missed the paragraph cache exactly once, at every size, which is why no counter in the toolkit saw this coming: ParagraphCache.misses() counts paragraphs, and the paragraph was the document.

Three consequences of the same fact:

  • The heap. The cache is sized in entries — 256 of them, “roughly 200 bytes an entry” as ADR-0299 sized it, which is a word. Two hundred and fifty-six versions of a 500 kB note is four gigabytes. Every keystroke put one in.
  • The raster. Paragraph.paint draws every line of its layout and the clip only decides what survives, so a ten-thousand-line note made ten thousand glyph-run fills to show thirty-five rows. 105 ms of the 207.
  • The gutter. ADR-0331 draws the numbers as one paragraph with a blank line per wrap. It built that string for the whole document — fifty kilobytes for a 500 kB note, rebuilt every frame and re-shaped whenever the line count moved — to draw thirty-five numbers. Real, and second-order next to the text.

There were O(text) scans in the frame too: hardLines() counted newlines across the whole note on every frame, and caretRect walked every visual line in the document to find the caret’s. Both are third-order, and both are gone anyway.

Decision

A text-area holds a document, not a label: the geometry comes from a text shaped one hard line at a time, and the glyphs are one paragraph of the rows on screen.

  • io.github.digitalsmile.goldberry.text.document.TextDocument shapes a text one hard line at a time. Wrapping was already per hard line — Paragraph.layout splits on \n first and breaks each piece on its own — so nothing is lost by shaping the pieces apart. Given the document the last frame built it compares the two strings from both ends, a scan that allocates nothing, widens the bracket to whole hard lines, and rebuilds only those. Every other line keeps the Paragraph instance it had, and its wrap memo with it.
  • DocumentLines is that document broken at one width: a computed List<TextLine> in the whole text’s offsets, so everything written against Paragraph.layout().lines() keeps working and a ten-thousand-line note does not allocate ten thousand records to answer three questions.
  • TextAreaBox draws the rows in view, as a slice of the text between two line starts. Greedy wrapping restarts at every line start, so re-wrapping that slice at the same width gives back exactly the rows the document said.
  • One box for the text, not one per row. Yoga rounds every box it places onto the pixel grid; a box per row would round each row separately while the rows inside a wrapped line stayed exact. The numbers are placed from the same origin for the same reason — one rounding, shared, is what keeps them from drifting.
  • Value.carrier is a text-value that shapes nothing and reports what the cascade resolved. The node still exists, because that is where the ink, the white-space and the .placeholder rule are decided; it cannot hold the glyphs, because which rows are in view is settled during render and a child is described before it.
  • ParagraphCache.shapedCharacters() counts what the misses shaped. The miss count could not see this and a stopwatch is not a test.

Consequences

Same benchmark, same machine, two hundred frames at every size, medians in ms:

notebuildstylelayoutrasterframe
2 kB0.090.830.473.474.92
50 kB0.060.530.333.084.10
500 kB0.050.900.273.044.36

The rows are flat. A keystroke into a 500 kB note shapes 2 021 characters and draws 2 020, against 1 941 and 1 940 for a 2 kB one — the rows on screen and the numbers beside them, and the small difference is that six-digit line numbers are wider than four-digit ones. A settled frame shapes nothing, as it did before. What G44 said this blocked was a preview under 100 ms on a large note: the whole frame is 4.4 ms.

  • Opening a note is still proportional to it, and that part is irreducible here. A 500 kB note shapes 499 079 characters when it is first rendered — 78 to 95 ms across runs on this machine — and again on a theme change that resolves a different face. How tall the content is, how far it scrolls and where every line breaks are facts about every line, and nothing knows a line’s height without shaping it. What changed is that it is paid once per document rather than once per keystroke. Shaping the lines below the fold off the frame would close that too, and is in book/src/TODO.md rather than here: nothing has measured a note large enough for 95 ms of opening to be the complaint, and ADR-0045 is about exactly that.
  • The heap is bounded. The cache never holds more than one version of a hard line, and no entry is larger than a line. The benchmark that could not run two hundred frames at 500 kB now does.
  • Scrolling re-shapes the window — about 2 kB per step, which did not happen before because the whole document was already shaped. It is flat in the note’s size and it is what makes everything above flat.
  • The pictures are unchanged. No golden moved: the text box is one box at one origin, as it was, and its lines fall where they fell.
  • text-value no longer carries a text-area’s glyphs. A test that read the value node’s paragraph reads the control’s own box instead. TextAreaGutterTest checks the numbers against the text’s own top now rather than against the scroll offset — both are drawn from the first row in view, and what the entry is about is that they agree.
  • text-input is untouched. A single line is its own window, and the same change there would be machinery around a string nobody types half a megabyte into.
  • TextDocument is exported. Editor — the canvas editing seam from ADR-0285 — has the same whole-string shaping and is the obvious second caller. It is not changed here: nothing has measured it, and ADR-0045 is about exactly that.
  • TextAreaKeystrokeCostTest guards it, in counts rather than milliseconds: a keystroke into a 500 kB note shapes and draws about what a keystroke into a 2 kB one does. Run against 5d70609b it fails, with 500 005 characters shaped against 2 062 and 524 599 drawn against 2 126 — which is the point of a guard.

389. A block nobody typed in keeps its widget

Date: 2026-09-18

Status

Accepted, closing docs/gaps.md G45.

Uses ADR-0315’s first guard — the same description is not a description — from the caller it was waiting for, and narrows ADR-0301’s numbering of words from the document to the block.

Context

G45 was measured downstream: a note editor with a markdown-view beside it, rebuilt with a new Document on every keystroke, as the view’s own documentation says to. One paragraph changes and every stage of the frame grows with the whole note.

A number from somebody else’s application is a number nobody here can re-take, so the first thing this did was reproduce it in the repository: MarkdownFrameBenchmark, a markdown-view bound to a property, inside a scroll, with a character typed into a paragraph in the middle of the note and the four stages of FrameStats timed around it. Mean / worst, in ms, on this machine:

notebuildstylelayoutrasterframe
2 kB0.45 / 16.440.29 / 0.960.34 / 0.862.22 / 5.213.30 / 23.47
50 kB8.71 / 18.337.29 / 12.494.98 / 7.503.16 / 5.3624.14 / 43.68
500 kB85.35 / 109.4178.32 / 102.9342.60 / 60.943.71 / 4.99209.97 / 278.26
50 kB, typing a space6.84 / 12.1018.30 / 25.8572.48 / 82.332.42 / 3.11100.03 / 123.39

It reproduces. The absolute numbers are smaller than the entry’s — a faster machine and a narrower preview — and the shape is exactly what it describes: every stage grows with the note rather than with the paragraph.

The last row is not in the entry and is the interesting one. A keystroke that adds a word costs fifteen times the layout of a keystroke that does not, and in ordinary prose roughly one keystroke in six is a space. Typing fo into fx is cheap; typing the space after the word is 72 ms of Yoga.

md4c is timed alongside as a control — the same code whatever the view does — at 1.06 ms for the 50 kB note and 8.35 ms for the 500 kB one. So the build column is the view, not the parser, which is what the entry claimed.

Why the element tree was not already doing this

ADR-0315 put the mechanism in place a fortnight ago:

void update(Widget next) {
    if (next == previous) {          // nothing below this is walked
        if (needsBuild) rebuild();
        return;
    }

The same widget instance describing the same node means the node’s subtree cannot have changed, so nothing under it is re-described, re-cascaded or re-measured. A scroll gets this for free because it keeps its children in a record field and hands the same objects back.

markdown-view cannot, because it has nothing to hand back. MarkdownView.build makes a MarkdownWidgets per build and walks the whole document with it, so every block is a new Column, every paragraph a new Row and every word a new Word — equal to what was there and identical to none of it. The guard never fires and the element tree re-describes the note top to bottom.

That is the first half. The second half is why re-describing it was so expensive even where the description was the same.

A word was numbered by the document, and that is what a space moved

ADR-0301 gave each word an index — its position in document order — and used it for three things at once: the key the element tree reconciles on, the slot in the geometry’s flat array of entries, and the order a selection reads in.

Insert a space in the first paragraph and every word below it is renumbered. The element keyed 412 is now asked to be the word that was 411; its text changes, so its shaped paragraph changes, so the box it renders changes, so Yoga re-measures it. One row per paragraph is created and one destroyed, because each row has one more or one fewer key at its edges. Nothing about those paragraphs changed and the whole tail of the note was re-measured and re-laid-out.

That is the 72 ms, and no amount of matching blocks would have removed it: a block handed back unbuilt would still hold words whose indices are wrong.

What could not be fixed here, and is not

WidgetRenderer.render walks every element every frame and asks every Paints node for a fresh Box; the retained render tree then lays out whatever box tree it is handed. Both are O(document) by construction, and the style cache and Yoga’s own dirty-tracking make the per-node constant small rather than zero. So the style and layout columns cannot be removed by matching anything — only by not building the off-screen blocks at all, which the entry rightly says is a separate decision. This one does not take it.

Decision

Four changes, none of them API. MarkdownView is what it was; an application threads no previous document through anything.

1. Entries belong to a block, not to the document

WordGeometry stores its entries per block and lays them end to end afterwards, and a Word is keyed on the entry it reports to rather than on a number.

An entry is the identity a word is reconciled on, so where it is stored decides what a keystroke costs. Held per block, a paragraph keeps its own entries however many words appeared above it: the same objects, in the same order, holding the same text — so the element tree matches each word to the node that reported its rectangle, nothing is re-shaped and Yoga sees the same boxes.

The flat view a Caret indexes into is rebuilt only when a block is added, dropped or resized, and costs a reference copy per word. The block number each entry carries — what a triple-click takes — is written there rather than at registration, because it is a fact about the document and not about the block.

2. BlockMemo, positional, over the top-level blocks

One per mounted document, beside the geometry in SelectableDocument’s state — the only thing in either content view that outlives a build. The ith entry answers for the ith block and nothing else. It hands back the widget when:

  • the source is equals to the one it was built from — a record tree, so a deep comparison of the text, which is two orders of magnitude cheaper than building the widgets again;
  • the fold is standing where it stood then;
  • the geometry still holds the blocks it was built into.

A block that moved is built again. That is the right answer rather than a missed optimisation: its words report into a different block’s entries.

3. A mark is a block boundary, and one number

WordMinter.Mark is how many blocks have been opened and nothing else. Between two blocks the words behind the minter are about to be forgotten and the next word opens a block, so that is the whole of what distinguishes one boundary from another. The first version of this carried the word count as well, and a paragraph that gained a word moved the mark of the paragraph after it — which rebuilt the rest of the note for a number nobody reads again. The test caught it by counting.

The fold’s mark wraps the minter’s with what only the fold knows: how many task boxes it has numbered, so a skipped block does not reset a task ordinal, and which handlers the view has, because a link with no handler is drawn inert and is a different widget.

4. The two things a block is not a function of

  • The handlers. An application that writes onLink(this::open) inside its own build hands the view a new object every frame, and a button built three keystrokes ago would keep calling the first one it saw. So the buttons press through MarkdownWiring — one object for the life of the view, given the current handlers at the start of every build. Their presence is in the mark; their identity is not.
  • A missing image. ImageSource answers null while it loads and the picture arrives on a later frame (ADR-0300). A block that drew alt text says so, and the memo does not keep it — so it is asked again every frame until the image lands.

Alternatives considered

MarkdownView.of(next, previous) and Block.sourceRange(), which is what the entry proposed. Rejected, and the entry expected it to be: it makes every application hold the document it last rendered and thread it through its own state, to answer less than the memo does. A diff of two documents says which blocks are equal; it does not say whether the fold was standing in the same place, which is the question that actually decides whether a widget can be handed back. sourceRange() is the same story — equals on a record tree is the same answer at the same cost, and does not put an offset on a model that is otherwise about text.

Compare widgets with equals rather than identity. ADR-0315 rejected it for being quadratic and the reason has not changed.

Memoize nested blocks too — the items of a list, the paragraphs inside a quotation. The marks would have to nest and the accounting gets much harder, for a document whose top-level blocks are already paragraph-sized. The top level is where the blocks are.

Lazily build only the blocks inside the viewport. The only thing that fixes 500 kB, and deliberately not built here: the entry says it is a separate decision and it is right. See the consequences.

Consequences

The measurement, same machine, same benchmark, md4c control at 1.09 ms and 9.81 ms against the 1.06 and 8.35 above:

notebuildstylelayoutrasterframe
2 kB0.19 / 0.420.48 / 12.000.49 / 0.932.88 / 4.624.04 / 17.96
50 kB2.05 / 7.436.68 / 21.575.10 / 9.453.01 / 5.8716.83 / 44.32
500 kB16.09 / 24.16109.67 / 146.7462.19 / 77.095.42 / 6.82193.37 / 254.82
50 kB, typing a space1.41 / 6.287.17 / 16.955.93 / 9.232.93 / 5.6617.44 / 38.13
  • The build is the parse now. 2.05 ms at 50 kB of which md4c is 1.09; 16.09 ms at 500 kB of which md4c is 9.81. The view’s own share of a keystroke fell from 7.7 ms to 1.0 at 50 kB and from 77 ms to 6 at 500 kB.
  • A space costs what a letter costs. Layout 72.48 → 5.93, style 18.30 → 7.17, the whole frame 100.03 → 17.44. This is the entry’s “the worst is layout”, answered.
  • 50 kB is inside the preview < 100 ms budget, mean and worst, for both kinds of keystroke. That is what closes G45.
  • 500 kB is not, and the reason is now visible rather than mixed in. Style and layout did not move: at that size they swing between roughly 60 and 180 ms from run to run on this machine, before and after alike, tracking the md4c control. They are the render walk and the Yoga pass over 106,509 elements, and nothing about matching blocks touches them. Lazily building only what is in the viewport is the only thing left that would, and it is its own entry.

The guard is a count, not a clock. BlockReuseTest mounts a twelve-paragraph note and asserts that a keystroke builds one block and keeps eleven; that a space does the same; that the untouched blocks hold the same Widget instance and the same Element; that at most two paragraphs are shaped; and that what a copy of the whole document produces is still the document. Each of those fails without one of the four changes above, and each says the same thing on a machine somebody else is also using — which a millisecond does not (docs/testing.md §4).

What is now expensive to get wrong. A fold carries state as it walks, and anything it carries that is not in its mark is a bug that looks like a rendered document: a task ordinal that resets, a counter that runs backwards. tasksSeen is in the mark for that reason. A fold that grows a second counter and forgets has no compiler to tell it, and the symptom is a check box that toggles the wrong line six paragraphs away.

html-view got the plumbing and not the memo. Its fold takes the minter and ignores the memo it is handed. The same lever is available to it and is a separate piece of work; the measurement that justified this one was taken on Markdown.

Word lost its index, which was a public component of a record in a package nothing exports. What reads an index now is the geometry, through the flat view it owns.

390. A turned shape is a path, and the frame can compose

Date: 2026-09-18

Status

Accepted. Closes docs/gaps.md G46. Promotes example.motion.Rotated (ADR-0354) into the toolkit and adds the composing transform ADR-0068 left out, without touching the native boundary.

Context

An application’s landing page turns its tiles as they settle: each starts a few degrees over and lands flat. It is drawn on a canvas, and inside a canvas a painter has no way to turn anything.

Frame.transform(a, b, c, d, e, f) replaces the matrix, and the matrix already holds the translation that put the canvas on screen. A painter cannot read that translation back — bl_context_get_transform is not on the export list — so setting a rotation draws the tile at the window’s corner instead of at its own. ADR-0197 fixed the same bug once already, for the charts, by handing the ambient matrix down to paintCanvas; an application’s painter is handed no such thing.

That left rewriting the path’s own coordinates, which is what the showcase does: example.motion.Rotated, forty lines of switch over every Path.Segment kind. It is path geometry, it is not the showcase’s to own, and a second copy of it in every application that animates a canvas is a second toolkit growing beside this one. It is also wrong in the interesting case. Rotated adds the turn to an arc’s rotation and leaves the radii and the sweep flag alone, which is right for a rotation and right for nothing else: a scale stretches the ellipse, and a mirror makes the arc run the other way round.

Decision

The transform is a path’s, in paint.geom, over the matrix type the toolkit already has — and Frame gains a composing concat that costs no native change.

  • paint.geom.Transformer, beside Flattener and Dasher, maps a Path through an affine. Path.transformed(Affine) is the entry point, with rotated(radians, cx, cy), translated(dx, dy) and scaled(sx, sy) for the three cases that would otherwise be a matrix spelled out at every call site. The identity gives back the same path rather than a copy, so a tile at rest costs nothing.
  • css.value.Affine is that matrix, unmoved. It already has rotate, translate, scale, then and about, its about is transform-origin, and its arithmetic is the arithmetic hit testing inverts. A second matrix type in paint.geom would be two implementations that must agree exactly, which is the failure its own javadoc was written to prevent. It stays in css.value because moving it would churn the cascade for a package name: Path already imports css.Corners for the same reason.
  • The arc is decomposed rather than adjusted. An arc carries the shape of its ellipse — two radii and a rotation — and the transformed ellipse is the unit circle under the caller’s linear part times the arc’s own basis. Recovering radii and an angle from that product is the singular value decomposition of a 2×2 matrix, which has a closed form: the singular values are the semi-axes, the left rotation is the angle they sit at, and the right rotation is discarded because it spins the unit circle onto itself. The large-arc flag is unchanged. The sweep flag flips when the determinant is negative, because a mirror reverses the direction the arc is travelled and the endpoints do not say which side of the chord the ink is on. The rotation comes back in [0, π), since an ellipse is unchanged by a half turn and a shape turned a degree at a time for an hour should not accumulate an angle.
  • Frame.concat(a, b, c, d, e, f) multiplies rather than replaces: the caller’s matrix applies first, in the coordinates it draws in, and whatever was in force applies to the result. Same display-scale semantics as Frame.transform — a concatenated translate(10, 0) moves ten logical pixels at any scale — and the same absence of a push, since save() and restore() are already the state stack.
  • The composition is Java’s, and no native symbol or constant was added. This is the part G46 asked to be checked. bl_context_apply_transform_op is exported — it is how the display scale reaches the rasterizer — but the compose operation is an enumerator, BL_TRANSFORM_OP_TRANSFORM, and the enumerators the bindings hard-code are checked against the compiled library by LayoutVerifier. Only RESET, ASSIGN, TRANSLATE and SCALE are in goldberry_shim.c, so naming a fifth means a new GB_CONSTANT row and a rebuilt native library — a native change for six multiplies, and six multiplies that must agree with what hit testing inverts. So Frame mirrors its own logical matrix in an Affine field, composes there, and assigns the answer through the ASSIGN op that was already bound. The mirror is exact because every change to the matrix goes through transform, concat or resetTransform, and save/restore push and pop it alongside the rasterizer’s own stack.
  • example.motion.Rotated is deleted. TileFloor composes the turn and the drop into one Affine and calls Path.transformed. No golden image moved, which is the evidence that the geometry it had was the geometry it kept.

Consequences

  • A canvas painter has two ways to turn what it draws, and they are for different things. A transformed path is a value: it can be measured, hit-tested and drawn many times in the coordinates it will appear in, and it needs nothing on the frame to be balanced. concat is for a painter drawing many shapes under one matrix — text included, which a path transform cannot reach.
  • Transformer keeps curves as curves and arcs as arcs. Nothing is flattened, so a transformed path costs one pass over the segments and the rasterizer still sees the shape the author wrote.
  • Frame holds state it did not hold before — six doubles and a stack of them. It is the frame’s own matrix, mirrored, and it is what makes a composing transform possible without either a native change or a second answer to “where am I”.
  • ADR-0068 is unchanged where it matters: the painter still assigns an absolute matrix per box, hit testing still inverts that same matrix, and a run of untransformed boxes still costs no native call. concat is a convenience over the assignment, not a second mechanism.
  • The arc arithmetic has no other caller today. It is tested on the four cases that break it separately — a turn, a stretch, a mirror and a large arc — and on one that cannot be faked: the transformed arc is flattened a thousand times finer than a frame ever is and asserted to pass through the transformed points of the original.

391. A QR code is a specification and a grid of squares

Date: 2026-09-18

Status

Accepted, closing docs/gaps.md G47.

Context

Telegram signs a person in by QR code. TDLib hands the client a tg://login?token=… link and renews it about every thirty seconds until a phone scans one, and the connect dialog has to draw that link and redraw it when the link changes. The same need arrives from two other directions — a share link handed to a phone, a device invite — and none of them is about chat.

A QR code is two things that have nothing to do with each other. The first is ISO/IEC 18004: mode selection, version selection, Reed–Solomon over GF(256), eight mask patterns scored by a penalty rule, format and version bits. It is a specification with one right answer, the same kind of thing as the GIF decoder ADR-0329 wrote rather than linked. The second is a grid of square modules painted crisply at whatever scale the window is at, which is painting.

An application that writes its own is the second toolkit ADR-0015 is about, and it would write it badly: every part of the first half is the sort of thing that produces a code which looks right and does not scan.

Decision

The encoder is :core‘s and the widget is :widgets’, and they share nothing but a value.

  • io.github.digitalsmile.goldberry.qr is the encoder — QrEncoder.encode, and a QrMatrix out the other end. It is in :core beside image.gif and image.png and for their reason: a specification small enough that owning it costs less than linking it, and nothing in it names a widget. An application that wants a code in a PNG rather than on a screen reaches it directly and builds no widget tree.
  • No third-party dependency, which was never in question — this repository vendors or writes its codecs — and no native one either. The whole encoder is nine files and about six hundred lines.
  • Three modes, chosen as the narrowest that covers the whole payload: numeric, alphanumeric, or the bytes of its UTF-8. Not an optimal multi-segment split, which is a shortest-path problem over the payload and buys nothing on the payload this exists for — tg://login?token=… is lower-case base64 and is byte mode from the first character to the last. What the simple rule does buy is the case beside it: a numeric pairing code or an upper-case device link drops a version or two, which at a fixed pixel size is a visibly coarser and more scannable code.
  • Kanji mode is absent, and so are ECI and structured append. Kanji is Shift-JIS, which an API taking a String has no way to be handed.
  • The quiet zone is not in the matrix. §6.3’s four light modules are a property of where a code is put rather than of the code, and a matrix carrying them could not be drawn on a background it already matched.

qr-code is a leaf widget in widgets.core.qrcode, beside image.

  • QrCode(payload, level, quietZone, attributes), @Markup("qr-code"), value=, level=, quiet-zone=. A misspelt level= falls back to M rather than throwing, because a document is reloaded on every keystroke while it is being written; a payload that no version holds still throws, because that is not a typo that fixes itself on the next character.
  • Sized by the stylesheet, like image: a code has a size in modules and not in pixels, and how big a module should be is a question about the screen. controls.css answers it once at 160×160 and an application overrides it.
  • Two colour tokens, --gb-qr-ink and --gb-qr-paper, identical in both themes. Near-black on white, and the dark theme does not get a vote: the standard’s module convention is dark on light, plenty of phone scanners will not read an inverted code, and a sign-in screen that stopped working at night would be a theme deciding whether an application functions. The quiet zone is painted in the paper colour, because a code drawn straight onto a themed surface has no quiet zone wherever that surface is not white.
  • Semantics are an image’s — Role.FIGURE, named by the application’s name=. The payload is never part of the accessible name. While it is valid it is a credential, and a screen reader announcing a sign-in token aloud is the reason.
  • A rebuild does not re-encode. QrCache keeps the last eight codes keyed by payload and level and hands the same QrMatrix back by identity. The dialog this exists for is rebuilt on every keystroke in every field on it; encoding a version 5 code sixty times a second for a picture that has not changed is the defect, and identity is also how the test states the promise.

Modules are whole device pixels, and that is arithmetic rather than a wish.

  • A module is quantized twice, in that order: the largest whole number of logical pixels that fits the box, and then that number times the display scale, floored. The code is centred in the box with the remainder as margin.
  • The order is the part that was got wrong first and is the second half of the promise. Quantizing straight into device pixels gives the sharpest possible code and a code that changes size with the scale: a 108-pixel box holding 37 modules gets 2 device pixels a module at 100% and 5 at 200%, because 5 is not 2 doubled, so the picture grows by a quarter when the window moves to a retina display. ADR-0157’s scale-invariance check on the gallery golden caught it, which is exactly what that check is for. Through the logical number the answers are 2 and 4, and the cost is a few per cent of slack at a fractional scale — a code a few per cent smaller is a code, and a code with soft edges is not.
  • It works because layout already rounds: Yoga runs with its point scale factor set to the display scale, so a box’s corner is on the pixel grid before a painter ever sees it.
  • The modules are drawn as a Path and not as fillRects, and this is the one surprise in the change. Frame.fillRect takes floats, and a float is not enough: a module boundary at device pixel 152 is logical 101.333… at 150%, and the nearest float to that multiplied back by 1.5 is 152.000004 — which Blend2D dutifully draws as one pixel of #1b1b1b beside a run of #1a1a1a. Path carries doubles, whose round trip is off by a part in 10¹⁴ and lands on the pixel Blend2D would have chosen anyway. Setting the frame’s transform to 1 / factor and working in whole device pixels was the first attempt, and it was not available: Frame.transform replaces the transform rather than composing with it (ADR-0068), and what it would replace is the translation to the box’s own corner. ADR-0390 landed Frame.concat the same day, from the same batch of gaps, which makes it available now. The path stays: it is verified down to the decoded module, and a composed transform would be a second way of being right rather than a better one.
  • A box with no room for one logical pixel per module draws nothing. A 21-module code in sixteen pixels is a grey square, and drawing it would be claiming a scanner could read it. Logical rather than device for the reason above: a widget that appeared when the window moved to another display would be worse than one that is absent on both.

The encoder is verified against the specification and against other people’s software, never against itself.

Four kinds of evidence, and the first three are in the repository:

  1. Worked examples, at the codeword level. 01234567 at version 1 level M — the standard’s own — and HELLO WORLD at version 1 level Q. Data and parity are asserted byte for byte, which is where a wrong table shows itself; a matrix comparison would only say that something somewhere differed.
  2. The tables the standard prints rather than derives. All thirty-two format-information strings, all thirty-four version patterns, and §7.3.5’s alignment-centre table for versions 2 to 40. The first two are read back out of a built grid rather than compared against the arithmetic that produced them, so they also pin where the bits go — a BCH computation that was right and written into the wrong modules would pass an arithmetic test and fail every scanner.
  3. Twenty-six whole matrices from libqrencode 4.1.1, in core/src/test/resources/…/qr/libqrencode-vectors.txt. They span every level, versions 1 to 40, both block structures, the versions that carry version information, version 32’s alignment exception and all three modes.
  4. A cross-check run during development, against both libraries that were already on the machine — no third-party code was installed, and none is on the build path. libqrencode.so.4 was called through its own ABI for 2117 payloads across the four levels and all three modes; 1556 of them came out identical module for module, and all 2117 were rendered and decoded by libzbar.so.0, each reading back as exactly its own payload. The three QR codes in the showcase screenshot decode out of the PNG the same way.

The 561 that differed differ only in the mask, and in the same direction every time: on a sample of 120, this encoder’s chosen mask scored strictly lower under §7.8.3’s four penalty rules than libqrencode’s in 119 cases and equal in the other. libqrencode’s mask evaluation is its own; the rules here are the standard’s, and each of the four is tested on a grid built to trip exactly it.

Two numbers that fell out of this deserve writing down, because both were real bugs found by the cross-check and neither would have been found by a test written against this code:

  • The data placement steps over the vertical timing line rather than renaming the column pair around it, so the walk continues at 5, 3, 1 and not 4, 2, 0. A version that only renamed visited column 4 twice and column 0 never.
  • The level H block counts from version 32 up were off by one row — the table that no formula produces, and the one place in the whole specification where three hundred and twenty numbers have to be transcribed correctly.

Consequences

  • :core exports one more package and gains no dependency. The encoder has no decoder: nothing in this toolkit reads a QR code, and the interesting half of Reed–Solomon is the half that corrects errors rather than the half that produces them.
  • The catalogue is 72 widgets. qr-code is documented in core-widgets.md §1 rather than in content-widgets.md, and not because it was convenient: §1 is where image and canvas are, content-widgets.md is a list of modules that wrap native engines, and a QR code needs neither a module nor an engine.
  • The showcase’s Canvas screen gains a card beside its image one, with the same link at levels L, M and H — so the price of error correction is a picture rather than a sentence. One gallery golden is re-blessed and it is the only one that moves: no screen was added, so no digit and no tab moved either. The three codes in that golden were lifted back out of the PNG and decoded with libzbar, which is the most direct statement of the whole change that exists.
  • The two colour tokens are the first pair in the sheet that is deliberately identical in both themes. That is a precedent worth being careful with, and the rule it sets is narrow: a token may ignore the theme when the thing it colours is read by a machine rather than by a person.
  • An application can now put a code anywhere a picture goes, including into a PNG through Offscreen, without the widget catalogue being involved at all.

392. A timeline opens at its end and keeps the reader’s line

Date: 2026-09-18

Status

Accepted, closing docs/gaps.md G48.

Context

A chat timeline wants three things of a viewport and scroll did none of them. It should open on the newest message rather than on the oldest. It should stay on the newest while the reader is already there, and leave them alone when they are not. And when older messages are paged in above, everything the reader is looking at should stay exactly where it is instead of jumping down by the height that arrived.

ScrollController.scrollBy answers none of the three, and the reason is the same each time: they are all layout facts. Where the content ends is not known when the build that added a row runs; “am I at the end” is a comparison between two rectangles neither of which exists yet; and a scroll issued after the fact is a visible jump rather than a viewport that never moved. §1 already gives scroll “scroll position is retained state surviving rebuilds”, and this is that promise one step longer — retained against the content changing, not only against the widget being re-described.

The third is the hard one, and it is hard for a precise reason. Content getting taller and content arriving above are the same number. Twelve lines added to the top of a log and twelve added to the bottom both make the content 240px taller, and the first must move the offset by 240 while the second must move nothing. A naive “the content grew, so shift” implementation passes a test that only prepends and drags the reader down every time anything is appended, which is the failure this whole record is arranged around.

Nothing in the toolkit could tell them apart. Extent on an event, Measured and Located all report a property of one frame; the difference between the two is a property of a pair. So it had to be built.

Decision

Three attributes’ worth of behaviour, on one new geometry facility.

Anchored, the fourth geometry facility

io.github.digitalsmile.goldberry.input.handler.Anchored is told, once a frame, how far the content slid under a widget — the one thing Measured and Located cannot say because it is a difference between two frames.

PointerRouter.notifyAnchored is a third walk beside notifyMeasured and notifyLocated, and it is the right home for the same reason those are: the router is the one place that holds the painted rectangles, the one place that holds per-element identity across frames, and the one call every window makes once per frame.

  • It picks, inside the part the widget names, the deepest node in document order that begins at or after the viewport’s leading corner — the reader’s first whole line. A node straddling the corner is not it, but it is descended into, which is what makes one rule cover scroll { row … row … } and the far commoner scroll { column { row … } } alike.
  • Both axes at once, so the router needs to know nothing about which way the viewport scrolls: children of a vertical one agree about the left edge and children of a horizontal one agree about the top.
  • It remembers where that node sits inside the part, in layout coordinates. A scroll is a transform on the part, so subtracting the part’s own origin cancels it exactly: the remembered number moves when something inside the part changed and not when the viewport did. That is the whole trick, and it is why “grew at the bottom” reports nothing at all rather than reporting something small.
  • It keeps that node while it keeps moving and re-picks on the first quiet frame. Re-picking during a shift would measure the correction that is about to land and count the insertion twice.
  • anchorPart() is nullable, and that is the switch. Finding an anchor walks a subtree per frame per widget that asked; a viewport nobody asked to preserve returns null and is skipped before anything is walked.

Identity is the reconciler’s, and a key is what it is made of. Children matched by position are not the same node when a list is prepended to — element 0 simply describes a different message — so nothing moved and nothing is reported. That is honest rather than a limitation: Widget#key() has asked for keys on list items since it was written, and this is the first widget that cannot work without them.

anchor="end", and what “at the end” is worth

ScrollStick holds one boolean per axis: is this viewport at the end. It is written whenever the offset moves on purpose — moveTo and scrollBy, which between them are the wheel, the keys, a drag, a track click and every scrollIntoView — and read when a measurement arrives.

A flag and not a recomputation, because by the time a message has arrived the extents have already changed and offset == overflow is false for exactly the viewport that was at the end a moment ago.

Half a logical pixel is how near the end counts as at it (ScrollStick.TOLERANCE). The same figure ScrollController.Position has used since it was written, below a device pixel at every scale this toolkit renders at, and there because an offset is a double arrived at by adding wheel fractions and clamping — asking a dragged-to-the-bottom viewport for exact equality with an overflow computed from float extents loses the stick roughly whenever the arithmetic feels like it.

It is deliberately small, and there is no hysteresis and no grace period. The frame after the user scrolls one pixel up the stick is off and stays off until they come back down, because a pixel up is a decision and a tolerance wide enough to be forgiving is wide enough to drag somebody back down while they are reading.

Opening at the end is not a separate case. initState turns the flag on before the first layout — a standing instruction rather than a position — so the first measurement lands at the end by the same line of code that keeps it there, and the two cannot disagree about the frame in between.

preserveOnPrepend, and the three states

The component on the record is a @Nullable Boolean: unset, true, false. The default is the anchor’s, so collapsing “unset” into either boolean would make .anchor(END).preserveOnPrepend(false) and .preserveOnPrepend(false).anchor(END) mean different things. preservesOnPrepend() resolves it. KdlNode.flagProperty is the markup-layer half — booleanProperty folds absent into false, which is right for every attribute whose default is a constant and wrong for this one.

The correction is applied by ScrollState.shiftBy, which is deliberately not scrollBy: a glide would draw a 240ms slide every time a line was logged (ADR-0363), and waking the bars would say the user had scrolled when they had not. Nothing moved; only the offset did.

An END viewport that is at the end skips the shift entirely, because keeping to the end has already put it at the new end and that is the same number. A START viewport that merely happens to be scrolled to its bottom is a different thing and takes the shift like any other.

The axes, the directions and the glide

END means the far end along the scrolling direction, not “the right”. The offset is measured from the content’s start edge and the layout direction decides which edge that is, so under a right-to-left document the same anchor puts the newest item where that language’s reader ends up, with no case for it anywhere in this code. BOTH sticks on both axes.

The glide is untouched. A programmatic scroll still glides and still sets the offset to its target immediately, so settle() reads the target and a scrollIntoView onto the last row is how a timeline is deliberately caught up with.

It is one frame late

The heights of rows that have just been inserted do not exist until a frame has been laid out, so the correction lands on the frame after the insertion. That is Measured’s bargain unchanged — a thumb has been one frame behind since ADR-0117 — and at frame rate it is not a jump anybody sees. It is not the jump G48 complains about either: that one is an application scroll, a frame late and animated over a quarter of a second.

Consequences

  • scroll anchor="end" preserve-on-prepend=#true is writable in markup, and the Navigation screen’s new console card is the demonstration: Log a line follows the end when you are on it and leaves you alone when you are not, and Load older drops twelve lines above the viewport without the words you are reading moving.
  • ScrollTimelineTest states the three behaviours as three cases and adds the fourth — the one that regresses — as its own: a message arriving while you are reading history does not move you at all. Its rows are a whole number of pixels tall on purpose. Yoga snaps each node’s position to the pixel grid, so the anchor is preserved exactly whatever the heights are and a row either side of it can land a rounded pixel from where it was; whole rows take the grid out of the assertions and leave the arithmetic.
  • Anchored is public and general. “Where has this container’s content moved to” is the same question a virtualized list asks when its estimated row heights are replaced by measured ones, and nothing else in the toolkit could ask it.
  • Every existing scroll is unchanged: START is the default, it preserves nothing by default, and a viewport that asks for neither returns a null anchor part and costs the router one reference comparison a frame.
  • The gallery’s Navigation golden is re-blessed for one more card.

393. An emoji is routed by the text and drawn in layers

Date: 2026-09-18

Status

Accepted. Closes docs/gaps.md G49, and pays off the promise docs/ARCHITECTURE.md §5 made for M2 — “emoji sequences (ZWJ, VS-16, modifiers) detected during itemization and routed to the emoji slot”.

Follows ADR-0384, which moved the face into its own artifact, and ADR-0386, which put a sheet of it on screen.

Context

The toolkit bundled an emoji face, documented an emoji slot, shipped a screen showing 1205 emoji — and drew every one of them as a .notdef box in any text that was not already in the emoji face. G49 reported it from the first application that needed it: a chat window, where 👀 in a message body and a reaction chip that is an emoji and a count are not decoration but content.

Two separate things were missing, and either one alone would have left boxes on screen.

Nothing split the text. Paragraph shaped one string with one Font. Rolling to eu-2 🎉 at 14:00 went to Inter entire, and Inter has no party popper. An application could not work around it: picking the face for a whole run is wrong for that sentence and impossible for a chip whose label is a picture and a number.

Nothing could have drawn it in colour anyway. The face that shipped was OpenMoji’s monochrome build, and Asset.OPENMOJI said why in as many words: the colour build “is opt-in and not bundled until something can draw layered outlines”. So even a correctly routed emoji would have been a silhouette.

Decision

Itemization is a new package, and reads Unicode out of the JDK

text.itemize holds Itemizer, TextRun and Slot. Itemizer.runs(text) splits a string into consecutive runs, each labelled TEXT or EMOJI, covering the string exactly.

The rules are UTS #51’s, reached through java.lang.Character — isEmoji, isEmojiPresentation, isEmojiModifier and their siblings, which the JDK has carried since 21. That is the whole reason this is thirty lines rather than a table the toolkit has to re-fetch every time Unicode moves: the properties come with the JDK and move with it.

What the sequence rules add on top of the per-character properties is where a naive split goes wrong, so they are each a test:

  • U+FE0F turns an emoji character into a picture and U+FE0E turns it back into a glyph. ❤️ and ❤︎ are two strings and two faces.
  • U+200D joins, so 👨‍👩‍👧 is one run and the face gets the chance to ligate it into one family rather than three people.
  • Skin tones, tag sequences and keycaps belong to the emoji they follow.
  • # is Emoji_Component, so an itemizer that extended a cluster over every component would swallow the hash of 🎉#ship. It does not.

Slot is an enum and not a boolean because the list grows: splitting by script and splitting by direction are the same operation with more answers, and the bidi approximation Paragraph still carries is the next one.

A Font may name a second Font, and the book joins them up

Font.emoji() is the emoji face at this font’s size, or null. It is set rather than passed to a constructor, because the alternative is a constructor that opens two and a half megabytes of OpenMoji for every font an application ever makes.

Fonts — the per-window book that already opens each face once and keeps it — is the one place that joins them. Every font it hands out gets the emoji font at the same size attached, opened lazily on first use like every other face, and closed with the book. An application that never draws an emoji never parses the face; one that does gets routing without asking for it. A build with no goldberry-emoji on its module path gets null, silently, because BundledAssets.hasEmojiFont() is how an application that cares asks and a warning per font opened is not an answer to anything.

There is one real trap in this, and it is recorded in the code: the emoji font is an entry in the same map, so filling it from inside another key’s computeIfAbsent would corrupt the LinkedHashMap. The lookup is a get-then-put now.

A paragraph is one measurement over up to two shapings

Paragraph keeps its central promise — shaped once, wrapped many times, every measurement a prefix sum — and now builds that one array out of two shapings.

The subtlety that makes this work is units. A design unit is a fraction of an em and the fraction differs per face: Inter is 2048 to the em and OpenMoji is 1024. Appending one face’s advances to the other’s would make an emoji half the width it is — and half a width is a plausible number, which is exactly what makes it dangerous. ColourEmojiTest.theRescaleIsRight asserts the sum directly. So the paragraph holds two representations of the same shaping:

  • the concatenated run, rescaled into the base font’s design units with the clusters rebased onto the paragraph’s offsets, which everything measures against — wrapping, carets, hit tests, text-overflow;
  • the segments, each keeping its own face’s own shaping in its own units, which is what is drawn, because the rasterizer scales a run by the face’s matrix and would place a rescaled one wrongly.

Rounding is to the nearest design unit, a thousandth of a pixel at any size anybody reads text at.

A paragraph with no emoji in it has one segment and takes exactly the path it took before — one shaping, one draw call, and an Itemizer scan that allocates nothing. Paragraph.glyphs() now says out loud that its glyph ids may belong to two faces, which is a thing a caller could otherwise only discover by drawing the wrong pictures.

COLR version 0, read in Java, drawn a layer at a time

The colour half is text.font.sfnt, a new package for OpenType table readers, holding TableDirectory and ColorLayers.

COLR version 0 is the format worth having because a colour glyph in it is not a new kind of thing: it is a list of ordinary glyphs in the same face, each filled with one colour from the CPAL palette. The outlines are in glyf beside every letter, so the rasterizer that draws an a draws these; all that was missing was somebody to read the list and set the colour between layers.

The same OpenMoji release ships the same pictures four other ways, and each would have cost more: the two SVG-in-OpenType builds are 10 MB and want an SVG renderer inside the font pipeline, and the CBDT and sbix builds are 6 MB of fixed-resolution strikes that blur at 150% — on a toolkit whose whole claim is that it is crisp at every scale.

Reading it in Java rather than binding a library is FaceCoverage’s argument and GifDecoder’s before it: the table is three flat arrays and the reader fits on a page.

GlyphFace reads it once per typeface, not per size — 57,000 layer records parsed once. GlyphPen then has one branch: a face with no colour in it takes exactly the path it always took, and a face with colour draws each glyph’s layers in order. Every glyph is staged with a zero advance and an absolute offset, which is what lets the buffer be flushed between two glyphs without the ones after it losing their place.

Version 1 of COLR is read as version 0. Its gradient machinery lives in fields after the version 0 ones, which stay where they are and keep meaning what they meant, so a version 1 face draws its non-gradient glyphs correctly and its gradients flat. That is a smaller wrong than refusing the face, and it is written down rather than discovered.

The artifact ships the colour build

goldberry-emoji now carries OpenMoji-color-glyf_colr_0.ttf: 2.5 MB against 1.4 MB for the monochrome build it replaces. The condition the old comment set has been met, so the trade it deferred is taken. It is paid only by an application that adds the artifact on purpose, which is ADR-0384’s whole shape.

Consequences

Emoji are pictures. In prose, in a text-input, in a markdown-view, in a table cell — anywhere a Paragraph is drawn through a Fonts book, which is everywhere the cascade resolves a font.

An application that does not add goldberry-emoji sees exactly what it saw before, and pays nothing: no itemization split, no second shaping, no table parsed.

A colour glyph costs one rasterizer call per layer, where a line of text costs one for the whole line. An OpenMoji glyph averages fourteen layers, so a reaction bar of ten emoji is a hundred and forty calls. They are not a hundred and forty passes — a layer is one small glyph and the work is proportional to the ink — but a wall of emoji is measurably dearer than a wall of text, and the pen’s javadoc says so. Batching layers by colour is the optimisation if it ever shows; it costs the ordering guarantee, which is why it is not taken now.

An emoji is as tall as the emoji face makes it, and the line box is the base font’s. Measured at 14 logical pixels: Inter ascends 13.563 and OpenMoji ascends 14.287, so an emoji reaches about three quarters of a pixel above the line box it sits in, and its natural line height is 18.047 against Inter’s 16.939.

That is not a defect and it is what a browser does too — CSS takes the line box from the primary font and lets a fallback overhang — but it is not what an earlier draft of this record claimed, which was that it fits. A box that clips to its content will clip that sliver on its first line. Scaling the emoji face so its ascent matches the base face’s is the alternative, and it is a separate decision because it trades an overhang for emoji that are visibly smaller than the text around them, which is a typographic judgement rather than a fix.

Kerning across the seam is lost. Each run is shaped alone, so a kerning pair spanning the boundary between a word and a picture is not applied. There was never such a pair.

Two new packages, and paint now reads text.font.sfnt. That is not a new direction of dependency — paint already reads text.ShapedRun — but it is worth naming: the table readers are font-format knowledge, and the pen is the only thing in paint that needs any.

394. A diagnostic that fires on everything says nothing

Date: 2026-09-18

Status

Accepted. Re-scopes the overflow watch of ADR-0375 and the nested-viewport notice of ADR-0251, and moves the resize and popup chatter of the window layer down a level.

Context

Running the showcase and reading the log is how an application author finds out what the toolkit thinks of their layout. What they actually got was a wall:

WARN  OverflowLog - `card#links-card` overruns `masonry-cell` by 1.0 tall — …
WARN  OverflowLog - a box overruns `icon-tile-name` by 1.0 wide and 2.0 tall — …
WARN  ScrollState - a vertical `scroll` is inside another one; §2.4 rules that out …

None of these is wrong about the geometry. All three are wrong about whether anybody needed to be told.

Decision

The overflow watch reports what a reader could see

The numbers first, because they are the argument. Instrumenting Overrun.between and running :example:test and :widgets:test produced 688 reports. Sorted by how far the child overran:

overrunreports
≤ 0.5 px10
≤ 1 px252
≤ 2 px384
≤ 4 px3
≤ 16 px22
> 16 px17

Ninety-two per cent of them are two pixels or less, and the cliff between 2 and 4 is nearly total. A distribution shaped like that is not a codebase with 650 layout defects in it; it is a diagnostic measuring something other than what it meant to. Three separate causes, each now exempted in Overrun.between with the case that found it:

  • A container with no size. slider-tick sits in a 0 × 0 box — an anchor for placed children rather than a box anything could fit inside. Every child overruns it by its own whole size. 22 reports.
  • A child that starts outside. slider-thumb is a 16 px square centred across a 4 px groove: box=16.0x16.0@(100.0,-6.0). Flow never produces a negative offset — a flowed child begins at its container’s content origin — so a child at -6 was put there, which is the “placed rather than flowed” exemption OverflowWatch already made for an absolute box, arriving through insets instead. Together with the above, 41 reports.
  • A pixel or two. Layout is rounded onto the device pixel grid, and a line box may be shorter than the face’s natural leading. CSS allows line-height tighter than the text in it and text overhanging its line box is the ordinary consequence — the same phenomenon ADR-0393 measured for emoji, where OpenMoji ascends 0.7 px above Inter’s box. The tolerance goes from a tenth of a pixel to two.

688 reports become 23, and the 23 are real: a masonry 585 px taller than the screen it is on, a badge 16 px outside its panel, a button#reset 64 px past the end of its row.

Two pixels is a number and numbers in a diagnostic deserve suspicion, so it is worth saying what it is not. It is not tuned to make the showcase quiet — the showcase still reports 23 things. It is the point where the measured distribution stops being arithmetic, and it is well under what this watch exists to catch, which is a control pushed off the edge of a window.

The nested-viewport notice was firing on its own advice

It said: “Give the inner box a size and let the outer one scroll.”

It was firing on scroll.tall-list in the showcase’s Collections screen — a virtualized list given height: 256px; flex-grow: 0; flex-shrink: 0, inside the gallery’s own viewport. That is the recommendation, followed exactly. It is also what every chat window, console and settings page with a log box is, and the engine’s behaviour there is defined rather than accidental: the inner one takes the wheel until it reaches its edge.

What §2.4 is actually about is an inner viewport with no size of its own on the scrolling axis, which grows to its content and leaves the wheel ambiguous. Telling those two apart needs the inner box’s resolved height, and the check runs in build, before the cascade has resolved anything.

So the message stays, drops to debug, and stops saying §2.4 “rules that out” in favour of “discourages” — because on the evidence of the toolkit’s own showcase it does not rule this out, it rules out the sizeless case. A WARN claims something is broken; nothing is. The sharper rule is recorded as needing a layout-time signal that does not exist yet rather than guessed at now.

Resize and popup chatter is trace

Both are per-interaction rather than per-event-of-interest. A resize drag is one window resized line and one allocating a frame buffer line per pointer motion; a popup opening and closing says so every time a select is touched. Neither is something an author reads on purpose, and both drown what is. They are trace now, which is where a per-frame fact belongs.

Two deliberate exceptions, because “all of them” would have been wrong:

  • drawing during a resize failed stays a WARN. It carries a stack trace and only fires when something threw.
  • walking the window's size a pixel a frame stays INFO. It is printed once at start-up and only when --resize=WxH asked for it; silencing the confirmation of a flag the author passed is not quieting, it is hiding.

Consequences

An author who reads the log now sees 23 things instead of 688, and each of them is a box a reader could actually see in the wrong place.

Between two and four pixels is now silent. An overrun in that band is not reported at all, and three of the 688 were there. If one of them ever turns out to matter, the answer is not a smaller tolerance — the cliff would come back with it — but a diagnostic that knows about line boxes.

The nested-viewport notice is off by default. An author who nests two viewports without giving the inner one a size gets a working, slightly confusing scroller and no message unless they turn debug on. That is the cost of not crying wolf on the arrangement the message recommends, and it is the right way round: the failure mode is mild and the false positive was constant.

OverflowLog.reported() is unchanged as an API. An application that reads it on a frame gets the same list the log would have printed, which is still the sanctioned way to assert on this in a test.

395. A resource is opened to whoever reads it

Date: 2026-09-18

Status

Accepted. Fixes the showcase’s Canvas screen and gives ImageSource.resource the diagnostic Stylesheet.resource already had.

Amends ADR-0093, whose “A bare opens in the application’s module” says the showcase opens the package “to the core module only”. That was right when the only resource being read was a stylesheet and is wrong now: a package is opened to whoever reads what is in it, and for an image that is :widgets.

Follows ADR-0387, which is the same fact — a resource directory is a package — biting from the other side.

Context

The showcase logged this, four times:

WARN ImageState - image resource:…example.ui.CanvasScreen:canvas-sample.jpg did not load:
  java.io.IOException: no image resource "canvas-sample.jpg" at resource:…:canvas-sample.jpg

The file is there. example/src/main/resources/io/github/digitalsmile/goldberry/example/ui/canvas-sample.jpg has been there all along, and DeclaredResourcesTest asserts it is.

JPMS encapsulates resources, and only on the module path. A file in a package of a named module is invisible to other modules unless the package is opens — exports does not do it, because it governs types rather than bytes. The showcase knew that and said so in its module-info, and then got the target wrong:

opens io.github.digitalsmile.goldberry.example.ui to io.github.digitalsmile.goldberry.core;

Its own comment explained the choice: the package is opened “to whoever loads them, which is exactly one module”, and ADR-0093 said the same. That was true when the only thing being read was a stylesheet. It stopped being true when the Canvas screen grew an image card, because ImageSource.Resource.load is :widgets’ code, and a package opened to :core is closed to :widgets.

Three things then went wrong at once, and each is worth fixing on its own.

Decision

The package opens to both modules that read it

One line, and a corrected comment above it. :core parses the stylesheet and the markup; :widgets loads the picture; the package names both.

Verified on the module path in both directions, because a classpath test cannot see this at all:

  • with the open, ImageSource.resource(CanvasScreen.class, "canvas-sample.jpg").load() returns a 96 × 64 image;
  • with it reverted, it fails — with the message below.

The failure says what is wrong instead of what is missing

“No image resource” is a true sentence and a misleading one: it sends somebody looking for a file that is sitting exactly where they put it. Stylesheet.resource had already learned to tell the two cases apart, and ImageSource.Resource now does the same:

the image resource "canvas-sample.jpg" at resource:…CanvasScreen:canvas-sample.jpg
is encapsulated: module io.github.digitalsmile.goldberry.example does not open
io.github.digitalsmile.goldberry.example.ui to io.github.digitalsmile.goldberry.widgets,
and JPMS encapsulates resources as well as classes. Add `opens
io.github.digitalsmile.goldberry.example.ui to io.github.digitalsmile.goldberry.widgets;`
to its module-info — the file itself may well be there.

It names the module that must be opened to, computed from where the reading happens rather than written down, so it stays right if the loader ever moves.

A broken source is reported once

One picture drawn at four fit values is four ImageViews, four loads and four identical lines about one file. ImageState now keeps a bounded set of the source keys it has complained about — OverflowLog’s argument and OverflowLog’s answer, down to the 256-entry cap.

Keyed on the source, not the view: that is what failed, and two views of one key share a cache entry, so the reason cannot differ between them.

A test that can see what the suite cannot

This is the part worth keeping. Every test in this repository passes with the opens wrong, because tests run on the class path where nothing is encapsulated. The bug was invisible to the suite and obvious in the application.

OpenResourcePackagesTest therefore reads the repository rather than the running JVM: it walks src/main/resources, derives the package each file is in, and asserts that a package holding an image opens to :widgets and a package holding a stylesheet or markup opens to :core. DeclaredResourcesTest is its sibling and exists for the same reason — a native image’s missing resource is another failure a passing suite cannot see.

Checked by reverting the fix: the test fails, and its message names the package and the module to add.

Consequences

The Canvas screen draws its sample. On the module path, which is where it did not.

An application that hits this is told how to fix it. The message is long, and deliberately: the reader is looking at a file that exists and being told it does not, and nothing shorter closes that gap.

The rule is now stated where an application can copy it. “Open the package to whoever reads it” is a sentence somebody has to get right per package and per module, and the showcase is the worked example. :html needs no open because it takes an ImageSource the application supplies and reads nothing itself.

The test is the showcase’s, not the toolkit’s. It encodes which toolkit module reads which kind of file, which is a fact about the toolkit — so it will need amending if a third module ever reads an application’s resources. That is a cheap price for a check that catches a whole class of module-path-only failure, and the alternative is a rule that lives only in prose.

396. A test presses the same modifier on every desktop

Date: 2026-09-18

Status

Accepted. Fixes Snapshot run 15, whose macOS verify leg lost 3 tests in :core and 22 in :widgets after ADR-0378 landed.

Follows ADR-0378, which is the decision this one keeps testable.

Context

ADR-0378 put the toolkit’s editing accelerators — select-all, copy, cut, paste, undo, redo — on the platform primary modifier: Cmd on macOS, Ctrl everywhere else, read once from os.name by PrimaryModifier.current(). That is right for an application. It was wrong for the suite, silently, on one platform.

Fifteen test files type Modifiers.of(Mod.CTRL) into a field and expect a selection, a clipboard or an undo. On Linux and Windows Ctrl is the primary modifier and nothing changed. On the macOS runner it is not, so Ctrl+C became a keystroke the editor does not answer:

TextInputTest > the clipboard > copy puts the selection on the session's clipboard FAILED
    expected: <Goldberry> but was: <>
TimePickerTest > the field is the source of truth > clearing it reports null rather than nothing FAILED
    expected: <2> but was: <1>

Twenty-five failures, one cause, and every one of them green on the machine the change was written on. ADR-0378 had already provided the override — -Dgoldberry.input.primary=ctrl|meta, “for a test and for an application that has a reason” — and nothing set it.

Two fixes were possible. Every test could ask PrimaryModifier.current() instead of spelling Mod.CTRL, so the suite would press Cmd on macOS and Ctrl elsewhere. Or the build could pin the answer, so the suite is one suite.

Decision

The test conventions pin the primary modifier to Ctrl, and one test holds the pin.

  • goldberry.java-conventions.gradle sets -Dgoldberry.input.primary=ctrl on every Test task. A test that presses Ctrl is the same test on every desktop, which is what a golden or a clipboard assertion needs to be.
  • PrimaryModifierTest asserts the property is set and current() is Ctrl. It fails on every platform if the pin is dropped, rather than on the one runner where the twenty-five tests it protects would fail instead.
  • What macOS resolves to without the override stays covered by ShortcutTest, through PrimaryModifier.resolve(osName, override) — the testable half ADR-0378 built for exactly this, which needs no macOS to run.

The tests were not rewritten to ask current(). A test named “Ctrl+A selects everything” that pressed Cmd+A on one runner would be a test whose name and body disagree on that runner, and the platform-specific answer is one function with three unit tests, not twenty-five integration tests run three times.

Consequences

  • Snapshot’s macOS verify leg is green again; the other legs never saw the problem and do not change.
  • A test that means to exercise the macOS mapping end to end — a text-input answering Cmd+C through the whole event path — has to say so by setting the property to meta for that test’s JVM. None does today; EditKeysTest’s “the accelerators are the same six on all three” asks current() and is therefore a Ctrl test under this pin, which is what it was before ADR-0378.
  • docs/testing.md §1.2 records the pin beside the virtual clock, which is the same kind of thing: a source of platform variance the suite fixes so that an assertion means the same everywhere.

397. A benchmark’s names are resolved under check

Date: 2026-09-18

Status

Accepted. Fixes the Nightly Benchmarks job, red since 2026-09-12.

Keeps ADR-0045, which is why a benchmark here asserts no timing: what moves to check is a name, not a number.

Context

BindingBenchmark.showcaseModel times the showcase’s own app.click through the action registry. On 2026-09-12 (a90c3096) the showcase’s actions moved out of ShowcaseModel into a nested ShowcaseModel.Actions record, which is the right shape (ADR-0137) and which every screen and test in :example was updated for. The benchmark was not:

the binding schema, before and after > the showcase's own model, end to end FAILED
    java.lang.IllegalArgumentException: no action named "app.click" is bound. Bound: (none)

Nothing caught it for six days, because nothing could. A benchmark is tagged benchmark, check excludes the tag (docs/testing.md §1.5), and the only thing that runs it is the nightly lane — whose failure is one more red badge on a job people read when they are looking for numbers, not for breakage. A rename in a class the benchmark reaches into is invisible to every push.

Decision

The names a benchmark resolves are resolved under check by a test beside the code, as a count.

  • BindingBenchmark.showcaseModel resolves app.click on new ShowcaseModel.Actions(model) and reads app.clicks off the model, which is the pair Showcase publishes.
  • ShowcaseActionsTest.theRoadsClickCounts resolves the same two names under check and asserts that two clicks count two. It is a count, so the reason timings stay out of check (docs/testing.md §1.5) does not apply to it; and it is the benchmark’s setup line by line, so the next rename fails the push that made it rather than the nightly that follows.
  • The benchmark’s doc names the guard and the guard names the benchmark, so the pair is found from either end.
  • Both hold the Actions record in a local and fence it with Reference.reachabilityFence: a binding is a weak window onto its model (RuntimeBinding), and an actions record built inline was collected half-way through the benchmark’s loop the first time this was run.

The general rule this states: a benchmark’s scaffolding is not exempt from check, only its measurement is. Where a benchmark reaches into a class by name — an action, a binding path, a resource, a widget type — the name belongs in a check test too, and the benchmark should say which one.

Consequences

  • Nightly’s Benchmarks job is green again on the next run; nothing about the numbers it prints changes.
  • Other benchmarks that resolve names by string (BindingBenchmark’s app.say/app.noop/app.label on its own local models, and the :weaver scheme benchmarks on the test models beside them) resolve names on classes in the same file, so a rename there is a compile error and needs no guard. The showcase case was the only one reaching across.

398. The build declares what it actually writes

Date: 2026-09-18

Status

Accepted. Answers B1, B2, B6 and B7 of the whole-tree review recorded in docs/review-2026-09-18.md.

Context

Four of the build’s own declarations were false, and each was false in the same direction: the build said it was doing something it was not, or doing it somewhere it was not, and nothing failed to say so.

The weaver declared javac’s output directory as its own. weaveCatalog rewrites the compiled classes in place — it collects a module’s @Markup widgets into a WidgetCatalog, patches provides … with into module-info.class and writes a service entry — and it declared that directory as an outputs.dir so that Gradle would know what it touched. Gradle’s answer to two tasks writing into one place is to throw away the compiler’s incremental state, so every build of a tree where nothing had changed reported

Task ':widgets:compileJava' is not up-to-date because:
Full recompilation is required because no incremental change information
is available.

for :widgets, :html, :example and everything downstream of them. The declaration bought nothing at all: the task also carried outputs.upToDateWhen { false }, so it ran unconditionally either way.

Spotless formatted no markdown. The format 'markdown' step lived in goldberry.java-conventions, which every module applies, and its globs were docs/**/*.md, book/src/**/*.md and *.md. A spotless target resolves against the project that declares it, and no module has a docs/ or a book/src/. The root, which has both, applies no Java conventions. So the step had existed for months and had never touched a file.

PMD read no module descriptor. Every pmdMain run reported

ParseException ... at line 28, column 32: Encountered <IDENTIFIER: "org">.
Was expecting one of: ";" ... "." ...

on module-info.java. PMD’s Java grammar takes at most one modifier on a requires directive; every module here declares requires transitive static org.jspecify, which the JLS has allowed in either order since Java 9. The review proposed requires static transitive as the workaround. That was measured against PMD 7.19.0 and 7.20.0 and fails identically — the order is not what PMD objects to, the second modifier is.

blessGoldens named tasks that do not exist. The root task depended on ":${it.name}:blessGoldens" for every subproject but :bom, and :assets and :weaver apply plain java rather than the conventions plugin. ./gradlew blessGoldens — the one command docs/testing.md §1.3 tells a contributor to run after a deliberate visual change — failed with Task with name 'blessGoldens' not found in project ':assets' before blessing anything.

Decision

A task declares the files it owns, and only those.

  • weaveCatalog and weaveModels declare a stamp file as their output and the classes directory as an input only. The stamp is a file each task alone writes, which is enough for Gradle to treat it as a producer rather than as a second writer into javac’s directory. In-place weaving stays: it is idempotent, and a separate woven directory would have to be threaded through output.classesDirs, the jar, testClassesDirs and every consumer — a much larger change to fix a declaration.
  • The root formats its own prose, with two ordinary tasks — checkMarkdown and formatMarkdown — rather than with spotless. Spotless refuses a target outside its own project directory, so no module can reach these files; and the root cannot apply a build-logic plugin, because that puts that build’s whole classpath under every module and :core’s alias(libs.plugins.jmh) then fails to resolve, which the conventions plugin already records beside its CI annotations. Two tasks that trim trailing whitespace and end a file with one newline are the whole of what the spotless step claimed to do.
  • module-info.java is excluded from PMD on purpose, with the reason written at the exclusion. A module descriptor has no resource to leak and no string built in a loop, so the triaged ruleset has nothing to say about it; an explicit exclusion says that where a stack trace in every report did not.
  • blessGoldens depends on tasks.matching { it.name == 'blessGoldens' } per subproject — a live view, so a module that gains the conventions plugin later is picked up with no edit here.

Alternatives considered

  • Weaving into a separate directory. Correct, and the shape a from-scratch design would take: compileJava writes raw classes, the weaver produces the directory everything else consumes, and Gradle can cache and skip it. Rejected for now because sourceSets.main.output.classesDirs is what the jar, the test task’s testClassesDirs and every IDE read, and pointing those at a woven directory while leaving the raw one on the same classpath puts two copies of module-info.class in front of the JVM. That is an ADR of its own, not a line in this one.
  • Dropping transitive or static from the jspecify requires. It would let PMD parse the file, at the cost of changing what consumers of the toolkit see: transitive is there because @Nullable appears on exported signatures and -Xlint:exports requires a consumer to be able to read it. A static-analysis tool’s grammar is not a reason to change a module’s API.
  • Leaving the markdown step where it was and making the globs absolute. Spotless rejects it outright (“All target files must be within the project dir”), and if it had not, ten modules would each have formatted the same root files.

Consequences

  • :widgets:compileJava is up to date on a second build, and so is everything downstream. What this costs is that Gradle no longer knows the weaver writes into the classes directory — a clean build and a --rerun-tasks are the same as before, but a hypothetical task that wanted “the woven classes” as an input cannot ask for them by output. Nothing asks today.
  • The prose is formatted by a task that is not spotless, so a contributor now has two formatting commands rather than one: spotlessApply for Java, formatMarkdown for everything else. checkMarkdown runs in linux.yml’s java job beside checkLicenses, which is where the first run found five files with trailing whitespace in them.
  • PMD’s report is empty rather than full of stack traces, which means the next real finding in it will be visible. It also means a module descriptor is analysed by nothing at all — accepted, and written down here so that the next person who wonders finds the answer rather than the exception.

399. A task box is counted by the parser that found it

Date: 2026-09-18

Status

Accepted. Answers H1 of the whole-tree review recorded in docs/review-2026-09-18.md.

Follows ADR-0300, which decided that toggling a task rewrites one character of the source.

Context

markdown-view renders - [ ] milk as a checkbox, and a reader who clicks it calls Markdown.toggleTask(index). The index is md4c’s: it is counted by the fold that walks the parsed document and mints a widget per task item. The character it rewrote was found by something else entirely — a line-at-a-time regular expression in Tasks, anchored ^\s{0,3}[-*+] \[[ xX]\], counting its own way to the indexth match.

Two counts, one ordinal. They agree on a flat list and disagree on almost anything else, because whether - [ ] on a line is a task box depends on facts a line does not carry:

  • how deeply the list is nested — CommonMark allows a sub-item four or more spaces in, which md4c counts and \s{0,3} rejects;
  • whether a > is in front of it;
  • whether it is inside a fenced block or an indented code block, where it is text and not a box at all.

So ticking the second box in any list with sub-tasks ticked a different box. The doc comment at Markdown.java described a third failure — indented code blocks being mistaken for tasks — which was not the one that happened.

Widening the indent bound would have fixed the sub-task case and left the quoted one and the code-block one exactly as they were. There is no pattern that can be made to agree, because the disagreement is not about the pattern.

Decision

Tasks asks md4c where the box is.

MD_BLOCK_LI_DETAIL.task_mark_offset is the offset of the [ ] mark in the source md4c was handed. It was already plumbed through :natives to BlockDetail.Item.taskMarkOffset() and read by nothing. toggleTask now walks the event stream for task items, takes the indexth offset, and rewrites that one character.

ADR-0300’s constraint is preserved and is worth restating, because the old code’s comment conflated two things: the rule is that a toggle is a one-character edit of the source, not that it is a scan rather than a parse. The document stays the model; nothing is re-serialised; a reader’s own formatting, their trailing spaces and their hard line breaks survive a tick, which is the whole point of the rule.

One detail that is easy to get wrong and is now written at the conversion: task_mark_offset counts UTF-8 bytes of what md4c was handed, and a Java String counts UTF-16 code units. Without the conversion, a note with an emoji anywhere above its task list edits a character to the right of the box.

The dialect is MarkdownSyntax.gitHub() internally, because a task box is that dialect: under plain CommonMark the same text contains no tasks and there are no ordinals to count.

Alternatives considered

  • A better regular expression. Rejected above: three known failure modes, and the next one is whatever CommonMark allows that nobody has typed yet.
  • Carrying the offset on the widget. The view already knows where each task came from, so onTask could hand back an offset rather than an index. It is a better shape and it is a public API change — Markdown.toggleTask(int) is documented and used — so it is not what a defect fix should do. The signature is unchanged.

Consequences

  • toggleTask parses the document once per tick. That is a keystroke’s worth of work for an answer nothing cheaper can give, and it is the same parse the view does on every edit anyway.
  • Nested, quoted and code-fenced documents tick the right box. TasksTest.NESTED holds all three cases, and AgreesWithMd4c runs over it as well as over the flat document.
  • Markdown’s doc comment now describes the failure that existed rather than one that did not.

400. A clause is a start and a length

Date: 2026-09-18

Status

Accepted. Answers W2 and W3 of the whole-tree review recorded in docs/review-2026-09-18.md.

Follows ADR-0376, which is where the three editors agreed on one key map and did not agree on this.

Context

An input method reports a composition as four numbers: the preedit text, a caret inside it, and the clause the platform is converting — a start and an extent. SDL hands them over in SDL_TextEditingEvent as start and length, and PreeditEvent.length() is translated and documented as a count of chars.

Everything downstream computed start + length. Both TextInputState and TextAreaState did, and so did :core’s Editor.onPreedit. Only the name said otherwise: the parameter was clauseEnd, and TextEditor’s @param described an end. A seam whose four numbers are only three distinct facts is a seam where one of them gets ignored, and one was: the early return that decides whether a composition changed compared the text, the caret and the clause start, and never the clause’s extent. An input method that resizes a segment at the same start — which is what every Japanese IME does while a reader presses the arrow keys to grow a conversion — changed nothing on screen.

The same two classes carried a second bug of the same family. clip, which enforces maxLength, counted code points to find the cut and then took Math.min(end, room) in chars, undoing the step it had just taken: clip("a🎨b", 2) ended in a lone high surrogate. The comment above it promised more than code points — “never through a cluster” — and the code delivered less than one.

Both bugs existed twice because TextInputState and TextAreaState carried byte-identical copies of clip, room, compose and clearPreedit. The review found the duplication independently and noted that form/Carets.java had been created to stop exactly this.

Decision

The clause is a start and a length, everywhere, and the end is derived once.

  • clauseEnd is clauseLength in TextEditor, AreaEditor and both implementations. A doc comment cannot change what the platform puts in the struct; what it can do is stop disagreeing with it.
  • The end — which is what a painter wants, and what Composing carries — is computed in one place rather than at each of four call sites.
  • The early return compares all four numbers.

A limit cuts on a grapheme boundary. clip steps back to the nearest boundary from BreakIterator.getCharacterInstance() — the same class TextEdit steps a caret with, so a clip and a caret cannot disagree about where a character is. A surrogate pair survives, and so does a combining accent, which is what the comment always said.

The shared arithmetic lives in widgets/form/parts/. MaxLength holds room and clip; Preedit holds the four preedit fields, the clamping, wouldChange, set, clear and composingAt. What was not lifted is what actually differed: a text-input’s compose refuses a password, each clearPreedit wraps its own setState, and accepts asks a text-input’s filter. form.parts is the package module-info already describes as “public in a package nothing can see”, so none of this becomes API.

Alternatives considered

  • Making the implementations match the doc — treating the number as an end. It would have meant translating SDL’s length to an end at the boundary and back again for anything that wanted an extent, to satisfy a comment. The platform’s shape wins.
  • Lifting all four methods. Two of them differ in a way that matters, and a shared method with a boolean for “is this a password” is the duplication back in a worse form.

Consequences

  • A composition whose clause grows or shrinks at the same start now redraws. The defect was latent through today’s only caller, which is worth recording honestly: PreeditEvent.caret() is defined as clamp(start + max(0, length)), so it is the clause end whenever a clause is reported, and the caret comparison caught the resize by accident. It bites the moment an event carries a caret of its own — which is how IBus and macOS report a caret inside a converting clause. The tests therefore drive the editor seam directly, with a fixed caret and a changing clause length.
  • maxLength can now refuse a character that a code-point count would have admitted: pasting a🎨b into a maxLength(2) field yields a and not a + half an emoji. That is a visible behaviour change and the right one.
  • Two controls share two classes they did not share before. The next preedit or limit bug is one fix rather than two, which is the whole argument — and the reason this record exists rather than two commits.

401. The router tells the living, and finishes the application’s pair

Date: 2026-09-18

Status

Accepted. Answers C7 of the whole-tree review recorded in docs/review-2026-09-18.md.

Corrects ADR-0303, whose “safe by construction” no longer holds. Extends ADR-0317 with a second clause, and depends on ADR-0327 for the half of emit that must keep running.

Context

PointerRouter.updateHover walks the chain under the pointer and tells each element it was entered or exited. Its emit() has no isMounted() check, where mark() and notifyFocus both do.

ADR-0303 argued that this was safe by construction: a rehover is asked on the next frame, not inside the handler that caused it, so by the time EXITED is sent the tree has settled. That was true when it was written. It stopped being true once a widget could unmount the element under the pointer and the same dispatch could reach it — and State.setState on an unmounted state throws, deliberately, because ADR-0317 decided that a callback outliving its widget should be loud rather than silent. CanvasScreen.java calls setState on EXITED, so the showcase contains the shape that crashes.

The obvious fix is an isMounted() guard around emit, which is what the review proposed. It is too broad, and the suite says so: emit speaks to two audiences in one method.

Decision

The router does not call a widget it has disposed. It does finish the application’s own enter/exit pair.

  • The widget’s handler is not called on an unmounted element. There is nobody left to hear it: State.dispose has run, the bindings are closed, the subtree is gone. The one thing a final EXITED could have been for — releasing something the enter acquired — is what dispose is, and dispose has already run. Nothing is lost by not telling them, which is ADR-0317’s argument transferred unchanged.
  • The Attributes hook beside it still runs. onPointerEnter / onPointerExit are the application’s half of a pair, held on a widget value rather than in element state, and nothing disposes them. ADR-0327 added them precisely so that a hover-hold timer can be cancelled from the hook rather than from two places, and HoverHookTest.anUnmountedSubtreeIsToldItLostThePointer pins it. Dropping the exit would leave every enter unmatched at exactly the moment the hook exists for. A blanket guard was written first and failed that test, which is how the line was found.
  • The check is read inside emit, per element, at the moment of telling — not hoisted, not computed once per move. updateHover tells a chain one element at a time and any handler may rebuild, so one read covers both cases: the element unmounted by the action that moved the pointer (ADR-0303’s path, on the next frame) and the element unmounted by a handler two steps earlier in the same loop.

So ADR-0317’s rule — “a guard is per element, not per notification” — gains a second clause: per audience, too. Two things are being told; only one of them dies with the element.

Alternatives considered

  • An isMounted() guard over the whole of emit. What the review asked for. It breaks ADR-0327 and its test, silently, by dropping the application’s exit.
  • Making State.setState tolerant of an unmounted state. Rejected in ADR-0317 and rejected again here: the throw is the assertion that catches a real leak, and a toolkit that swallows it trades one visible crash for a class of invisible ones.
  • Sending EXITED before unmounting, from the reconciler. It would make the contract “you always get an exit” true rather than nearly true, at the cost of a reconciler that knows about pointer state. The router is where hover lives.

Consequences

  • A handler that unmounts the element under the pointer no longer takes the window down. RehoverTest.theDeadAreNotToldTheyExited holds the crash with the showcase’s own shape — a stateful panel whose leaf calls setState on EXITED — and anAncestorUnmountedMidDispatchIsSkipped holds the mid-loop case.
  • The contract a widget can rely on is now precisely: you will not be told anything after you are unmounted, and an EXITED is not guaranteed. A widget that needs to release something on the way out releases it in dispose. That is written at emit rather than only here.
  • An application hook can still be called once after the widget it was attached to is gone. That is the ADR-0327 contract and it is now the only asymmetry in the method, which is better than an undocumented one in both directions.

402. A sheet has a position in its layer

Date: 2026-09-18

Status

Accepted. Answers C4 and C5 of the whole-tree review recorded in docs/review-2026-09-18.md.

Context

CSS resolves a property by asking, in order: is it !important; which cascade layer; how specific is the selector; and, when all of those tie, which declaration came later in source order. The last of those was wrong here.

Match.order was StyleRule.order, which CssParser assigns as the rule’s index within its own sheet. Once the type buckets flatten several sheets into one matching pass, that number is all the cascade has, and it means nothing across sheets: an earlier sheet’s rule 3 beat a later sheet’s rule 0 at equal specificity. Three of the toolkit’s own sheets sit in TOOLKIT_BASE together — controls.css, MarkdownStyles and HtmlStyles — so this is not a hypothetical about applications.

The lint had the mirror of the same confusion. StyleLint.checkRule resolved the element and then looked each declaration’s property up in the result, which is the cascade’s winner for that property and not this declaration’s value. A declaration that lost was therefore checked against somebody else’s value: a bad loser produced no finding at all, and a finding that did fire printed the winner’s value at the loser’s line and column. The comment claiming that resolve returns null for an overridden rule was false — the property is in the result either way, which is exactly the problem.

Decision

A sheet carries its index, and the cascade compares it between layer and rule order.

Candidate already existed to carry the layer back after the buckets flatten the sheets away; it carries the sheet index too. CASCADE therefore sorts by !important, then layer, then specificity, then sheet, then the rule’s index within that sheet.

Placing it between layer and rule order is what makes the change provably narrow: the new key is only ever consulted when two matches already agree on !important, specificity and layer, so no layer comparison and no specificity comparison can change. It separates exactly the pairs that were previously separated by the wrong number.

The lint asks for a written value rather than a resolved one. StyleResolver gained substitutedFor(element, value), which substitutes a declaration’s var()s against an element without cascading, and checkRule checks that. A losing declaration is now checked on its own value, at its own line.

Alternatives considered

  • A composite order, sheetIndex * K + order. It needs a bound on the number of rules in a sheet, and past that bound it silently transposes — the failure mode is the bug being fixed, back again and harder to find. Rejected for being a smaller change that is not actually correct.
  • A global counter assigned at registration. The only place to put it is StyleRule.order, a public record component documented as “the rule’s position in its stylesheet” and produced by CssParser, which does not know it is inside a sheet, let alone which one. Renumbering at StyleResolver construction would rewrite every rule and make order mean different things in a Stylesheet a test built and one a resolver had seen. The sheet index is also strictly more information: it is still possible to ask which sheet a declaration came from, which a flattened counter throws away.

Consequences

  • Two sheets in one layer cascade by the order they were added. StyleResolverTest.Cascade.sheetOrderWithinALayer holds it, with sheetOrderReversed, layerBeatsSheetOrder and specificityBeatsSheetOrder pinning the two orderings the new key must not disturb.
  • resolveStarting is fixed for free: @starting-style rules now sort into their own sheet’s position instead of being interleaved with ordinary rules by bare index.
  • The lint is stricter and found nothing new in the toolkit’s own sheets — SupportedPropertyTest holds them to zero findings under three theme and density combinations, and still does. What changed is that a future bad declaration under a good one will now be reported, at its own line, which it would not have been.
  • Nothing shipped renders differently: no golden image moved. That is worth stating rather than assuming, because a cascade change is exactly the kind that should have moved one — and the reason it did not is that the three TOOLKIT_BASE sheets do not currently collide at equal specificity.

403. The events a failed handler never saw wait for the next pump

Date: 2026-09-18

Status

Accepted. Answers the EventSink row of §6 of the whole-tree review recorded in docs/review-2026-09-18.md, and records the decisions behind C8, C9, C17 and C18 alongside it.

Context

EventSink’s javadoc has always said:

Throwing propagates out of pumpEvents — the backend is mid-drain and has no way to make sense of a failure here, so it does not try. Events already delivered stay delivered; the rest wait for the next pump.

Neither backend did that. HeadlessBackend.pumpEvents drained its queue into a batch and lost the tail when the sink threw; Sdl3Backend had already pulled its events out of SDL’s queue and dropped them the same way. The test named “a throwing sink propagates and leaves the rest queued” asserted only the throw, so nothing noticed.

Two readings were available. Either the contract is aspirational and should be rewritten to describe what happens, or the code is three lines per backend away from the promise it has been making.

The argument that looks like it favours rewriting — the SDL events have already been pulled out of the platform queue — cuts the other way on inspection. It does not show the events are unrecoverable; it shows the backend is the only place they can wait, because there is nowhere to put them back.

And what a dropped tail costs is not recoverable further up. The events behind a throw are disproportionately the ones that end something: a pointer release that leaves a button held, a key release that leaves a modifier stuck, a FileDropCompleted that leaves a drag open. Nothing above the SPI can synthesize those — the router cannot know a release it was never told about happened.

Decision

The code moves to the contract.

  • Sdl3Backend keeps an undelivered list and delivers through one deliver(sink, events) used by the pump, by emitDueFrames and by the resize watch. HeadlessBackend’s queue became a Deque and a requeue puts the tail back at the front — ahead of anything the failing handler posted on its way out, because those events happened later.
  • The event that threw is not redelivered. The sink saw it; re-offering it would fail on every pump for ever.
  • Carry-over is cleared in close() and pruned in forget(window), so a dead window’s events do not outlive it.
  • EventSink’s doc now says what it costs a backend to keep the promise, that the throwing event is consumed, and that throwing is not a way to decline an event.

Three smaller decisions landed with it, each recorded at its own code:

  • A wait shorter than a millisecond still waits (C8). (int) wait.toMillis() truncated every sub-millisecond remainder to 0, which the branch below read as “poll” — so the pump returned having delivered nothing and EventLoop.run came straight back round, spinning for the last millisecond of every frame interval whenever the display rate was adopted. waitMillis ceils, which also removes the extra pump a 16.6 → 16 truncation buys, and caps at Integer.MAX_VALUE because SDL reads a negative as “wait for ever”.
  • A directory that will not open is not knowledge (C9). listDirectory returned Optional.empty() for “not a directory” and threw UncheckedIOException for “a directory I cannot read” — two ways of not knowing, one of them fatal, from a lister called in the Sdl3Backend constructor for a cosmetic diagnostic. The constructor catches only SdlException and UnsatisfiedLinkError, so the throw left SDL initialised and the event buffer unclosed. Both cases are Optional.empty() now, which the comment beside the throw already claimed.
  • The flag close sets is the flag wakeup reads (C17). closed is volatile. It has two off-thread readers rather than the one the review names: wakeup(), and drawDuringModalLoop, which the class’s own doc says runs on whichever thread pushed the event.

Alternatives considered

  • Rewriting the contract to say the remainder is dropped. Honest, cheap, and wrong: it would document a hole an application cannot patch. A toolkit that tells a widget “you may or may not hear the release” has made every gesture handler defensive for the toolkit’s convenience.
  • Catching and logging inside the pump. It removes the propagation the contract also promises, and a swallowed handler failure is the bug that takes longest to find.
  • A synchronized block for C17 instead of volatile. It orders more than is needed and costs more; the residual overlap — a wakeup() already past its read when close() runs — is not a visibility question and no lock on this flag would order it either. It is harmless because close() sets the flag first and reaches Sdl.quit() only after taking down the watch, the windows, the trays and the cursors, so a push that slips through lands on a live queue. That is written on the field rather than claimed to be a race that is gone.

Consequences

  • A handler that throws no longer costs the events behind it. Sdl3EventPathTest is the strong test: the second pump pushes nothing onto SDL, so anything that arrives can only have been held by the backend.
  • Both backends now carry a small amount of state they did not have, and it has to be cleared in two places — close() and forget(window). That is the price, and it is the reason this is a record rather than a commit message.
  • What could not be tested here, stated so the next reader does not assume otherwise: this machine has no display server, so Sdl3EventPathTest builds a real Sdl3Backend under SDL’s dummy driver — which covers C17, C18 and the SDL half of the sink contract against shipping code. Out of reach: the C8 busy loop end to end (the dummy driver reports no display rate, and “did it spin?” is a stopwatch question, so the fix is pinned as arithmetic); the actual ADR-0211 failure, a macOS popup’s event in its owner’s space; anything Wayland or libdecor, so C9 is pinned at the lister rather than through diagnose; and the C17 interleaving itself, which is a race no test can lose on demand.

404. A memo sees the source a picture came from

Date: 2026-09-18

Status

Accepted. Answers H3, H6 and H7 of the whole-tree review recorded in docs/review-2026-09-18.md.

Extends ADR-0389, whose §4 argued the signature question for handlers and not for sources.

Context

Three decisions in :html came out of the review, and each of them is a case where the obvious fix is not the right one.

A memoised block kept its picture for ever. ADR-0389 lets a markdown-view keep the widget of a block nobody typed in, keyed on a wiring signature. The signature was four presence bits — is there a link handler, a task handler, an image source, a code highlighter — which is exactly right for a handler: a block asks a handler to do something later, so two handlers that both exist are interchangeable as far as the block is concerned. An ImageSource is not a handler. A block asks it what to draw, during the build, and keeps the answer for as long as the memo keeps the block. Swapping the source was therefore invisible, and a note went on showing the picture the old source had returned.

A selection could not wash a link. A Word that wraps a child widget returns early from render and never calls WordGeometry.shaped — the label is shaped by the button that holds it, in the button’s own style, and nothing hands that paragraph back. With no paragraph, xOf answered rect.left() for every offset, so both ends of a link were the same place: a selection ending inside one washed none of it, and a double-click highlighted nothing. (The copied text was already right, which is why nobody noticed.)

A <tr> outside a table lost its cells. The fold sent it through rows(), which matches only tr, sections and caption, so a row asked for its rows got none. Here the HTML Living Standard and this codebase’s own rule disagree: “in body” treats a stray <tr> as a parse error, ignores the tag and keeps only its text.

Decision

The image source’s identity is part of the wiring signature. present * 31 + System.identityHashCode(images), computed in of(...) — which a build calls exactly once — so signature() is a field read and a note of N blocks pays one identityHashCode per keystroke rather than N. That is what keeps ADR-0389’s promise intact: the memo is cheap because it is asked once per block per build and answers from a number it already has.

Two costs are written at signature() rather than discovered later:

  • Replacing the source rebuilds the whole note once, not just the blocks holding pictures. A swap is not a per-keystroke event, so once is affordable; a finer answer would mean asking every block which images it holds.
  • A source that answers differently without being replaced is not noticed. Noticing would mean calling it per image per keystroke, which is the cost ADR-0389 exists to avoid. ImageSource now tells an application to hold a source rather than mint one inside build.

An unshaped word is measured across its own rectangle. offset 0 is the left edge and the last offset the right one — exact at both ends, proportional in between. The alternative was to shape the text a second time in the word’s own style, which would produce widths that are not the ones drawn: a wash that is wrong in a way that looks right. An image, whose text is "", still washes as a whole box or not at all.

A stray <tr> is drawn as a row of its cells. Element’s rule — “nothing is dropped for being unknown” — is this repository’s and not the standard’s, and it is the rule the fold already follows everywhere else: a stray <p> in a list and an unknown tag are both drawn where they are. The text a reader sees is the same text a browser shows; what differs is that the structure survives.

HtmlParser does follow the standard where the standard is about structure: the “in cell” insertion mode closes an open cell for a section, which is H5.

Alternatives considered

  • Presence rather than identity for the image source, keeping ADR-0389’s four bits. It is what was there, and it is the bug.
  • Comparing sources by equals. An ImageSource is an interface an application implements; most implementations are lambdas or records over a path, and requiring value equality of them would be a contract this module cannot enforce and applications would silently fail. Identity is honest about what is actually being compared.
  • Following the spec for the stray <tr>. It would drop a cell’s structure to match a parse-error recovery rule written for a browser that has a table insertion mode to fall out of. This model has neither.

Consequences

  • A picture whose source was swapped is redrawn. BlockReuseTest now holds all three invalidation paths the review found missing: the picture after a kept block, tasksSeen resumed after a kept block, and a memoised link calling the new handler. Pressing a task box needs the real router, so that test grew a mount/router pair.
  • A selection that ends inside a link washes the part of it the pointer covered, at the cost of a measurement that is proportional rather than glyph-accurate inside the word. Nothing in the toolkit needs glyph accuracy there — a wash is a rectangle — and the ends, which are what a reader aims at, are exact.
  • <tr> outside a table renders where a browser renders text and keeps a structure a browser throws away. An application reading the model sees the cells; a test comparing against a browser’s DOM would not match, and nothing does that.

405. check generates the published javadoc

Date: 2026-09-19

Status

Accepted. Completes ADR-0343, whose last consequence — “the release’s last step cannot be the first to find one” — was not true as written.

Context

Snapshot run 17, on 1deba933, went red in publish / Maven Central (snapshot):

widgets/.../data/linechart/LineChart.java:106: error: reference not found
/// [ChartSpec#fill] puts a flat wash or a fade beneath the data — `charts.md`
1 error
> Task :widgets:javadoc FAILED

ChartSpec has no fill, and says so itself: its own class comment reads “the one knob that is not here is fill”, and LineChart.fill carries a paragraph explaining why it is declared on the chart rather than the interface. The link wanted [#fill] — this type’s own member, which is what it said before 502ed0b1 rewrote it while merging a sweep that moved the other knobs’ links onto ChartSpec. That commit was right about [ChartSpec#markers], [ChartSpec#curve] and the eight others, and wrong about the one member that had deliberately stayed behind.

The one-word mistake is not the interesting part. Where it was caught is.

ADR-0343 turned doclint on for every published module and recorded that ./gradlew javadoc would now fail on a rotted [link]. Nothing runs ./gradlew javadoc. The only thing that generates javadoc is javadocJar, and the only thing that runs that is publish.yml — so the lint fired:

  • after the three Java jobs had built and tested every module on Linux, macOS and Windows, all green;
  • after four native libraries had been built on four runners and verified on four more;
  • in the last step of the twelfth and final job, thirteen minutes in;
  • and partway through an upload. This was the first run with the Central credentials actually set, so the gate resolved to upload=true and the job published rather than rehearsing. Seven modules finished publishMavenPublicationToMavenCentralRepository — :common, :core, :gpu, :emoji, :toolkit, :bom and :natives, the last two after :widgets:javadoc had already failed, because Gradle finishes the tasks it has started. :widgets and :html never uploaded.

That last point is the failure mode publish.yml’s own header warns about, arrived at from a direction the header did not anticipate. It reasons about a runner publishing its own platform; this was one runner publishing most of the modules. The snapshot repository was left holding seven of the nine at that version, with the two catalogs — the modules an application actually writes widgets against — missing. A snapshot is overwritten by the next run, so the repair is the next green snapshot rather than anything by hand; a release would not have been so forgiving, which is why publish.yml refuses a release without credentials but allows a snapshot to rehearse.

A lint nothing invokes is a lint that runs once, at the least recoverable moment.

Decision

check depends on javadoc, in goldberry.publish — so the modules that are linted are exactly the modules that ship.

pluginManager.withPlugin('java-library') {
    tasks.named('check') {
        dependsOn tasks.named('javadoc')
    }
}

Three things about the shape.

In goldberry.publish, not goldberry.java-conventions. The rule being enforced is “what we publish must document itself”, so it belongs to the plugin that decides what is published. :assets and :weaver are build-time modules that nobody consumes; both have reference not found errors today, and neither is worth the churn of fixing to buy nothing. Putting the dependency in the java conventions would have made those two errors block every build in the repo.

Under pluginManager.withPlugin('java-library'). :bom is a java-platform and has no javadoc task. A bare tasks.named('javadoc') at this plugin’s top level fails configuration of :bom, which is every Gradle invocation in the repo rather than only a publish — the loudest possible way to land a fix for a quiet problem.

check, not build. It is a correctness gate, and it belongs with the other ones — the coverage floor, SpotBugs, PMD — so -x check turns all of them off together and nothing else has to know about it.

Consequences

  • A broken [link] in a published module fails the Linux, macOS and Windows Java jobs, on every push and every pull request, about four minutes in. It can no longer reach publish.yml.
  • Every check across the eight published modules with sources costs roughly 35 s of javadoc on this machine, three times over in CI because the three OS jobs each run build. That is the price, and it is paid against a failure that otherwise costs thirteen minutes and a half-finished upload.
  • :assets and :weaver keep their four reference not found errors. They are recorded here rather than fixed: neither module is published, and javadoc is not wired into their check. A later decision to publish either one has to clear them first, which is exactly the gate this ADR installs.
  • PublishedJavadocTest holds both halves — the doclint flags and the check wiring — as text, the way ADR-0082’s other drift guards do.

What to write instead

A link to a member of the type the comment is on is [#member]. A link that names a type spells a member that type actually declares — [ChartSpec#curve] resolves because ChartSpec declares curve; [ChartSpec#fill] does not, because the whole point of LineChart.fill is that ChartSpec has no such knob. The IDE’s resolution is not the test; ./gradlew check is, now.

406. A uri-list is a list of names, and only some of them are files

Date: 2026-09-19

Status

Accepted. Closes the “No file lists” entry under “The clipboard” in book/src/TODO.md.

Extends ADR-0286, which put bytes under a MIME type on the clipboard and deliberately stopped there, and ADR-0330, whose rule about a name the file system will not accept is reused here.

SDL_EVENT_DROP_TEXT is not bound here. The reason is recorded below, because it is not the reason ADR-0330 gave — and it was paid and bound immediately afterwards by ADR-0408, once another change in the same batch made the native half’s bill payable.

Context

The entry:

text/uri-list is bytes like anything else and works today, but nothing turns those bytes into paths.

Both halves of that are true and the second is the whole cost. A uri-list is not a list of paths; it is a list of percent-encoded URIs, and the twenty lines that turn one into the other are lines every application would write for itself. They are also lines that are easy to get wrong in a way nothing notices: the naive reader — split on newlines, chop off file:// — is correct for every file in a test fixture and wrong for the first one with a space in its name, which arrives as /tmp/my%20file.png and is a file that does not exist. That bug does not fail a build. It ships, and it is reported as “dragging from Downloads does not work” by a user whose Downloads folder has a space in one name.

The format is RFC 2483’s: one URI per line, CRLF between lines, # starts a comment. What actually arrives is looser than that — a bare LF is common, a trailing NUL happens on X11 — and none of the looseness is the interesting part. The interesting part is that a uri-list is not a file list. A drag out of a browser is a list of https: URIs; a mail client offers mailto:. Deciding what happens to those is the decision this ADR exists to record.

Decision

A record in the clipboard’s own package, holding URIs, with the file half as a conversion.

// io.github.digitalsmile.goldberry.render
public record UriList(List<URI> uris) {
    public static final String MIME = "text/uri-list";

    public static UriList parse(byte[] bytes);
    public static UriList parse(String text);
    public static UriList of(List<Path> paths);

    public List<Path> paths();
    public String text();
    public byte[] encode();

    public static boolean onClipboard(Clipboard clipboard);
    public static UriList fromClipboard(Clipboard clipboard);
    public boolean toClipboard(Clipboard clipboard);
}

It lives beside Clipboard, unlike Image

ADR-0286 put the image convenience beside the decoder and argued why: a backend implementing the SPI must not have to know what a PNG is, and putting Image on Clipboard would have made render depend on image, which depends on render.

That argument does not reach this type, and it is worth saying why rather than applying it by analogy. UriList has no decoder to live beside: its only dependency is java.nio.file, which is the JDK. It drags nothing into the SPI, it forces nothing onto any backend — it is a value, like Cursor and DamageRect in the same package — and the alternative is not “a smaller render” but “every application parsing this itself”. Clipboard’s own javadoc names it now, one line below where it names Image.fromClipboard, so a reader who has the bytes finds the type that reads them.

Non-file: entries are kept, and are not paths

uris() is every entry that parsed. paths() is the file: subset, converted.

Filtering the others out at parse time would be cheaper and it would throw away the answer to the question a failing paste actually asks. An application that asked for files and got none needs to know whether the clipboard was empty or was offering a list of web links, and those are the same answer if the web links were silently dropped. This is the argument Clipboard.types() is on the interface for (ADR-0286), one level up.

So a paste of a browser’s drag is a UriList with two URIs and no paths, and an application can say so.

A line that is not a URI is dropped, with a log

Not thrown. The list came from another application across a protocol with no schema and no validator, and ADR-0330 already decided this for the names that arrive by drag-and-drop from the same desktops: a drop shortened by one bad name is better than a drop that failed. A parse that threw would turn one bad line into a paste that does nothing, and the bad line is usually the tenth of ten.

Three kinds of entry are dropped, and each is a separate judgement:

  • Not a URI at all. file:///tmp/%ZZ is a malformed escape pair and file:///tmp/a b has a raw space; URI’s own parser refuses both.
  • No scheme. A bare /tmp/x is dropped rather than read as a path. Guessing here is how C:\Users\… written by another machine becomes a name this one would happily create, and a line without a scheme is, by the format’s own definition, not an entry.
  • A name this file system refuses. file:///tmp/a%00b is a legal URI: %00 is a well-formed escape and NUL is a byte. It is not a legal path, and Path.of says so. This is exactly the case ADR-0330 handles for drops, arriving through the other door, and it gets the same answer.

file://localhost/x is this machine

RFC 8089 blesses both file:///x and file://localhost/x, and Java’s file system accepts only the first: Path.of(URI.create("file://localhost/tmp/a")) throws IllegalArgumentException: URI has an authority component. Dropping that entry would be the toolkit inventing a failure the desktop did not have, so the one authority that is this machine is normalised away.

Any other authority is left to fail. file://fileserver/share/a.png names something on another host; resolving it to /share/a.png here would open the wrong file, and opening the wrong file is worse than opening none.

Read loosely, write strictly

Reading accepts CRLF, LF, a bare CR, blank lines, # comments and a trailing NUL, and decodes as UTF-8 — which is what percent-decoding a file: URI produces on every desktop this runs on. Writing emits RFC 2483’s form: one entry per line, CRLF-terminated, percent-encoded by Path.toUri. The half of the protocol this toolkit controls is the half it can afford to be strict about.

fromClipboard is empty rather than Optional

Clipboard.text() already argued this: “there is nothing to paste” and “what was copied was empty” are the same paste, and a caller that had to distinguish them would have nothing different to do. Image.fromClipboard returns an Optional because a decode can fail; nothing here can. An application that does care asks onClipboard first, which is the cheap question.

SDL_EVENT_DROP_TEXT is still unbound, and now for a different reason

ADR-0330 left it out because nothing had asked for it — “a second event with no caller is a second event with no test”. That is still true, and it is no longer the binding constraint. The constraint is that the event number is a verified constant, and the verification lives in C.

Every value of SdlEventType is entered into NativeConstants.registry() by a loop over values(), and LayoutVerifier reports any registered constant that goldberry_shim.c does not report back:

SDL_EVENT_DROP_TEXT is declared in Java but not registered in goldberry_shim.c, so nothing verifies it

That is the design working (ADR-0010): a hard-coded event number that nothing checks is a binding that silently never fires. It also means adding DROP_TEXT(0x1001) to the enum is not a Java-side change. It needs one more GB_CONSTANT line in the shim, which is a native rebuild on four platforms and an ABI version bump — the same bill ADR-0330 paid to take the ABI to 11 for the other four drop events.

So the work is recorded rather than done: one line of C, one version number, and then the DROP_TEXT arm in Sdl3Backend, a BackendEvent case and a Window.onTextDrop shaped exactly like onFileDrop. Building the Java half now and leaving the constant out would produce API that no platform can ever raise — surface with no test, which is the thing ADR-0330 refused in the first place.

Consequences

  • text/uri-list is readable in five lines of application code: UriList.fromClipboard(clipboard).paths(). Writing one is UriList.of(paths).toClipboard(clipboard).
  • UriListTest holds every decision above as a named case — 20 of them, including percentDecodes, aBarePathIsNotAUri, anUnparseableLineIsSkipped, aNulByteInTheNameIsSkipped, localhostIsThisMachine and anotherHostIsNotLocal. The three that matter are the last three: they are the cases that came from running the conversion rather than from reading the RFC.
  • Path.of is stricter than RFC 8089 and than the desktops, which was not obvious until it threw. That asymmetry is now in one place instead of in every application.
  • Nothing in :natives changed, and no native rebuild is needed for this half. The drop-text half cannot be landed without one.
  • FileDrop and UriList stay separate types. They are the same information from two different platform mechanisms — a gesture versus a clipboard offer — and ADR-0330’s reason for FileDrop carrying a position is the reason they do not merge: a drop landed somewhere and a paste did not.

Alternatives considered

  • List<Path> paths() as the only accessor, dropping non-file URIs at parse. Shorter, and it destroys the evidence a failing paste needs.
  • Guess that a scheme-less line is a local path. Tolerant of one real producer and wrong about Windows names that came from elsewhere, where the guess resolves to a path this machine would create rather than find.
  • Throw on an unparseable line. Makes one bad entry in a list of ten into a paste that does nothing, and the toolkit is not the validator of another application’s output.
  • Percent-decode by hand. URI and Path.of already do it, including the UTF-8 that %D0%BF is, and a hand-rolled decoder is a second place for the same bug.
  • Put the type in input.drop beside FileDrop. That package is input events; this is a value on a clipboard, and a paste is not an event.
  • Add SDL_EVENT_DROP_TEXT to goldberry_shim.c here. One line, and it makes this a native change: a rebuild on four platforms, a new ABI version, and a verification matrix, for an event with no caller. It belongs in whatever change first needs dropped text.

407. The headless clipboard serialises when asked, and can say no

Date: 2026-09-19

Status

Accepted. Closes two entries under “The clipboard” in book/src/TODO.md — “The headless clipboard is eager” and “A refusal is not modelled anywhere”.

Finishes a sentence ADR-0286 wrote about its own work: “what it cannot model is laziness or a refusal, and it does not pretend to”.

Context

The two entries:

It keeps the bytes rather than serialising on demand, so nothing in a test exercises the laziness the platform imposes; the upcall path is covered in :natives against the real SDL instead.

Every write returns a boolean and the in-memory clipboard always returns true, so the branch an application writes for “the compositor declined” is only ever taken on a real desktop.

Both describe the same gap from two sides: the test double was honest about values and silent about the protocol. Neither is a defect in the double — it was built deliberately simple — and both are branches in application code that no run of the test suite has ever entered.

The cost of the first one is the more interesting. ADR-0286 established that laziness is not SDL’s taste but the protocol’s: there is no eager call for arbitrary clipboard data in SDL3, and none in X11 or Wayland either, because the selection owner is asked to serialise. An application written against the headless double therefore learns the wrong lesson — that write is where the bytes are produced — and the shape it grows is one that pays its serialisation cost per copy rather than per paste. The double taught that, and the real clipboard then silently forgave it, because a copy that nobody pastes costs nothing visible.

The second one is smaller but sharper. Every write on Clipboard returns boolean precisely because a compositor can decline, and Clipboard says so in its own javadoc — “a refusal is a real outcome rather than an exception”. An outcome that can only be produced on a real desktop is an outcome whose handler is dead code in CI, and the handler for a refused copy is the one place an application is supposed to tell the user that nothing was copied.

Decision

The headless clipboard becomes its own class, holds suppliers rather than bytes, and can be told to decline.

// io.github.digitalsmile.goldberry.render.backend.headless
public final class HeadlessClipboard implements Clipboard {
    public boolean offer(Map<String, Supplier<byte[]>> byMime);
    public HeadlessClipboard refuseWrites(boolean value);
    public boolean isRefusingWrites();
}

// HeadlessBackend
@Override
public HeadlessClipboard clipboard();

A class, and a narrowed return type

It was an anonymous Clipboard in a field initialiser with its state in two HeadlessBackend fields. Two test seams and a laziness invariant do not fit there, and the argument for pulling it out is one this backend has already made about HeadlessFileDialogs: a test that cannot reach answerWith(…) has to cast, and the cast is then the only thing in the test that knows which backend it is running on. clipboard() is narrowed for exactly that reason, which is legal because the SPI declares Clipboard and a subtype is still one.

The store is Map<String, Supplier<byte[]>>

offer is the lazy front door and read is the only thing that calls a supplier. So a test can assert the sentence the platform’s contract actually makes:

clipboard.offer(Map.of(SHAPE, () -> { produced.incrementAndGet(); return bytes; }));
assertTrue(clipboard.has(SHAPE));
assertEquals(0, produced.get());   // advertised, and never serialised

has and types are answered from the keys, because they are the cheap questions a paste button asks when its menu opens and Clipboard’s own note forbids making them expensive.

Each read calls the supplier again. SDL’s request callback runs once per paste and so does this. Caching the first answer would be the more obvious code and it would hide the application that re-encodes a megabyte on every paste — which is the only bug laziness introduces, so it is the one the double must be able to show.

write(Map) stays eager, and is stored as a supplier anyway

Its signature already holds the bytes; there is nothing left to defer, and Image.toClipboard encodes at copy time on purpose (ADR-0286). So write copies the caller’s array once — the array is the caller’s and may be reused — and stores copy::clone. One code path, two front doors, and the copy-per-read behaviour that the previous implementation had is unchanged.

refuseWrites(boolean) covers every write, and changes nothing

text(String), both write overloads, offer and clear all return false while it is on. A compositor declines a request, not a type, so a seam that refused only the byte half would be modelling something no platform does.

A refused write leaves the clipboard exactly as it was. This is the part worth asserting: an application that read false and then found its own earlier copy gone would be looking at a bug this class had invented. Reads keep working for the same reason — a clipboard that will not accept a new offer still has the old one on it.

It is off by default. This is a test seam, not a new policy: every test written before it exists behaves identically, which is the only acceptable price for adding a switch to a double that dozens of tests already depend on.

Not on the Clipboard interface

Neither seam. The refusal needs nothing there — the boolean is already in the SPI and this only makes it reachable. Laziness is the closer call, because SDL_SetClipboardData really does take a callback and a lazy write could be expressed: boolean write(Map<String, Supplier<byte[]>>).

It is not added, and the reason is the memory. ADR-0286’s hardest decision was that an offer’s arena is owned by the write and released by an upcall that arrives while the next offer is being installed; a Supplier in the SPI would put a Java lambda on the far side of that boundary, so the offer would have to keep the supplier, its captured graph and an arena alive together, and a supplier that throws would be an exception crossing back into C. That is a real cost for a capability an application can already have by encoding at copy time — which is what ADR-0286 decided images should do anyway.

Consequences

  • HeadlessClipboardTest holds both halves. Laziness has nine cases, of which nothingIsSerialisedUntilItIsRead, theCheapQuestionsStayCheap and everyReadProduces are the contract; Refusal has five, of which changesNothing and offByDefault are the ones that would catch this class growing a policy. Seams checks that the two new methods are UI-thread confined like every other SPI call and that the narrowed return type makes the cast unnecessary.
  • HeadlessBackend lost about sixty lines and two fields, and clipboard() gained the paragraph explaining the narrowing. The class was already long.
  • ClipboardDataTest’s own javadoc was wrong from this commit — it said the double “cannot model the platform’s laziness or a refusal” — and now points at the class that does. ADR-0286’s consequence list says the same thing and is left as written: it was true when it was written, and the ADR that changed it is this one.
  • The false branch of a copy is reachable in CI. Whether any application code actually has one is a separate question this does not answer; it makes the question askable.
  • The laziness is still only modelled, not shared. SdlClipboard’s upcall is the real thing and :natives still tests it against real SDL. What is new is that the two now agree about when bytes are produced, so a widget tested headlessly is tested against the shape it will meet.

Alternatives considered

  • Keep the bytes and add a boolean serialisedLazily flag. Records the claim without making it true; nothing would call a supplier because there would be none.
  • Cache the first read and count it once. Simpler, and it makes the expensive-per-paste bug invisible, which is the one thing laziness is worth testing for.
  • refuseNextWrite(), a one-shot, like HeadlessFileDialogs.answerWith. The dialog queue models a user answering each dialog differently. A compositor that declines is a state of the session, not a queue, and a test that wanted one refusal can turn the switch off again.
  • Throw from a refused write. Clipboard’s javadoc rejected this before the interface existed: “a copy that did not happen must not take the window down with it”.
  • A separate LazyClipboard test double next to the headless one. Two clipboards, one of which every existing test uses and the other of which is where the contract lives. The double a test gets by default is the one that has to be right.

408. A dropped line of text is a dropped file in every way but one

Date: 2026-09-19

Status

Accepted. Closes what ADR-0330 left out — “onTextDrop is not here” — and what ADR-0406 recorded as blocked.

The library has not been rebuilt yet. The one C line this needs is in goldberry_shim.c in this change; until the superbuild runs, LayoutVerificationTest.handWrittenLayoutsAgreeWithC is red. That is stated plainly below rather than glossed, because a green suite is the claim this ADR would otherwise be making.

Context

ADR-0330 bound four of SDL’s five drop events and left SDL_EVENT_DROP_TEXT alone with a one-line reason: “the same shape and nothing has asked for it. A second event with no caller is a second event with no test.”

ADR-0406 went looking for it while building the clipboard’s file-list reader and found that the reason had changed underneath. Binding it is not a Java-side change, and the interesting part is why — because the failure it produces looks nothing like the failure a missing binding usually produces.

The blocker was a constant, not a symbol

A missing native symbol fails loudly and early: the lookup does not resolve and the descriptor never binds. An event number is not a symbol. It is an int the Java side hard-codes, and a wrong one does nothing at all — the event simply never arrives, which is the quietest possible failure and is exactly why NativeConstants exists (ADR-0010).

Every value of SdlEventType is entered into that registry by a loop over values(), so adding one enumerator adds one row to the contract, and LayoutVerifier fails any row the compiled library does not report back:

SDL_EVENT_DROP_TEXT is declared in Java but not registered in goldberry_shim.c,
so nothing verifies it

That is the message this change produces today, verbatim, against the libgoldberry built before it. So the bill for a “Java-side” event was: one GB_CONSTANT row in C, a rebuild on four platforms, and an ABI bump — which is why ADR-0406 stopped and wrote the reason down instead of paying it alone. It is paid here because ADR-0422 is bumping the ABI 12 → 13 for its own reasons in the same batch; this change adds a row to the same table and deliberately does not touch GOLDBERRY_ABI_VERSION or GoldberryShim.SUPPORTED_ABI_VERSION, so the version number has exactly one author.

What a text drop actually is, which is not what ADR-0330 assumed

ADR-0330 said DROP_TEXT was “the same shape” as DROP_FILE and meant it loosely — one event, one payload. Reading SDL’s own senders makes it literally true in a way that matters, and the reason is in the tokeniser:

/* SDL_waylandevents.c — and Windows, macOS and Emscripten all do this */
char *token = SDL_strtok_r((char *)buffer, "\r\n", &saveptr);
while (token) {
    SDL_SendDropText(data_device->dnd_window, token);
    token = SDL_strtok_r(NULL, "\r\n", &saveptr);
}
SDL_SendDropComplete(data_device->dnd_window);

SDL splits dropped text on \r\n and raises one event per line. A two-line selection is two SDL_EVENT_DROP_TEXTs followed by one SDL_EVENT_DROP_COMPLETE — structurally identical to two files. It is the same loop, three lines above, that turns a text/uri-list into one SDL_SendDropFile per entry.

Two consequences fall out of that and neither is a matter of taste:

  • The separators are gone. Nothing downstream can tell \n from \r\n, or a trailing newline from none, because SDL_strtok_r consumed them. A record holding one String would have to invent them.
  • Empty lines are gone too — SDL_strtok_r skips empty tokens — so a blank line in the middle of a dropped selection does not arrive.

Decision

TextDrop is FileDrop with lines instead of paths, and the two share the gesture’s end.

// io.github.digitalsmile.goldberry.input.drop
public record TextDrop(List<String> lines, LogicalPoint at) {
    public String text();   // lines joined with \n
    public String first();
    public int count();
}

// io.github.digitalsmile.goldberry.Window
public Subscription onTextDrop(Consumer<TextDrop> listener);

// io.github.digitalsmile.goldberry.render.event.BackendEvent
record TextDropped(BackendWindow window, String text, float x, float y) { … }

Lines, because the platform says lines

Not TextDrop(String text, …). The value is the list because the list is what arrives, and text() — which joins with \n — is a named reconstruction rather than the model. Its javadoc says the \n is this toolkit’s choice, and textJoinsWithNewline asserts it so that changing it is a decision somebody makes on purpose. For the overwhelmingly common drop — a URL, a word, a line out of a terminal — there is one line and nothing to reconstruct, and drop.text() is what an application writes.

One completion for both kinds

SDL has one SDL_EVENT_DROP_COMPLETE and no per-kind completion, so BackendEvent.FileDropCompleted now ends a text drop as well. Its name is narrower than its job.

The name is kept, and that is a decision with a cost. FileDropCompleted is a case of a sealed SPI type: it is the word every exhaustive switch over BackendEvent spells, in this repo and in any backend outside it. Renaming it to DropCompleted would be a source-breaking change to the SPI to gain an adjective, so the javadoc carries the correction instead — the record already said “the drag-and-drop gesture ended” rather than “the file drop ended”, so only the identifier is wrong.

What was renamed is the internal half: Window.handleFileDropCompleted is now handleDropCompleted, because it is package-private, has three call sites, and raising a TextDrop from something called handleFileDropCompleted is the kind of line that gets read as a bug for years. The rule the two halves come from: a name inside the toolkit is worth fixing when it is wrong; a name in the SPI is a promise.

Two buffers, not one

Window accumulates paths and lines separately, and on completion raises a FileDrop if paths arrived, a TextDrop if lines did, and both if both did.

No platform SDL supports is known to send both in one gesture — Wayland’s handler is if (has_mime_file) … else if (has_mime_text), and Windows and macOS pick a representation the same way. The second buffer is not modelling a case that happens; it is refusing to lose half of one if it ever does, which costs one list and one if. A shared buffer would have had to decide whether /tmp/a.png was a path or a line, and the answer would have been whichever kind arrived first.

An empty line is not text

The backend drops an empty token rather than forwarding it, and Window drops one too if a platform sends it anyway, so an otherwise empty gesture stays silent — FileDrop’s rule, for FileDrop’s reason. TextDrop refuses to be constructed empty, like FileDrop: a listener handed a drop with nothing in it would have to check, and every listener would forget.

droppedText() beside droppedPath()

One SDL_DropEvent.data field, two accessors on SdlEventBuffer, because SDL’s header says the field is “the text for SDL_EVENT_DROP_TEXT and the file name for SDL_EVENT_DROP_FILE”. Two names cost one delegating method and make each backend arm say which event it is reading; a droppedPath() in the text arm would be read as a bug every time anybody looked at it.

Nothing is interpreted

A dropped URL is a line of text. The toolkit does not notice that it looks like a file: URI and does not turn it into a Path — an application that wants that says so, with UriList (ADR-0406). This is the same restraint ADR-0330 applied to dropped file names: the platform handed over a name, and what it means is the accepting application’s business.

Consequences

  • LayoutVerificationTest.handWrittenLayoutsAgreeWithC fails until the superbuild runs. The GB_CONSTANT row is in this change; the .so on this machine predates it. The only claim that cannot be checked from Java is the number itself, and it was checked the one other way available — compiling against SDL’s shipped header on this machine, which reports SDL_EVENT_DROP_TEXT=0x1001. After the rebuild the probe compares the same number against the same library and this ADR is either confirmed or loudly wrong, which is the whole point of that table.
  • TextDropTest holds the gesture — twelve cases, of which severalLinesAreOneGesture, textJoinsWithNewline, theTwoKindsDoNotCrossOver and aGestureCarryingBothRaisesBoth are the ones that would catch the shared completion being got wrong. SdlDropEventTest gained three: the text reads back out of data, the two accessors are one field, and the five drop numbers are SDL’s in SDL’s order.
  • No end-to-end test through a real drag exists, and none is added. The sdl3 arm is one switch case, the number in it is the probe’s business, and the reassembly is tested where ADR-0330 put it. A test that needed a desktop to drag from would not run in CI on any of the three OSes.
  • BackendEvent gained a case, so every exhaustive switch over it stopped compiling until it said what it does — the property ADR-0004 chose the shape for, working for the second time on this same interface.
  • HeadlessBackend still produces no drops, and Window is still where the gesture lives, so the headless path is exactly the file drop’s: install the backend, open a window, deliver the run.
  • SDL had already made ADR-0406’s decisions. SDL_URIToLocal, which SDL uses for the drop path, rejects a non-file: scheme, accepts localhost case-insensitively as this machine, and percent-decodes — the same three judgements UriList arrived at independently. It goes one step further and also accepts this machine’s own gethostname(); UriList does not, because Java’s cheap equivalent is not cheap (InetAddress.getLocalHost() can go to a resolver), and a paste is not a place to block. That difference is recorded rather than fixed.

Alternatives considered

  • TextDrop(String text, LogicalPoint at), joining SDL’s tokens on arrival. The obvious shape, and it invents a separator the platform destroyed while hiding that it did. text() does the join where a caller can see it.
  • Raise one TextDrop per DROP_TEXT, immediately. Simpler, and it breaks the one promise onFileDrop makes — once per gesture — for the kind where the gesture most often has one payload anyway. It would also give the drop the position from the middle of the gesture rather than its end.
  • Rename FileDropCompleted to DropCompleted. Honest, and a source-breaking change to a sealed SPI type for one word. Recorded in its javadoc instead.
  • One buffer for both kinds, discriminated by the event that filled it. Fewer fields, and the discrimination has to be invented at exactly the moment a platform does something unexpected.
  • Bump the ABI version here. Two changes in one batch both editing #define GOLDBERRY_ABI_VERSION is a conflict on the one line that must not be wrong. ADR-0422 owns the number; this owns a row in the table.
  • Treat a dropped file: URL as a file drop. Convenient, and a guess about intent made in the layer with the least information. UriList is one call away for an application that wants it.

409. Present costs a tenth of a millisecond, and the wait is the rasterizer’s

Date: 2026-09-19

Status

Accepted. Corrects the numbers in ADR-0031 and the question ADR-0045 left open. Neither record is edited; this one supersedes their present figure.

Context

book/src/TODO.md carried this, under Rendering and performance:

Present costs 6.6 ms with no compositor to wait for. The question ADR-0045 opened while closing another. ADR-0031 measured present at ~10 ms and concluded “most of it is waiting on the compositor rather than copying”. Under SDL’s dummy video driver — no compositor, no display, no surface to hand anyone — present still measures 6.6 ms, essentially the same as under Wayland. Whatever that time is, the explanation on record is wrong, and present is the largest single term in a frame.

The entry is right that the explanation on record was wrong. It is wrong about everything else, including its own number.

Two hypotheses were worth ruling out before measuring, because both would have made the 6.6 ms real and misattributed rather than absent.

That the Blend2D join was being counted as present. A frame’s context is asynchronous: draw queues commands and end() joins the workers (ADR-0042), so a timer boundary in the wrong place would report rasterization as presentation. It does not. Window.paint takes painted = System.nanoTime() after frame.end(), and present is measured as done - painted. The boundary was already right.

That the frame pacer’s sleep was being counted as present. 6.6 ms is suspiciously close to what is left of a 16.67 ms budget after a 10 ms frame, which is exactly what a pacer would sleep for — and a pacer is ours rather than the compositor’s, which would explain why the number did not change between Wayland and dummy. It is not that either: FramePacer caps the event wait (pacer.capWait in Sdl3Backend.pumpEvents), which is outside the painted frame entirely.

Decision

Measure it, and record what it is. 300 frames of the showcase under -Pgoldberry.backend.videoDriver=dummy with the per-frame trace on, on this machine:

Stagemedianp95
buffer0.062 ms0.131 ms
paint — begin0.060 ms0.132 ms
paint — draw5.944 ms39.327 ms
paint — end10.158 ms20.978 ms
present0.127 ms0.319 ms
whole frame16.928 ms61.244 ms

A separate 300-frame run agrees: present’s median 0.113 ms, minimum 0.057 ms, p95 0.219 ms, mean 0.138 ms.

Present costs about a tenth of a millisecond, which is 0.8% of a frame. The entry’s 6.6 ms is off by a factor of roughly fifty, and its conclusion — “present is the largest single term in a frame” — has the frame upside down.

The largest single term is end, at 10.2 ms median: the join that waits for Blend2D’s worker threads. draw at 5.9 ms is the second, and the two are one thing rather than two — draw queues the commands and end is where they are actually rasterized, so a frame under dummy is essentially all rasterization and nothing else. That is what ADR-0042 said the asynchronous context would do, stated in numbers for the first time.

Consequences

  • The entry is closed as corrected rather than fixed, because there was nothing to fix. What was wrong was a number in the list.
  • ADR-0031’s ~10 ms stands unchallenged for Wayland, and deliberately so: the Wayland figure cannot be re-measured here. Opening a real surface on this machine takes GNOME Shell down with it — a compositor bug with a core dump behind it, recorded in TODO.md — so reproducing it costs the developer their session. What this record measures is the driver the entry itself claimed to have measured.
  • The qualifier that matters is the branch. Under dummy, acquireFrame succeeds, so the frame is rasterized straight into SDL’s own surface and present is SDL_UpdateWindowSurfaceRects over the damage with nothing to copy. A driver where SDL refuses the surface takes Sdl3Window.present’s other branch and pays a full-buffer copy. So 0.127 ms is the floor, not the universal figure, and a like-for-like Wayland measurement is still owed — which is a separate entry and stays open.
  • The absolute totals here are inflated by the measurement. The per-frame trace is enabled by LOG.isTraceEnabled(), which adds five nanoTime calls and a formatted log line per frame; the frames are ~17 ms with it on and ~5 ms in a run where only present was extracted. The ratio is what this record claims, and the ratio is not sensitive to it: present is a rounding error against paint either way.
  • --frames=300 under the dummy driver is the reproduction, and it is cheap and safe. Written down because the previous numbers on record cannot be reproduced at all, which is how they survived being wrong.

410. A page is the caller’s height

Date: 2026-09-19

Status

Accepted. Closes the book/src/TODO.md entry “No word-wrap-aware PageUp/PageDown” under Editing text. Adds one method to the canvas editing seam ADR-0285 opened; the key map itself (ADR-0376) is untouched, because what a page is was never the map’s question.

Context

Editor.pageLines() returned 10, with a comment admitting it:

/// Lines to a page. Ten, and it is a guess: a page is the height of a
/// viewport, and an editor drawn on a canvas has none. A caller that knows
/// better moves the caret itself.
private int pageLines() {
    return 10;
}

The admission is right about the problem and wrong about the remedy.

Right about the problem. EditKeys turns PageDown into MoveLine(1, byPage = true, extend) and leaves “how many lines is a page” to whoever knows how tall the viewport is — which for text-area is visibleRows(), a measured height divided by a line height (ADR-0297). An Editor has no box. It is handed a Frame and an (x, top) and told to draw; the viewport it is inside belongs to the canvas, at the canvas’s transform, which is the whole point of the class. So ten it was, at every size: PageDown in a six-line sticky ran off the end of it, and PageDown in a forty-row pane moved the caret a quarter of the way down the screen and left the reader looking for it.

Wrong about the remedy. “A caller that knows better moves the caret itself” costs more than it sounds. Moving the caret by a page means asking TextGeometry.moveLine for a target offset, which means holding the column a run of vertical movement is keeping — and desiredX is private state that Editor.verticalBy sets after the move for a reason ADR-0285 argues at length. A caller doing this itself re-implements that, gets the column-keeping subtly wrong, and then has to intercept PageUp/PageDown before onKey sees them so the editor does not also move by ten. The caller knows one number. The editor knows everything else.

Nothing about Up and Down was ever wrong, which is why this sat in book/src/TODO.md rather than in docs/gaps.md: a page key that moves by the wrong amount still moves by lines, still keeps its column, and still lands on a grapheme boundary.

Decision

A caller says how tall its viewport is, and a page is the whole lines that fit.

public Editor viewportHeight(double height) {
    this.viewportHeight = height;
    return this;
}

private int pageLines() {
    var lineHeight = font.lineHeight();
    if (Double.isNaN(viewportHeight) || viewportHeight <= 0 || lineHeight <= 0) {
        return DEFAULT_PAGE_LINES;
    }
    return Math.max(1, (int) Math.floor(viewportHeight / lineHeight));
}

Four things about the shape, and the last one is the decision.

A height, not a line count. A height is what a caller has: a Canvas’s paint callback is handed an Extent, a sticky on a board is a rectangle, a cell in a drawing is two corners. A line count is what a caller would have to derive, by dividing by a leading it does not own — this editor’s font is this editor’s, and a caller that guessed 16 for a 13-point face would page by the wrong number in a way no test of theirs could see. One division, done on the side that has both operands.

In the text’s own space. Every other number this class takes or hands back is (ADR-0285’s Coordinates), so a caller under a scale transform divides once and this class stays free of the notion that there is a transform at all.

A page is a screenful, not a screenful less a line. Editors that scroll page by rows - 1, so that the bottom line of the old screen is the top of the new one and the eye has an anchor. That overlap is a property of the scroll, and this editor does not scroll — its caller does, if it does at all. Keeping a line back here would take a line off every caller’s page to buy an anchor only some of them can show.

Ten is still the answer for a caller that says nothing, and that is not inertia. Every alternative default is worse in kind rather than by a factor:

  • Zero — PageUp reports true, consumes the key and moves nothing. The worst outcome a key can have, because it also stops the application’s own handler from seeing it.
  • One — PageDown becomes Down under a second name, and the key that is meant to cover ground covers none.
  • The whole text — PageDown becomes Ctrl+End, which the map already has, and the selection a Shift+PageDown builds becomes select-all.
  • Refusing the key, so it falls through unhandled — defensible for a label and wrong for an editor: an editor that has a caret and a text has an answer to “move down a screenful”, and the one thing it does not know is the screen.

Ten lines is a guess about the box and never a guess about the meaning: it moves by lines, keeps the column, and stops at the ends. It is also exactly what this editor did before today, so no caller’s PageDown changed under it — which is the property that lets the new method be optional rather than a migration.

Consequences

  • Nothing is invalidated when a caller says it. How tall the viewport is changes what one key means; it does not change where a line breaks, so the shaping and the wrap memo survive being told. EditorPageTest asserts that with assertSame on both, because the cheap thing to write would have been an invalidate() and nobody would have noticed for a year.
  • A viewport shorter than one line pages by one line. The caller has said it is drawing into something, and a PageDown that reports true and moves nowhere is the outcome ruled out above.
  • The count is in visual lines, so a wrapped text pages by rows and not by paragraphs — which is what “on screen” means, and what ADR-0411 makes cheap to ask.
  • text-area and text-input are untouched. A text-area already divides its measured height by its line height, and a text-input is one line, where EditSurface.FIELD gives the page keys no meaning at all.
  • No picture moved. gallery-canvas.png is the same image: a viewport is a key’s meaning and not a pixel.
  • EditorPageTest holds it — a declared viewport in whole lines, in visual lines under a wrap, extending with Shift, running off the end, and the three ways of saying nothing (NaN, zero, negative) all meaning ten.

411. An editor shapes a line at a time

Date: 2026-09-19

Status

Accepted. Closes the book/src/TODO.md entry “Editor still shapes its whole text” under Rendering and performance, which ADR-0388 left open in as many words: “TextDocument is exported. Editor — the canvas editing seam from ADR-0285 — has the same whole-string shaping and is the obvious second caller. It is not changed here: nothing has measured it.”

Something has now. TextDocument is unchanged; TextGeometry gains the four questions over one.

Context

Editor held one Paragraph over everything it was given:

public Paragraph paragraph() {
    var current = paragraph;
    if (current == null) {
        current = Paragraph.of(font, displayText());
        paragraph = current;
        layout = null;
    }
    return current;
}

A Paragraph is shaped whole and keeps two prefix sums over it, an int per character each (ADR-0388 measured that at about 17 MB for half a million characters). Every keystroke makes a different string, so every keystroke paid all of it — the same fact that made a text-area over a 500 kB note cost 207 ms a frame, one layer down and with no widget in the way.

The entry said nobody had measured it, and that was the honest reason not to act: Editor was written for “a sticky on a board, a label on a shape, a cell in a drawing” (ADR-0285), and shaping a sticky is microseconds. But the class is exported, it is what an application drives when it wants an editor the toolkit has no widget for, and example’s own canvas screen puts one on a board. Nothing in its API says “not for documents”; it takes multiline(true) and a wrapWidth, which is a document’s two knobs.

EditorKeystrokeBenchmark (:core) asks the narrow question: one keystroke — a character typed, the caret placed — at three sizes. On linux-x64, medians in ms, on a machine with a load average of 13 (this repo’s own note about FrameBudgetTest applies: the absolute numbers move by a factor between runs, the ratio does not):

textbefore, one paragraphafter, a hard line at a time
2 kB2.4980.235
50 kB10.8600.553
500 kB111.4541.444

A quieter repeat of the same benchmark read 1.583 / 9.702 / 145.731 against 0.206 / 0.413 / 0.780.

Counted rather than timed, which is the number that does not depend on the machine: one keystroke into a 500 kB text used to shape 500 097 characters. It now shapes 132 — the hard line the caret was on — against 129 for a 2 kB one, and the difference between those two is that Line 3521 is four characters longer than Line 9.

Decision

An Editor holds a TextDocument: the text shaped one hard line at a time, re-shaped one hard line at a time, exactly as text-area has since ADR-0388.

The shaping

public TextDocument document() {
    var current = document;
    if (current == null || stale) {
        current = TextDocument.of(font, displayText(), current, shaper);
        document = current;
        stale = false;
    }
    return current;
}

displayText() and not text(), unchanged from before: a composition is drawn inside the text so the words after it move along, and the caret, the hit test and the paint must all measure the same shaping (ADR-0289).

The old document goes in as well as coming out, which is what makes it incremental: TextDocument.of brackets the edit by comparing the two strings from both ends, widens the bracket to whole hard lines, and rebuilds only those. Every other line keeps the Paragraph instance it had, and its wrap memo with it.

stale is a flag rather than a null, because the stale document is the input to the new one. And a flag rather than letting TextDocument.of notice by itself: that comparison is two passes over the text, and one frame asks for the caret, the selection, the rows and the paint. One comparison per edit is the bargain this class is making; four per frame is not.

shaper, so a caller with a cache can offer it

TextDocument.Shaper is a function so that the class that shapes does not decide who caches (ADR-0388). Editor passes it along: Paragraph::of with its own font by default, which caches nothing, and a caller inside a frame can hand over the renderer’s paragraph cache and get the sharing the rest of the frame gets. Lines already shaped keep the paragraphs they have — a new shaper is asked only for the lines that change from here, so handing one over on the first frame that has one is free.

The geometry moves to TextGeometry’s document forms

caretAt, offsetAt, moveLine and selectionRects gain a form over a TextDocument and its DocumentLines. They are in TextGeometry and not in a second class, because what a caret is does not depend on how the glyphs are held, and two classes would be two places for that answer to drift.

One of the four is not a translation:

var last = Math.min(lines.indexOf(to), lines.size() - 1);
for (var i = lines.indexOf(from); i <= last; i++) {

The paragraph form walks every line of the layout and skips what does not intersect. That is free for a label and is a walk over ten thousand rows to draw a highlight over three of them — every frame, in the one place a user is holding the mouse button down. The document form visits the rows the selection is on.

The paint is one paragraph per hard line

for (var k = 0; k < shaped.hardLineCount(); k++) {
    shaped.paragraphOf(k).paint(frame, x, top + rows.firstVisualOf(k) * lineHeight, wrapWidth, argb, flow);
}

Where the whole text’s single paragraph drew each line is exactly where this draws it: wrapping was already per hard line — Paragraph.layout splits on \n first and breaks each piece on its own — and the rows of a wrapped line are consecutive, so hard line k starts at the number of rows above it.

ADR-0388’s other half is not available here, and that is a decision rather than an omission. A text-area draws the rows in view because it is the viewport: it owns the scroll offset and the box. An Editor is handed an (x, top) and told to draw, under a transform it never sees; the rows on screen are the caller’s arithmetic, and a class that guessed at them would clip a board’s sticky at a zoom it knew nothing about. So the raster is still proportional to the text, the caller clips as it already must, and viewportHeight (ADR-0410) is deliberately not read here: it is how tall the viewport is, not where it is.

Consequences

  • paragraph() and layout() are gone, replaced by document() and lines(). This is a published API and the break is the point: a method that hands out one Paragraph over the whole text cannot survive a text that has no such paragraph, and leaving it to shape one on demand would have kept the cost under a name that looks like a getter. lines() returns a DocumentLines, which is a List<TextLine> in the whole text’s offsets, so everything written against layout().lines() reads the same. In-repo there were three callers and all three are tests.
  • A resize now re-wraps and shapes nothing. The old wrapWidth threw the layout away and kept the shaping; TextDocument.lines(width) memoises the break per line, so both survive.
  • Opening is still proportional to the text, and this does not fix it: how tall the content is and where every line breaks are facts about every line, and nothing knows a line’s height without shaping it. Measured, on the same loaded machine: 3.9 ms at 2 kB, 13.7 ms at 50 kB, 130.1 ms at 500 kB. What changed is that it is paid once per text rather than once per keystroke. Shaping the lines below the fold off the frame would close it, needs a viewport this class does not have, and is ADR-0045’s subject.
  • A keystroke is no longer flat in the text’s size, but it is flat in the shaping. 0.235 ms to 1.444 ms across 250× the text is TextDocument.of’s own comparison — two passes over the characters, which its javadoc names as the trade — plus an array copy per hard line. It is the ratio the class was designed around, and it is 77× cheaper than shaping.
  • No picture moved. gallery-canvas.png, which draws the example’s sticky through this editor, is byte-identical: one box, one origin, the same rows.
  • text-area keeps its own copy of the arithmetic. TextAreaState answers the same four questions inline against TextDocument, and it is not moved onto TextGeometry’s new forms here. That is a real cost — two implementations of “which row is this offset on” that only agree because both are tested — and the reason it is paid is that a text-area carries a gutter, a padding, a scroll offset and nine golden images, and nothing about this change asks for that risk. The forms exist now, which is what a later sweep would need.
  • EditorDocumentTest holds it in counts, like TextAreaKeystrokeCostTest one layer up: one line re-shaped per keystroke at both sizes, the untouched lines keeping their Paragraph instances, a resize shaping nothing, a selection measuring nothing. Its second half, Agreement, is the part a user would notice — carets, presses, selections and Down are compared against the whole-text shaping they replace, at every TextAlign, and land within a logical unit of it.
  • EditorKeystrokeBenchmark is where the milliseconds are, and it asserts nothing.

412. A field shows its beginning

Date: 2026-09-19

Status

Accepted. Closes the book/src/TODO.md entry “A text-input holding a long value shows its end, not its beginning” under Content modules, which is ADR-0297’s last consequence written down and left. Finishes the job ADR-0326 started for read-only fields, and inverts one of its tests. Two golden images move and are not re-blessed here; see Consequences.

Context

Three controls in this toolkit hold a value a reader might have to read, and until today they had three different answers to “which end of it do I show”:

  • a text-area shows the top of its value until somebody touches it (ADR-0297);
  • a read-only text-input shows the head of its value, always (ADR-0326);
  • an editable text-input shows the tail.

The third is not a decision anybody made. TextEdit.of puts the caret at the end of the value it is given — right, because that is where typing goes — and TextInputState.laidOut scrolls to keep the caret in view from the very first layout:

var offset = Math.max(scrollOffset, caretAt - room + caretWidth);

From a scroll of zero and a caret at the end, that is the whole width of the value less the box, on the first frame, before anybody has pressed anything. A field handed a URL shows its query string; a field handed a path shows the file; a field handed a sentence shows the end of it. The example’s own Forms screen has the case in it as a demonstration, and its golden shows exactly this:

agues of road, and a field that is not wide enough for it.

— which is the tail of “Eighteen hundred leagues of road, and a field that is not wide enough for it.”

ADR-0326 fixed the read-only half and was explicit about not fixing this one: a read-only field has “no ‘where I left off’ to preserve”, and an editable one does. ADR-0297 fixed the text-area and was explicit for a different reason — “a field is not a document, and changing two controls on one screen’s evidence is how a fix becomes a regression somewhere nobody looked”. Both were right to stop. The question this ADR has to answer is the one both of them deferred, and it is not “should there be a flag”. It is what a field should do by default, since the flag ADR-0297 gave text-area (caretMatters) is private state with no markup, no CSS and no API on it.

Decision

An untouched field shows the head of its value. Its caret stays at the end.

Those are two sentences and they are both load-bearing. Nothing moves the caret: TextEdit.of is unchanged, an application reading edit().caret() sees what it saw, and the four other controls that lean on caret-at-the-end keep leaning. What changes is when the field chases it:

var offset = scrollOffset;
if (caretMatters) {
    offset = Math.max(offset, caretAt - room + caretWidth);
    offset = Math.min(offset, caretAt);
}
offset = Math.clamp(offset, 0, Math.max(0, textWidth - room));

caretMatters is false until a press, a key, an edit, a composition or the focus arrives — text-area’s flag, its name, and its argument.

Why the head is the right default for a field

The entry’s caution is that a field is not a document, so the answer that suits a document need not suit a field. Three differences, and none of them argues for the tail:

  • A value in a field is a value being shown to somebody before it is a value being typed. A form pre-filled from a model is read first, and what a reader needs first is what the value is: the scheme and host of a URL, the label on a path, the first name in a name. The tail answers a different question.
  • The end is a keystroke away and the head was not. The moment the field is focused — by Tab, which also selects everything, or by a click, which says where the caret goes — this chases the caret exactly as it always did. Nothing about typing, arrowing, selecting or pasting changes. Before, seeing the head meant pressing Home on a field you may not have wanted to touch.
  • It is what every text box on the web does, which matters less as an appeal to authority than as an appeal to habit: a user who has never read this ADR has already learned what an <input> does with a long value.

And one difference that does argue the other way, which is why it is recorded rather than waved past: a field is often appended to. Editing a file name, adding to a number, correcting the end of a sentence — the tail is where the work is. That case is exactly the case where the field is about to be focused, and focus restores it. The default is for the frame before anybody has decided to work; the moment somebody has, the old behaviour is back and it never leaves again.

The flag is private, and there is no attribute

No head=#true, no text-input property, no CSS. ADR-0326 refused the same thing in the same words for the same reason — “a call site that has to say where the caret goes is a call site that can forget to” — and a markup attribute here would be worse than a forgotten one: it would be a second way to express something the control can decide, written into documents that then disagree with each other. A value has one sensible opening. If a downstream case ever needs the other, it can be argued then, with the case in hand.

Touched is set before the refusals

private boolean apply(TextEdit next, EditHistory.Kind kind, boolean filtered) {
    touched();
    if (next.equals(edit)) {
        return false;
    }

This caught a real bug in the first draft. End on a field whose caret is already at the end — which is every untouched field — produces an edit equal to the one it has, so apply returned false early and the flag was never set: pressing End to see the tail did nothing at all, twice. A key the user pressed in this field is the field being worked in, whether or not the model moved. The same goes for a keystroke a filter turns down.

It does not forget

Losing the focus leaves the flag set. A field that reset on blur would jump back to the head the moment the user tabbed on — at the exact moment they stopped being able to correct it — and jump again when they tabbed back. Where the user left the value is where the value is.

Consequences

  • Two golden images move, and they are deliberately not re-blessed here. example/src/test/resources/golden/gallery-forms.png — 2 574 of 1 800 000 pixels differ (0.14 %), worst channel delta 184 — and gallery-forms-light.png — 2 574 of 1 080 000 (0.24 %), worst delta 191. The difference is one 341 × 14 strip in both: the text-input#long on the Longer than the box card, which now reads Eighteen hundred leagues of road, and a field that is no… where it read agues of road, and a field that is not wide enough for it. Nothing else on either screen changed, and no other golden in the repo moved: field-stacked, field-horizontal, the colour picker’s hex field and the two search boxes all hold values that fit. The pictures are a screen’s owner to re-take; what matters for the record is that the pixel difference is the fix and is confined to it.
  • The showcase card still demonstrates what it says it does. Its caption is “Press End, then Home, and watch the text move under the caret” — which now demonstrates both directions from a starting point a reader can read, instead of starting at the end with only Home to press.
  • Three existing tests changed, and one of them changed its mind. ReadOnlyCaretTest.editableStartsAtTheTail asserted “an editable field still opens at the end, because that is where you type”; half that sentence survives and is still asserted — the caret is at the end — and the scroll that chased it is gone. AsymmetricPaddingTest and TextInputTest.Scrolling measure the chase, so both now focus the field first, which is what they were always really about.
  • A combobox’s editor inherits it. SelectState builds a TextInput for an autocomplete control, so a combobox holding a long committed label now opens showing the label rather than its end. That is the same fix and nobody asked for it, which is the argument for a default over an attribute.
  • A password field is unaffected in practice — bullets are narrow and a password long enough to overflow is rare — and follows the same rule if it ever happens.
  • text-area and the read-only field are untouched. All three controls now answer the same question the same way, by three mechanisms that stay different for the reasons their own ADRs give: a read-only field moves the caret, because there is no typing to come back to; the other two leave it and decline to chase.
  • FieldOpeningTest holds it — the head on opening, the caret still at the end, a short value unaffected, a value the application pushes later also opening at the head, and then the five ways of touching it that put the tail back.

413. A signature that lies turns the checker off

Date: 2026-09-19

Status

Accepted. Closes the TODO.md entry left open by ADR-0257, which is the record that created it.

Context

StyleElement has documented three of its five members as nullable since it was written:

The element type — button, row. … May be null, and a node with no type is the normal case for anything that exists only to compose.

The id, or null. At most one per element.

The element this one sits inside, or null if it is the root.

And declared all three non-null, in a css package that is @NullMarked. Under NullAway’s OnlyNullMarked mode that is not a gap in coverage — it is a checker that has been told the wrong thing and is enforcing it. Every caller that handled the null was, as far as the build was concerned, being paranoid about a value that could not occur.

Nothing had noticed because nothing was in a position to. Every implementation lived in an unmarked package: Element in widget, TestElement in a test tree, ThemeAudit.Root in css.contrast. An override in an unmarked package can say whatever it likes and NullAway never reads it. Then ADR-0257 promoted SupportedPropertyTest’s machinery into css.lint, whose Probe is the first StyleElement implementation ever written inside a marked package, and it could not be written honestly.

What happened next is the part worth recording. The package was unmarked — written marked, then taken back out — and a paragraph was added to its package-info explaining why. A checker was switched off to accommodate a signature that disagreed with its own javadoc, and the reason was written down so carefully that it read like a decision rather than a debt.

Decision

type(), id() and parent() are @Nullable, and css.lint is @NullMarked.

Nothing is unmarked to make this work. That is the whole point: the entry that opened this one put it exactly right — closing it properly “moves every implementation and every caller”, and that is the argument for doing it rather than against.

Selector.Compound was the same lie one package over

css.lint’s own package-info named both halves, and only one of them is in the entry:

StyleElement documents three members as “or null” … Unannotated, matching StyleElement and Selector.Compound, both of which document a null type and id and declare neither @Nullable.

Selector.Compound is a record in css.select, which is also marked. Its type is null for * and for every compound that names none — isUniversal() is written in terms of it — and its id is null for the overwhelming majority of compounds ever parsed. It was invisible for a different reason than StyleElement’s: the nulls are written by CssParser, in css.parse, which is not marked, so neither side of the boundary could see the disagreement.

Annotating StyleElement alone would have been enough to mark css.lint — Probe.type() returns compound.type(), and a non-null value is a legal @Nullable return. It would also have left the second lie in place, one import away, with a comment in Probe pointing at it. Both are annotated.

What the checker actually found

Six diagnostics, and it is worth being exact about what they were, because the entry predicted something else:

…and would probably find real nullness bugs on the way, which is the argument for doing it rather than against.

There were no latent NullPointerExceptions. Every call site in the repository that reads one of the three already handles null, and several handle it with a comment explaining why. SelectorMatcher.matchesCompound guards both; OverflowWatch.name falls back to “a box”; PointerRouter prints <composition>; ThemeAudit.Root was already written with all four annotations, in an unmarked package where nothing obliged it to be. The codebase had internalised the javadoc and ignored the declaration.

What the six were:

  • StyleResolver.candidatesFor(String type), called from three sites with element.type(). Its body opens with if (type == null) return untyped — the null case is not a defensive branch, it is the reason the method exists, since a composition node has no CSS type and the rules that can match it are exactly the untyped ones. The parameter is @Nullable now. This is the pure form of the defect: correct code that no checker could confirm, in a method whose whole first line is about the value its signature forbade.

  • StyleLint.Probe’s parent component, declared non-null and null for every single-compound selector — which is most rules in any sheet. It has to be: a null parent is how probeFor makes the leftmost probe the root, and being the root is how :root’s custom properties reach the rest of the chain. The one thing the package could not say was the thing it depended on.

  • Probe.type() and Probe.id(), the two overrides that carried the comment saying the disagreement “is older than this class and wider than it”.

So the entry was wrong about what it would find, and right about what it was worth. The finding is not a crash; it is that a @NullMarked package had been enforcing a contract nobody believed, for long enough that the first code physically unable to lie about it was made to leave the room instead.

Alternatives considered

  • Mark css.lint and write the three overrides non-null. What the package-info called “lying in three overrides”. It compiles, the lie is now in two places instead of one, and the next implementation written in a marked package has a precedent to copy.
  • Leave css.lint unmarked and move on. The state this closes. Unmarking is cheap once and compounds: css.contrast is unmarked too, and ThemeAudit.Root is annotated anyway, so the only thing the unmarked package buys is that nobody checks whether it kept doing that.
  • Annotate StyleElement and not Selector.Compound. Enough to close the entry as written and not enough to close the finding the entry’s own source named. A sweep that fixes the half that was written down is how the other half becomes a surprise later.
  • Mark widget while the sweep was open. Element is the largest StyleElement implementation and its parent field is unannotated, so marking widget means auditing a 800-line class with state, subscriptions and three caches in it. It is the right next package and it is not this entry.

Consequences

  • css.lint is @NullMarked, and its package-info no longer carries a section explaining which checker it has switched off and why. That paragraph was the visible cost of the defect, and deleting it is the visible fix.
  • candidatesFor takes a @Nullable String, which is a private method and therefore not a compatibility question. It is the only signature that changed where the null was already handled; the rest of the change is annotations.
  • Selector.Compound.type() and id() are @Nullable in a published API. A consumer compiling against the new jar with their own nullness checker on will be told about dereferences that were always possible. That is the change doing its job, and it is a source-compatible one: an annotation cannot break a caller that was already checking.
  • StyleElementNullnessTest asserts the annotations are present, which nothing else in this repository does for an annotation. It is here because the defect was invisible to every behavioural test by construction: the code was right and the signature was wrong, so only a test that reads the signature can fail when somebody takes the annotation off to quieten a warning. It also asserts classes() is not annotated, so a later sweep does not annotate the fourth member by symmetry — an empty set and a null set would be two spellings of one state.
  • Four behavioural tests cover the null paths the sweep exposed — a typeless node against the untyped rules, the same against the untyped @starting-style rules, a null parent being what makes an element match :root, and the lint probe’s chain. None of them failed before. They are written because “this was already correct” is a claim, and a claim about a path that runs on every frame the showcase draws is worth an assertion.

414. A rank applies to anything, so its name is reserved

Date: 2026-09-19

Status

Accepted. Closes the TODO.md entry opened by ADR-0153.

Context

The entry is one incident and one prediction:

A widget’s CSS classes share a namespace with the design system’s. A hud reading named display picked up §1.4’s .display type rank and rendered at 28px. Renamed, and nothing prevents the next one: there is no prefix convention, no check, and the two sets of names are written in different files by different people.

The prediction was right, twice over. Both are fixed here, and neither was visible in a sheet, a test or a review.

tree-row.heading, which drew at the wrong size for as long as it existed

TreeRow.classes() adds heading to a row that cannot be chosen — a parent in a leaf-only tree, which is §3’s default, so this is every branch of every ordinary tree. controls.css styles it:

/* A parent in a leaf-only tree: still a row, still openable, and not an answer.
   Dimmed rather than disabled, because disabled would say it is inert. */
tree-row.heading {
  color: var(--gb-text-muted);
}

One property. Meanwhile .heading — §1.4’s rank, written with no type on it because a rank applies to anything — set font-size: 15px, line-height: 20px and font-weight: 600 on the same element, and all three inherit into tree-label. So “Europe” and “United Kingdom” drew larger and bolder than “Norway” and “Scotland” beneath them, inside a row whose height is --gb-list-row-height and does not grow. The rule that was supposed to make a branch quieter than a leaf made it louder.

Three goldens have been drawing it since the widget shipped.

skeleton-bar.title, which was waiting

Skeleton.Shape.TITLE.cssClass() was title, put on the skeleton and on each of its bars, and skeleton-bar.title sets a height. .title set 20px/26px/600 on both nodes behind it. Nothing drew text in either, and the bar’s height is an absolute var(--gb-font-title), so no pixel moved — which is precisely why it survived. It is the same defect with the consequence not yet attached, and the first dimension anybody writes in em attaches it.

Decision

The convention is reservation, not a prefix

The entry guesses a prefix and the guess is wrong, for a reason worth stating: both sets of names are published vocabulary.

  • A rank is what an application writes: text class="heading", text style="title", TextRank.HEADING, and §1.4’s table. Prefixing it to .gds-heading renames the toolkit’s most-typed word in four places at once and breaks every application stylesheet that mentions it.
  • A widget’s class is what a stylesheet targets: tree-row.selected, button.primary, hud-reading.paint. Prefixing those to .gb-selected renames forty-odd published modifiers across 188 files and several thousand lines of sheet.

And a prefix on the widget side buys nothing, because a widget’s class is already namespaced — by its type. selected means nothing on its own; tree-row.selected does. That is the asymmetry the whole convention rests on:

§1.4’s seven type ranks are the only class names the base layer styles unqualified. Those seven names are reserved. Every other class a toolkit widget mints is a modifier of its own type, is written with that type in front of it, and may not be one of the seven.

A gb- prefix would be a second namespacing mechanism stacked on one that already works, paid for with a breaking rename of one of the two vocabularies. Reserving seven words costs one rename per collision, and there were two.

The check reads both sets rather than listing either

ClassNamespaceTest, four assertions, and the reserved set is never written down:

  1. The base sheet’s unqualified class rules are exactly TextRank’s values. Parsed by the real parser, matched as “one compound, one class, no type, no id, no pseudo-class” — which is what “applies to anything” means operationally rather than a proxy for it. An eighth unqualified rule is either a rank, and belongs in TextRank and in §1.4’s table, or a widget class that forgot its type. This is the convention as an assertion, and it is what stops the check rotting when §1.4 grows.

  2. No class is written both unqualified and beside a type. This is the tree-row.heading finding, read straight out of the sheet with no reflection and no instances — and it is the only one of the four that reaches a widget part. tree-row is not a registered node name and no inflater builds one.

  3. No registered widget’s classes() or classes(FrameStats) is a reserved name. The case the sheet cannot answer: a collision nobody styled, where there is no rule to read and the widget simply draws at the rank’s size.

  4. No enum that mints a CSS class mints a reserved one, except TextRank. This is the shape the hud’s display actually had, and the one the first three would still miss: the name was not a literal in a classes() body and the widget was not a registered node — it was an enum constant on a part, read through cssClass(). It is also how skeleton-bar.title was found.

TextRank is the one exception in (4), and it is the definition rather than a weakening: an enum whose entire job is to spell §1.4’s ranks has to spell them.

Alternatives considered

  • Prefix the ranks. Above. It is the cheapest change to make and the most expensive to have made, because the ranks are the vocabulary a document author types.
  • Prefix every widget class. 188 files, ~5 000 lines of sheet, and it duplicates the type qualifier that already separates them.
  • Check the CSS only. Assertion (2) alone would have caught both of today’s collisions and costs nothing to run. It also cannot see a collision nobody styled, which is the more dangerous one — a widget wearing a rank with no rule of its own has nothing pointing at the problem at all.
  • Enumerate widget classes by scanning class-file constant pools. Tried in outline and rejected: Dialog, GroupBox and Collapse all carry the string "title" because it is a KDL property name, so the pool cannot tell a class the widget mints from an attribute it reads. A check whose first three findings are false is a check somebody adds an exclusion list to.
  • Leave skeleton-bar.title, since no pixel moves. It is a collision that has not cashed in yet, in a widget whose whole purpose is to be “sized from the typography token it stands in for” — the one place in the catalog most likely to grow a relative unit.

Consequences

  • Three tree goldens move: tree-dark, tree-light and tree-cascade-dark, 3.38 % of pixels each. Branch labels drop from 15px/600 to the 13px/400 of the leaves beside them, and stay dimmed, which is what tree-row.heading’s comment said it was doing all along. The goldens are not re-blessed here; they are the deliverable of this ADR and want a human to look at the diff.
  • tree-row.heading is tree-row.group. Neither the class nor the selector appears in docs/ or anywhere in book/, and no test asserted on it, so nothing outside the two files changed.
  • Skeleton.Shape mints shape-text, shape-title, shape-circle and shape-rect. The whole family, not the one that collided: three of them are safe only because §1.4 happens not to have a rank called circle, which is not a property anybody is maintaining, and skeleton-bar.text read like the text type into the bargain. §5’s shape="title" is unchanged — what an application writes is a published word, what a widget puts in a class set is the widget’s own business, and that asymmetry is the whole reason this rename cost nothing. SkeletonTest asserts both halves.
  • A widget may no longer name a class display, title, heading, body, body-strong, caption or mono. Seven words, and the failure message says which one and why.
  • The check does not police an application’s own classes, and cannot. An application that writes class="caption" on a node is using the rank, which is what ranks are for. What this constrains is the toolkit.
  • .md-* and .html-* were already right. The two view modules invent the most class names of anything here — some fifty between them — and every one is prefixed, including .html-caption, which is one letter from a collision and does not have one. The convention this writes down is one two modules had already arrived at; what was missing was anything that said so.

415. “The root has no colour” is a fact about the sheets

Date: 2026-09-19

Status

Accepted. Closes the TODO.md entry opened by ADR-0066. Applies ADR-0257’s shape and ADR-0394’s constraint.

Context

A bare text with no ancestor setting color renders black, which is ADR-0066’s deliberate INITIAL and a trap all the same: the showcase’s new gain label was unreadable on the dark theme. A control gets away with saying nothing because controls.css sets color on checkbox, radio, toggle and slider themselves; a primitive does not. The showcase now sets color: var(--gb-text) on its root, which is what an application should do — but nothing warns one that has not.

Every clause of that is load-bearing, and one of them turns out to be the whole design.

Decision

It is a lint asked for, and the thing it asks about is the root

Not a per-node check on the resolved colour. This is the obvious shape and it is wrong twice.

It is wrong about the value: color: INITIAL is black because ADR-0066 decided it should be, and black text on a light theme is correct. A check that fired on a node resolving to black would be wrong about every light-themed application in existence. The resolved colour is not evidence of anything.

It is wrong about the rate: it would be wrong once per text node per frame. ADR-0394 took a diagnostic apart for exactly this — 688 reports of which 665 were the check misunderstanding its own question — and the conclusion there applies unchanged. A diagnostic that fires on everything says nothing.

What is evidence is that no declaration anywhere set one. That is not a property of a pixel; it is a property of the cascade, and color inherits, so one rule on the root settles the entire tree. The question collapses to one node, asked once:

new StyleLint(everythingLoaded).uncolouredRoot(tree.root())
        .ifPresent(finding -> LOG.warn("{}", finding));

The mechanism is the declared map rather than a ComputedStyle, and that is the whole trick. A resolved style always has a colour — the initial one if nothing else — so asking it can only ever report the value, never whether anybody chose it. StyleResolver.resolve returns property names to tokens, and color is a key in it if and only if a declaration won one.

A color: var(--nothing-defines-this) is absent from that map too, because substitution failing takes the declaration with it. That is the right answer rather than a gap: a root whose colour resolves to nothing has no colour, and the author who wrote the rule is exactly the person who wants to hear about it.

It takes the root element, because the application that does this right does

not write :root

This is the finding, and it is the reverse of what the entry implies.

ThemeAudit answers its question against a synthetic Root — no type, no id, no parent — because a theme is a :root layer and nothing else. The obvious move here was to copy that: build a probe, resolve color, report if absent. No argument, no tree, no application involvement.

It would have reported the showcase as the defect. The showcase writes:

#root {
  flex-direction: column;
  background: var(--gb-bg);
  color: var(--gb-text);
}

An id selector, on the root widget, which is a perfectly ordinary way to style a root and is the arrangement the entry itself holds up as “what an application should do”. A synthetic :root probe matches none of it.

So a root is whatever the tree’s root element is, its selector is the application’s business, and the only thing that can answer “does a rule reach it” is the element. The check takes one.

The finding names the root the way a selector would — #root, or window, or :root for a node with neither — so it says what to write and not only what is missing.

A node with a parent is refused rather than answered

uncolouredRoot throws on an element that is not a root. It would be easy to answer: resolve, look for color, report if absent. It would also be a per-node diagnostic with the word “root” in the method name, and the first application to call it in a loop gets the 688 reports back.

Alternatives considered

  • A WARN the first time a text resolves the initial colour. One-shot, so the rate problem goes away; the correctness problem does not. It fires on every light-themed application, once, for something that is not wrong.
  • A start-up check the toolkit runs itself. It cannot: the toolkit does not know when an application has finished loading its sheets, and a check that runs at window creation reports the sheet the application is about to add. ADR-0257’s “nothing calls it for you” is not modesty, it is the only correct time.
  • A Finding.Kind on check(linted) rather than a method of its own. The root’s colour is a fact about everything in force, and check is deliberately about the sheets under scrutiny — inForceIsNotLinted asserts that “someone else’s sheet is not this one’s problem”. Folding this into check would make the application’s own sheet answer for the theme’s omission.
  • Make ComputedStyle.INITIAL’s colour currentColor-ish, or theme-aware. Reopens ADR-0066, which decided black deliberately and for a good reason: a toolkit whose initial colour depends on a theme has no defined rendering for a tree with no theme.
  • Set color on text in controls.css, the way controls do. The narrowest fix and the wrong level. It makes text work and leaves every other primitive — and every widget an application writes — in the same trap, and it puts a colour on a node whose whole job is to inherit one.

Consequences

  • Finding.Kind.UNCOLOURED_ROOT exists, and isDefect() is now written as != UNTYPED_RULE rather than == DEAD_DECLARATION. Unreadable text is a defect; the untyped-rule finding remains the only one that is a cost rather than a fault. Inverting the test is deliberate — a third kind added later is a defect unless somebody argues otherwise, which is the right default for something called a finding.
  • Nothing calls it. An application that never asks gets the behaviour it has today, which is the trade ADR-0257 made for the whole package and is why a finding is a value rather than a log line.
  • The premise is asserted, not assumed. thePremise resolves a bare text under an uncoloured root through the real cascade and checks it really does come out at ComputedStyle.INITIAL’s colour. If ADR-0066 is ever revisited, the test that fails is the one describing why this check exists.
  • The showcase is not changed. It already does the right thing; what it now has is something that would have told it.
  • docs/design-system.md has no sentence about this. It is worth one — §1.2 or §10 could say that an application sets color on its root, and that a primitive inherits where a control does not — but this ADR does not write it.

416. rem is the root element’s size, and the walk is what knows it

Date: 2026-09-19

Status

Accepted. Closes the TODO.md entry left open by ADR-0242, which is the record that created it.

Context

ADR-0242 fixed em and wrote down what it had not fixed:

rem continues to use Context.rootFontSize(). In CSS rem is the root element’s computed font size, so these agree unless the root element itself declares one — and recovering that inside ComputedStyle.of is not possible, because a node is handed its parent’s style and not the root’s. Nothing in the catalog styles a root’s font-size, so this is exact today and is written down rather than fixed.

And named the two shapes a fix could take:

It needs a third thing passed down beside the parent style, or a mutable field on the renderer that is correct only after the root has resolved.

The diagnosis is exactly right and the pessimism is half misplaced. The problem splits in two, and only one half is out of ComputedStyle.of’s reach.

Decision

The root’s own half was already built, for em

ADR-0242 gave ComputedStyle.of two passes: font-size first against the parent’s size, then everything else against the size that produced. The reason was CSS’s one exception — 1.2em on font-size means “a fifth larger than my parent”, because the value being computed cannot be its own input.

rem has the same exception, from the other end. CSS: when specified on the root element’s font-size, rem refers to the property’s initial value. So on the root:

  • font-size: 2rem resolves against the configured size, because the root has not computed one yet;
  • everything else on the root resolves against the size it just computed.

That is one line in the second pass, in a method that was already shaped for it:

var rootSize = parent == null ? (float) style.typography().size() : context.rootFontSize();
var own = new CssLength.Context((float) style.typography().size(), rootSize);

parent == null is the root — the method’s own javadoc has said so since it was written. No plumbing, and the “correct only after the root has resolved” caveat turns out to be the specification rather than a wart.

The descendants’ half is the renderer’s, and it is the second shape

A node with a parent takes rootFontSize as given, because by then nothing in the method can do better: a node is handed its parent’s style and never the root’s, which is precisely ADR-0242’s point. What can is the thing that walks the tree, at the one moment it holds the root’s resolved style and has not yet descended.

WidgetRenderer keeps one field, lengthsBelowRoot, reset to the configured context at the top of every frame and set once:

if (element.parent() == null && self != null) {
    lengthsBelowRoot = lengths.withRootFontSize((float) self.typography().size());
}

It is the only mutable style state in the walk, so “is it ever stale” is the question this shape has to answer. It is not, for two separate reasons and both matter:

  • Within a frame, it is set before any descendant resolves and read nowhere else. A frame has either not reached the root — in which case the configured value is the right answer, because there is no root size yet — or has.
  • Across frames, a root whose font-size changed resolves a different style and therefore hands its children a different instance, and their cache is keyed on that by identity ([ADR-0070]). The subtree re-resolves without anything telling it to, which is the invalidation scheme already in place doing the work a “root size changed” signal would otherwise need.

Why the field rather than the third argument. Threading the root’s size into ComputedStyle.of means a fourth parameter on a method with two public overloads and callers in the renderer, the keyframe track and every style test — to carry a value that is constant for the whole tree, which is what CssLength.Context already is. The context is the third thing, and it is already threaded; the only change is that one of its two fields now means what it says.

Context’s two fields both narrow, and that is a pattern rather than a coincidence

ADR-0242 left Context.fontSize meaning “what the root’s em resolves against” rather than “what every em resolves against”. Context.rootFontSize takes the same step here: it is what the root’s own font-size declaration resolves rem against, and from the root’s computed size onward the renderer replaces it. A Context is now, precisely, the two numbers the root starts from.

Alternatives considered

  • A fourth parameter on ComputedStyle.of. Above. Every caller would carry a tree-wide constant through a per-node call, beside a parameter that already carries tree-wide constants.
  • Compute the root’s size in render(ElementTree) before the walk. One extra resolve of one node, and then the whole walk — root included — uses the final context. Simpler, and it gets the root’s own font-size: 2rem wrong by making it its own input. It also has to mirror Styled.restyle, or the pre-pass and the walk disagree about the root’s style.
  • Keep the configured value when the root declares nothing. This is the only alternative with a real argument behind it: it changes no existing answer. Rejected because it makes rem mean two different things depending on whether some other rule exists — the configured number for a silent root, the computed size for a declared one — and a unit whose meaning turns on a declaration elsewhere is worse than one that is merely different from what you expected.
  • Leave it, since nothing in the catalog styles a root’s font-size. That was the state, and it is the same argument ADR-0242 rejected for em: a unit that silently means something else is worse than an unimplemented one, because it looks like it works. The typography scale is what makes it reachable, and §1.4’s whole point is that an application moves the scale.

Consequences

  • rem below a root that declares font-size changes, which is the entry’s case and the thing that was broken. window { font-size: 20px } button { padding: 2rem } is 40 and was 32.
  • rem below a root that declares nothing changes too, and this one is worth stating plainly: a silent root computes Typography.INITIAL’s 13, so 2rem is 26 where it was 2 × the configured 16. The configured number now reaches only the root’s own font-size declaration. That is the cost of the decision above, paid once, and nothing shipped is affected — ADR-0242 checked that not one em or rem appears in nord-dark.css, nord-light.css, controls.css or the showcase’s sheets, and that is still true.
  • One existing test changed meaning and was rewritten, exactly as ADR-0242’s em test did. ComputedStyleTest’s “rem multiplies the root font size, not the local one” built an element with no parent, passed Context(20, 16), and asserted 32. An element with no parent is a root, and this one declares no size, so its computed size is 13 and 2rem is 26. On a root, 1rem and 1em coincide — which reads like the test losing its point and is CSS’s rule: the root has nothing above it for the two to differ about. The point moves to RootFontSizeTest, where there is a descendant to tell them apart.
  • RootFontSizeTest needs a real renderer, and that is the ADR in one sentence. The missing half was never arithmetic, it was reach, so a test built on ComputedStyle.of alone cannot fail against the old code — computeChild, the existing stand-in for the renderer, hands both contexts down unchanged and is itself an instance of the bug. Six tests: the entry’s case, a root and a descendant declaring different sizes so rem and em cannot be satisfied by one number, the silent-root case above, the root agreeing with its own descendants, and two renders in a row.
  • Paints.Context.length’s rem is exact now. That seam runs with currentElement set, so the walk has passed the root. Its em narrowing — against the root’s size rather than the node’s — stands, documented as before, because the element’s resolved style still is not in hand there.
  • CssLength.Context.withRootFontSize is the only wither on the record. There is deliberately none for fontSize: the element’s own size is derived per node where it is used, and a second way to say it would be a worse way.

417. A CSS type is a string, not a package

Date: 2026-09-19

Status

Accepted. Closes the fourth consequence of ADR-0182 — “SelectList is public and in the wrong package” — and corrects the reason that record gave for leaving it, which was false when it was written.

Context

ADR-0182 filed the move and priced it:

Option was moved into a package of its own the day it had two callers, and this now has two; the CSS type it carries is select-list, so moving it means renaming a type in every stylesheet and every golden rather than editing one file. Filed rather than done.

That price is wrong, and it is wrong in a way worth recording rather than merely fixing, because it is the only reason the move sat for two hundred and thirty-six records.

A CSS type in this toolkit is the string Styled.cssType() returns. SelectList returned a literal:

@Override
public String cssType() {
    return "select-list";
}

Nothing derives it from the class. Not its simple name — SelectList is not select-list and never was; not its package; not anything a compiler knows. The cascade matches on the string, controls.css writes the string, and SelectGoldenTest names its image select-list-dark because somebody typed that too. Moving the .java file could not have touched any of them, and did not: the whole of the stylesheet change in this record is one property on an unrelated widget, and the goldens that moved belong to ADR-0418.

So the entry was not a cost/benefit judgement that came out the wrong way. It was an estimate nobody re-derived, and it survived because the thing it protected — not touching every stylesheet — was frightening enough that the estimate was never worth checking. That is the general shape worth naming: a filed item’s stated cost is an assertion about the code, and it decays like any other comment.

The other half of ADR-0182’s sentence was true and is the real work here. SelectList’s own first paragraph says it is

A part — CSS-selectable and not constructible (ADR-0065)

while sitting public in …controls.select, which is exported. An application could build a dropdown’s panel with no dropdown around it, keyboard scope and all. …form.parts had already answered this for text-input and text-area’s shared caret: public to the module, in a package nothing outside it can see.

Decision

SelectList moves to io.github.digitalsmile.goldberry.widgets.controls.selectlist, which is not exported.

A package of its own rather than a share of somebody’s, because that is what every other widget in the catalog has and because its two owners are in different groups — …controls.select and …form.textinput — so there is no package they share below widgets. The name follows the catalog’s own rule that turns a hyphenated CSS type into a package: progress lives in …controls.progressbar, text-input in …form.textinput, and select-list in …controls.selectlist.

Not exported, because ADR-0065’s rule is not advice. A part is styleable and not constructible, and for a one-owner part that has always meant package-private. This one has two owners, so the enforcement that costs nothing is the module boundary instead of the package one.

The cost is named in the class. The paragraph that carried the false estimate is replaced by one that says what was actually true, and cssType() now carries a comment saying the string did not change when the file did — so the next person to read it is told the answer before they have to re-derive it.

Consequences

SelectList leaves the public API. This is a real break, pre-1.0, and it is the only thing in this record that costs anybody anything. An application that was constructing one was building a select’s panel by hand, which is the thing ADR-0065 says must not be possible; there is no supported way to do it and there never was a reason to.

Nothing in any stylesheet, golden or test text moved. Every select-list in controls.css (nine rules and four comments), the select-list-dark golden, and SelectTest’s assertEquals("select-list", list.cssType()) are byte-for-byte what they were. What changed is eight Java files: the moved class, its two callers, four tests that now import it instead of naming it, and module-info.

Two fully-qualified names became imports on the way, in SelectState.panel() and TextInputState.syncSuggestions — both were inlining the old package, so the move forced the issue. SelectState also picked up a Tree import it should have had.

SelectListTest exists to hold down a claim rather than a behaviour, which is unusual and deliberate. It asserts that the type is the literal, that the package and the type are different strings, and — the one that matters — that a rule written select-list still matches the class from its new home. If somebody ever makes cssType() a function of the class, those fail together and ADR-0182’s estimate becomes true after the fact, which is the only way it ever could.

The module path is the proof the suite cannot give. Tests run on the classpath, where a non-exported package is visible and this change is invisible. :example:run with the dummy driver is what actually resolves the modules, and it is the step that would catch an export removed one line too eagerly.

This does not make select-list unstyleable. It never could: the type is a string, which is the whole point of the record.

418. The indeterminate bar runs off both edges

Date: 2026-09-19

Status

Accepted. Takes the decision ADR-0235 left open and closes the last item on its list, the label half having been built by ADR-0255. Spends a mechanism ADR-0114 shipped eleven months ago.

Context

progress’s indeterminate sweep travelled there and back inside its track. The code said why:

It travels there and back within the track, rather than off one end and in at the other. The off-the-edges version is the more common drawing and it depends on clipping: a bar that ran past its track would otherwise be drawn across whatever is beside it, and the wrap from one end to the other — which clipping is what hides — would be a visible jump once a loop.

That reasoning is sound and its premise stopped being true with ADR-0114. ADR-0235 caught four comments claiming the toolkit could not clip, corrected them, and was explicit that this one was now a choice: “clipping exists, so that drawing is now available and changing a shipped animation is a design decision rather than a bug fix”. It then declined to take the decision, which was right — that record was about comments — and left it as the only thing on its list.

So the question is not can we but should we, and it has an answer. The there-and-back sweep says something the work has not earned: that there is a far end to turn at. An indeterminate bar exists precisely because nothing knows where the far end is. A bar that reverses reads as a scan of a bounded thing — a cylon eye, a seek — where a bar that leaves reads as “more is coming”. Every other toolkit ships the second, and not out of habit: the drawing is the claim.

The there-and-back version also has a tell that nobody notices until it is named. At the turn the bar is momentarily stationary — its velocity passes through zero — so a control whose entire job is to say “something is happening” holds perfectly still twice a second. The linear-each-way easing makes it a sharp reversal rather than a hesitation, which is the best that shape can do, and it is still two dead points per loop.

Decision

The bar crosses the track and leaves, and progress clips it.

The travel is now a straight line in one direction. The bar’s leading edge runs from -SWEEP_WIDTH to 1 in track fractions: it begins one whole bar to the left of the groove and ends with its left edge on the groove’s right-hand edge. In the unit translate is written in — a percentage of the moving box, which is CSS’s rule and is why travelAt became offsetAt — that is -100% to 333%, a travel of 433% of the bar’s own width.

static double offsetAt(double phase) {
    return (phase * (1 + SWEEP_WIDTH) - SWEEP_WIDTH) / SWEEP_WIDTH * 100;
}

The clip is overflow: hidden on progress in controls.css, not a flag set on the box in Java. overflow has been a cascaded property since ADR-0114 and every other number in that rule — the 4px track, the 2px radius, the 100% width — is the theme’s. A stylesheet that writes overflow: visible has asked for the overhang and should get it. The widget’s correctness already depends on that rule existing: a progress with no base stylesheet has no height and no background and is not a control at all.

It hides two things and needs to hide both. The overhang, which would otherwise paint across whatever is beside the bar; and the wrap, which is a real discontinuity of the entire travel once every 1.2 seconds and is invisible only because the bar is outside the clip on both sides of it. That second one is the reason a test asserts the jump exists rather than asserting it away.

Consequences

Two goldens moved, and only two. progress-sweeping and progress-sweeping-end. progress-determinate, progress-light, progress-reduced, spinner-turning and spinner-half-turn are unchanged, which is the evidence that overflow: hidden costs the other drawings nothing: a fill that is a width has never left its track, and reduced motion returns Transform.NONE before any of this arithmetic runs.

The new pictures are deliberately a pair that could not exist before. At 180 ms the bar is cut off by the leading edge with its left third outside the groove; at 1080 ms it is cut off by the far edge with its leading tenth outside. In the old drawing the bar was a free-floating rectangle in one image and a rectangle flush against the right-hand wall in the other — a picture that would look entirely plausible with the clip broken. In the new pair the bar touches an edge and is truncated by it in both, which is what makes the images able to fail.

progress-sweeping-end’s frame moved from 600 ms to 1080 ms, because 600 ms is now the midpoint — the least interesting frame in the loop. 1200 ms would be the true far end and is a golden of an empty groove, which is accurate and worthless.

The clip is a rectangle where CSS’s is a rounded one. Clip holds four edges and RenderTree.clipFor intersects rectangles; it does not follow border-radius. So while the bar passes an end, it paints into the two corner wedges the track’s 2px radius rounds off, and the track’s cap looks momentarily square. At §3’s metrics each wedge is (1 - π/4) × 2² ≈ 0.86 square pixels, it is the fill colour against the track colour rather than against the window, and it appears for about 7% of the loop at each end. It is named in ProgressFill rather than left to be found. Fixing it properly means a clip that carries corner radii, which is a change to Clip, RenderTree and the hit test, and is not worth one square pixel.

There is now a discontinuity in the animation where there was none. This is the cost the old drawing was buying, and it is bought back with a clip rather than removed. If an application sets progress { overflow: visible } it does not get the old bar — it gets the new one, unclipped, wrapping visibly and painting outside its control. That is the correct consequence of asking for it, and it is worth stating because “the widget still works with the rule off” was true before this record and is not true now.

travelAt is gone and offsetAt replaces it. The old one returned a fraction of the crossing and left the conversion to the caller, which is why the translate arithmetic was spread across two places. There is one number and one method now, and ProgressTest asserts it at both limits directly — including that phaseAt(1200) is zero, because 1200 ms is the top of the next loop and a test that expected 333% there would be asserting the modulus was broken.

Nothing on ADR-0235’s list is open. The four false comments were corrected there, white-space: nowrap was built by ADR-0255, and this was the last of it. The unrelated bug that record filed — a Box.of() wrapper defaulting to Yoga’s column direction — is untouched and still filed; no clip box was added here.

419. The slot size is named where the icon is built

Date: 2026-09-19

Status

Accepted. Closes the TODO entry left by ADR-0143 — “nothing says so at the door” — and is the first record to apply ADR-0394’s rule to a diagnostic written after it.

Context

An Icon is a path built at a size. Icon.of multiplies Lucide’s 24×24 coordinates by size / 24 at parse time and scales the stroke with them, so there is no transform at draw time and no way to change the answer afterwards (ADR-0043): “drawing the same symbol at two sizes is two Icons”. ADR-0143 then decided that an icon larger than the box it is given is centred in it, which turned a misalignment into a mere overhang. Its own consequence is the entry this record closes:

The toolkit cannot resize an Icon, so an application that wants its menu icons to fit the column builds them at 16 — which is what §3 sizes a glyph at, and what the showcase should have been doing.

The interesting part is not the overflow. It is that there is exactly one slot in the catalog this can happen in. Surveying every widget that places an icon — button, chip, tab, crumb, menu heading, timeline marker, segmented option, link marker, image error glyph — every one of them does

content.add(Box.icon(icon, style.color()));

with no .style(style) on that box. Box.icon sizes the box to the glyph, so there is no slot and 20 or 24 is ordinary. Only ItemLead applies the style after the icon:

return Box.icon(icon, style.color()).style(style);

and Box.style assigns style.width() and style.height() wholesale, so item-lead { width: 16px; height: 16px } wins and the glyph overhangs a box it did not size. (The comment on that line claimed the override went the other way. It did not, and that is a small part of why an oversized glyph looked like nobody’s decision rather than a wrong number.)

So the entry reduces to: the number 16 was written in controls.css, and the person who has to choose it is writing Java and has no reason to open a stylesheet. Icons — the registry an application hands its icons to — knew nothing about sizes at all; its only diagnostic was for an unregistered name.

Why the obvious diagnostic is the one ADR-0394 forbids

The reflex is a warning when a glyph is bigger than its slot. ADR-0394 spent a whole record on why that class of thing is worthless, and this case fails its sharpest test in advance: the showcase builds its menu icons at 20, in a 16px column, on purpose, and menu-icon-oversized.png is a golden that pins that drawing as correct. A WARN would therefore fire on the toolkit’s own demo, on every row of every menu, once per paint, and claim a defect where ADR-0143 recorded a deliberate decision. “A WARN claims something is broken; nothing is.”

Nor can the check live at the factory. Icon.bundled("palette", 20) is unimpeachable — it is what a button wants — and the factory has no idea which slot, if any, the icon is headed for.

Decision

Three things, and the diagnostic is the smallest of them.

1. Icons.SLOT names the number, in the class an application already goes through. Sixteen, documented as what the catalog’s fixed slots are and explicitly not as a maximum, because a button’s icon sizes the button’s own box and 20 and 24 are right there.

2. Icons.bind(String) makes the correct call the shortest one. icons.bind("folder") registers Icon.bundled("folder", Icons.SLOT). This is the half that actually closes the entry: a constant nobody finds is a stylesheet with extra steps, and the only thing that reliably competes with the shortest call is a shorter correct one. Icons.resolve’s unknown-name message points at it too.

3. ItemLead says so at debug, once per (name, size, column) triple. The repo’s established dedup shape — a ConcurrentHashMap.newKeySet(), a REPORT_LIMIT of 256 past which everything goes through rather than falling silent, and reportedOverhang() / forgetReportedOverhang() so a test can assert once rather than merely at all, there being no appender on the classpath. The message names the icon, both numbers, ADR-0043’s reason, and the fix.

It fires only against an explicit Length.Points width. A percentage or an auto column has no number to be bigger than, and inventing one is how a diagnostic starts reporting arithmetic. There is no tolerance beyond an epsilon for the double: unlike ADR-0394’s overflow watch, which was summing insets and found 92% of its reports inside two pixels, these are two numbers a human typed.

The count, because ADR-0394 asks for it

Every Icon that reaches a menu Item in this repository: MenuGoldenTest builds two at 16 and one at 20; ItemAlignmentTest builds one at 20; the showcase’s AppMenu uses the 20 from Showcase.ICON_SIZE. Everything else that calls Icon.bundled at 20, 24 or 48 goes to a button, a tray raster, or a test of the path scaler, and never reaches a slot.

So across :widgets:test this diagnostic produces one report — palette/20.0/16.0 — reached from two test classes, plus the same one triple in :example:test. Not 688 shading into arithmetic: one, and it is exactly the case ADR-0143 named and the showcase never fixed. A distribution of one is not a distribution, which is itself the argument that the check is measuring what it meant to.

Consequences

The showcase still reports. Deliberately, and this is the test that says the diagnostic is not tuned to be quiet. ADR-0143 decided the drawing is acceptable and a golden pins it; the showcase is not changed here, because changing it would delete the one true positive and leave a check that fires on nothing.

debug means it is off by default, and that is the cost. Nobody is told unless they go looking. ADR-0394 took the same trade for the nested-viewport notice and stated it the same way: the failure mode is mild — a centred, slightly large glyph — and the false positive would have been constant. What is bought is that when somebody does ask “why is this icon large”, the answer is one log line away instead of a bisect through controls.css.

Icons.SLOT and controls.css can drift, so a test pins them together. ItemLeadOverhangTest renders a menu the way Menus builds one and asserts the item-lead box is Length.points(Icons.SLOT) square. If somebody widens the column and leaves the constant, the door starts handing out the wrong number and this is what says so.

This does nothing for the other nine slots, because there is nothing to do. They size to their icon. If one ever gains a fixed width in a stylesheet it will overflow silently, and the check will have to move somewhere both it and item-lead can see — most likely Box.icon, which is where BoxInk.of already computes the overhang for culling and says nothing about it.

ItemLead.render does one comparison per iconed row per paint. A Length pattern match against a double, and the set is touched only when it already overflowed. The rows that do not overflow — which is the intended world — pay one branch.

A stale comment was corrected on the way. ItemLead claimed Box.icon’s size overrode the column’s. It is the other way round, it has been since the line was written, and it only ever looked harmless because both numbers were 16.

420. Measured’s third rule is checked by a fixed point

Date: 2026-09-19

Status

Accepted. Enforces the rule stated but not checked by ADR-0117, whose own consequence — “every widget in the catalog can now ask for geometry, and almost none should” — had nothing behind it.

Context

ADR-0117 gave every widget a way to be told what size it came out as, and hung three rules on it. The third is the one that matters:

What it triggers must not change what it reports. A widget that resized itself from this would be told a new size, resize again, and never settle. The one implementation obeys it by construction: a scrollbar is absolutely positioned, so nothing it draws can change the rectangle it was measured against.

“The one implementation” is now eleven, in five packages, written by whoever needed one. MasonryCell, ToastBox, SplitPaneView, ImageBox, ImageFigure, ScrollViewport, TextField, TextAreaBox, TableHead, TourCard, IconSheet. Obeying a rule by construction is the strongest kind of safe and the least transferable: the scroll view’s absolute positioning protects the scroll view and tells the twelfth widget nothing.

Nothing checked. The failure has no moment to be caught at — a widget that breaks rule 3 throws nothing, logs nothing, and draws nothing wrong. It asks for one more frame, forever. On a desktop that is a warm fan; in a test suite that renders one frame it is a pass. MasonryTest.itConverges is the only test in the repository that ever looked, and it looks at one widget by hand.

The runtime check, and why not

The obvious shape is a counter in PointerRouter: it already computes Measurement.sameAs per element per frame, so counting consecutive changes for the same element is nearly free, and past some threshold you have a widget that cannot settle.

It does not survive contact with a window edge. During a resize drag every Measured consumer is legitimately notified with a genuinely different size, every frame, for as long as the user holds the mouse down — hundreds of frames, no oscillation, nothing wrong. To tell that from a real loop the router would have to know whether the input changed, which means re-deriving the causality the check exists to discover. ADR-0394’s verdict applies before a line is written: a check a user can trigger by dragging a window edge is a check that gets turned off, and it would fire on the arrangement nothing is wrong with.

There is a deeper reason. Non-termination is a property of a sequence of frames. It cannot be seen from inside one, and the router only ever has one.

Decision

A test harness that drives a widget tree to a fixed point, and a test that runs every Measured consumer this module can build through it.

Settled runs the real loop — tree.flush(), render, RenderTree.update, router.updateRegions(HitTest.capture(render)) — and keeps running it until two consecutive frames lay out identically. That last step is the one that makes it work at all: Measured is delivered by the router, from the regions a laid-out frame produced, so a harness that stopped at render would drive nothing and pass everything.

Three details are load-bearing.

The signature is layout rectangles only. A transform is paint and cannot feed back into layout, so a sweeping progress bar and a turning spinner are still, and the harness is not fooled into calling an animation an oscillation. The clock is virtual and never advanced for the same reason.

A repeat that is not the previous frame is reported as a cycle, with its period. “Frame 5 is frame 3 again” is a different finding from “still moving after twelve frames”, and the first is the one that names the bug. Both messages carry the first few boxes that differed, because a diff of a whole tree is unreadable and three lines of one names the widget.

The limit is twelve where the worst honest case is two. Deliberately loose: the failure this catches is a tree that never settles, and a limit set near the observed maximum turns a widget gaining one legitimate frame into a red test with a misleading name.

Consequences

Six consumers are covered, and the numbers are these. The count is distinct layouts before one repeats — 1 means the second frame laid out identically to the first.

consumerlayoutswhat it does with the geometry
masonry2moves cards between equal-width columns
scroll2draws bars, absolutely positioned
split-pane2sets the first pane’s size from its own
table1banks a header width as a drag anchor
text-area1wraps text at its measured width
text-input1places a caret; never calls setState

Four of those six pass without demonstrating anything, and the record has to say so. A 1 means the consumer’s feedback changed no layout rectangle — which is the property wanted, and is also exactly what a tree with no consumer in it looks like. So Settled.consumers() counts the Measured widgets actually placed, and the helper asserts it is non-zero before trusting the layout count. The guard is not theoretical: it is what caught the seventh case.

toast is not covered, and the attempt is why the guard exists. A Toaster builds its plates into the host’s overlay layer rather than into its own subtree (ADR-0177), so a windowless harness lays out a toaster with nothing in it: settle() returned 1 and consumers() returned 0. Without the guard that would have been a seventh green row in the table above, asserting nothing about a widget whose measured never ran. ToastTest feeds measured(...) by hand for the same underlying reason.

tour, image and IconSheet are not covered either. TourCard needs a Host and an anchored element; ImageBox needs a decoded image, and its rule-3 argument is the most delicate in the catalog — it sets its own height from its own measured width, safe only in the branch where the width is a percentage and therefore the parent’s; IconSheet lives in :example and is out of this module’s reach. IconSheet is the one worth moving: its two consumers (IconsScreen and EmojiScreen) are the only ones that guard by quantising rather than by an epsilon — a width becomes a column count — and that is the pattern a twelfth widget is most likely to copy.

The negative control is the test that makes the other six mean anything. A Plank that reads its own width and picks a different one is exactly what rule 3 forbids; the harness catches it as a period-2 cycle and the assertion checks the message says so. A suite of fixed-point tests that has never seen a failure is a suite that might not be able to.

This catches oscillation, not slowness and not wrongness. A consumer that settles on a value that is simply incorrect passes. A consumer that takes four frames passes today and fails when somebody tightens the expected count, which is why the counts are asserted exactly rather than bounded — assertEquals(2, …) fails when masonry starts needing three, and that is the regression worth having.

Every consumer’s own epsilon is still load-bearing and is not what is being tested. The 0.5px guards in MasonryState, SplitPaneState, ImagePaint, TextAreaState and TourState are what keep a sub-pixel disagreement between two layout passes from requesting a frame forever; TableHeaderCell has an exact != and gets away with it because its rebuild is layout-neutral. This harness would catch the loop if one of those were removed — which is the point — but it asserts nothing about the epsilon itself.

There is still no shared frame-pump fixture. Nineteen test classes carry a private four-line Harness.frame(), and Settled is the twentieth thing that knows how to run a frame rather than a replacement for the other nineteen. It lives in :widgets’s test tree rather than :core’s testFixtures because every consumer worth driving is a widget and moving it would be a build change for one caller. If a second module ever needs it, that is the moment.

421. The release line moves on by itself

Date: 2026-09-19

Status

Accepted. Closes the consequence ADR-0333 recorded and left to a runbook.

Context

ADR-0333 listed this among its own consequences and wrote down that nothing enforced it:

Someone has to bump gradle.properties after every release, or master keeps publishing snapshots of a version that is already out — which Maven orders below the release, so a consumer on the snapshot silently goes backwards. The runbook says so; nothing enforces it yet.

docs/releasing.md step 5 is the runbook, and it is the fifth of five steps in a list whose fourth step is “Central takes a few minutes to an hour to sync”. The release is done by then. Everything that made the day feel like release day is over, and the remaining work is one line in one file.

What the forgotten step costs is not a red tick. It is a 2026.1-SNAPSHOT published on top of 2026.1, which resolves for anybody on the snapshot line and resolves to older code than the release, because Maven orders a snapshot below the version it is a snapshot of. Nothing fails. The consumer gets last week’s toolkit and an explanation is weeks away.

This is the shape of failure the repository already has a word for — a check that only a person performs is not a check — so the question was only where to put the enforcement.

Decision

A successful release opens the bump as a pull request. Three parts, and each boundary is deliberate.

The arithmetic is a tested value

VersionBump in build-logic takes the text of gradle.properties and a year and returns the line that follows, the line that was there, and the rewritten file. It is a record with a static factory and no I/O, so VersionBumpTest states the rules directly.

Two of those rules are decisions rather than mechanics.

A patch line bumps its patch. 2026.1.1 lives on a release/2026.1 branch, and the next thing that branch can cut is 2026.1.2. CalendarVersion already had nextRelease(Year) and nextPatch(), and nextRelease had no caller at all until now; picking between them on isPatch() is the whole of it. A branch that bumped to 2026.2 would have a maintenance branch claiming the next feature release, which is the one number it must never claim.

Only the value is rewritten. The declaration has four comment lines above it saying why it is never a snapshot, and a java.util.Properties round trip drops every comment in the file. So this is a single-line substitution against a pattern anchored to the start of a line — which is also what makes a commented-out copy of the declaration an error rather than something to bump.

The task is :core:bumpVersion

Registered in goldberry.versioning beside printVersion, and invoked through a module for the same reason that one is: the plugin is applied per module and the root project cannot apply it. It rewrites the settings directory’s file whichever module it is asked through, so there is one file and one answer, and its only output is the line 2026.1 -> 2026.2, which the workflow parses.

The job is a pull request, after publish

  bump:
    needs: publish
    if: github.event_name == 'push'

needs: publish because a release that went red has nothing to follow. github.event_name == 'push' because a workflow_dispatch rehearsal publishes nothing, and a rehearsal that proposed a version bump would train everybody to close the pull request without reading it.

A pull request rather than a push to master. Master may be protected, and a workflow that pushed to it anyway would be the one commit in the repository nobody reviewed — on the file that decides what every artifact is called. It is also the visible form of the failure this record is about: a bump that did not happen is now an open pull request, which is a thing somebody notices.

The guard is one comparison, and it covers two cases. The job checks out the default branch and compares what that branch declares against the tag:

declared=$(sed -n 's/^goldberryVersion=//p' gradle.properties)
if [ "v$declared" = "$TAG" ]; then ...

A patch tag is cut from a release/* branch whose line master left long ago, so the comparison fails and nothing is proposed — correctly, because master’s line is already ahead of the patch. A re-run of a tag whose bump has already merged finds master ahead for the same reason. Neither needed a rule of its own.

The branch is named bump/<to>, for the version it moves to, so re-running the job reuses one branch rather than opening a second pull request.

Consequences

  • release.yml needs contents: write and pull-requests: write on that one job. The workflow’s own permissions: stays contents: read, so the grant is as narrow as the thing that needs it.
  • The repository setting “Allow GitHub Actions to create and approve pull requests” has to be on, or gh pr create fails with a 403 after the branch has already been pushed. It is in docs/releasing.md’s setup list now. This is the one way the job can fail after a release is permanent, and the damage is a pushed branch with no pull request on it — recoverable by hand, which is why it is a consequence rather than a blocker.
  • The bump is still a human decision, one click later. The job proposes; a person merges. That is deliberate: a release sometimes turns out to need a patch before the next feature release, and the version the line moves to is a thing worth looking at once.
  • CalendarVersion.nextRelease has a caller. It was written by ADR-0333 and used by nothing, which is the state a method is in just before it drifts.
  • ReleaseBumpWorkflowTest holds the job as text, the way ADR-0082’s other drift guards do — the needs:, the event condition, both permissions, the default branch, and the fact that the arithmetic is delegated rather than sed-ed. It also holds the guard’s sed pattern against the real gradle.properties, because that pattern runs before the JDK is set up and so is the one piece that could not move into Java.
  • It has never run, like everything else in release.yml. The task half is exercised locally and by its tests; the job half is first exercised by the first tag, which is the same sentence this workflow’s header already carries.

422. What SDL compiled is the fact, and decorations are a capability

Date: 2026-09-19

Status

Accepted. Finishes the half of docs/gaps.md G32 that ADR-0325 left, and answers the standing question behind ADR-0083 and ADR-0084 with a report rather than a fix.

Context

book/src/TODO.md asked “what does the release container actually compile into its Wayland driver?” and answered its own question halfway:

Measured in quay.io/pypa/manylinux_2_28_x86_64: dbus-devel, systemd-devel, ibus-devel and mesa-libEGL-devel all install and all provide their .pc files; libdecor-devel and xkeyboard-config are in no repository the container has … Extending both to SDL_VIDEO_DRIVER_WAYLAND and HAVE_LIBDECOR_H is the remaining work, and the honest form of it is probably a Capability.WINDOW_DECORATIONS, since the answer for the container may be “it cannot” rather than “install this”.

Two facts decide the shape of this, and they point in opposite directions from the three integrations ADR-0325 built.

A prediction of SDL’s Wayland check cannot be made faithfully. ADR-0325’s pattern is a pkg_search_module probe that predicts what SDL’s CMake will find, followed by a cross-check against the SDL_build_config.h SDL then generates; a prediction that says “present” where SDL says “absent” is a fatal error, because that is a library about to claim a capability it does not have. It works because each of those three is one pkg_check_modules over one module set.

SDL_VIDEO_DRIVER_WAYLAND is not. SDL’s CheckWayland is a single pkg_check_modules over five specs — wayland-client, wayland-scanner, wayland-egl, wayland-cursor, egl — and it additionally needs wayland-protocols and the wayland-scanner binary. Lose any one and the whole driver is dropped silently. The existing function’s own comment already warns about the direction of error that matters for a search: “a probe that asks for fewer names than SDL does would report absent on a machine SDL builds fine on”. Under the all-of semantics Wayland needs, the error inverts and gets worse — a probe listing four of the five reports present on a machine where SDL builds no driver, and the cross-check then fails a build that was fine.

Neither package is installable everywhere. libdecor-devel is in no repository the manylinux release container has. A REQUIRED probe would make the release container unbuildable, which is the entry’s own point: for that build the honest answer is “it cannot”.

Decision

Two new capabilities, and their source of truth is SDL’s generated header rather than a probe.

foreach(_sdl_capability IN ITEMS SDL_VIDEO_DRIVER_WAYLAND HAVE_LIBDECOR_H)
    if(_sdl_build_config_text MATCHES "\n#define ${_sdl_capability}")
        list(APPEND _definitions GOLDBERRY_PLATFORM_${_sdl_capability})
    else()
        message(WARNING ...)
    endif()
endforeach()

Capability.WAYLAND and Capability.WINDOW_DECORATIONS, bits 0x40 and 0x20, reported through Goldberry.capabilities() like the other five.

Four things about the shape.

There is nothing to cross-check, and that is the improvement. The three integrations have a prediction and an answer, and the gap between them is the error ADR-0325 exists to catch. These two have only the answer. That is strictly better where it is available — the reason ADR-0325 kept the prediction is that it is what produces a message naming the package to install, and here the warning names those packages directly.

A warning, not a fatal error. The release container cannot install either package, and a build that stops there produces no library at all. So the consequence of absence is a reported capability, which is the whole mechanism ADR-0325 built for exactly this case.

A header SDL did not generate reports both as absent. The existing unreadable-config path warns and carries on, which was right when the capability bits came from the probe. Now that these two come from the file itself, “could not read it” resolves to “the bit is not set” — the right way round, because a build that could not read SDL’s answer has not earned the claim.

WINDOW_DECORATIONS does not promise a titlebar, and says so. It is a build-time fact: without libdecor, SDL compiles no client-side decoration support and a Wayland window opens bare however the session is configured (ADR-0083). Whether a titlebar then appears is a run-time question with its own defect — libdecor’s default plugin refuses to start off the process’s initial thread and a JVM is never on it (ADR-0084). Built able to ask is the claim; the javadoc on both enums says which half it is. Conflating them would have produced the worst possible value: a bit that is set on the exact machine where the bug is.

WAYLAND is Linux-only and unset elsewhere. A library claiming it on Windows would be claiming something false about the session it will run in. And like every other value here it describes the library: a build with the driver still runs on X11 when that is what the desktop is.

Consequences

  • The silent case is now audible in two places: a warning naming mesa-libEGL-devel at configure time, and a missing bit at run time. EGL’s headers are the spec whose absence has actually dropped the driver, which is why the warning names that one first.
  • The release container will warn on every build, twice, for as long as libdecor-devel is unavailable there — and the published linux-x64 library will report WINDOW_DECORATIONS absent. That is a true statement about it and the first time the artifact has said so. Whether Goldberry should carry its own decorations instead (SdlWindowFlag.BORDERLESS, reserved by ADR-0084) is unchanged by this record; what changes is that an application can now ask.
  • The ABI version goes 12 → 13. No exported symbol changed shape, so this is the looser reading of the shim’s own rule — but the constant table gained two rows that :natives’ enum now requires, and a :natives jar loaded against an older library should fail saying the library is old rather than saying a constant is missing.
  • :natives:test and :core:test need a rebuilt library. ./gradlew -Pgoldberry.allowDegradedPlatform=true :natives:cmakeBuild first, as after every ABI bump.
  • PlatformIntegrationBuildTest gains a second enum, SdlDecided, held to three things: that the define is read out of SDL’s header, that absence warns rather than stops, and that the unreadable-header path claims neither capability. The existing bitsMatchTheShim covers the two new bits without being touched, because it is parameterized over the enum.
  • What this does not do is test the negative path. No machine here can produce an SDL configured without Wayland, so the warning branch and the absent bits are reasoned about rather than observed — the same limit every other row in G32 has, and the reason the message names packages rather than diagnosing.

423. One frame sequence, shared by the window and the buffer

Date: 2026-09-19

Status

Accepted. Closes book/src/TODO.md’s “The frame sequence exists twice”, under Rendering without a window. Takes the refactor ADR-0284 listed under Alternatives considered as “the right answer on paper” and did not take during a feature.

Context

ADR-0284 shipped Offscreen by writing down the sequence a window runs:

prepare → flush → render → update → capture the regions → advance the clock
→ prepare → flush → render → update → capture the regions
→ prepare → flush → render → update → paint

and recorded, in as many words, that every step in it is there because leaving it out produced a picture that was wrong in a way nobody would notice for weeks. It also recorded that this was now the second copy of that list rather than the third, and that what held the two together was the golden suites: every golden image goes through Offscreen, so a divergence moves a picture.

That safety net is real and it is also the wrong shape. It is a detector, and what it detected last time cost nine committed images showing an arrangement no window ever drew — the old harness had never called ElementTree.flush(), and ADR-0284’s own evidence is that removing only that one call reproduces all nine old goldens byte for byte. A net that catches the fall after the fact is what you build when you cannot stop the fall. Two copies of an order-sensitive list is a fall you can stop.

The reason it was not stopped in ADR-0284 is worth stating plainly, because it is the reason to be careful now: Launcher.paint is forty lines and only about half of them are the sequence. Damage, the frame ring, the HUD’s four stage timings, the model sweep, the popup re-placement and the animation re-request are woven through it, and a refactor that pulled the sequence out badly would pull one of those with it.

Decision

io.github.digitalsmile.goldberry.frame.FrameSequence owns the order, and the two callers own everything that differs. Both Launcher.paint and Offscreen.render now go through it.

var stages = sequence.layOut(frame, renderer(), beganAt);
// ... the launcher's damage pass, frame ring and HUD, none of which the
// sequence knows about ...
regions = sequence.captureRegions(frame, router);

Two methods, and the split between them is not arbitrary — it is where the order is load-bearing:

  • layOut runs prepare, flush, render, update. Four steps, each in front of the next for a reason with an ADR behind it: the resolver before the build (ADR-0254), the flush before the styling (ADR-0052, and the nine goldens), one layout pass read by two later readers (ADR-0069). This is the method that matters. It is now impossible to run these four in the wrong order, or to leave one out, from anywhere in the toolkit.
  • captureRegions sets the window bounds and then hands the capture to the router, in that order, because a Located widget is told what clips it and “nothing clips me” has to resolve to a real rectangle (ADR-0119).

The paint step is deliberately not in it

The entry named six steps and this extracts five. The sixth — draw — differs between the two callers by design: a window paints the damaged rectangles because the backend promises last frame’s pixels are still there (ADR-0072), and a buffer has no last frame to promise anything about, so it paints in full. Both sides are one call with no ordering constraint around them.

So it stays out, and the rule the exclusion illustrates is the one worth keeping: extraction buys safety exactly where order is load-bearing. Wrapping render.paint(frame) in a shared method that took a flag would move a two-way branch about window backends into the one class that should not know what a window is, in exchange for nothing.

What reality did not match

Three things, and the first is the interesting one.

The three sequences are not one sequence. The entry says the two “run the same steps in the same order”, and at the level of layOut they do. At the level of a whole frame they do not, and no amount of extraction makes them:

lay outpaintcapture
a window’s frame✓✓✓
Offscreen’s measuring pass✓—✓
Offscreen’s drawing pass✓✓—

A window captures after painting, because what the pointer is tested against must be the frame the user can see (ADR-0054). A measuring pass captures without painting at all — that is what makes the two extra passes cheap. And the drawing pass paints without capturing, because the render is about to be unmounted and there is nobody left to tell.

A single “run a frame” method would therefore have had to grow two booleans to serve three callers, and the thing it was protecting — the order within layOut — would have been just as protected without them. The unit that is genuinely shared is smaller than the entry assumed, and it is the whole of the part that had a bug in it.

Launcher called renderer() twice per frame — once for prepare and once for render — where Offscreen resolved it once. Both are correct, because renderer() is idempotent after the first call clears stylesDirty, but the sequence takes a renderer as an argument and so the launcher now calls it once. Passed in rather than held, because a theme swap builds a new renderer (ADR-0067) and a sequence that cached one would paint last theme’s colours.

Offscreen set the router’s window bounds once, before its first pass; a window sets them every frame. Folding that into captureRegions means the offscreen render now sets them three times instead of one. Identical in effect — nothing reads them between the passes, and the frame does not change size inside a render — and one fewer thing for the two callers to do differently.

Consequences

  • Every golden image still matches. :core:test and :widgets:test ran green with no image re-blessed and none touched, which is the entry’s own stated safety net and therefore the only acceptable result for this refactor. That is the claim to check first if anything here is ever revisited: the net is still hanging, it simply is no longer the only thing holding the two callers together.
  • The HUD’s numbers are unchanged, and that took a second overload. layOut(frame, renderer) times itself; layOut(frame, renderer, beganAt) is told when the frame started. The launcher’s build budget begins before the model sweep that precedes the build, so a sequence that timed itself would have quietly moved the sweep out of a number that appears on screen. A refactor is allowed to be behaviour-preserving about pixels and careless about measurements only if nobody is reading the measurements, and hud is.
  • Stages is a record with the four timestamps and three differences. The timings are taken on every frame rather than behind a flag, which is ADR-0146’s existing judgement: the stages are what a hud shows, so a number from a frame that happened to be traced would be a different frame’s.
  • captureRegions refuses a sequence that has never laid out. Not pedantry — a capture of an unlaid tree is a list of rectangles at the origin, and nothing downstream treats that as an error. A menu would simply open in the corner of the window, once, for one user.
  • The package is not exported. frame names the element tree, the cascade, the render tree, the paint pipeline and the hit test, so it cannot live inside any one of them without pointing that package at the other four; and it is a seam between two callers in :core rather than a promise to an application, which is what Offscreen is for. An application that found and called it would be assembling a frame loop by hand.
  • FrameSequenceTest is the first test in this repository of the order itself. Everything before it asserted on what a caller produced — a window’s goldens, an offscreen render’s pixels — which is how a missing flush survived long enough to be committed nine times. Each case now names the step that would go missing and the symptom: flushesBeforeItStyles is ADR-0284’s exact bug, preparesBeforeItBuilds is ADR-0254’s, setsTheWindowBoundsWithTheRegions is ADR-0119’s.
  • Launcher.paint lost fourteen lines and none of its comments. The reasons each step is in front of the next one moved to where the steps now are, which is the only way this refactor is not a loss: those comments are the record of what each step was protecting against, and a step without its reason is a step somebody reorders.

Alternatives considered

  • One method that runs a whole frame, with a listener for the stage timings. The design that enforces the most, and it cannot serve three callers with three different orders (see the table above) without booleans that describe windows. The order it would have protected is the order layOut already protects.
  • Leave it, and trust the goldens. ADR-0284’s position, and it was defensible while Offscreen was new. It stops being defensible once the entry naming the duplication is being closed: the net’s own last catch cost nine wrong images and an ADR to explain them.
  • Put FrameSequence in paint.tree, beside RenderTree. It would be the fifth package it depends on deciding to own it. widget has the same problem from the other end.
  • Export the package. There is one thing an application wants from this, and it is called Offscreen.
  • Extract the launcher’s whole paint method and give Offscreen the parts it needs. This is the refactor ADR-0284 was afraid of, and rightly: the damage pass, the frame ring, the popup re-placement and the animation re-request are a window’s behaviour, and a shared object holding them would be a window with the window taken out.

424. A tree mounted once, photographed repeatedly

Date: 2026-09-19

Status

Accepted. Closes book/src/TODO.md’s “No animation strip”, under Rendering without a window. Answers the last consequence of ADR-0284: “An animation strip is not supported. One call, one picture … which is a different object with a lifetime — and nothing has asked for it.”

Context

Offscreen.render(Widget) mounts a tree, advances a virtual clock once, paints, and unmounts. One call, one picture, and the unmount is not incidental: it is what makes a render leak nothing and what makes two renders unable to see each other.

The obvious way to get four frames of a transition out of that is to call it four times with four settle times, and it does not work. It produces four first frames. A spinner at 48 ms is not the same picture as a spinner that has been spinning for 48 ms, and for anything with state the gap is wider than the animation: a text-area has just learnt its own width, a masonry has just finished arranging, a State’s initState has just run. Every frame would be a photograph of a tree recovering from having been born.

So the entry is right about the shape — “an object with a lifetime rather than a builder that renders once” — and the hard part is not the object. It is deciding what survives between two of its frames, because the answer is “almost everything”, and the one thing that must not survive is the thing a caller would never think about.

Decision

io.github.digitalsmile.goldberry.offscreen.Filmstrip, a closeable object that mounts a tree once and hands out one picture per call while the caller drives its clock.

try (var strip = Offscreen.of(400, 120)
        .stylesheets(Controls.stylesheets(Theme.NORD_DARK))
        .strip(new Banner("saved"))) {
    var frames = new ArrayList<Image>();
    while (strip.isAnimating() && strip.frames() < 60) {
        frames.add(strip.frame());
        strip.advance(16);
    }
}

Offscreen.strip(Widget) is a third terminal beside paint and render, and it is a terminal on the existing builder rather than a second builder with its own six setters — the size, the scale, the stylesheets, the fonts and the background are already knobs, and a second copy of them is the duplication ADR-0423 had just finished removing.

advance moves the clock and draws nothing; frame() is what turns a number into a picture. Two advances with no frame between them are one advance, which is how a caller skips a boring stretch of a long animation for free.

What lives between frames, and what does not

This is the decision, and the table is the ADR:

keptwhy
the element treeyesthe whole point. State, scroll offsets and a learnt width carry over; dispose runs once, at close()
the render treeyesretained layout, so Yoga re-lays out only what moved (ADR-0069)
the rendereryesits shaping cache holds every paragraph already shaped, and self-tunes to the frame (ADR-0299)
the routeryesa Measured widget is told its region changed rather than told it again
the clockyesit is the one object the caller is actually driving
the pixel buffernosee below

The buffer is allocated per frame, and that is the one entry that is not obvious. An Image handed back is a view over the pixels it was rendered into and never a copy of them — that is Image.of(PixelBuffer), from ADR-0283, and it is why an offscreen render does not copy a megabyte to say what it drew. A strip that reused one buffer would therefore hand out ten references to the tenth picture, and the caller would find it out by encoding all ten and getting the same PNG. Ten frames of a 400×120 strip is 1.9 MB; the alternative is one buffer and ten identical images, which is not an optimization but a defect with a smaller memory profile.

FilmstripTest.everyFrameGetsItsOwnBuffer is the assertion, and it is written the way the bug would actually be met: take a frame, keep its pixels, take another frame, and check the first picture is still the first picture.

The mount pass, and the pass it deliberately skips

Opening a strip runs one of Offscreen’s three passes: a lay-out and a region capture, with the clock still at zero.

That keeps half of ADR-0284’s argument and drops the other half, on purpose:

  • Kept: the region feedback. A text-area learns its own width from the rectangles a laid-out frame produced. A strip whose first frame had never fed them back would photograph the entire animation of a widget correcting a first guess it should never have been showing — an animation the application does not have.
  • Dropped: the settle. Offscreen advances past the transition duration because a still picture of four banners at zero opacity is useless. A strip is a request to photograph exactly that. Frame zero is frame zero.

So settle(int) does not apply to a strip and is ignored, which the javadoc says at strip and FilmstripTest.ignoresTheSettleTime pins.

After the mount pass, each frame is lay-out → paint → capture, which is the order a window runs (ADR-0423). Feedback therefore arrives on the following frame, exactly as it does in a window — a strip and a window settle a self-arranging widget over the same number of frames, rather than the strip settling it faster because it was being helpful.

Consequences

  • A strip must be closed, and the teardown order is ADR-0284’s. No frame is live by the time close() runs, because frame() ends its own; what remains is the render tree before the element tree, because a State that owns a Font closes it in dispose and Blend2D’s workers are still holding it until the join. Getting that wrong was a SIGSEGV in a worker thread, once, and it is written down in two places now.
  • A constructor that fails releases what it built. A strip is mounted inside its constructor, so a widget that throws on its first build would otherwise leave a mounted tree, a native Yoga tree and possibly a font book with no reference anywhere to close them — and the caller has no strip to close. The fault that got the strip torn down is the one thrown; anything raised while releasing is suppressed onto it.
  • Pictures outlive the strip. No two frames share a buffer, so closing invalidates none of them. framesSurviveClosing asserts it, because the opposite is exactly the sort of thing a caller discovers after writing the file.
  • isAnimating() is answerable from the moment the strip is open, because the mount pass is a pass — a caller can loop on it without taking a throwaway frame first. It is not a promise the picture has settled: a widget that moves without CSS, a chart streaming points, animates and this says false for it. A caller with a bound on frames wants both, which is what the example above does.
  • A strip cannot take the font(Font) form and says so. A single font ignores font-family, font-size and font-weight entirely, and a transition whose font-size moves is one of the things a strip exists to photograph. Refused at strip() rather than producing a picture that is quietly not of the animation.
  • A strip from a Studio shares the book and not the renderer (ADR-0425). A renderer holds exactly one clock, so a strip on a studio’s renderer would move the clock under every still picture taken beside it. The book is the expensive part and is shared; the cascade index is rebuilt, and that is what a strip costs.
  • Nothing here is a frame loop. No refresh, no request for the next frame, no system clock anywhere in it. A strip advances when told and by exactly as much, which is what makes the tenth frame of a transition the same picture on every machine — the same property Offscreen has and for the same reason.
  • The name. It is the strip, and the caller develops it one frame at a time; image.anim already calls a multi-frame picture what it is (ADR-0382), so the vocabulary was there. AnimationStrip says the same thing and one word longer, and a name like Recorder or Session describes the machinery rather than what comes out.

Alternatives considered

  • Offscreen.render(Widget, int[] times) returning a list of images. One call, no lifetime, no close(), and it decides for the caller when to stop. A caller who wants “frames until it stops moving” cannot say so, and a caller who wants the tenth frame has to pay for nine. The clock is the input; a list of times is a guess about which inputs matter.
  • Reuse the buffer and copy on the way out. Symmetrical with the decision above, and worse: it pays a memcpy per frame to save an allocation per frame, and it makes Image.of(PixelBuffer)’s borrowed-buffer doctrine — which the whole offscreen path is built on — a special case here.
  • Advance the clock inside frame(), by a fixed interval. A strip would then be a 60 Hz camera and nothing else. advance and frame() being separate is what lets a caller take two pictures of one instant, or jump 400 ms in one step.
  • Make Offscreen itself stateful, with render callable repeatedly. The builder would then have two modes and a caller would have to know which one they were in; and Offscreen’s current promise — the tree is unmounted before it returns, so nothing survives the picture — is worth more than the method name.
  • A settle on the strip as well, for symmetry. It is the one knob a strip cannot want: a caller who wants to start 200 ms in calls advance(200) before the first frame, which is the same thing said in the object’s own vocabulary.

425. What a render may keep, and the thread it may keep it on

Date: 2026-09-19

Status

Accepted. Closes two entries in book/src/TODO.md under Rendering without a window — “No reuse and no cache” and “Nothing renders off the UI thread” — because they turned out to be one decision. The argument that they are one is the first section below; it was not obvious in advance and the brief for this work asked for the two to be separated if they stayed separate.

Completes ADR-0284, whose consequences recorded both halves as open: “A font book is opened and closed per render unless one is given” and “No Offscreen reuse across calls … It also means the builder is not a cache”.

Context

The two entries, in their own words.

Reuse. “Each render builds a fresh element tree and unmounts it, so rendering the same document twice does the work twice. A font book can be handed in and kept; nothing else can.”

Threads. “A render touches no window and no backend, so a server thread is probably fine — ‘probably’ is why it is written here rather than in the javadoc. What would have to be checked first is the shaping cache and Blend2D’s own worker pool.”

Why they are one decision

The reuse entry is right that a font book can be handed in and nothing else can, and it is wrong about which part of that is expensive. Per render, Offscreen currently throws away:

  • a Fonts — memory-mapped faces, 681 µs to parse Inter (ADR-0044);
  • a StyleResolver — the parsed sheets indexed by selector, which ADR-0070 measured as the largest term in a frame and ADR-0142 as the one that had stopped being cached;
  • a ParagraphCache — 56 µs per distinct paragraph, twelve times a wrap and two hundred times the Yoga crossing (ADR-0037).

The last two belong to a WidgetRenderer and cannot be handed in, because the constructor does not take one. So the reusable unit is a renderer over a book.

And that is exactly the object that cannot be shared between threads. Auditing the render path for the second entry produced a list of fifteen classes that hold a Thread owner field and refuse a foreign caller — ParagraphCache, RenderTree, YogaConfig, YogaNode, BlendContext, ShapedFont, BlendFont, BlendPath, BlendImage and the rest — and the two things a renderer keeps are on it. So:

What a render may reuse is exactly what it may not share, and the unit of both is one object.

One object, one decision, one ADR. Writing them separately would have produced two records that each had to describe the other’s object to say anything true.

What the audit actually found

The entry named two suspects. Both were cleaner than expected, and the real blocker was neither.

The shaping cache: already correct, and already fail-fast. Offscreen’s own javadoc claimed “the shaping cache is per renderer”, and it is — a ParagraphCache created in the WidgetRenderer constructor, on the calling thread. It also carries private final Thread owner = Thread.currentThread() and checks it on every paragraph, frame, size and clear. Not a hazard.

Blend2D’s worker pool: process-wide, and the fallback was already written. The pool lives inside libgoldberry and has no Java handle anywhere in this repository. Per-context state is per-instance with a confined Arena and an owner check (BlendContext), and the number of workers a frame asks for is a pure function of surface size and a system property read once at class init (PaintThreads). N concurrent renders therefore contend for one pool — and BlendContext.begin already falls back to a synchronous context when it cannot get workers, logs it, and reports threadCount() == 0. ADR-0042 anticipated “a thread pool at its limit, or a process that has run out”; it did not anticipate us being the other tenant, and the handling is the same either way. Slower, non-deterministic in how many workers each render gets, and identical in output, which is the thing a preview cares about.

Also checked and clean: Yoga holds no global config, node pool or measure registry; every lazily-initialized native global uses the class-holder idiom, so first-call init is serialized by the JVM rather than raced; the native library extracts to a uniquely-named temp directory once, from inside class init; there is exactly one non-final static field in the whole toolkit (GoldberryRuntime.instance, guarded by static synchronized) and the offscreen path never reaches it, so no window and no SDL are involved. The static one-shot warning sets on the path — OverflowLog, ComputedStyle’s dropped declarations, KeyframeTrack’s unknown names — are all ConcurrentHashMap-backed and memory-safe.

The blocker was Fonts, and the shape of the problem is the point. Its class comment has said “confined to the thread that created it” since ADR-0044 and nothing checked it — unlike every other confined object on the path. Its faces and fonts caches are plain LinkedHashMaps written through a get-then-put in fontOf, so two threads in it are an unsynchronized map mutation: a lost entry, a native face opened twice, or a corrupted table. The eventual symptom would not have named the book — it would have been a requireOwner from inside HarfBuzz’s or Blend2D’s wrapper, on some later frame, naming a font.

And the configuration that reaches it is the one this toolkit’s own javadoc recommended: “A server rendering many previews should hand over one Fonts and keep it.” Correct for serial renders on one thread. Followed from a worker pool, which is what “a server rendering many previews” means, it was the single worst thing a caller could do.

Decision

Three parts, and the second is the one that matters most.

1. Studio — the reusable unit, named and closeable

try (var studio = Studio.of(Controls.stylesheets(Theme.NORD_DARK))) {
    for (var document : documents) {
        write(studio.picture(1200, 630).render(new Card(document)).encodePng());
    }
}

A studio owns one Fonts and one WidgetRenderer — and therefore one cascade index and one shaping cache — and hands out Offscreen builders wired to them. Studio.of opens its own book; Studio.over borrows the caller’s and does not close it.

It is not a result cache, and this is deliberate. Two renders of the same document still build, style, lay out and rasterize from scratch. A preview is a picture of program state, and the only honest cache key for one is the caller’s — Offscreen guessing at it would be a correctness bug with a performance justification. What is kept is the machinery, which is where the repeated cost actually was.

The evidence is a count, not a stopwatch: StudioTest.keepsTheShapingCache renders a document, reads ParagraphCache.misses(), renders it again, and asserts the number did not move. ADR-0299 put that counter there for exactly this kind of claim, and a timing assertion about the same thing would be a flaky test.

2. The promise, written without “probably”, and the enforcement under it

Offscreen.render may be called on any thread, and several may run at once. That is now in the javadoc on Offscreen, and OffscreenThreadTest is what makes it a promise rather than a hope: rendersConcurrently puts eight threads inside the render at the same moment behind a latch and asserts every one of the eight produced a picture pixel for pixel identical to a render done alone.

Pixels rather than “it did not throw”, because the failure mode of sharing something that should not be shared is a wrong picture long before it is a crash: a paragraph shaped against a half-written cache is a paragraph, and it draws.

The limit is sharing, not the thread, and the three shareable-looking objects now say so by throwing:

  • Fonts gained the owner check every other confined object on the path already had, on both use and close. Checked before the closed flag goes up, so a refused close leaves the owner’s book intact rather than half-closed with its maps cleared.
  • Studio carries the same check, with a message that distinguishes the two things a caller might have got wrong: “rendering off the UI thread is supported, sharing one studio between threads is not”.
  • Font, handed in through Offscreen.font(Font), was already confined by the HarfBuzz and Blend2D objects under it.

A pool of four workers wants four studios. That is the whole rule, it costs four font books, and it buys four renders that cannot interfere.

3. The javadoc advice that was wrong is gone

Offscreen’s “What it costs” section no longer tells a server to hand over one Fonts and keep it. It names Studio, states the thread rule, and records that Blend2D’s pool is shared so concurrent renders may paint synchronously — slower, same pixels.

Consequences

  • Reuse is invisible in the output, and that is asserted. StudioTest.matchesAPlainRender compares every pixel of a render from a warm studio against a plain Offscreen render of the same scene. If keeping the cascade index or the shaping cache ever changed one pixel, every golden in this repository would move the day the harness started using a studio.
  • A studio’s renderer is shared sequentially, and its per-frame state is reset by every render — the frame’s time, whether anything is animating, which element is being styled. keepsRendersIndependent renders two different documents and then the first one again, because “the second document left nothing behind” is the claim a shared renderer has to earn.
  • Setting stylesheets or fonts on a studio’s builder gives the kept renderer up, rather than ignoring them. A renderer is its cascade, so one built over other sheets would resolve other styles. Silently preferring the studio’s would be the worst of the three options and the easiest to write.
  • Fonts now throws where it used to corrupt. This is a behaviour change to a published class, and it is a refusal in a case that was already broken — every path that worked before still works, because every object below Fonts would have refused the same caller a moment later. The whole suite is the evidence: it passes unchanged.
  • Blend2D’s pool is shared and this is recorded rather than fixed. N concurrent renders × up to four workers each contend, and the losers rasterize synchronously. Fixing it properly means bounding the pool or bounding the concurrency, and neither is Offscreen’s to decide — a server that cares sizes its own worker pool, which is the same knob.
  • Concurrent renders suppress each other’s one-shot diagnostics. OverflowLog.REPORTED and friends are process-wide by design, so “the first overrun of each shape is said out loud once” (ADR-0375) is once per process and not once per render. Memory-safe, and it means any future test asserting “warned exactly once” becomes order-dependent the day it is run beside a concurrent render. Written down here because that test does not exist yet and the next person to write one deserves to know.
  • JUnit still runs every suite serially. There is no parallel execution configured anywhere in this build, so OffscreenThreadTest is the only place in the repository where two renders are ever in flight at once. That is a narrow net under a broad promise, and it is the honest state of it.
  • Studio does not serve a Filmstrip’s renderer (ADR-0424). A renderer holds one clock; a strip drives its own for its lifetime. A strip from a studio shares the book and builds its own cascade index, which is what it costs and is cheaper than the alternative of a strip that moves the clock under every still picture beside it.

Alternatives considered

  • Two ADRs, one per entry. The brief allowed it and the audit removed the reason: the object that makes reuse possible is the object that must not be shared. Two records would each have had to describe the other’s decision.
  • A result cache keyed on the widget. A Widget is a description and often a record, so it has an equals; that is precisely what makes this tempting and wrong. A Card(document) whose document is mutable, or which closes over a model, is equal to a stale one. A cache that is right only for the callers who did not need it is a trap with a hit-rate graph.
  • Make Offscreen itself hold the renderer across render calls. No new type, and it needs a close() — a kept book has to be released — so Offscreen would become AutoCloseable and every existing one-shot caller would start getting a resource-leak warning for doing the simple thing correctly. Reuse needs a lifetime; a builder should not have one.
  • A per-thread static cache of renderers. ADR-0044 already answered this for fonts, and the answer has not changed: a per-thread cache of native memory has no hook that would ever free it. A pool whose threads outlive the work would hold every face it ever opened.
  • Make Fonts thread-safe instead of thread-checked. Synchronizing two LinkedHashMaps is the easy half. The hard half is that what it vends is confined: a Font is a ShapedFont and a BlendFont, both of which refuse a foreign thread from inside the native wrapper. A book that handed out a font safely to a thread that could not use it would have moved the exception one frame later and made it harder to read.
  • A @ThreadConfined annotation instead of a runtime check. Documentation with better spelling. Fonts has carried the sentence since ADR-0044 and the recommendation in Offscreen contradicted it anyway.
  • Say nothing in the javadoc and leave the entry open. The audit is done and the answer is favourable; leaving “probably” in a TODO after checking would be keeping a question we know the answer to.

426. A paragraph is one row of words until somebody ends a line

Date: 2026-09-19

Status

Accepted. Amends ADR-0295, whose last consequence — “No hard break inside a paragraph … two trailing spaces therefore do nothing in the widget renderer” — is no longer true, and closes the book/src/TODO.md entry that carried it. Everything else ADR-0295 decided about a paragraph stands: it is still a wrapping row of word widgets, and a paragraph nobody ended a line inside is still exactly the one row it was.

Context

Both folds mapped both breaks to the same thing:

case LineBreak _ -> out.add(Words.Fragment.SEPARATOR);   // markdown-view
case "br" -> pending.add(Words.Fragment.SEPARATOR);      // html-view

Fragment.SEPARATOR ends the token and draws nothing, so a hard break came out as the space a soft break comes out as. The model has distinguished the two since ADR-0295 — LineBreak(boolean hard), documented as “whether the author asked for one” — and every other consumer of the model already honours it: MarkdownHtml writes <br>\n for a hard break and a bare newline for a soft one, and Inlines.text appends '\n' and ' '. The widget fold was the only reader that threw the flag away, so a note and the HTML served from the same Document disagreed about a line ending — which is the precise failure ADR-0295 wrote a single fold over one model to prevent.

The comment left in its place named the trap and not the way out:

A spacer with flex-grow would fill the rest of the line, which is the trick this deliberately does not play: it would make a hard break look like justified text.

That is still right. A wrapping row breaks where the width runs out; the only way to force a break inside one is to push the remaining width away, and the words before the break then spread across the full measure. A reader cannot tell that from justification, and justification is the one thing a renderer with no shaped runs must never appear to be doing.

What the entry did not say is that the trick is only needed if the paragraph has to stay one row. It does not.

What the parser actually produces

Asked directly, md4c is narrower than the entry’s two edge cases suggested:

sourceinlines
one␠␠\ntwoText, LineBreak[hard], Text
one\\\ntwothe same
one␠␠\n\\\ntwoText, LineBreak[hard], LineBreak[hard], Text
\\\noneLineBreak[hard], Text
one␠␠\nText — no break at all
*a\\\nb*the break is inside Emphasis
| a<br>b |RawHtml, not a LineBreak

So a trailing hard break is not something Markdown can write: two spaces before the end of a paragraph are stripped. It is reachable only through the model — which is public — and through html-view’s <p>a<br></p>. And a table cell can never hold one, because a table row is a single line of source and <br> in a cell is raw markup; that is what makes it safe for a cell to stay one row.

Decision

A hard break is a piece, and Words cuts the run at it

Words.Piece gains a third variant beside Fragment and Node:

public sealed interface Piece permits Fragment, Node, Break {}

Break draws nothing and carries no marks — what it is is a boundary — and Words.lines(pieces) returns one list of token widgets per line. The two folds disagree about what a paragraph is and agree about what a line of mixed faces has to become, which is the split Words has always been: it says where the lines are and nothing about their shape.

markdown-view sends a soft break to Fragment.SEPARATOR and a hard one to Break.HARD; html-view sends <br> to Break.HARD. A run with no break in it is minted by exactly the call it was minted by before — same words, same order, no boundary — because a fold that reordered the minting would move every selection rectangle in the document. tokens keeps its old behaviour for a Break it is handed directly (it ends the token and no more), which is what a box that is one line by construction wants: a table cell asks for tokens.

One line is still one Row; more than one is a Column of them

no break   ->  Row  .md-prose .md-line  [words…]
a break    ->  Column .md-prose .md-lines
                 Row .md-line [words…]
                 Row .md-line [words…]

The no-break case is the old tree with one class added. That invariant is not an optimisation, it is the test: a golden image that moves for a document nobody wrote a break in means this change is wrong.

The CSS split, which is the decision this ADR exists for

.md-prose styled a row. A column inheriting it would have taken three row-shaped declarations onto the other axis, and one of them is not a near miss but an inversion: gap: 0.25em on the row is the space between two words, and on the column it is the space between two lines. A paragraph with a break would have had a different word spacing and a different leading from the paragraph above it.

So the declarations went where the shape is, and the class that names the block kept nothing:

  • .md-line — flex-wrap: wrap, align-items: baseline, gap: 0.25em. The three declarations that were on .md-prose, unchanged, on the box that lays words out: the paragraph’s own row when it is one line, each row of the column when it is not.
  • .md-lines — flex-direction: column, gap: 0.25em, align-items: stretch. The gap is deliberately the same number: 0.25em is what a wrapping row already puts between two lines the width ended, so a line the author ended sits at the same leading. A value of its own here would give one paragraph two leadings and let a reader see which of its breaks were typed. stretch gives every line the paragraph’s full width, so a line after a break wraps where the line before it did.
  • .md-prose — no declarations at all, and a comment saying why. It is the hook, the way .md-word has been one since ADR-0295.

.md-prose is on the paragraph’s box in both shapes, and that is what makes the split safe rather than merely tidy. Three things depend on there being exactly one of it per paragraph:

  • Typography is inherited. md-heading md-h2 lands beside it, on the outer box, so a heading’s size reaches the words of every one of its lines. Put the paragraph class on each line instead and a heading’s size would have to be repeated per line — or, worse, be nearer the word than the block, which is the mistake markdown.css already records about setting the size on words.
  • A fill is painted once. An application that gives .md-prose a background, a padding or a border expects one box round the paragraph. Had the column been the unnamed one and the lines carried md-prose, that rule would have drawn a panel per line.
  • The flow is identical. One box per paragraph, no margin on it either way, so a paragraph with a break and one without sit in the same place in .markdown’s 12px column. This is the requirement the whole split serves.

html.css takes the same split for the same reasons: .html-line, .html-lines, and .html-prose as the hook. It is also where the choice is visible in a test — HtmlViewTest counts html-prose elements to assert “one paragraph, not three blocks”, and that assertion keeps meaning what it says.

The price is stated plainly: an application’s stylesheet that set a geometric property on .md-prose must now name .md-line. gap: 0 to tighten a paragraph is the realistic case. The classes are the published contract, so this is a breaking change to it, taken now while the alternative is a class that means two different things depending on whether an author pressed space twice.

A link is one button.link — one Tab stop, one hover, one press (ADR-0293, ADR-0300). A hard break inside its text would have to become two buttons for one destination, and a reader tabbing through a document would meet the same link twice. That is a worse lie than a line that did not end where it was typed, so the break becomes a space in the label, in what is drawn and in what is copied. Inlines.text still answers '\n' there and is still right to: that newline is what the <br> in the HTML is made of.

A break inside emphasis, strong, strikethrough or an underline does split, with both halves keeping their marks, because those are faces on words rather than one widget. A link with no destination folds to words and therefore splits too — there is no button to keep whole.

An empty line survives, and it costs a word

Two hard breaks in a row are a blank line an author wrote, and a browser draws one for one<br><br>two. A row holding no words measures zero high, so the blank line would have collapsed and the bug would have survived in miniature. The empty line therefore gets one space, which is the rule a code fence’s blank line already follows in this same fold, for the same reason and with the same comment.

That space is a minted word, so it is in the copy: one\n \ntwo. Every line after the first also opens a block, which is what puts the newline in a copied selection at all (ADR-0301) — a hard break that pasted as a space would be saying the author’s line ending was the width of the pane, which is exactly what a soft break means. The cost is that a triple-click takes one line rather than the whole paragraph. For the documents hard breaks exist for — an address, a stanza, a signature block — taking the line is the better answer anyway.

Consequences

  • book/src/TODO.md’s entry is closed, and the fourth bullet of MarkdownWidgets’s “what this cannot do” list is gone rather than reworded. html-view got the same fix in the same commit; MarkdownHtml was already right and was not touched.
  • No golden in :html moved. Both Markdown goldens, both HTML goldens and the selection golden are unchanged, which is the evidence for the no-break invariant: the documents they draw contain soft breaks and no hard ones.
  • gallery-markdown in :example moves, and is not re-blessed here. The showcase’s sample document says “Two spaces at the end of a line␠␠/ are a hard break, which the HTML writer emits as <br>” — it has always been a document demonstrating the thing that did not work. The preview now breaks the line, everything below it shifts down by one, and the pane’s caption re-rasterises because the paragraph’s longest line got shorter and the two panes share the width by content. It is the fix, in a picture; whoever takes the showcase’s goldens next takes it deliberately. Every other test in :example passes.
  • A broken paragraph costs one box per line on top of ADR-0295’s word count. A note of ordinary prose gains nothing, because nothing about it changed.
  • A table cell is still one row. It cannot hold a hard break from md4c, and a model built by hand that holds one gets the old degradation — the break ends the token. Two classes could have been split out of .md-cell to make a cell break too; a box that is provably one line does not need them.

Alternatives considered

  • A spacer with flex-grow in the row. The obvious trick, named and refused by the comment this replaces: it makes the line before the break look justified, and a renderer with no shaped runs must not appear to justify.
  • A zero-width “line break” widget. It would need the layout engine to understand it, which means :core learning about inline flow — the rich-text engine ADR-0295 declined to write, for a paragraph that breaks in two.
  • The column carries no class and the lines carry md-prose. The smallest diff, and it puts the paragraph’s identity on N boxes: an application’s background is painted per line, a heading’s size is set per line, and html-view’s “one paragraph, not three blocks” assertions start counting lines.
  • A leading of its own for .md-lines. A knob nobody asked for, and the first thing it buys is a paragraph whose typed breaks are visibly a different distance apart from its wrapped ones.
  • Two buttons for a link with a break in it. One destination, two Tab stops, two hovers; and the label a screen reader is given is cut in half.
  • Dropping an empty line. It renders one<br><br>two as one<br>two, which is the same class of silent loss as the break doing nothing, only rarer and therefore harder to notice.

427. The shadow is cut out of its box

Date: 2026-09-19

Status

Accepted. Closes the deviation ADR-0310 recorded and could not fix, and retires the occlusion flag that decision introduced to soften it.

Context

CSS paints an outer box-shadow only outside the border box. The box’s own rectangle is knocked out of the shadow, so a translucent background does not have its own shadow showing through from underneath.

ADR-0310 could not do that, and said so in as many words — in the decision, in ShadowGeometry’s class comment, and in a test written to fail the day it became possible. The reason was that the Blend2D binding exported neither a path clip nor a fill rule, and the obvious trick without either is worse than the problem it fixes: a reversed sub-path under the default non-zero winding fills the parts of itself the outer shape does not cover, so the inner half of a blur would paint a dark ring exactly where it was supposed to erase one.

So the toolkit painted the whole shape and relied on the box being drawn on top of it. That is invisible under an opaque background — which is every shadowed surface the design system has — and shows under a box mid-opacity transition, which fades its shadow by the same factor and therefore darkens itself slightly as it fades.

The choice the entry offered does not exist

book/src/TODO.md named the fix as “BLContextSetFillRule or a path-clip call on the export list”. Only one of those is real. Blend2D clips to a rectangle and to nothing else. Its whole clipping surface in core/context.h is:

BL_API BLResult bl_context_clip_to_rect_i(BLContextCore*, const BLRectI*);
BL_API BLResult bl_context_clip_to_rect_d(BLContextCore*, const BLRect*);

Both were already exported, for damage-driven painting (ADR-0072) and for scroll’s viewport (ADR-0114). There is no clip_to_path to add. A rectangular clip cannot cut a rounded hole, and every shadowed surface in this toolkit is rounded — so the alternative the entry held open is not a cheaper way to do this, it is not a way to do this.

That leaves one call, and the decision is really about how to use it.

Decision

Export bl_context_set_fill_rule, and fill every shadow band together with the border box under the even-odd rule.

The painter builds two sub-paths into the pooled rasterizer path — the band, then ShadowGeometry.borderBox — and fills the pair even-odd:

path.reset();
outline.replayInto(path);
hole.replayInto(path);
frame.fillPathEvenOdd(x, y, path, band.argb());

A point inside both sub-paths is crossed twice, which is even, which is outside. The hole costs one more sub-path per band and no extra fill.

Even-odd and not a reversed sub-path under non-zero. This is the whole point of binding a rule rather than being cleverer with geometry. Under non-zero the answer depends on winding direction, so it depends on Path.roundRect emitting its corners in a particular order and on that order surviving every future edit — and when it is wrong, it is wrong by painting a dark ring rather than by failing. Under even-odd the direction is not an input. The band and the hole can be wound identically, which they are, because both come out of the same Path.roundRect.

The hole does not move with the shadow. The band sits at (offsetX - grow, offsetY - grow); the hole sits at (0, 0) with the box’s own radii, ungrown. That asymmetry is the whole of why an offset shadow is still visible: a hole that travelled with its band would land exactly on top of it and the even-odd fill would paint nothing at all.

It is unconditional. A painter that asked whether the background was opaque before cutting the hole would be making the deviation conditional rather than removing it, and would carry two paint paths to do it.

The rule is set and put back in the same call. BlendContext.fillPathEvenOdd sets BL_FILL_RULE_EVEN_ODD, fills, and restores BL_FILL_RULE_NON_ZERO in a finally. The fill rule is context state and Blend2D offers no stack for it, so a caller that set it and forgot would hand the rule to whatever drew next — and the symptom is a hole in an unrelated shape three boxes later, with no error anywhere. A bare setter on BlendContext would have been the honest binding and the wrong surface; there is exactly one drawing in the toolkit that wants this, and it does not get to leak.

Nothing is added to Path or to the public Frame.fillPath. A Path is a value describing a shape and a fill rule is a statement about how to read one; attaching a rule to the value would mean every path in the toolkit carries an answer to a question one drawing asks. Frame.fillPathEvenOdd is package-private, and grows a public form the day something outside paint needs a hole.

The flag ADR-0310 introduced is deleted

ADR-0310 gave ShadowRamp.bands a second argument — whether the box would paint an opaque fill over its own rectangle — and dropped the bands hidden under it when it would. That was two things at once: an optimisation, and an admission, because a translucent box got every band precisely because its shadow showed through it.

With the hole cut, a band entirely inside the border box paints nothing whatever the background’s alpha is, so the question has one answer and the parameter is gone. What replaces it is ShadowGeometry.coveredAt(shadow), which is geometry and says so:

grow <= -max(|offsetX|, |offsetY|)

and the painter stops there, because grow only decreases. The bands are still never filled; they are now skipped by the painter rather than never built by the ramp. That costs at most twenty-four Band records per shadowed box — a double and an int each — and buys the thing the split was actually for: the alpha solver can be tested across the whole curve rather than across whatever a culling rule left of it, which is where ShadowRampTest’s sharpest assertions live.

Consequences

  • ShadowPaintTest.throughATranslucentBox was renamed and inverted. It used to assert luminance(pixel(60, 50)) < 128 — the shadow visibly darkening the middle of a 50%-red box — as a deliberate pin on known-wrong behaviour. It now asserts that the pixel is identical to the same box painted with Shadow.NONE, and that a 50% red over white is a light pixel. blurredUnderATranslucentBox joins it, because a blur reaches inside the border box by half its radius and a knock-out that only handled the hard case would pass the first test and still darken every fading card.

  • Twelve golden images move, and none is re-blessed here. All twelve move in the same place and for the same reason: the anti-aliased arc of a rounded corner. Along a straight edge on an integral coordinate the box covers the pixel completely and nothing changes; along a corner arc it covers a fraction, and the shadow underneath that fraction is now cut away instead of painted. Every one is far inside the 2.00% pixel tolerance and fails on the per-channel ceiling of 2:

    goldendifferingworst channel
    elevation-light91 / 19800 (0.46%)10
    elevation-dark94 / 19800 (0.47%)8
    dialog-light74 / 138000 (0.05%)8
    dialog-dark66 / 138000 (0.05%)5
    toast-light123 / 96000 (0.13%)9
    toast-dark129 / 96000 (0.13%)7
    toast-top-start129 / 96000 (0.13%)7
    toast-arriving127 / 96000 (0.13%)3
    toast-reflowing86 / 96000 (0.09%)7
    card-hover79 / 30600 (0.26%)4
    card-light48 / 33000 (0.15%)7
    card-dark50 / 33000 (0.15%)4

    This is the seam a browser has too — it composites a clipped shadow and then the box, in that order, and gets the same fractional coverage twice. The new pixels are the faithful ones and the goldens encode the old behaviour, so re-blessing them is the correct next step and is deliberately somebody else’s: ten of the twelve are under widgets/, which this change does not own.

  • The ABI is 14, in goldberry_shim.c and GoldberryShim.SUPPORTED_ABI_VERSION together. BL_FILL_RULE_NON_ZERO and BL_FILL_RULE_EVEN_ODD are rows on the layout table through BlendFillRule, because an enumerator this narrow is exactly as silent when wrong as a struct offset: the wrong value paints the hole solid and reports success.

  • BlendFillRuleTest proves the three claims separately — that even-odd leaves the hole, that the same path under the default rule does not, and that the rule does not leak into the next fill. The second is what says the new call is doing the work rather than the geometry having changed underneath it.

  • An inner box-shadow is still not implemented and this does not bring it closer in kind, but it does bring it closer in parts: inset is the same band stack with the hole and the shape swapped over, and both halves now exist.

What to write instead

A drawing that needs a hole builds both sub-paths into one path and calls Frame.fillPathEvenOdd. It does not reverse the inner sub-path and hope: under the rule Blend2D starts with, a reversed sub-path inside an outer one is still filled, and the mistake looks like a shading bug rather than a winding bug.

428. A resampled copy is not a scaled blit

Date: 2026-09-19

Status

Accepted. Adds the one image operation the binding could already perform and did not expose, under the ownership rule ADR-0283 set for the decoder.

Context

book/src/TODO.md recorded the absence and the reasoning for it:

No Image.scaled(...). Scaling happens at the blit, which is where the destination size is known. A resampled copy — for a thumbnail written to disk — is a different operation and would need a filter argument that bl_image_scale has and nothing has asked for.

The first sentence is still right and is the reason this method is easy to misuse. Drawing an image smaller does not go through here: Frame.drawImage resamples on its way onto the surface through bl_context_blit_scaled_image_d and keeps nothing, which is what a picture on screen at a display scale wants (ADR-0157). Going through scaled(...) first would allocate a buffer to throw away a frame later.

What the entry then treats as a reason not to build it — “nothing has asked for it” — is the argument for building it now. bl_image_scale is in the library that already ships. Every other thing an application might want to do to an image’s pixels is either here (decode, encode, read a pixel, blit) or genuinely absent from the binding. This is the one operation the rasterizer can do and the toolkit cannot, and the three cases that want it are ordinary: a thumbnail written to a file, an over-sized paste cut down before it enters a document, an icon resampled once and drawn a hundred times.

The open question was the filter

BL_API BLResult bl_image_scale(BLImageCore* dst, const BLImageCore* src,
                               const BLSizeI* size, BLImageScaleFilter filter);

The filter argument is mandatory in C. The choice exists whether or not a caller is offered it; hiding it means making it on their behalf, silently, once, for every use.

The temptation is to pick one, call it “good”, and be done. The reason not to is that Blend2D’s filters are not ordered by quality — they are ordered by what they assume about the image:

  • making a photograph smaller wants as much of the source averaged in as possible, because a source pixel never consulted is detail discarded. Lanczos consults the most;
  • making a 16×16 icon twice as big wants the opposite: the sixteen pixels it already has, doubled, with nothing invented between them. Nearest is the only filter here that invents no colours, and it is the only one that is not simply a worse version of the others.

No single default is right for both, and the mistake is silent in both directions: a nearest-neighbour photograph looks like a bug somebody files, and a Lanczos icon looks like a slightly soft icon that nobody does.

Decision

Bind bl_image_scale. Expose it as Image.scaled(width, height) with a named default, and Image.scaled(width, height, Resampling) for the case that is not the default.

The filter is an enum the caller may pick, not a knob they must. Both halves matter. Offering only the default would make the wrong answer unreachable for upscaled pixel art; requiring the argument would put a decision in front of every caller whose case is the ordinary one. Resampling is a :core enum of four values mapped onto BlendImageScaleFilter by a switch — not by ordinal, so a :core type is not pinned to the order of a :natives one.

The default is LANCZOS, chosen for the operation the method exists for rather than as a general “best”: a thumbnail is a downscale, and a downscale wants the widest neighbourhood. BICUBIC is in the enum beside it precisely because that argument reverses when the factor goes the other way.

BL_IMAGE_SCALE_FILTER_NONE is not bound. It is the enum’s zero value — the absence of a filter — rather than one of them, and a constant nobody can usefully pass is the same dead weight BlendStrokeJoin refuses for the two miter variants it leaves out.

The result is a value, like every other Image. This is the second place in the toolkit where Blend2D owns pixels, after the decoder, and it is held to ADR-0283’s discipline word for word: BlendScaledImage destroys the destination on the call that made it, once its rows are copied into a PixelBuffer Java owns. The exception is weaker than the decoder’s — a decode must allocate because the size of a PNG is inside the PNG, whereas a resample’s size is the caller’s own argument — but it is forced all the same, because bl_image_scale resizes the destination itself and has no form that writes into a buffer somebody else owns.

Asking for the size it already is returns this. An image is a value, so there is nothing a copy could be used for that the original cannot, and a caller normalising a batch to one size should not pay a buffer for the ones that already are it.

What is resampled is premultiplied

The source is premultiplied BGRA, which is what every buffer in this toolkit is, and Blend2D gives the destination the source’s format — so no conversion happens and none is asked for. That is also the correct space to filter in: averaging straight alpha weights a fully transparent pixel’s colour as though it were there, which is what puts a dark halo around a resampled cut-out.

The cost is named rather than hidden. A filter with negative lobes — Lanczos, bicubic — can overshoot at a hard edge and leave a channel above the alpha it is premultiplied by, which is not a representable colour. Image.argb clamps on the way out, as it already did for the rounding premultiplied storage costs.

Consequences

  • Image.scaled(int, int) and Image.scaled(int, int, Resampling) exist, with Resampling and ImageScaleException beside them in io.github.digitalsmile.goldberry.image. The exception is separate from ImageEncodeException because the two say different things to an application: an encode that refuses WebP’s size limit will refuse again, and a resample that could not allocate may not.
  • The ABI is 14, shared with ADR-0427, which lands in the same build. bl_image_scale joins the export list and the four BL_IMAGE_SCALE_FILTER_* enumerators join the layout table — positional values, so one inserted upstream shifts the rest and resamples with a filter nobody chose while returning BL_SUCCESS.
  • BlendScaleTest pins the binding at the level where it can be wrong silently: that the BLSizeI crosses the right way round (an 8×2 is not a 2×8), that NEAREST doubles a checkerboard into exact blocks of four and invents no colour, and that BILINEAR on the same input does not — which is what proves the filter argument reaches the library rather than being ignored. ImageScaledTest covers the value half: a flat colour survives a downscale exactly, alpha survives it, the result encodes to a PNG, and scaling twice needs no lifetime management at all.
  • Nothing in the toolkit calls it. That is the state the entry described and it is unchanged: no widget resamples an image, and Frame.drawImage still should not. This is public API for applications, and the javadoc opens by saying which of the two operations a reader probably wants.
  • An Image.cropped(...) is now conspicuous by its absence in a way it was not before. It needs no new symbol — bl_context_blit_image_d already takes a source rectangle (ADR-0283) — and is a pure-Java copy besides. It is not built here because nothing has asked, which is an argument this record has just spent four paragraphs declining to accept; the difference is that cropping adds no capability the binding uniquely has.

What to write instead

An image being drawn at a size is frame.drawImage(image, x, y, width, height), which keeps no pixels. Image.scaled(...) is for pixels that outlive the call — and a caller resampling up names Resampling.NEAREST or BICUBIC rather than taking the default, which is tuned for the way down.

429. A light theme’s thumb is a disc with an edge

Date: 2026-09-19

Status

Accepted. Closes the last entry on ContrastTest.MARKS_BELOW_FLOOR, which ADR-0239 opened with sixteen and ADR-0258 left holding one.

Context

docs/design-system.md §1.2 states the problem and declines to solve it:

One exception is left and it is arithmetic: the light theme’s slider track sits between a white thumb and a dark accent fill, and clearing 3:1 against both needs its luminance at once ≤ 0.300 and ≥ 0.688. What that asks for is a sentence §3 does not contain about what a light-theme thumb is — a border, or a fill that is not white — and until it does, MARKS_BELOW_FLOOR carries it alone.

The arithmetic is done and it is right. --gb-slider-thumb-bg is #ffffff on --gb-slider-track-bg, which is --nord4: 1.35:1, the worst mark measurement in either theme. Nothing about the track can fix it, because the track is squeezed from both sides.

What §1.2 leaves open is the choice between the two fixes, and it is not a toss-up. The second one does not work.

Why a fill that is not white fails

Run the same arithmetic on the thumb instead of the track. The groove is --nord4 at relative luminance 0.727, so a thumb that clears 3:1 against it needs luminance ≤ 0.209.

Now look at what else the thumb is lying on. A slider’s thumb is centred on the value, which means it straddles the boundary between the fill and the rest: half of it is over --gb-slider-fill-bg — the light accent, #5c7ea8, luminance 0.200 — and half is over the bare groove. A thumb at 0.209 and a fill at 0.200 are the same colour to a tenth of a percent. The disc would clear the groove and vanish into its own filled half, which is not an improvement; it is the same failure moved to the other side of the thumb’s centre.

Clearing both wants luminance ≤ 0.083, which is #525252 or darker: a near-black disc on a light theme. That is a shape the theme file has already rejected twice, in its own words, about the switch:

a thumb the colour of the window reads as a hole rather than as a disc

the result read as a hole punched through the switch rather than a disc sitting in it

Both of those were ADR-0075, paid for twice. A third instance was available for free and is declined here.

The thumb is the one shape in the system with two backdrops

That is the whole of it. Every other entry in MARKS is a mark on one box — a tick on a checkbox’s fill, a dot on a radio’s, an arc on a knob’s track — so one colour answers one pair and a ramp slide is always available. A slider’s thumb answers two pairs at once, and there is no colour that answers both, because the two backdrops are 3.6:1 apart from each other by design.

A shape with two backdrops needs two means of being seen. §1.2 already allows exactly that, and ContrastTest.everyControlIsDistinguishable has been written on it since it was written:

a control offers two means of it at once: a fill that differs from the surface, and an edge drawn around it. WCAG asks that some means clears the floor

Decision

The thumb keeps its white fill and gains a 1px edge, --gb-slider-thumb-border — and the marks sweep credits the better of fill and edge, as the boundaries sweep already does.

slider-thumb {
  width: var(--gb-slider-thumb-size);
  height: var(--gb-slider-thumb-size);
  border-radius: 8px;
  border: 1px solid var(--gb-slider-thumb-border);
  background: var(--gb-slider-thumb-bg);
}
/* nord-light */  --gb-slider-thumb-border: var(--nord3);   /* 5.46:1 */
/* nord-dark  */  --gb-slider-thumb-border: transparent;

Four things about the shape.

The fill is what carries it against the accent and the edge is what carries it against the groove. This is not a belt-and-braces argument; it is the only division of labour that works, and it falls straight out of the two-backdrop analysis above. White is the best available answer to the accent fill and the worst available answer to the groove, so the thing to add is an answer to the groove and the thing to keep is white.

One token, not three. The light theme has three thumb fills — --gb-slider-thumb-bg, -hover and -active — and re-choosing the resting one would have left the other two to be re-chosen after it. Worse: -active is literally var(--nord4), which is that theme’s groove, so a pressed thumb has measured 1.00:1 against the track it is being dragged along for the whole of this control’s life. An edge covers all three states without a ramp moving at all.

That measurement is new, and it is the second thing this change buys. The marks sweep looked only at resting fills, so the worst pair in the file was the one nobody had asked about. All three states are swept now.

transparent on the dark theme, and the token exists anyway. nord6 on nord3 is 6.40:1 and needs no help. The token is declared in both files for --gb-toggle-thumb-bg-checked’s reason — “two tokens because a theme may need two, not because this one does” — and because a rule in controls.css cannot ask one theme for a border and not the other. A transparent border composites to nothing: BoxPainter fills the whole border box before it strokes, so the dark theme’s disc is the same 16px of nord6 it always was. The golden proves it, by not moving.

Drawn inside the border box. BoxPainter insets the stroke path by half the border width, which is what border-box sizing means. The thumb is still 16 wide, so slider-ticks’ half-a-thumb inset still lands on the thumb’s centre and SliderGeometryTest still passes untouched. An edge drawn outside would have been 18px of thumb and a scale pointing two pixels wrong.

Consequences

  • MARKS_BELOW_FLOOR is empty, and is asserted to be. §1.2’s non-text sentence has no exceptions left. ContrastTest.MARKS gained three rows — slider thumb, :hover and :active — and an optional edge, measured as max(fill, edge) with NaN meaning “this shape has no edge to credit”. A transparent edge is an absent edge, not a failing one: alpha is ignored by Contrast.ratio, so crediting it would score black and hand the thumb a 3:1 it has not got.
  • Two goldens moved, both light-theme, and both show one thing: white discs gaining a slate ring. slider-light (520 of 60000 pixels, 0.87%) and controls-on-surface-light (104 of 120600, 0.09%). The entry that asked for this expected twenty-two, which is what a ramp slide would have cost — the accent and the groove appear in every image with a range control in it. An edge touches only the thumb, and only where a thumb is drawn.
  • The light theme’s slider looks slightly more drawn and slightly less soft. That is the price and it is visible in slider-light: at 0% the thumb used to be a white disc on a pale groove that a reader had to look for, and is now a disc.
  • Nothing in docs/design-system.md §3 says this yet. The sentence it needs is in “What to write instead” below; §3’s slider row should gain the edge beside the two metrics it already pins.

What to write instead

§3’s slider row today is a size and a radius and nothing else:

| `slider` | track 4; thumb 16 (`full` radius); hit ≥32 cross-axis |

A thumb is not only a size. It is the one shape in the system drawn across two backdrops at once, and the row has to say what carries it against each:

A thumb is a disc with a 1px edge, and it needs both: it is centred on the value, so it lies half over the accent fill and half over the bare groove, and no single colour clears §1.2’s 3:1 against both — the light theme’s white fill is 1.35:1 on its groove, and every fill dark enough to clear that is within a hair of the accent fill’s own luminance. The fill answers the accent, the edge (--gb-slider-thumb-border) answers the groove, and a theme whose fill already clears its groove sets the edge to transparent rather than inventing a decorative one.

430. A slider maps the pointer over its travel

Date: 2026-09-19

Status

Accepted. Finishes what ADR-0079 started and ADR-0080 narrowed, by spending the door ADR-0251 opened.

Context

A slider’s thumb is a 16px disc centred on the value, so its centre travels from 8px to width − 8. Half a thumb at each end is unreachable by construction: that is what it means for a disc to be centred on a point.

The pointer was mapped over the track’s full width:

return isVertical() ? 1 - event.local().fractionY() : event.local().fractionX();

So a press at x = 8 — the leftmost position the thumb’s centre can occupy — asked for 4% of the range, and the disc drew itself at 4% of the travel, 7px to the right of the finger. At x = width − 8 the same in reverse. The error is zero at both extremes (0 and width clamp to min and max either way) and worst at exactly the two points where the thumb is when the value is at an end: 8px, in a control where 8px is half the thing being dragged.

It shows up as a readout that disagrees with the grip. A user drags to the right edge of the track, sees the thumb stop short of the finger, and lets go early.

The door that was open, and the one that was shut

The entry that asked for this had already found the shape of the fix and where it jammed:

“a widget being told a resolved metric” is Paints.Context.length and has been since ADR-0251 — but it is a render-time read, and the pointer arrives at onPointer where there is no context to ask.

That is exactly right, and scroll had already hit it and solved it: --gb-scroll-line is resolved in ScrollViewport.render and banked into ScrollState, because a wheel arrives where there is no cascade. The bargain is that the number is one frame late, and it is sound because a paint always precedes an input — there is no frame in which a finger reaches a tree that has not been drawn.

A Slider could not take that bargain. It was a record implementing Widget.Leaf, with nowhere to put a number that outlives a rebuild.

The tick marks were never wrong

Worth saying plainly, because it is the part that looks like it should have been broken and was not. slider-ticks is padding: 0 8px — half a thumb, written in the stylesheet immediately below the thumb’s own width, with a comment saying so — so a mark has always named a position the thumb’s centre can actually reach, and SliderGeometryTest.thumbCentresOnTheEndMarks has always asserted it at both ends. The marks and the thumb agreed with each other. What disagreed with both was the finger, and a scale is exactly the arrangement in which that becomes visible: the user drags until the thumb is on a mark and the readout says something else.

Decision

slider is stateful, on scroll’s arrangement, and the pointer is mapped over width − thumb.

Three nodes where there was one:

Slider          the value. A record, Stateful, styles nothing
└── SliderState the banked thumb width, and nothing else
    └── SliderControl   `slider` in the cascade; Styled, Paints, Handles, Semantics
        └── SliderTrack …
private double travel(double along, double extent) {
    if (extent <= 0) {
        return 0;
    }
    var length = extent - thumb;
    return length <= 0 ? clamp01(along / extent) : clamp01((along - thumb / 2) / length);
}

Four things about the shape.

The split is scroll‘s and tabs’, and it is not optional. A stateful widget that was also styled would put two slider nodes in the cascade, one inside the other, and every rule would apply twice (ADR-0109, ADR-0116). WidgetParityTest already knows this pattern and checks parity against what a widget describes rather than against the widget, so slider passes its “exactly one node carries this type” assertion unchanged.

SliderControl holds the Slider rather than copying it. ScrollViewport takes thirteen separate components; a second record with eleven of Slider’s would be eleven chances to forget one, and every one of them would be a value that draws perfectly and is wrong. The arithmetic — resolved, fraction, snap, clamp, stepFrom, ask — stays on the record where it always was, and only the two handlers and the render move.

It is state about the stylesheet, not about the value. ADR-0063 is untouched: a slider still owns no value and a drag still travels up as a request. What SliderState holds is a measurement, which the application has no opinion about — the same category as ScrollState.line, and a different category from ScrollState.offsetY.

A track with no travel falls back to the position fraction. When extent ≤ thumb there is nowhere for the centre to go and no mapping is “correct”; what is available is a mapping that is still monotonic and still reaches both ends, which are the two properties anything mapping a pointer to a value has to keep. It also covers PointerEvent.Local.UNKNOWN — the zero-sized local a widget poked with no layout behind it receives — which reads as the start of the track exactly as it did before.

Consequences

  • The thumb is under the finger everywhere on the track, on both axes. Scale is untouched: the curve is applied to the fraction, and only the fraction changed.
  • --gb-slider-thumb-size is a token now, and slider-thumb sizes itself from it, so the disc the user drags and the arithmetic the pointer goes through are one declaration rather than two numbers that happen to agree.
  • Two numbers still have to follow it by hand, and this is the cost: the thumb’s border-radius, which is half of it, and slider-ticks’ inset, which is also half of it. §8’s subset has no calc() — CssLength.parse takes a single token and a calc(…) is many, so padding: 0 calc(var(--gb-slider-thumb-size) / 2) parses, resolves to nothing, and is dropped with a warning. SliderTest.ticksAreInsetByHalfAThumb already asserted the relation rather than the number and now carries the weight of it; theTokenAgreesWithTheDefault holds the CSS declaration against SliderControl.THUMB. An author who moves the token and not the inset gets a failing test rather than a scale that points eight pixels wrong.
  • A slider is an element deeper than it was. Anything that reached for the slider node by taking a widget’s own element now has to walk to the first styled one — SliderTest.styleOf and SliderGoldenTest.PseudoState both do, and the second is the interesting one: a :hover forced onto the composition is a :hover no rule can see. The router has no such problem, because it dispatches to the element that handles, which is the styled one. The dark interaction golden not moving is what says so.
  • No golden moved for this change. The pointer mapping is not drawn.
  • Slider is no longer Styled, Paints, Handles or Semantics. It is still Attributed<Slider> and Bindable<Slider>, so every chained call an application writes still compiles and #gain still reaches the node, which is the whole point of a composition handing its attributes down.
  • Knob has the same class of problem and is not fixed here. Its geometry is angular rather than linear and its mapping is a drag delta rather than a position, so nothing above transfers; it wants its own entry.

431. A translucent fill is measured on the frame

Date: 2026-09-19

Status

Accepted. Fills the four holes ADR-0087 opened and ADR-0239 and ADR-0241 widened without being able to close.

Context

ContrastTest resolves a background and a color through the real cascade and divides. That is the right check and it has found real failures — seven button pairs, five semantic hues, twelve control boundaries — and it has, in four places, written down a pair it refused to measure:

  • button.ghost, whose fill is transparent and whose hover is a wash;
  • --gb-selection, which is #5e81ac66 and #88c0d04d;
  • a segment’s hover and press, which are the same overlay tokens;
  • button.link, measured as ink alone because the variant itself cannot be swept.

Every one of those exclusions is correct. Contrast.ratio ignores alpha, so a transparent fill measures as black — button.ghost would have scored a comfortable pass on every surface in both themes while guaranteeing nothing at all. A check that pretends to a guarantee it cannot make is worse than the absence of one, and the file says so.

But the exclusions are a hole, and the entry named its shape exactly:

A backdrop-aware check would need the painted frame rather than the cascade, which is a different kind of test.

It is a different kind of test, and the toolkit already has every piece of it. TestFrames hands out a real frame, BoxPainter paints a real box tree into it, and TestFrames.Target.pixel reads a pixel back out. Two suites already read pixels — ChartFrame and TextAreaGutterStripTest. Nothing was missing except somebody pointing them at §1.2.

The other option, and why not

The alternative is to composite in Java: over(argb, backdrop), nine lines, no renderer, no native library, runs everywhere. PlaceholderContrastTest already has exactly that helper, privately.

It is a second opinion about blending, and the first opinion is the one that ships. A test that agrees with its own arithmetic and disagrees with Blend2D about premultiplication, gamma or rounding is a test that reports a number no user will ever see. Rendering costs a native library and buys the actual answer.

Decision

BackdropContrastTest renders a real widget tree over each surface the toolkit paints, reads the painted pixels, and measures §1.2 against those.

Three assertions, and the third is the one that keeps the other two honest.

The label is measured against the pixel behind it. Five surfaces × four probes × two themes = forty pairs, every one of which is a pair no check in the repo could previously express. The backdrop comes off the frame; the ink comes off the cascade, and deliberately. Ink is opaque everywhere in the toolkit — --gb-text and the ranks around it are palette entries, never washes — so there is nothing about it a paint would decide that a resolution would not, and reading it out of the frame would mean sampling the inside of a 13px glyph, where every pixel is partly the backdrop.

A translucent state has to change the pixel. Not 3:1 — §2.1 asks hover for “one surface step” and designs it to be subtle, and §1.2’s 3:1 is about telling a component from its background rather than a hover from a rest. Asserting 3:1 here would invent a rule the design system does not hold itself to and fail every state in the catalog on the strength of it. What is asserted is the claim the system does make: that the surface steps. The exact ratios are printed, so the size of each step is on the record — the smallest is 1.151:1, a light-theme selection on --gb-surface-2.

That assertion has a concrete failure in view. --gb-overlay-hover is a white wash on the dark theme and a black one on the light theme, and the light theme’s --gb-surface and --gb-surface-raised are both literally #ffffff. Had the light theme taken the dark one’s wash — which is what a single shared “overlay” token would have forced — hovering a ghost button on a panel, a card, a dialog or a toast would have changed not one pixel, and nothing in the cascade could have reported it, because the declaration would have been present and correct on both.

The surface list is the themes’, and is checked against them. “A ghost button on any surface” is what controls.css claims, and a check over any surface is a check nobody can satisfy: an application may paint a photograph behind a toolbar. What the toolkit can be held to is the surfaces it paints itself, and there are five — --gb-bg, --gb-surface, --gb-surface-2, --gb-surface-raised, --gb-surface-sunken. That list is pinned, and theSurfacesAreTheOnesTheThemesDeclare reads both theme files for their --gb-surface* declarations and asserts the set, so a sixth surface fails a test until the sweep covers it. This is noBareHueDrawsInk’s technique, in the same file’s register and for its reason: a claim about what a stylesheet contains has to be checked against the stylesheet.

Each backdrop is a stack rather than a token, which is the second thing the cascade could not do. --gb-surface-sunken is rgba(0, 0, 0, 0.22) and rgba(0, 0, 0, 0.07): a text field’s fill is itself a wash over whatever holds the field. Resolving it gives a colour with an alpha channel and no answer; painting it gives #2e3440 and #ededed, numbers written down nowhere.

Consequences

  • Nothing failed. Forty pairs, all above 4.5:1. The four exclusions ContrastTest carries were correct to make and correct to leave: the colours behind them were fine, and there was no way to say so. That is the result, and it is worth having — an unmeasured pass and a measured one are different states, and only one of them survives the next theme edit.
  • Two margins are now on the record. button.ghost:active on the dark theme’s --gb-surface-2 and --gb-surface-raised is 4.79:1, the tightest pair here and 0.29 above the floor; both surfaces are --nord2, so this is a pressed ghost button in a card, a dialog or a toast. --gb-selection on the same two is 5.41:1 — controls.css claims of it that “the label under it does not need a foreground of its own the way a segment’s does on its opaque pill”, which had never been measured and is now true by 0.91.
  • The first draft found three failures and they were its own. INSET sampled three pixels into an unpadded row and took a bite out of the A, which reads as ink-over-fill and moved --gb-selection on --gb-surface from 5.92:1 to 4.14:1 — a plausible number, in the right region, pointing at a real token. A pixel test that samples the wrong pixel is more confident and more wrong than the cascade test it replaces. The guard is two lines: the sampled pixel must equal its right-hand neighbour, because ink is never flat and two neighbours that agree are two pixels of surface.
  • It costs a render per pair — 70 renders across the three tests, about eight seconds — and it skips rather than fails where libgoldberry is not loadable, through RendererRequirement.enforce(). ContrastTest needs no renderer and still covers every opaque pair, so the cheap check stays the broad one and this is the narrow one.
  • Two frames per measurement, not one: the rectangles come from a layout pass and the colours from a paint. Running both into the same frame would composite a translucent wash over itself, which is precisely the quantity being measured, doubled.
  • button.link is still not swept as a variant. Its fill is transparent and it could now be measured the way button.ghost is; it is left alone because its ink is already swept on all three surfaces and the variant adds no second pair. A later entry can take it.

432. A menu is anchored by the name it was opened with

Date: 2026-09-19

Status

Accepted. Closes the first of the two TODO.md entries ADR-0270 left behind, and is the prerequisite for ADR-0433, which is the one worth having.

Context

ADR-0270 gave a popup the ability to follow a widget that scrolls under it, and then gave it to exactly one caller. Its own last-but-one consequence says so:

A popover follows; a menu and a select do not. Following is a property of having been opened by id […] Menus and SelectState resolve their anchor to a rectangle themselves because they need a minimum width and a Fit as well, and there is no Host overload that takes all three.

Host had five popup overloads. Four of them take a LogicalRect, and they are the ones that accumulated the parameters: a floor under the width for a dropdown (ADR-0145), a Fit so content taller than the screen can be handed back wrapped in a viewport (ADR-0179). The fifth takes a String and takes nothing else. So a caller with a name and a viewport had to choose, and both of them chose the viewport — Menus.open did

host.anchor(anchorId).flatMap(anchor -> host.popup(content, anchor.painted(), placement, 0, VIEWPORT))

which is the by-name overload written out by hand, minus the one thing the by-name overload is for. A menu hanging off a heading in a scrolling list stayed where the heading used to be drawn, and the reason was a missing signature.

The entry that recorded this was right that nothing had asked for it, and gave the reason a dropdown gets away with it: a menu is dismissed by a press elsewhere, and the wheel over an open one scrolls the menu’s own list rather than the window beneath. The gesture that exposes the gap is narrow — a wheel over the owner window while a menu is up, or an application that scrolls its own content while one is — and it has never been reported.

It is still worth building, and the honest reason is not this entry. It is the next one. A popup that cannot follow cannot be asked what to do when the thing it is following leaves, and that question — the anchor scrolls out of sight and the menu is left pointing at a widget that is no longer drawn — is a real defect with three plausible answers. ADR-0433 picks one. It can only pick one for the popups that follow, so this ADR is what decides how much of the catalog that decision covers.

Decision

One overload, taking a name and the other two things

Optional<Popup> popup(Widget content, String anchorId, Placement placement, float minimumWidth, Fit fit);

Menus.open(host, anchorId, …) passes the name through it, and a menu opened against a heading now travels with that heading for the same reason a popover does: the name is a question the next painted frame can answer again, where a rectangle is only ever the answer it already was.

It is a default, which minimumWidth deliberately was not

ADR-0145 added minimumWidth as an interface method rather than a default, “so every implementation says what it does with the floor”, and paid two test stubs for it. This one goes the other way, and the difference is what the parameter is. A floor is something an implementation has to do — it lands in the middle of a two-pass measurement, and a host that ignored it would be silently wrong. A name is not: resolving it is anchor(id) followed by the rectangle overload, and an implementation that wrote that out by hand could only write it out differently. That is precisely the duplication Menus was carrying.

What a real Launcher adds on top of the default is the remembering — the entry in placements that carries the name rather than the rectangle, which is what replacePopups re-resolves. That is not behaviour a caller can observe on a host with no popup windows at all, which is what every other implementation of Host in this repository is.

select is not a customer, and the entry was wrong about why

The entry, and ADR-0270 before it, names SelectState alongside Menus as a caller that resolves its anchor by hand because it needs a width and a Fit. SelectState does need both. It does not resolve an anchor by hand, and giving it this overload changes nothing, because a select has no id to open by.

SelectField is Located, and its own class comment has said since it was written why that was chosen over an id:

Anchoring by id was the other way and it is worse here: a select that a document gave no id would have to be given a generated one to be able to open itself, and two of them in one window would then depend on that generation being unique.

ADR-0145 says the same thing from the other end — “no new plumbing, and no id to anchor by”. So the rectangle a select opens against does not come from Host.anchor at all; it comes from the frame, through located(self, clip), and the control keeps it in a field. That is a different mechanism with a different freshness, and it is not fixed by an overload.

The consequence is that a select still does not follow, and the decision in ADR-0433 does not reach it. That is stated rather than repaired here: repairing it means either generating ids for anonymous controls, which ADR-0119 refused with reasons that have not changed, or a second anchoring mechanism, which is a decision of its own and nothing has asked for one.

Alternatives considered

  • Following by id only when a document wrote one. A select with an id would follow and its neighbour without one would not: the same widget with two behaviours, decided by something a stylesheet author wrote for an unrelated reason, and no way for a user to tell which they have. A bug that is intermittent across instances is worse than one that is uniform.
  • A Supplier<LogicalRect> on the popup instead of a name. General enough for select to hand over its Located rectangle, and it is the wrong generality twice: the supplier closes over the widget state that opened the popup, so a popup outliving its opener holds it alive; and the rectangle it would supply is still the one located last reported, which ADR-0270 already rejected as one frame late at the start of a scroll.
  • Making Menus keep the name itself and re-place the popup by hand. It would work, and it would be the second implementation of replacePopups — in a module that cannot see the window’s paint, timed off a build rather than off a frame. The facility that has the frames is the one that should be asked.
  • Leaving it, as the entry proposed. Defensible on its own: the gesture is narrow and nobody has reported it. Not defensible once ADR-0433 is on the table, because a decision about what a following popup does when its anchor goes is worth very little if one widget follows.

Consequences

  • A menu follows its anchor, and a context menu still does not: one is opened against a name and the other against the point the pointer was at, which is a rectangle and always was. Menus.open(host, LogicalRect, …) is unchanged.
  • A submenu still anchors to a rectangle, and must: it hangs off a row inside the popup above it, which is not a node this window painted and not something Host.anchor can find. The private open in Menus now takes how to open as a function rather than an anchor, so the name form and the rectangle form share the rest — the rows have to be described against an OpenMenu that does not exist until the popup does, and that wiring is the same either way.
  • Host gained a default and lost a method body. The three-argument by-name overload is now a default too, delegating to this one with a zero floor and no Fit, so the two cannot drift.
  • More frames end in a re-placement. replacePopups runs at the end of any frame with an id-anchored popup open, and menus are now in that set. The cost is one anchor(id) scan of the last capture per open popup per frame, and only while a menu is showing.
  • A select list still stays where the field used to be. Named above, not fixed here, and now the only widget in the catalog for which that is true.

433. A popup whose anchor leaves goes with it

Date: 2026-09-19

Status

Accepted. Closes the second of the two TODO.md entries ADR-0270 left behind, and depends on ADR-0432 for how much of the catalog it reaches.

Context

ADR-0270 taught a popup to follow a widget that scrolls under it and recorded, in its own last consequence, that it had not said where the following stops:

A popup whose anchor scrolls out of sight follows it out of sight, clamped to the work area rather than dismissed. Whether it should instead close is a behaviour decision nobody has asked for.

The mechanism is exact about the wrong thing. replacePopups re-resolves the name against the last painted frame, asks Placement where the popup goes, and Placement’s job is to keep it inside the display’s work area — so an anchor four hundred pixels above the top of its viewport produces a placement four hundred pixels above the top of the screen, which is clamped back down to the work area’s edge. The popup therefore does not follow its anchor out of sight at all. It stops at the edge of the screen and stays there, beside whatever happens to be drawn at that edge now, which is the thing it does not belong to.

That is a menu pointing at a widget that is no longer drawn, and it is the defect. Everything else on the entry is about which of three repairs to make.

What the clip can actually tell you

The entry says “the region carries the clip that would answer is it still visible, so the mechanism is there”. That is true, with one boundary worth stating because relying on the wrong half of it would be silent.

HitTest.Region carries the clip the box was painted under, in the frame’s coordinates, and Region.contains already tests it first — which is what makes “not visible” and “not clickable” the same thing for a pointer (ADR-0114). Asking it of the whole rectangle rather than of one point is clip.intersect(painted), and the answer is exact for the case that matters: a translation is mapped exactly, so a row scrolled past the top of an axis-aligned viewport has an empty intersection and nothing else does.

Two things do not fall out of it:

  • A row scrolled entirely away is still reported. ADR-0114’s “an empty clip stops the walk” is about a subtree whose own clip went empty — a viewport that has been scrolled off, not a row inside one. The row is handed to the visitor under its parent’s clip before that check is reached, so Host.anchor(id) finds it, with a rectangle a long way outside the clip beside it. A caller that trusted the id resolving at all would conclude the anchor was fine.
  • A box with no clipping ancestor is painted under Clip.NONE, which is infinite and admits everything. forEachPlacedBox starts the walk at Clip.NONE rather than at the frame, so the clip alone would call such a box visible for ever, wherever it had got to. Nothing in the catalog scrolls a window’s root without a viewport in the way, so this is a gap rather than a bug — but it is one line to close and it is where “no longer drawn” stops being a figure of speech.

So the predicate is the clip and the window’s own rectangle, and the second half is the caller’s because a region does not know what window it came from.

Decision

Close it

replacePopups asks, of every popup anchored by name, whether the anchor is still somewhere a user could look at it. When it is not — no intersection with the clip above it, or nothing left inside the window, or no region under that id at all — the popup is closed.

The three candidates, and why the other two lose:

Pin it to the viewport’s edge is what happens today, arrived at by accident rather than chosen: Placement clamps into the work area and the clamp is the pin. It keeps the popup visible and on that basis reads as the gentlest of the three. It is the worst, because it is the only one that makes the toolkit tell a lie. A menu is a statement about the thing it points at; parked at the top of a scroller it points at whatever row scrolled up to meet it, and a user reading it has no way to know it is not about that row. A bug that changes what the user believes is worse than one that costs them a gesture.

Hide it and bring it back keeps the most: the submenu chain, the typeahead buffer, a multiple select’s half-finished set. It is also the only one of the three that can fail silently, and it fails in the place a toolkit can least afford to. A popup holds the keyboard — Menus focuses a row on opening, and Launcher.topmostKeyboardPopup routes keys to the topmost popup that wants them — so an invisible popup is an invisible keyboard target. Down moves a selection nobody can see and Enter runs a command nobody chose. Making hiding safe means dropping the focus on the way out and restoring it on the way back, which is a focus-restoration problem of its own, and the platform half is not free either: a hidden popup is an unmapped window, and remapping it is a restack that most window managers will not give back exactly.

Close it costs the user their in-progress interaction, and that is its whole cost — stated plainly rather than argued away. Three things make it the right one anyway.

It is the only candidate whose failure the user can see and undo. A closed menu is gone; the anchor is one scroll back the other way and the menu is one press after that. Neither of the other two failures is recoverable by a user, because in neither case can the user tell they are in it.

It is what light dismissal already means, one gesture over. A popup is dismissed by interacting with the window behind it, and the wheel is the single gesture the router deliberately lets through to that window while a popup is up. Scrolling the anchor out of the viewport is not an accident of the input model; it is the user using the thing underneath. Closing there makes the rule “a popup goes away when you go back to the window it came from” true in the one case where it was not.

It is honest about what the toolkit knows. The popup exists because a particular widget was under the pointer; when that widget stops being drawn, the toolkit’s reason for the popup is gone, and it has no second reason to fall back on. Host.anchor has said as much since it was written — “a rectangle for something invisible would be a lie a menu would then point at”. That sentence is a rule about opening. This makes it a rule about staying open.

Partly visible is visible

The threshold is an intersection, not a containment. A menu hanging off the last twenty pixels of a row still points at something the user can see, and a rule that closed it there would make small scrolls destructive and would put a hair trigger on the most common gesture. The popup goes when none of the anchor survives the clip.

The stack above goes too

A submenu is anchored to a rectangle inside the menu it came from — it must be, because that rectangle is not a node this window painted (ADR-0432) — so it has no name to re-resolve and would never notice its root going. Left alone it would be a panel of commands floating over nothing.

So closing a popup for this reason closes every popup opened after it. Open order is containment order here: a popup opened while another was up is either its submenu or something standing on it. That is the same sweep dismissPopups makes for a press, restricted to the tail.

lightDismiss(false) is not consulted. It says that input does not close this popup — a menu that stays up while its owner is dragged, or one under a test’s control — and an anchor that stopped being drawn is not input. A tooltip outliving the menu item it was describing is the same orphan by a shorter route.

Alternatives considered

  • Pin, and mark it. Keep the popup at the viewport edge and give it a class a stylesheet could dim. It keeps the lie and adds a convention nobody would write the stylesheet for; and a dimmed menu is still a menu you can click.
  • Hide, and drop the keyboard. The safe version of hiding. It buys back the in-progress interaction at the price of a focus-restoration mechanism, a platform remap whose result the window manager decides, and a state — open, holding a tree, not on screen — that nothing else in the toolkit has. Worth revisiting if a real interaction is ever lost to this; the recorded cost of closing is the thing that would justify it.
  • Close on the first frame the anchor is missing from the capture, without the clip test. Simpler, and it fires when a rebuild has not yet painted the anchor — the capture is the last painted frame’s, so an id genuinely absent from it is genuinely not drawn, but this would also make any future culling of off-screen subtrees into a menu-closing event. Testing the clip as well is what keeps the rule about what the user can see rather than about what the walk happened to visit.
  • Closing rectangle-anchored popups too, by remembering the clip the anchor was under at open time. There is nothing to re-resolve: the rectangle was the whole of what the caller said, the caller may have computed it from something that is not a widget at all — a context menu’s anchor is the point the pointer was at — and a popup that was never following cannot be discovered to have lost what it was following.

Consequences

  • A menu closes when the heading it hangs off scrolls out of its list, and so does a popover, and so does anything else opened by name. It is the first behaviour in the toolkit that closes a window because of something a paint discovered.
  • A select list is not covered. It is anchored to a rectangle reported by Located rather than to a name, for reasons ADR-0119 gave and ADR-0432 restates, so a field scrolled out from under an open list still leaves the list where the field was. That is now the only widget in the catalog for which the original defect survives, which is a better place for it to be than spread across three.
  • HitTest.Region gained isVisible(), which is contains asked of the rectangle instead of a point. It answers about the clip only, and says so: the window is the caller’s to know, and Launcher.stillOnScreen is where the two halves are put together.
  • An anchor that is merely not painted this frame closes its popup. That is deliberate and it is the same condition Host.anchor refuses to open against. If the render tree ever learns to cull whole off-screen subtrees from the capture, this becomes a false positive, and isVisible is where the distinction would have to be made.
  • One existing test was asserting the defect. ADR-0270’s followsAScrollingAnchor scrolls an 80-pixel anchor by 120 and asserts the menu travelled the whole way — with the anchor by then entirely above the top of the window. That was the following working, and it is the exact picture this ADR calls wrong. It now scrolls by 60, which keeps twenty pixels of the anchor drawn and keeps the test about what it was about; the limit is PopupAnchorVisibilityTest’s subject instead. Worth recording because it is the only evidence that this decision changes shipped behaviour rather than filling a hole.
  • Five tests in PopupAnchorVisibilityTest now depend on a viewport that really clips. PopupLifecycleTest’s Scrolled translates without clipping, which is why its anchor could scroll for ever and never leave; the clipping arrangement — the clip on the outer box and the translation on the inner one — is the only one that behaves like a scroll view, because a clip set on the translating box would move with the content it is supposed to be cutting off.

434. Every check sweeps 1.25, and nothing sweeps 1.75

Date: 2026-09-19

Status

Accepted. Extends ADR-0162, which built the sweep and chose its two multipliers, and ADR-0157, which is the bug the sweep exists because of.

Context

Every one of the 246 committed goldens — 208 in :widgets, 21 in :example, 11 in :core, 6 in :html — is drawn again at 2× and 1.5× its own scale and checked for describing the same picture. TODO.md recorded the hole in that:

What no test at any scale covers is a fractional scale other than 1.5 — 1.25 and 1.75 are ordinary Windows settings and neither is exercised.

:widgets holds at both — ./gradlew :widgets:test -Dgoldberry.golden.scales=2,1.5,1.25,1.75 passes over its 208 images, and so does :core:test. :example does not, and that turned out to be the interesting part of this decision rather than a footnote; it is worked through below. So the question was never quite “does it pass”: it was which multipliers belong in every developer’s check, and what the one image that fails is actually saying.

What a multiplier actually buys

Not “a scale somebody uses”. What the sweep exercises is Yoga’s rounding: a point scale factor rounds every computed edge to a whole device pixel, so what a multiplier m samples is the set of sub-pixel offsets an integer logical coordinate can land on — k·m mod 1 over integer k. Written out:

multiplieras a fractionoffsets visited
22/1{0}
1.53/2{0, ½}
1.255/4{0, ¼, ½, ¾}
1.757/4{0, ¼, ½, ¾}

That table is the decision. 2 never lands between pixels at all, which is exactly why it catches a doubled subtree and catches no rounding. 1.5 adds halves. 1.25 adds quarters, which strictly contain the halves and reach two offsets nothing else in the list reaches. And 1.75 is the same four quarters in a different order — a different denominator would have been a different question, and 7/4 and 5/4 have the same one.

The magnitude argument does not rescue it either. Whatever 1.75 does by being large — a hairline rounding up rather than down, a raster allocated a pixel wider — is bracketed by 1.5 below it and 2 above it, both already in the list.

What it costs

The wall-clock A/B that prompted this — :widgets:test --rerun-tasks at 3m47s with four scales — is not a measurement of the sweep. Re-run on this machine it gave 97 s for two multipliers, 98 s for three, 139 s for four, and 101 s for no sweep at all: the whole-invocation number is dominated by compilation and by whatever else the machine is doing, and the sweep is inside its noise. That is worth stating plainly, because it is the number somebody will quote.

Measured properly — JUnit executor time, the *GoldenTest classes alone, minimum of four runs:

multipliers:widgets (187 goldens swept):example gallery (21 goldens)
none2.4 s3.3 s
23.5 s5.9 s
2, 1.54.4 s8.3 s
2, 1.5, 1.255.3 s11.2 s
2, 1.5, 1.25, 1.756.3 s13.5 s

Dead linear in both, at about 0.95 s per multiplier in :widgets and 2.5 s per multiplier in the gallery — 5 ms per widget golden and 120 ms per gallery golden, which is the ratio between a 200-point control and a 1200×1720 screen. Across the whole repository a multiplier is about four seconds of check.

The one image that fails, and what it is telling us

gallery-canvas misses at 1.25× by 13,278 pixels of 1,080,000 — 1.229% against a 1.200% budget. The first instinct is that a threshold set for two multipliers is simply too tight for a third, and that is wrong. Measured across the whole gallery at 1.25×, every other image lands between 0.001% and 0.107%; the noisiest is gallery-markdown at 0.096%. The canvas screen is not near the edge of the distribution, it is two orders of magnitude outside it, and it always was:

multiplierpixels with no matchworst nearby delta
1.50.605%163
20.743%163
1.751.189%229
1.251.229%229

The diff says why. The disagreement is not spread over the screen — it is concentrated in two kinds of thing, both solid rather than outlined: the three QR codes, and the decoded PNG drawn at its natural size. Everything vector on that wall — the cubics, the arcs, the dashed baseline, the gradient — shows the ordinary faint edge noise every other golden shows.

That is not a geometry fault, and it is not antialiasing either. It is the invariance claim being false. A QR module is a hard-edged square in a dense grid; re-render it at 5/4 and a module boundary rounds to the other side of a device pixel, and a run of modules comes back inverted rather than blurred. The three-by-three neighbourhood search cannot forgive that, and should not: the neighbouring pixel is the opposite colour, which is exactly the signature the search exists to refuse. A raster drawn at its own pixel size is the same story with a resample on top.

So the honest options are two, and one of them is wrong. Raising the budget to 1.5% would buy this one screen at the cost of loosening the check on 245 images that meet 1.200% with a factor of ten to spare — and ScaleInvarianceTest’s own header warns about precisely that: “a threshold set one step too generous produces a suite that runs at three scales and notices nothing.”

The canvas golden is excluded from the sweep instead, through a new GoldenImage.assertMatchesAtOneScale, and the argument is the one TODO.md already makes about DamageTest: a damage rectangle is in physical pixels by design, so an invariance check there would assert something false. A barcode is the same, from the picture’s side rather than the geometry’s.

The cost is real and is not hidden: the paths, strokes, gradient and dashed rule on that wall are facts about logical space and are no longer checked to be. Keeping them would take a sweep over part of an image — a mask, or a per-region budget — which is a mechanism nothing else in the repository needs yet, and building it speculatively to save one screen is not this ADR’s trade.

The nightly is not the answer

The obvious split — 1.25 in check, 1.75 in the nightly — is refused by nightly.yml itself, in its own header:

Deliberately NOT here: the golden images, the unit suite and the static analysis. Those gate every PR in linux.yml, macos.yml and windows.yml, and a check that only runs at night is one nobody associates with the change that broke it.

And there was a golden job there until the 2026-09-18 review deleted it (B5) — whose comment described a scale sweep it did not run and could not have, because -Pgoldberry.skipNative=true builds no rasterizer and every golden skips. Adding a nightly scale sweep three weeks after removing one, for the multiplier that buys the least, would be putting the worst of the four in the one place nobody reads.

Decision

ScaleInvariance.DEFAULT_MULTIPLIERS becomes 2, 1.5, 1.25. 1.75 goes nowhere.

Three multipliers, chosen so that no two share a rounding denominator. The cost is four seconds on check — under 2% of :widgets:test and invisible in its run-to-run variance — for the offset family that 208 widget goldens and 21 gallery screens have never been drawn at, on the display scale more Windows machines are set to than any other non-100% value.

1.75 is a one-line command when the rounding path itself changes:

./gradlew check -Dgoldberry.golden.scales=2,1.5,1.25,1.75

The reasoning is not left in prose. ScaleInvarianceTest computes the offset sets and asserts that offsets(1.25) == offsets(1.75), that the quarters contain the halves, that 2 visits only zero, and that the three defaults are three distinct families. If a change to how edges are rounded ever makes 1.75 a different question, that test goes red and this ADR is wrong in the place it is wrong.

Consequences

  • Every golden in the repository but one is now checked at 1.25, and the corpus passes unchanged. No golden image moved, which is the whole point of the sweep being a second question rather than a second set of files (ADR-0162).
  • gallery-canvas is that one, and is now the only golden here with no scale sweep behind it. GoldenImage.assertMatchesAtOneScale is how it says so, with the measurements and the argument at its call site rather than in a list of exclusions somewhere else. It is worth being uncomfortable about: a golden that opts out is a golden nothing checks at 2×, which is the blindness ADR-0157 was about. The second opinion that screen loses is worth less than the 245 checks a looser budget would have cost, and not much less.
  • The natural-size image tile on that screen deserves its own look, and this ADR is not it. It differs wholesale between scales while the stretched and cropped tiles beside it differ only at their outlines, which is the shape of a pixel size being used where a logical one belongs — ADR-0157’s bug — rather than of a resample. It may be nothing; nobody has checked; the sweep can no longer tell anyone.
  • check costs about four seconds more. The gallery pays three of them, because its images are fifty times the area of a widget’s.
  • -Dgoldberry.golden.scales= still turns the whole thing off, and a comma-separated list still replaces the default — the 2,1.5,1.25,1.75 run above is the same mechanism, not a new one.
  • The four classes of direct pixel assertion TODO.md listed are unchanged and stay unchanged: BoxPainterTest and TextPaintTest carry their own scale cases, DamageTest is excluded because a damage rectangle is in physical pixels by design, and ThreadedPaintTest is about worker counts. Adding a third multiplier does not make any of those four a better idea.
  • Taken with [ADR-0435], check now runs a three-multiplier display-scale sweep over 246 goldens and a two-scale text-scale audit over 28 screen layouts, and the two do not multiply: the audit runs at display scale 1.0 only, on purpose. Combining them would be a third axis over the whole corpus, at roughly the cost of the entire gallery again, to ask whether a label fits its box — a question that is settled in logical units before a device is chosen.

435. A 150% check is a rule, not a picture

Date: 2026-09-19

Status

Accepted. Finishes what ADR-0267 started — it built renderer.textScale and said in as many words that nothing enforced §1.4’s 150% yet — and answers the half of TODO.md’s typography entry that was waiting on a decision. Sits beside ADR-0118, whose single-font gallery turns out to be load-bearing here in a way nobody had noticed.

Context

§1.4 asks that “every component must survive 150% without clipping”. ADR-0267 built the switch: a factor applied where a ComputedStyle becomes a Font, so the text grows and a height: 32px does not. It recorded that nothing enforced the condition, because nothing could — the mechanism had only just arrived.

The obvious enforcement is a golden: the eleven gallery screens, photographed again at textScale(1.5). TODO.md warned against it, and the warning is the whole context:

since text-overflow: ellipsis shipped, some cutting is correct, so “no text is clipped” is no longer the sentence, and a golden of eleven screens at 150% would pin every one of those decisions at once in a picture before anybody had taken them.

That is exactly right, and it is worth being precise about why, because “a golden is expensive” is the weaker half of the argument. A golden asserts this is what it drew, which a wrong picture satisfies as well as a right one. Before text-overflow existed the gap was survivable: the rule was “no text is clipped”, a reviewer could check eleven images by eye once, and the images then held the answer. It stopped being survivable when a cut became a legitimate outcome. At 150% some labels are supposed to end in …, some are supposed to wrap, and which is which is a decision per label — three hundred of them across the gallery. A photograph would freeze all three hundred in one commit, in a form nobody reviews, before a single one had been made.

And there is a second thing the entry could not have known. The gallery’s goldens are taken with WidgetRenderer’s single-font constructor, whose paint context is:

this.paintContext = context(style -> font);

The style is discarded. The text scale is applied to a style — that is ADR-0267’s central design choice, and the reason an em chain does not take the factor once per level. So textScale is a no-op with the one-font renderer. A 150% golden of the gallery, taken the way the gallery’s goldens are taken, would have rendered the 100% tree, matched the 100% image, and passed for ever. The clipping half was not only waiting on a decision; it was waiting on one more mechanism nobody had looked for, hidden behind the same single-font constructor ADR-0118 recorded as the reason the gallery cannot see typography at all.

Decision

The 150% check is a rule evaluated against the laid-out tree, and the rule is differential. TextScaleAudit in core/src/testFixtures lays a widget tree out twice — at 100% and at 150%, through a font book, at display scale 1.0 — and asserts:

Growing the text to 150% introduces no cut nobody asked for, and pushes no box past its container.

Two arms, and four candidates were weighed to get there.

Adopted: no line is cut without something asking for the cut

For every laid-out box with text, the audit mirrors BoxPainter exactly — inside the padding, wrapped at the content width under normal and laid out unconstrained under nowrap ([ADR-0255]) — and compares the widest line against the content width. Over it is a cut; TextFlow.ellipsises() says whether the stylesheet asked for one.

That is the first candidate, “no text is clipped without an ellipsis”, made exact. Mirroring the painter rather than re-deriving it is deliberate: a second opinion about where the text goes is a check that passes while the picture is wrong, and [ADR-0111]’s padding bug is what that looks like.

Adopted: no box overruns its container

OverflowWatch already exists, already runs inside every RenderTree.update, and already deduplicates through OverflowLog — so the audit gets this arm for the price of reading a static list. It is half of the answer and could not be all of it, which is worth writing down because the question “is OverflowWatch the answer” is the obvious one to ask:

  • Its noise is a fact. It caught five rows of buttons running off a 720-point window at 150%, and the navigation wall growing 556 points taller than its 900-point screen.
  • Its silence is not evidence. The walk is gated on the root node’s hadOverflow ([ADR-0375]). A button that overruns its row while the window still has room reports nothing. Measured: at 150% the Emoji sheet has 119 tile captions taller than their tiles and the Icons sheet has 36, and OverflowWatch said nothing about any of them, because the window was not full.

Rejected: every ellipsis at 150% was also reachable at 100%

Backwards. Making the text half again as wide is exactly what makes a new ellipsis appear; a check that forbade it would forbid the feature. It is also false on the corpus today: at 150% the HTML screen gains one marked cut (2 → 3) and the Markdown screen gains two (1 → 3), and all three are text-overflow working as designed. This candidate would have failed three correct ellipses and called §1.4 broken.

Rejected: nothing overlaps

Siblings overlap on purpose throughout the catalog — an absolutely positioned child, a popover over its anchor, a tab’s underline across its header, anything elevated, every transform. The exception list would be longer than the rule, and each entry in it would be a place the check had been told to stop looking.

Measured but not asserted: a paragraph taller than its box

The audit also compares the paragraph’s height against the content height, and does not fail on it. This is the one call in the file worth arguing over, so: nothing in the painter clips a paragraph vertically. The ellipsis is applied per line; a fourth line past the bottom of a three-line box is drawn, over whatever is beneath it, unless an ancestor’s overflow cuts it. So a spill on its own is not lost text — it is a box that overran, wearing a paragraph’s clothes, and the overrun arm is where that question belongs.

It is counted because it is the best evidence in the repository about where 150% actually hurts: Emoji 45 → 119 spilled captions, Icons 0 → 36. Those are real defects, in two sheets whose tile captions are given a height a 13.5-point line does not fit, and they are invisible to every other check here.

Differential, and a ratchet

An absolute “nothing is ever cut” would be a claim about the stylesheets as they stand, and it makes the 150% question hostage to an unrelated backlog: the Collections screen has one silent cut at 100% and at 150%, and the narrow Basic screen already overruns twice at 100% before the text grows at all. What §1.4 asks is narrower and answerable — growing the text must not break what was not already broken — so the audit compares the two runs and fails only on what 150% added.

The overruns 150% adds today are six, and they are not fixed here: they are listed in GalleryTextScaleTest, one line each with its reason, as accepted. The list is a ratchet in both directions. A seventh fails. So does an accepted one that stops happening, because a line left behind after the defect is fixed is a line that will silently welcome it back.

They are recorded rather than repaired because OverflowLog’s own warning is right that nothing here can pick the answer: “a scroll around it, an ellipsis on it, or a min-width it may not go below are the three answers; nothing here picks one.” Choosing is a change to the showcase’s widgets and its stylesheet. Choosing it inside the commit that installs the check would be the golden’s mistake made in Java.

And it opens a book

There is no single-Font form on TextScaleAudit, for the reason in Context: a renderer built over one font applies the scale to nothing. So the audit opens a book — which makes it the first check in the repository to lay the gallery out with font-family, font-size and font-weight resolved per node, the blindness ADR-0118 recorded and ADR-0386 chipped one screen off. That was not the goal. It is a consequence of the mechanism, and it means the audit and the golden beside it are looking at two different trees: a heading is 20px SemiBold in one and 13px Regular in the other. Only one of them is looking at what the application draws.

Consequences

  • GalleryTextScaleTest lays out fourteen scenes — the eleven screens, the two an optional module draws, and the narrow window — twice each, and inspects 2,602 paragraphs per scale. It costs 1.3 s of :example:test, against GalleryGoldenTest’s 12.0 s for 21 images. That ratio is the decision paying for itself: no pixel is rasterized anywhere in the audit, because the question is about rectangles and the rectangles come out of the layout pass. It runs Offscreen’s settling sequence with the paint removed — the two measuring passes are still there, or every masonry and text-area would be audited on its first guess.
  • Nothing in the gallery loses a line horizontally at 150%. Across 2,602 paragraphs the silent-cut count is 1 at 100% and 1 at 150% — the same one, on the Collections screen. That is a real result and it is the weakest part of this ADR: an arm that has never fired on the corpus is an arm this corpus cannot vouch for, which is why TextScaleAuditTest proves the rule on boxes whose numbers are in the test file rather than leaving the eleven screens to speak for it.
  • The gallery does not survive 150% today, and now says so out loud: six accepted overruns and 155 spilled tile captions. That is the check’s first and largest finding, and none of it was visible before.
  • Combined with [ADR-0434], check now pays a three-multiplier display-scale sweep over 245 goldens and this two-scale text-scale audit over 28 layouts. The sweep is about four seconds; the audit is 1.3. They do not multiply: the audit runs at display scale 1.0 only, on purpose. Crossing the two axes would cost the whole gallery again — 1.3 s becomes 5.2 — to ask whether a label fits its box on a Retina display, which is a question already settled in logical units before a device is chosen. A text scale is not a zoom.
  • -Dgoldberry.golden.scales.report=true prints what every screen measured, at both text scales, alongside what the display-scale sweep measured. One switch, reused rather than added, because example/build.gradle forwards a fixed list of properties into the test JVM and a new name would have needed a line there.
  • The vertical spill is carried in Result.spills() and asserted by nothing. If the Emoji and Icons captions are ever given a height that fits, the honest way to lock that in is to promote the spill to an assertion — which is a one-line change here and a conversation about 155 tile captions first.

What this does not do

It does not take a picture. If the 150% layout of a screen is ever worth pinning as an image, the order is the one TODO.md gave: decide what the rule is, then photograph the screen that obeys it. Not the other way round.

436. A column count is a width the window does

Date: 2026-09-19

Status

Accepted. Builds min-column-width on masonry, which ADR-0196 left out and ADR-0117 is the rule for. Corrects one sentence of core-widgets.md §1 that this went to check and found false. Its fixed-point evidence is ADR-0420’s harness.

Context

TODO.md carried this for a long time as a thing that could not be built:

A masonry’s column count is a number and not a breakpoint. Two columns at 1200px are two columns at 720px — half as wide and twice as tall — because the count is a constructor argument and no selector can count columns.

The entry’s first draft said the reason was the layout: “as many columns as fit at a minimum width” is a layout pass that reads its own width, which is the loop ADR-0196 built the last-frame read to avoid. That reads the record backwards. ADR-0196 is the last-frame read. A masonry already banks every card’s height through Measured and re-deals its columns on the strength of it; reading its own width is the same door one step over, and Measured’s third rule — what it triggers must not change what it reports — has the same answer for the width that it has for the heights: a column count changes the wall’s height and not its width.

What actually blocked it was a document. masonry had no row in core-widgets.md §1 at all — it was named once, in passing, as what the showcase’s screens are made of. §5’s spec-then-metrics-then-gallery gate had nothing to have passed, so there was no specification to build against and no way to tell an addition from a drift. Masonry’s own class comment said so, in the first line under the code fence: “Not in core-widgets.md.”

The row exists now, and so does the design-system.md §3 metrics row. This builds them.

Decision

A masonry says how many columns it wants or how narrow a column may get, and never both. The width it counts against is its own, from last frame.

Two modes, one of them the default, and no third

columns=N is a fixed count. min-column-width=N is as many columns as fit at this width, at least one. Both at once throws at construction, and that is the choice worth defending: the alternative is a precedence rule, and a precedence rule is a thing an author reads once and then guesses at for ever. A document that says both meant one of them, and the machine cannot tell which.

Neither gets min-column-width: 320, from §3’s row. The default moved from a count to a width, and that is the whole of this ADR in one line. A count cannot be right at two window sizes; a default is exactly the value nobody thought about; so the value that ships with no thought behind it must be the one that survives a resize. DEFAULT_COLUMNS = 3 is gone.

The absent one is Masonry.UNSET, which is -1 and not 0. Zero is a number an author can type and a spreadsheet can produce, and reading it as “I said nothing” would turn columns=0 — which has thrown since ADR-0196 — into a silent change of layout mode. inflate clamps neither property now, for the same reason the two together throw: a document that disagrees with its layout is found by looking at a picture, and a document that throws is found by running anything.

The width comes from the node the stylesheet already selects

MasonryBox carries the CSS type, the id and the classes — the arrangement every stateful widget in the catalog uses — which makes it the one node in the subtree whose rectangle is the masonry’s. So it implements Measured and reports its width, and no wrapper had to be invented to hold the question.

One had been invented already. IconsScreen wanted as many tiles as fit and built icon-sheet to measure the room it had (ADR-0309), with a class comment arguing rule 3 from first principles and reaching the same conclusion this does. That wrapper turns out to have been the widget’s job all along; it stays where it is, because that screen is a virtualized list now (ADR-0316) and no longer has a masonry in it to hand the job back to.

The gap is read in render, and it is the fence-post

n columns need n minimums and n − 1 gaps, so the count is ⌊(width + gap) / (minColumnWidth + gap)⌋ — add one gap to both sides and the fence-post goes away. Counting without the gaps over-counts by one at every boundary, and an over-counted wall is one whose columns are each a few pixels under the minimum that was the point of asking.

Which means the widget needs the sheet’s gap, and only render is handed the style the cascade resolved. That is toaster’s shape exactly (ADR-0178) and is taken for the same reason: a stylesheet that changed masonry { gap } and nothing else would otherwise leave every wall counting against the wrong pitch. It is banked without setState, and not as an optimization — render is inside the frame a rebuild would dirty. It does not need one either: the gap arrives on the first render, which is strictly before the first width the router can deliver, so the very first count is already counted against the real pitch.

A percentage gap reads as none rather than as a guess. A percentage gap on a row is a fraction of the row’s own width, which is the number being counted against, so honouring one would make the count a function of itself.

A width does not rebuild the wall; a count does

MasonryState.width banks every reading and calls setState only when the derived count changes. A wall told it is one pixel wider has not changed shape, and a wall that rebuilt on every pixel of a window drag would re-deal a hundred cards per frame to put them all back exactly where they were. §1.7’s idle frame loop is the reason the heights have had the same guard since ADR-0196.

What this went to check, and got wrong

§1’s row hedges the safety argument: “This holds only for a masonry whose width comes from its parent […] a masonry inside a shrink-to-fit box would oscillate, and that combination is refused for the same reason columns and min-column-width together are.”

It cannot be refused where the other one is. Shrink-to-fit is a property of the box a wall was put in, and a widget cannot see its parent; nothing at construction knows. So the plan was to make it a named red case in the fixed-point harness instead — and the harness says it is green, in one layout, which is the number a tree with no feedback in it at all returns.

It does not oscillate, and the reason it does not is one line of controls.css. The feared loop is real arithmetic: a wall sized to its own content would be n columns each as wide as the widest card in it, so a bigger n makes a wider wall, which asks for a bigger n. It needs the columns to be as wide as their cards — and since ADR-0373 they are not. A masonry-column is flex-basis: 0 with flex-grow: 1, so it contributes nothing to its parent’s content width, and a masonry with no definite width of its own is measured at zero however many columns it has. The count is independent of itself by construction, which is precisely what rule 3 asks for, and the construction is a stylesheet rule rather than a promise about somebody’s parent.

So there is nothing to refuse, and what is left is worse documentation rather than a worse layout: a masonry in a shrink-to-fit box is zero pixels wide with its cards hanging out of it, and has been since ADR-0373, with a fixed columns exactly as much as with this. Blaming that on min-column-width would have attached a real defect to the wrong change and left the older one unnamed. MasonrySettleTest.ShrinkToFit holds both halves so the claim is a number and not a paragraph.

core-widgets.md is not this ADR’s to edit. The sentence to strike is the “would oscillate” clause; what belongs there instead is that a masonry needs a box that gives it a width, in either mode, and that the toolkit cannot produce the loop the sentence describes.

Consequences

  • Three distinct layouts, and Offscreen affords exactly three. A responsive wall of prose is measured (1), re-columns — which puts every card at a different width and makes every banked height stale in the same instant (2) — and re-deals on the new heights (3). A fixed wall takes two; a wall whose cards have written-down heights takes two, because re-columning cannot move a height that is written down; a wall too narrow to divide takes one. Offscreen.render runs two measuring passes and paints the third (ADR-0424), so a responsive wall is photographed settled with nothing to spare. A fourth layout would not be a red test, it would be a gallery of walls caught mid-reflow — which is why MasonrySettleTest asserts the number rather than bounding it.
  • The first frame of a responsive wall is one column. Nothing has measured it, and a wall that guessed would be photographed mid-guess by anything that renders a fixed number of passes. It is the round-robin first frame ADR-0196 already accepts, one step wider.
  • Wall carries two numbers now. A showcase screen hands its document’s mode on rather than its count, because a screen that came back as a count when the document said a width would quietly stop following the window and nothing would say so. ShowcaseDocumentsTest asserts the two legal shapes and no third.
  • One golden moved, and it is the one the entry is about. basic.kdl says min-column-width=560; that wall is 1168 wide in a 1200 window and 688 in a 720 one, with a 12 gap, so it is the two columns it always was at 1200 and one at 720. gallery-basic and gallery-basic-light are unchanged, and gallery-basic-narrow is now a picture of a wall reflowing instead of a picture of a wall surviving. The assertion it makes is stronger: the old one could only tell you that nothing burst.
  • 560 and not §3’s 320, on that screen, and the other eight walls keep their counts. At 320 the default would give three columns at 1200 — a third narrower than these cards were built for, on a screen where §10’s wrap is not built to catch what overflows — and still two at 720, so the picture this change exists to fix would not have moved. Converting the rest would re-column six screens at the width the gallery is photographed at, which is a redesign of the gallery rather than a demonstration of the attribute. The default is right for a wall of unknown cards; a showcase knows its cards.
  • Every masonry is told its width, including the fixed ones, which throw the number away. One MasonryBox is cheaper than two, and the router only notifies on a change, so a still window notifies nothing.
  • design-system.md §3 says gap 16 (12) and controls.css says 12 flat. That disagreement predates this and is not touched here: the gap is read from the sheet, so the count follows whichever number the sheet ends up with.

437. A focus name resolves in the composite the keyboard is in

Date: 2026-09-19

Status

Accepted. Closes the residue ADR-0212 left behind and TODO.md had been carrying since: “a row’s focus name still collides between two unnamed lists”.

Context

host.focus(id) takes a name that is global to the window (ADR-0176). The router walks the focus root and takes the first element whose id matches. That is the right namespace for the names an application writes down, because an id is the document’s name for a node and a stylesheet resolves it the same way.

The names in this entry are not written down by anybody. A list builds a row per item and has to be able to put the keyboard on one by name — Home, End, type-to-select — so it manufactures one: the item’s identity, prefixed with the list’s own id. The prefix was a guess that an application had named the list, which is a good guess (a screen with two lists on it needs to tell them apart for the stylesheet anyway) and not a rule. Two lists, neither named, over items with the same identity both call their rows list-Iceland, and End in the second one moved the focus into the first.

tree had it worse and the entry says so. A tree names its rows tree-<node> with no prefix at all, so two trees sharing a node id answered each other’s keys whether or not the application named them. There was no spelling of a tree that avoided it.

The entry names the fix precisely: a focus name that is relative to a subtree, which the router has no notion of. That is the right diagnosis. Manufacturing a name into somebody else’s global namespace and hoping is the bug, and lengthening the prefix is haggling with it.

Decision

focusById resolves a name in the composites the focused node is inside, innermost first, and in the window only if none of them holds it.

private @Nullable Element findNamed(String id) {
    for (var scope = enclosingScope(focused); scope != null; scope = enclosingScope(scope)) {
        var within = findById(scope, id);
        if (within != null) {
            return within;
        }
    }
    return findById(focusRoot, id);
}

The subtree is the focus scope, and the focus scope was already there

The first question was whether focus-scope — a traversal boundary since ADR-0073, with an axis since ADR-0078 — is also the right naming boundary. It is, and not by convenience.

A composite is one Tab stop whose items the arrow keys move between. The elements a widget manufactures names for are exactly the elements the keyboard has to move among, because that is why it manufactures them: a row gets a name so that End can reach it. So the set of nodes a composite names and the set of nodes a composite roves over are the same set, described twice. list, tree, menu, tabs and the rest already say they are scopes, every one of them because it needed arrow keys — which is not a coincidence, it is the same fact.

So nothing new is declared. No namespace attribute, no explicit scope widget, no id forced onto a list, and no widget in the catalog changed except its comments. The notion the router was missing turns out to be one it already had, used for one thing and not the other.

Outwards scope by scope rather than the nearest one only, so a composite nested in another answers before the one containing it, and a name the inner one does not hold is still found in the outer one before the window is asked.

The anchor is the focus, and that is not a guess about the caller

Resolution is anchored on the focused node. That reads like a guess at who is asking, and it is not: every caller of a manufactured name is a key pressed on a row. Home and End on a list row, type-to-select on a list or a tree row, a tree’s Left walking to its parent. There is no fifth. The focused node is where the key landed, so it is the asking subtree by construction rather than by approximation.

The cost is real and worth naming: resolution now depends on state outside the call. The same string can reach two different elements depending on where the keyboard is. That is only true of a duplicated id, which had no defined answer before — “the first in document order” was an implementation fact, not a promise — but it is a new coupling, and it is the kind that is invisible in a stack trace. A test that asks for a duplicated name with nothing focused sees document order; the same call after a click on a row sees something else. That is the price of not changing the published spelling, and the next paragraph is what it buys.

The published spelling does not change and no caller moves

Host#focus(String, boolean) keeps its signature, keeps its meaning for every name that names one node, and keeps its meaning for a caller outside every composite. There is no new overload to reach for, nothing deprecated and nothing to migrate, because there is nothing to opt into: an application’s own host.focus("save") means today what it meant last week.

What an application gives up is the ability to say “the second list’s Iceland” from outside — which it never had, and which it gets by naming the second list, which is what it should do and what its stylesheet already wants.

What this was weighed against

A second, scoped Host.focus, taking the asking widget’s subtree as a handle. It is the honest reading of the entry and it is four more published methods — Host, the launcher, Popup, the router — plus a handle each widget has to bank across rebuilds and pass at every call site, to arrive at the subtree the focused node already names for free. It also leaves the old spelling doing the old wrong thing for everything that does not move to the new one, so the bug survives in the API rather than in the code.

Minting a unique prefix for an unnamed list — list7-Iceland from a counter. The cheapest of the three, and it puts a counter in a CSS id: a row’s name would depend on how many lists had been constructed before it, so building an unrelated screen first would rename it, and no stylesheet could address it. It fixes a collision by making the names unaddressable, which is a worse namespace rather than a smaller one. It also churns every assertion that names a row — and those are in select’s tests as well as the list’s and the tree’s.

tree needed no fix of its own, and is fixed more than list is

Both are composites; both now resolve a row name in their own. A list’s prefix already settled the named case, so what this adds there is the unnamed one; a tree had no prefix, so what it adds there is both. tree was deliberately left without the prefix rather than given one to match: a prefix is the guess this ADR is replacing, and adding one now would churn every row assertion in the tree’s and select’s tests to buy a disambiguation that has already been bought.

The residue, written down rather than fixed

A virtualized list’s row that has not been built yet is not inside its own scope to be found. ListState#reach widens the window and retries by name across a frame (ADR-0213), and on an attempt where the row does not exist the window fallback can still hand back another list’s row. It takes two lists, both unnamed, both virtualized, over the same identities, on the frame before the rebuild lands; the steady state is right, and the retry is bounded at two. Closing it properly means the list telling the router which subtree it means — the rejected design above — and it is not worth that API for this corner.

A request made with the focus nowhere resolves in document order, unchanged. That is not residue: with no keyboard anywhere there is no subtree for a name to be relative to.

Consequences

  • PointerRouter#focusById goes through a new findNamed, which is the only code change in :core. findById is untouched and still what the fallback uses.
  • ListState#rowId keeps the list’s id as a prefix. It is no longer what keeps End in one list out of another; it stays for what it was also doing, which is giving a row a name that means something from outside every list — to a stylesheet, or to an application focusing one.
  • FocusNameScopeTest in :core states the rule over bare widgets, the way FocusTrapTest states the trap: a duplicated name resolves in the keyboard’s composite, in document order when the keyboard is in none, in the window when the composite does not hold it, and innermost-first when composites nest.
  • ListFocusScopeTest and TreeFocusScopeTest in :widgets are the entry’s own case, end to end — two unnamed lists and two unnamed trees over the same identities in one window, a real router, a real key, and an assertion about which element the focus is on afterwards rather than about what the widget asked for. Both also assert that the first one is not simply always losing, because a rule that preferred the later widget would pass every other case here and be just as wrong.

438. A JVM consumer carries no platform, so a variant has nothing to match

Date: 2026-09-19

Status

Accepted, and it answers rather than builds. Qualifies ADR-0336, whose open consequence proposed two fixes of which one does not work.

Context

book/src/TODO.md carried this:

An application still adds its platform’s natives jar by hand. The goldberry umbrella cannot pick goldberry-natives:<v>:linux-x64 for the consumer’s platform — a POM has no way to — so the BOM lines up its version and the classifier is the application’s. A Gradle plugin, or module-metadata variants keyed on OS and architecture, would close it.

docs/releasing.md repeats the same pair. The first half of the diagnosis is exactly right: a POM is a version table and has no notion of an operating system. The question is the second half — whether Gradle Module Metadata variants are a way out that costs less than shipping a plugin.

They are not, and the reason is worth writing down because it is not obvious from the documentation: a variant is selected by matching the consumer’s attributes, and a plain JVM consumer has no platform attribute to match with.

The experiment

A throwaway two-project build, published to a file repository: a producer whose java component carries two extra variants attributed with OperatingSystemFamily and MachineArchitecture and carrying a classifier jar each — precisely the shape the entry proposes — and a consumer that is an ordinary java-library.

A consumer that does not ask gets the plain jar, silently.

RESOLVED: producer-1.0.jar

No error, no warning, no classifier jar. Which is the current situation with more machinery behind it: an application that did nothing would still ship without a native library, and would still find out at UnsatisfiedLinkError time.

A consumer that does ask gets an ambiguity failure.

Adding the two attributes to the consumer’s runtimeClasspath:

However we cannot choose between the following variants of probe:producer:1.0:
  - linuxRuntime
  - runtimeElements
…
- Variant 'runtimeElements' …
    - Unmatched attributes:
        - Doesn't say anything about org.gradle.native.operatingSystem (required 'linux')

This is the load-bearing detail. In Gradle’s variant model a missing attribute is compatible with any requested value, so the ordinary runtimeElements variant — the bindings jar every consumer needs regardless of platform — remains a candidate alongside the platform-specific one, and the two tie.

There are only three ways out of that tie and the producer owns none of them:

  • Attribute runtimeElements too. Then the plain jar is platform-specific, which it is not: goldberry-natives is the FFM bindings, shared by every target, and the .so is the separate classifier artifact.
  • Remove the unattributed variant. Every consumer then has to opt in, which breaks the ones that work today.
  • A disambiguation rule. These are registered on the consumer’s attributesSchema. A producer cannot install one.

Decision

Variants are not the answer, and the entry’s second option is the only one. The closing move is a consumer-side Gradle plugin that adds the right classifier dependency from os.name and os.arch — which is what JavaFX, LWJGL and sqlite-jdbc all ship, and is not a coincidence.

That plugin is not built here, and the reason is scope rather than difficulty: it is a new published artifact with its own coordinates, its own release surface and its own compatibility promise, on a publishing chain that has never run once. It is a decision for after the first release, not a line item in a sweep.

What lands instead is the correction and one documentation fix.

The documented snippet now adds all four classifiers, not one. NativeLibrary picks the right jar at run time by os.name and os.arch, so four runtimeOnly lines are the arrangement that works on every machine an application is built or run on — including the case the one-line form gets wrong quietly, which is a developer on macOS building an application that ships to Linux. Slimming to a single platform is the deliberate act, and is documented as such rather than being the default that happens to work where it was written.

Consequences

  • The entry stays open in a narrower form: an application still adds its natives jars by hand, and the thing that would close it is named exactly instead of being one of two guesses.
  • docs/releasing.md’s “or Gradle module-metadata variants” is corrected. Leaving it would have cost somebody the afternoon this ADR cost, and they would have got as far as the ambiguity error before finding out.
  • Four runtimeOnly lines is about 10 MB of jars on the runtime classpath where one would be 2.5 MB. That is the price of the default working everywhere, and the one-platform form is one line away for anybody who cares.
  • Nothing in this repository changes shape. No variant is published, so a later plugin is unconstrained by a half-mechanism that had to be kept working.
  • The experiment is not kept as a test. It measures Gradle’s behaviour rather than Goldberry’s, and a test that pins another tool’s variant-matching rules would fail on a Gradle upgrade while telling us nothing about this code.

439. A viewport is found by walking up from the target

Date: 2026-09-20

Status

Accepted. Completes ADR-0120 at the seam it named and left open, and closes the TODO.md entry “a tour cannot find the viewport its target is in”.

Context

docs/core-widgets.md §5 asks a tour to scroll its target into view before it places the card. A Stop names its target by id — an application holds ids, not elements (ADR-0108) — and until now it also had to be handed the ScrollController of whatever viewport the target lives in:

new Stop("export-button", "Exporting", "…").within(settingsScroll)

Every application writing a tour had to know, for each stop, which viewport encloses the widget that stop describes. That is a fact about the screen’s layout, restated by hand in a file that is usually written somewhere else entirely, and it goes stale the first time somebody wraps a panel in a scroll.

The TODO.md entry recording this had been read against the code at least twice and marked “it stands”:

Discovering it means walking from an element to its nearest scrolling ancestor. BuildContext.findAncestorState looks like the answer and is not: it walks up from the element being built, and what a tour needs is a walk up from the target it names — a different question, and one the tree offers no way to ask.

Decision

The entry’s two premises are true and its conclusion is false

findAncestorState does walk up from the element being built, and a tour does want a walk up from the element it names. What does not follow is that the tree cannot be asked, because of two facts the entry never put together:

  • Element implements BuildContext. The walk is for (var current = parent; current != null; current = current.parent) on the element it was called on. Nothing binds it to the node currently building; that is only where the caller usually happens to be standing.
  • A hit-test region carries its element. HitTest.Region.owner() is “what the renderer tagged the box with — an Element in the widget stack”, and Host.anchor(id) already returns a region. The tour was calling it on every build, for the rectangle, and throwing the owner away.

So the walk is one call, from a node the tour already had in its hand.

ADR-0120 half-wrote this down three hundred decisions ago and nobody read it back:

BuildContext.findAncestorState is added and used by nothing in the end — the downward case is what the catalog needed — but it stays, because it is how an application-level scrollIntoView from inside a scroll view reaches the viewport, and that is the case §1’s wording is actually about.

That is this call, and the method was kept for it.

And the same fact had already closed a different entry. TODO.md’s answered half contains “a tooltip’s 500ms delay is a constant, and the token that would replace it cannot be read”, closed by ADR-0254 with the words “the launcher holds an Element, which is a BuildContext”. So the observation this entry needed was written down in the list itself, in an entry two screens away, and neither re-reading found it. What that says about the list is more useful than what it says about the tour: an entry’s blocker is worth re-checking against the answered half, because the thing that unblocks it may already have been discovered for something else.

ScrollScope is the walk, and it is not a ScrollController

ScrollScope.enclosing(element) answers the nearest enclosing viewport, or empty. It is a new type rather than a static on ScrollController because the two are opposite directions and only one of them is a handle somebody owns:

  • a controller is created above a viewport and handed down into it, by whoever will need to scroll it later. It outlives frames, carries a listener, and reports a position.
  • a scope is found below a viewport and points at the state that was already there. It has no identity worth holding and nothing to listen to.

Minting a ScrollController for the second case was tried on paper and rejected: its onChange would never fire, because a viewport notifies the one controller it was given. A handle where half the methods silently do nothing is worse than a second, smaller type.

One piece of arithmetic, moved to where both callers can reach it

reveal’s “how far is this rectangle out of view” lived on ScrollController and operated on the state through a field. It moves to ScrollState.reveal, and the controller delegates. Two callers doing the same subtraction is how two of them end up disagreeing about what in view means — which is the argument ADR-0120 made for putting it on the controller in the first place, applied again now that the controller is not the only door.

The controller still wins when an application named one

Stop.within(controller) is kept, and a stop that names one uses it. This is not backwards compatibility for its own sake: the walk finds the innermost viewport, and an application that names a controller may deliberately mean an outer one — a row inside a list inside a page, where the interesting move is the page’s. Discovery is what happens when nobody said.

Consequences

Stop’s fourth component is now the exception rather than the requirement, and the three-argument constructor — which every stop in the showcase uses — went from “a stop whose target is not inside a scroll view” to “a stop that lets the tour find the viewport”.

The reveal path had no test before this. TourTest drove everything a tour decides against a stub host and never exercised the scroll, because a stub cannot move pixels. It does now, against a real painted viewport whose regions the stub host answers with — and the new test was checked failing first: row 20 sits at 314.0 without the walk and inside the viewport with it.

Nested viewports are revealed in the innermost one only. A row brought into view inside an inner list can leave that whole list scrolled out of the outer one, and nothing here notices. That is exactly what a hand-wired controller did — an application passes one controller, not a chain — so this is the old behaviour with the wiring removed rather than a new promise, and it is written on ScrollScope as the place it stops telling the truth. Walking the rest of the way needs each viewport’s own painted rectangle, which only the router holds and only for nodes it has regions for.

A scroll named by its own id resolves to itself, which looks like a contradiction of “the walk starts at the parent” and is not: an id written on a scroll lands on the ScrollViewport the widget builds — scroll as a CSS type is that node — so the first parent of the only element anybody can name is the state that holds the offset. A tour stop naming a viewport therefore reveals the viewport inside itself, costs one lookup and moves nothing. This was found by a test asserting the opposite, on the assumption that the id was on the stateful node; the assumption was wrong and the test now pins what actually happens.

The scroll tests share one harness. ScrollControllerTest carried a private one and ScrollScopeTest would have been the second copy in the same package. Lifting it found a leak on the way: the test that builds two harnesses in one method was closing only the second, because each constructor overwrote the field the teardown read.

The entry is the fourth in the 2026-09-19 batch to have been wrong about itself rather than merely unbuilt, and the most expensive kind: it had been re-read and re-confirmed, so the cost was two milestones of an API every tour had to carry.

440. The accessibility bridge is on hold, and the semantics tree stays

Date: 2026-09-20

Status

Accepted. Puts on hold what docs/ARCHITECTURE.md §13 has planned since before this log existed, and what book/src/status.md has listed under M5 since M5 was written. Supersedes the schedule in ADR-0077 §Consequences and in ADR-0225, neither of whose decisions changes.

On hold rather than deferred, and the distinction is the whole record. A deferral names a later milestone; this names none, and nothing in the repository should be written as though one existed.

Context

docs/ARCHITECTURE.md §13 says screen-reader bridging “is planned via AccessKit (C ABI, fits the FFM stack) in a post-v1 milestone — but the semantics tree exists from the start precisely so this is an adapter, not a rearchitecture.” Nine entries in book/src/TODO.md wait on it, and the list is emphatic that they should: “a role nothing consumes is a value written for a bridge that does not exist”, “adding LINK and a landmark now would make this gap look closed”.

The interface was never the difficulty. accesskit_c is a C API over the core data structures and all three platform adapters — one API where the alternative is three — and it can be had either by driving cargo from CMake through Corrosion or by consuming the project’s prebuilt package, which exists so that toolkit developers need not deal with Rust at all.

Decision

The bridge is not being built, and no milestone owns it.

Why, in the toolkit’s own words

The nine entries each refuse to write a constant until something consumes it. That argument applies one level up, and it is the reason this is a decision rather than a delay:

  • Nothing has asked. TODO.md §12 already has a bucket for work waiting on a consumer rather than on a decision — a clipboard watcher, a primary selection, a cached canvas layer. This joins it. Role’s own javadoc says a role nothing implements “is a promise to an assistive technology that nothing keeps”; a bridge nobody has asked for is the same promise, one level larger.
  • Two of its three platforms cannot be run here. UIA and NSAccessibility are behind the same missing Windows and macOS machines that TODO.md §12 already blocks the tray, the MSVC .def, the Mach-O visibility branch and the transparent-popup corners on. An accessibility bridge is not a feature that can ship untested on two thirds of its surface: the failure mode is silent, and the people it fails are the ones with no other way in. Shipping the AT-SPI third alone would put “screen reader support” in a README that is false on two desktops.
  • The cost is permanent and it is in :natives. Either a Rust toolchain in the superbuild on four platforms and in CI, or four platforms of vendored prebuilt binaries in the release — which is the same licence-and-provenance question TODO.md has open and unanswered for PDFium. Neither is wrong; both are a standing obligation taken on for something with no consumer.

What stays, and why it is not dead weight

Role, Live, Semantics and SemanticsSweepTest all stay exactly as they are. They are not a rehearsal of a bridge that is not coming:

  • The sweep pays for itself today. docs/testing.md §1.7 walks the gallery and asserts that every interactive node exposes a role and a name. What that buys, in Role’s own words, is “that the catalog cannot grow a focusable widget that has no name, which is the defect an accessibility pass finds late and expensively”. That is true whether or not anything reads the tree.
  • It is the seam. §13’s claim that a bridge would be “an adapter, not a rearchitecture” is the part worth keeping true. The data being in the tree is what keeps the door open at no running cost.

So nothing is removed and nothing is added. The nine entries move from open to on hold, which in todo-sweep.md’s legend is answered: decided not to build, with the reason recorded.

What §4’s baseline still means

design-system.md §4’s accessibility baseline is not withdrawn, and most of it never depended on a bridge. Keyboard reachability, the focus ring, contrast authored to WCAG AA, hit targets, reduced motion and text scale to 150% are built, and every one of them is checked by something. What is now explicitly not provided is the screen-reader half, on every platform.

That should be said plainly where a reader looks for it rather than implied by the absence of a milestone, which is why this ADR is cited from README.md, status.md and ARCHITECTURE.md rather than only from the entries.

Consequences

M5 loses its last toolkit item. What remains under M5 is the release half — the publishing chain that has never run, and the three-platform frame evidence M1 is waiting on — both of which are blocked on an account and a tag rather than on code. Text editing depth and IME preedit are already done.

Nine entries stop being read as neglected. They keep their prose, gain a pointer here, and stay in the list, because each still records a trap and the reasoning that got out of it — which is what TODO.md says the top half is for.

Role will not grow LINK, a landmark or a list role, and the four widgets whose specifications spent a sentence on what they would say still have nowhere to put it. docs/core-widgets.md keeps those sentences: the specification is what the catalog would say if something were listening, and it is not wrong for having been written.

The way back is a consumer, not a milestone. If somebody asks — an application that needs it, or a machine to test the other two platforms on — this is reopened, and the entries are already written. Nothing here makes that harder than it was; the decision is that it is not scheduled, not that it is refused for ever.

441. A web page is a window, not a box

Date: 2026-09-20

Status

Amended by ADR-0442, which keeps every finding here and reverses the conclusion: a page can be a widget where the window system allows a child window, and Wayland — for the reason set out below — is where it cannot. What was wrong was the step from “Wayland cannot” to “then nowhere”: that is an argument against a silent fallback, and a widget that says plainly why a session cannot show a page misleads nobody.

The window form described here still ships, for the application that wants a page in a window of its own.

Accepted. Takes goldberry-web out of docs/content-widgets.md’s optional modules, where ADR-0190 had put it and where it had been parked since, and builds it on webview/webview instead of on Servo — as the second member of §9’s widget.shell group, beside tray-icon, rather than as a box in the catalog.

Context

The parked entry said one thing and it was true: libservo is Rust-only against a deliberately unstable API, so the module would own a cdylib shim and its breakage. What it never said is that Servo was not the only way to put a page on screen, and the entry sat unexamined for two milestones because “parked” reads like an answer.

webview/webview is a different proposition in every respect that mattered. It is MIT, it is a C API over a C++ header, and it brings no engine of its own: it drives WebKitGTK on Linux, WebView2 on Windows and WKWebView on macOS — libraries the desktop already has. So the two arguments that quarantine a content module under ADR-0190, a heavy native payload and an attribution or copyleft obligation, do not apply to it. Nothing is vendored, nothing is shipped, and the licence question PDFium is parked on does not arise.

That is what takes it out of content-widgets.md. What decides its shape is a different argument, and it is entirely about Wayland.

A page is never pixels the toolkit owns

webview/webview cannot render offscreen. There is no software surface, no buffer, no “draw into this”: it creates a real platform window and the engine draws into it. Every other content widget in content-widgets.md obeys the third rule of §11.1 — everything here rasterizes on the CPU into a buffer — and this one cannot, at all, on any platform.

So a page is a platform window, and the only question is where that window may be put.

Wayland forbids both ways of putting it somewhere

There are exactly two ways to make a platform window look like a widget, and a Wayland session refuses both:

X11WindowsmacOSWayland
Reparent the window into the SDL windowXReparentWindowSetParentaddSubview:no
Align a separate window to a widget’s boxyesyesyesno

The first is missing because a Wayland surface belongs to the client that made it: a subsurface may only be a child of another surface on the same connection, and GTK’s WebKit surface and SDL’s window are two clients as far as the compositor is concerned. There is no protocol for it and no plan for one.

The second is missing because a Wayland client is not told where it is and may not say where it goes. That is not a gap in this toolkit — it is written into the SPI already: BackendWindow#position() returns an Optional and documents it as “empty when the platform will not say”, and SDL_SetWindowPosition is a no-op for toplevels there. A companion window cannot follow a box it cannot locate.

Wayland is the default session on GNOME, and on the machine this was built on.

So the widget was the thing that had to go

The first draft of this decision was a companion window aligned to a widget’s box, and it was written up before the positioning half of that table was checked. It does not survive the check. What was left was a choice between a web-view that is a box on three platforms and a free window on the fourth, and a web-view that is the same thing everywhere.

Two behaviours wearing one name is the failure this project keeps naming — and the one that breaks here is the common Linux desktop, which is the worst possible platform to have the degraded path on.

Decision

A web page is a window the application opens, not a widget in a layout.

WebViews.open(host, page) takes a [WebPage] value and returns a handle: a real top-level window, transient for the application’s own, which the desktop places and the user moves and closes like any other. It navigates, it evaluates script, it reports its title and its load state, and it closes. It has no box, no cascade, no hit test and no place in the element tree.

This is tray-icon’s shape exactly (ADR-0191), which is why it goes in widget.shell beside it — §9’s group is already described as the one group whose first member is not a widget, because the desktop draws it. A page is the second such member, and for the same reason: the engine draws it, into a window of the platform’s own.

It is also the only shape that is honest on all four targets. Nothing is degraded on Wayland, because nothing was promised that Wayland cannot do.

The native library is its own, and is loaded on demand

libgoldberry does not link WebKitGTK, and must not. A load-time dependency on GTK and WebKit would be carried by every Goldberry application on Linux, including the overwhelming majority that never open a page, and an application on a machine without them would fail to load the toolkit at all rather than fail to open a web view.

So the build produces a second, optional shared library — libgoldberry-webview — which is linked into nothing and opened lazily the first time a page is asked for. Where the build had no WebKit headers, or the machine has no WebKit at run time, the library is simply absent: [Capability#WEB_VIEW] is not reported and WebViews.open answers empty, which is the same answer Host#tray gives a desktop with no notification area.

That is ADR-0325’s pattern, and its reason: “could not ask” and “asked and was told nothing” are different facts, and only the first one is fixable.

The page is pumped on the UI thread, not run on its own

webview_run() takes over a thread with its own loop, and the obvious move — a dedicated thread per page — is wrong on macOS, where AppKit requires the process’s first thread and SDL already has it. It is also wrong for this toolkit: a navigation callback that lands on a private thread cannot touch a widget, and ADR-0020 is the rule that everything a listener will touch belongs to the UI thread.

So a page is created on the UI thread and webview_run is never called. What services it is a goldberry_webview_pump() the frame loop calls, and its implementations are deliberately not alike:

  • macOS and Windows: nothing. SDL’s own pump already drains the run loop and the thread’s message queue, which is what services a WKWebView and a WebView2 HWND created on that thread. pump is a no-op that exists so the caller has one shape.
  • Linux: g_main_context_iteration. SDL does not drive GLib’s main context and nothing else will, so the shim iterates it, non-blocking, once per call.

One model, three platforms, and callbacks that arrive where widget code may run.

And the loop has to stay awake to do it

Draining GLib once per loop iteration is not enough on its own, and this was found by reading EventLoop rather than by running it: an iteration parks in backend.pumpEvents for the loop’s one-second heartbeat whenever the desktop is idle, because SDL has no events and does not know WebKit has any. A page serviced once a second does not scroll, does not animate and barely loads.

Nothing can wake the loop from GLib’s side. The honest fix is a file descriptor out of g_main_context_get_poll_func handed to SDL to wait on, and SDL has no API for waiting on somebody else’s descriptor.

So while a page is open — and only while one is open — the loop’s wait is capped at 8 ms. That is a real cost, a loop waking 125 times a second with nothing else to do, and it is confined to the lifetime of a page precisely so that it is invisible to every application that never opens one. Zero would be the obvious alternative and is a busy loop.

The counter that answers “is a page open” is read before anything that could load the library, so an application with no page neither pays the wakeups nor maps GTK.

On Linux the toolkit is a GTK 3 process, because the tray makes it one

Found by pressing the showcase’s own button, which took the window down with a SIGSEGV in gtk_init_check:

GLib-GObject-CRITICAL: cannot register existing type 'GdkDisplayManager'
GLib-CRITICAL: g_once_init_leave_pointer: assertion 'result != 0' failed
C  [libgtk-4.so.1+0x5629a4]  gdk_display_manager_get_default_display+0x4

GObject’s type registry is process-global. gdk-3 and gdk-4 both register a type named GdkDisplayManager, so whichever initialises second gets 0 back from g_type_register_static, trips the g_once_init_leave_pointer assertion, and dereferences NULL. Two GTK majors in one process is not a conflict to manage; it is a crash.

And a Goldberry process is already a GTK 3 process whenever it shows a tray icon: SDL’s Linux tray is libayatana-appindicator, which links libgtk-3. The crash log has both, and the chain is exact —

SDL tray  →  libayatana-appindicator3  →  libgtk-3
web-view  →  libwebkitgtk-6.0          →  libgtk-4

tray-icon is an ordinary member of the catalog (ADR-0191), so this is not an exotic combination — it is the showcase, and it would be most applications that use both features.

So the Linux build links webkit2gtk-4.1, which is WebKitGTK on GTK 3, sharing the one libgtk-3 the tray already mapped. That is the reverse of webview’s own CMake preference and of every upstream recommendation, and the reason is written where the choice is made.

And the shim refuses rather than trusting that. Before creating a page it asks dlopen(…, RTLD_NOLOAD) whether the other GTK major is already in the process, and returns NULL if it is — which Java already reports as “the engine would not start”. A build that ends up on 6.0 because 4.1 was unavailable then declines to open a page in a process that has a tray, instead of killing it. It costs one dlopen that cannot itself load anything, and it is the difference between a feature politely unavailable and a crash in somebody else’s application.

This is the sharpest edge in the whole decision, and it was invisible until the button was pressed: the C probe and the FFM probe both opened pages happily, because neither had a tray and therefore neither had GTK 3.

Verified after the fix, on the combination that crashed — a real Host, a real tray up, and a page opened through Host.webView exactly as the showcase’s button does:

tray shown   = true          ← GTK 3 is in the process
capabilities = [… WEB_VIEW]
page opened  = true
page closed  = true

libgoldberry-webview.so now carries NEEDED libgtk-3.so.0, which is the one the tray already mapped, and the two coexist because there is only one of them.

What this couples, and what would uncouple it

web-view and tray-icon are now a pair on Linux: they must agree about GTK, and the build is what makes them agree. That is a constraint this decision accepts rather than solves, and it is worth naming because it will be the reason something breaks later — a distribution that ships only webkitgtk-6.0, a future SDL tray that moves to GTK 4, a third dependency that wants the other major.

The thing that would sever it for good is running the page in a separate process, where its GTK is nobody else’s business. That is a real design and it is not this one: it needs an IPC protocol for navigation and lifecycle, a supervised child, and an answer for what happens when it dies. Worth reopening if web view usage grows past “open the handbook”.

Consequences

What this is not, said where a reader looks

Written on WebPage itself rather than left to be discovered:

  • A page cannot be put in a layout, and there is no web-view node in markup. There is nothing for a row to size and nothing for KDL to place. An application that wants a page beside its widgets opens a window and arranges the two, which is what the platform lets it do.
  • Nothing in a frame can cover a page, and nothing in a page can cover a frame beyond what window stacking already does. A dialog is modal to the application’s window and not to the page’s.
  • No golden image can see a page. Every pixel of it belongs to WebKit. This is the second entry in the catalog, after tray-icon, that docs/testing.md §14’s rule cannot reach — and for the identical reason.
  • A page that is open holds the loop up. Goldberry.run() returns when the last window closes, and a page’s window is not one of the toolkit’s, so an application that opens a page and closes its own window must close the page too. WebViews.open returns something AutoCloseable for that reason.

Where it is unverified

macOS and Windows are unverified, in the tray’s sense and recorded the same way: the design says SDL’s pump services both, and nothing here has run it. The Linux leg is the one that was built and exercised.

Alternatives

Servo, as the parked entry proposed. Still Rust-only against an unstable API, and still a cdylib this project would own. The reason to revisit was never that Servo improved; it is that the goal — a page on screen — had a second route that needed no engine at all.

CEF off-screen rendering. The documented escape hatch, and it stays one. CEF does render into a buffer, which is the one property that would have made a real web-view box possible — but it is a hundred-megabyte vendored binary per platform, which is ADR-0190’s quarantine case in its purest form, and it would be an eleventh module rather than anything in the catalog. An application that needs Chromium in a box still embeds CEF itself.

An embedded box, Wayland excepted. Rejected above: the platform it fails on is the default Linux session, and it would need ARCHITECTURE.md §12’s native-handle escape hatch — which §12 promises and nothing has built — to be built first.

442. A page is a child window, where the window system allows one

Date: 2026-09-20

Status

Accepted. Amends ADR-0441, which stands on everything it found and is wrong about what follows from it: a page can be a widget on X11, Windows and macOS, and the reason it cannot on Wayland is the one ADR-0441 wrote down.

Context

ADR-0441 made a page a window rather than a widget, and the argument was in two parts. The first is a fact and has not changed: webview/webview cannot render offscreen, so a page is always a real platform window. The second was a conclusion, and it does not follow:

a web-view that sat in a layout on X11, Windows and macOS and became a loose window on Wayland would be two behaviours wearing one name

That is an argument against a silent fallback, not against embedding. Given the third option — embed where the window system allows it, and say so plainly where it does not — the objection disappears. Nobody is misled by a widget that says “this session cannot put a page in a window, and here is why”.

What made this worth revisiting is that the alternative engines are worse. An offscreen renderer would make a page an ordinary raster with none of these sharp edges, and the survey (docs/servo-web-view-plan.md) found: Ultralight is proprietary and revenue-gated per downstream application; CEF is a 150 MB vendored prebuilt per platform; and Servo’s servo_capi, though newly real, has no input, resize or scroll — a page you cannot click.

Decision

WebView is a widget. It has a box, it takes part in layout, and the page’s own platform window is made a child of the application’s, positioned over that box and moved with it.

Where the window system does not allow a child window, it opens nothing and paints a message saying why. Not a loose window, not a silent degradation.

SessionWhat happens
X11 (including XWayland)XReparentWindow — a real widget
WindowsSetParent — unverified
macOSaddSubview: — unverified
Waylandnothing opens; the widget says why

WebPage and WebViews.open stay as they are, for the application that wants a page in a window of its own. The two are a pair rather than a replacement.

§12’s escape hatch, finally built

ARCHITECTURE.md §12 has promised since day one that “backends expose raw native window handles for apps embedding external renderers”, and nothing had built it. Embedding needs exactly that, so BackendWindow.nativeHandle() now answers a NativeHandle — an X11 Window, an HWND or an NSWindow* — over three newly exported SDL symbols.

Wayland answers empty on purpose. There is a wl_surface and it is not reported, because nothing may be done with it and a handle that cannot be embedded into is an invitation to try.

Three things found by building it, all of which bit

1. The shipped web-view was already broken, by HarfBuzz

libgoldberry exported 25 hb_* symbols from its statically linked HarfBuzz 14.3.1. Anything that pulls in GTK — web-view through WebKitGTK, or tray-icon through libayatana-appindicator — brings the system’s HarfBuzz 12.3.2. One global symbol namespace: pango’s calls to those 25 names bound to ours while its other ~500 bound to the system’s, and the process died in hb_font_set_var_coords_design, inside gtk_init, before any Goldberry code ran.

This was not embedding’s bug. It was in the detached-window web-view that had already shipped, and it was missed because the C probe that pumped was not linked against libgoldberry, and the FFM probe only worked because a tray had already initialised GTK so gtk_init was a no-op.

HarfBuzz is the only upstream that collides: Blend2D, Yoga, SDL and libwebp share no symbol name with the GTK stack. So the fix is 25 goldberry_hb_* wrappers and no raw hb_* export — which is what the superbuild’s own header has always said it wanted: “an app embedding its own SDL or HarfBuzz must not collide with ours”. The export list was the hole in that.

It is a departure from §3.1’s “no C glue in between”, and the only one.

2. The two halves of one process disagreed about the window system

SDL is asked for X11 first on Linux (ADR-0086). GDK, asked nothing, prefers Wayland whenever WAYLAND_DISPLAY is set. On an XWayland desktop that makes the application’s window an X11 window and its GTK surfaces Wayland surfaces — invisible until something needs the two related, and embedding is exactly that.

A page then refuses to embed on a machine where everything works. Worse, it depended on order: a tray shown first initialised GTK on Wayland and the page had no say afterwards, which is precisely the showcase’s start-up.

So the backend now says which window system it picked, once, before anything can call gtk_init — which on Linux means before the first tray-icon.

3. An ABI check that runs after the binding is not a check

A stale libgoldberry-webview.so failed with “does not export goldberry_webview_gtk_conflict” rather than “this library is ABI 1 and this build binds ABI 2”, because bind looked up all ten symbols and threw on the first missing one long before the version was read. The probe is now bound and asked alone, first.

The same library also took Goldberry.capabilities() down with it: a throw from a static initialiser poisons the class, so the catch that handled the first failure asked again and got a bare NoClassDefFoundError. An optional feature must not be able to break the call that lists features.

Consequences

What an embedded page still cannot do

These follow from the page being a window above the frame rather than a layer in it, and are written on WebView itself:

  • Nothing painted can cover it. A dialog, popover, tooltip or toast overlapping the page is drawn underneath and is invisible where they meet.
  • A scroll viewport does not clip it — the child clips to the window, so a page scrolled halfway out is still drawn whole.
  • opacity, transform and frost do not reach it, there being no raster.
  • No golden image can contain it. A picture of this widget is a picture of what it paints when there is no page, and it says so.

A page must be closed before the window it is inside

The X server destroys a window’s children with it, so an embedded page torn down after its parent is GTK unwinding a window the server has already reclaimed:

Gdk-WARNING: GdkWindow 0x2400003 unexpectedly destroyed
GLib-GObject-CRITICAL: g_signal_handler_disconnect: assertion failed
Gdk-CRITICAL: gdk_frame_clock_end_updating: assertion 'GDK_IS_FRAME_CLOCK' failed

This is the popup problem exactly, and Sdl3Window.close already carried the answer two lines above where the fix went — “SDL destroys a window’s popups with it, so after this call their handles are dangling”. Pages are now closed beside popups, before destroyWindow, and the backend tracks which window each one is in so it knows what to close.

Wayland is not waiting for anything

There is no protocol and no ratified proposal. xdg-foreign is toplevel parenting and raises invalid_surface on anything else; the nearest tracking thread is wayland-protocols #194; the request dates to a 2012 wayland-devel thread. The one live idea is ext_image_sampler_v1, which would let a client read another surface’s contents — that plus our own compositing would be a real Wayland widget, and it is early and may end up privileged-only.

An application on a Wayland desktop that wants a page can have one today by asking SDL for the x11 driver, which runs it under XWayland.

Input needs no routing

The page is a real child window, so the window system delivers its clicks and keystrokes to WebKit directly. Nothing forwards events and the pointer router never sees them — correct, since they were never this toolkit’s.

Where it is unverified

Windows and macOS. SetParent and addSubview: are the calls and neither is written, because neither can be run here — the tray’s situation exactly. The Linux path was built and exercised: the X server reports the page as a viewable child of the Goldberry window at the widget’s own box.

443. Somebody else’s log line is still a log line

Date: 2026-09-20

Status

Accepted.

Context

A Goldberry process on Linux prints this, and nothing in this repository put it there:

(java:1034459): libayatana-appindicator-WARNING **: 21:30:36.281:
libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib
in newly written code.

That is g_log_default_handler writing to the process’s stderr. It comes from libayatana-appindicator, which SDL loads the moment docs/core-widgets.md §9’s tray-icon creates a tray, and which ADR-0441 already names as the GTK 3 in the process.

Read it as an application author. It has no level, so nothing can filter it. It has no logger name, so nothing can route it. It does not reach the file the rest of the logs are in. Its timestamp is in a different format from every other line on the console. It names a library the application has never heard of and cannot upgrade, about a deprecation it cannot act on — and it arrives looking exactly like Goldberry shouting at it.

Worst of all, it appears on the console of an application that deliberately configured logging to be silent. ADR-0023 made the toolkit bind no SLF4J provider precisely so that “nothing” can mean nothing; Logs goes to the trouble of turning SLF4J’s own missing-provider notice down to errors for the same reason. A library underneath writing to fd 2 defeats both.

SDL has the same shape of problem and a smaller version of it: SDL_Log writes to stderr too, and “no video driver could be initialized” is a line worth having in a bug report rather than in a terminal that has scrolled.

None of this is GLib’s fault or SDL’s. Both are C libraries with no idea that a logging framework exists in the process, and both offer a documented hook for exactly this. Nothing had used either.

Decision

A native library’s log messages are routed into SLF4J, on logger names an application can configure.

native.<source>.<domain> — native.glib.libayatana-appindicator, native.sdl.video. Three segments, because all three are things somebody wants to level separately, and hierarchical, so that native silences the lot:

<logger name="native" level="warn"/>
<logger name="native.glib.libayatana-appindicator" level="off"/>

Two hooks are installed, and the asymmetry between them is the whole of the interesting part:

HookCatchesInstalled
g_log_set_default_handlerg_log, so g_warning, g_message, g_critical, g_debugalways
g_log_set_writer_funcg_log_structured, which a default handler never seesopt-in
SDL_SetLogOutputFunctioneverything SDL emitsalways

g_log_set_writer_func aborts the process if it is called twice. Not a return code and not a warning: GLib calls g_error, which is fatal by definition, when the writer is no longer the default one. Goldberry cannot know whether an embedding application, a JNI library or WebKit itself has already set one, and a toolkit that could kill its host process to redirect a log line has made a bad trade. So the structured path is behind -Dgoldberry.log.glib.writer=true, set by an application that knows its own process, and the legacy path — which is where the message in the Context actually comes from — is on for everyone.

The whole bridge is off under -Dgoldberry.log.native=false, which gives each library its own stderr back. That is not a courtesy: a bridge is a filter, and a message dropped by a logging configuration is one somebody debugging the platform layer wanted.

Where GLib is found, and when

GLib is neither ours nor optional-ours. It is the system’s, it is in the process because something else wanted it, and it cannot be exported from libgoldberry — exports/goldberry.symbols is a version script over the archives the superbuild statically links, and GLib is not one of them. So it is dlopened by soname, libglib-2.0.so.0, and ExportListTest is taught that this is a third library whose symbols are not that file’s business.

And it is asked for late. A lookup by soname maps the library, so the bridge is installed at the three points that are about to load GLib anyway — creating a tray, and the two ways of opening a page — rather than at start-up. An application with no tray and no page never maps GLib.

SDL’s bridge is installed before SDL_Init, which is the point: the message worth having most is written during initialisation.

What it does not do

It does not change what either library emits. SDL keeps its own per-category thresholds; SDL_SetLogPriorities would lower them and is deliberately not bound, because how verbose SDL should be is not a decision a toolkit should make on every application’s behalf. What changes is the destination.

Alternatives considered

Redirect fd 2 and parse it. Catches everything, including libraries with no hook at all, and is the wrong shape in every other way: it would capture the JVM’s own crash output and anything the application writes to System.err, it has to re-parse a format each library is free to change, and reassembling a multi-line message out of a byte stream is guesswork. It also cannot recover the level or the domain except by matching on prose.

Set G_MESSAGES_DEBUG or GLib’s environment variables. These control what GLib prints, not where. The line still goes to stderr.

Do nothing and document it. Which is what was happening. The message is already documented — Webview.open explains libayatana-appindicator’s GTK 3 in as many words — and documentation does not get it out of the console of an application that asked for silence.

Install only the writer function, which is the modern GLib API and catches both paths. Rejected on the abort: one process in the wild where something else has set a writer is one process that dies at start-up, and the failure mode is a SIGABRT with a GLib message rather than anything a user could act on. The legacy handler catches the messages that actually occur and cannot fail this way.

Put the bridge in :core. The hooks are FFM bindings and belong to the native layer. What is in :common is the destination and the naming convention alone — two classes that depend on nothing but SLF4J — which is exactly the bar ADR-0174 sets for that module.

Consequences

A platform message is now an ordinary event. It can be levelled, routed to a file, switched off by name, correlated by timestamp with the frame that caused it, and included in a bug report. The showcase’s logback.xml shows the libayatana-appindicator line arriving as a WARN, which is the demonstration.

Three upcall shapes were added, and ForeignSurface names them so a native image is told about them before a tray exists. GlibLog is the first owner in that list to declare two.

One struct is laid out by hand and is not on the layout table. GLogField is three machine words and ADR-0010’s rule is that such a layout is checked against what the target’s own C compiler computed — and it cannot be, because GLib’s headers are not a dependency of the superbuild and must not become one for the sake of a log line. What makes it acceptable here: every field is read and none written, GLib has published the struct unchanged since 2.50, the segments are reinterpreted with a bound before anything is read out of them, and nothing reaches it at all unless an application opted into the writer. A wrong offset is a garbled message, not corrupted memory. It is the only exception in the module and it is not a precedent.

The native ABI is 15, because SDL_LogPriority and SDL_LogCategory are on the layout table now. They are ordinals in enums SDL has already renumbered once — SDL_LOG_PRIORITY_TRACE was inserted at 1, below VERBOSE, moving everything above it — and a binding that predates the insertion reports every message one rung too loud with nothing anywhere to say so.

A message can now be lost that used to be unmissable. An application whose root logger is at error will not see the deprecation notice, and that is the point rather than a regression — but it is a real change, and -Dgoldberry.log.native=false is the way back.

The bridge must never throw. Every entry point is an FFM upcall called from C, sometimes with a GLib lock held and sometimes on the way to abort(). Both handlers catch Throwable and so does NativeLogBridge.log. That is two bare catches in a codebase that has almost none, and they are correct: a logging bridge that can take the process down is worse than the stderr line it replaced.

Only GLib and SDL are covered. WebKit’s own logging, D-Bus’s and Wayland’s are not, and each would be its own hook. The shape is now there for them.

444. A page stands aside for a modal

Date: 2026-09-20

Status

Accepted. Amends ADR-0442, which made web-view a widget and listed the sharp edges that came with it. This answers one of them and leaves the rest standing.

Context

ADR-0441 and ADR-0442 together made a page a child platform window of the application’s, positioned over the widget’s box. That buys layout and costs compositing: a child X11 window is stacked above its parent’s own drawing, and nothing Goldberry rasterises into the frame can be painted over it. WebView’s javadoc listed the consequences and the showcase’s WebScreen was built to demonstrate the worst of them:

Press it and then Escape: the dialog was there the whole time, holding the keyboard, exactly where it could not be seen.

A dialog is the one item on that list that cannot be left as a caveat, and the reason is what a modal is. A tooltip that is hidden by a page is a tooltip nobody reads. A modal that is hidden by a page is an application that has stopped responding: it holds the keyboard and the pointer, it is waiting for an answer, and the user can neither see the question nor reach the buttons. The application looks broken, and the only way out is a key the user has no reason to guess.

docs/dialog-over-web-view.md is the survey. Four options were looked at, and the table that decides it is which platform each one holds on — remembering that Wayland embeds nothing at all, so the problem exists on X11, and would exist on Windows and macOS once goldberry_webview_create_embedded has branches for them.

Decision

While a modal is in force, the web-view widget parks its page. The child window is moved off the parent’s top-left corner, by more than its own size, so the parent clips every pixel of it; when the modal goes, it is moved back.

Two things make this cost nothing:

It moves rather than resizes. goldberry_webview_set_bounds calls gtk_window_resize as well as XMoveResizeWindow, deliberately, so that the WebKit widget inside lays out to the new size. A page parked at 1x1 would therefore reflow the document to one CSS pixel of width and reflow it back from there — and the scroll position after that is not something to reason about. Keeping the size and changing only the origin is not a reflow at all.

It uses the call that already exists. No new export, no C, and no ABI bump: set_bounds is already called on every frame the box moves.

The widget needs one new fact, and it is a fact about the window: is a modal in force. Host.isModal() answers it, from the modal element PointerRouter already finds once per frame beside the hit-test regions. It could not be found by walking up from the page: a filling Overlay is a sibling of the content under WindowRoot, not an ancestor, so BuildContext.findAncestorState looks straight past it.

The page visibly disappears while the dialog is up, and that is the decision rather than a side effect. A modal is a demand for the whole of the user’s attention; a browser’s own modal dims the content behind it and here the content vanishes instead, which is a difference of degree. The alternative is a modal nobody can see.

Modals only. A tooltip, a popover and a toast over a page are still invisible where they overlap. Blanking a page to show four words in a corner would be worse than the problem, and WebView’s javadoc still says so.

Alternatives considered

Hide the page with gtk_widget_hide (six lines of C, ABI 5). Tells the engine plainly that it is not visible, which parking does not — a clipped-out child is still mapped and may keep painting and keep running requestAnimationFrame. Rejected for now, not on merit: it costs an export and an ABI bump, and goldberry_webview_embed_into carries a comment saying the show-then-realize-then-reparent order was got wrong once and is load-bearing. Whether gtk_widget_show restores an already-reparented window into the same X parent at the same position is exactly that class of question. It is the fallback, it is designed, and it is what to build if a parked page turns out to burn a core.

Give the dialog its own platform window. A full-owner-size transparent SDL_CreatePopupWindow is a separate top-level and therefore stacks above the owner’s child windows, so the dialog would be genuinely on top. Rejected, and not merely deferred:

  • The stacking argument is an X11 argument. It says nothing about Windows or macOS, where the page is a child HWND or an NSView rather than an X child.
  • The scrim could not be translucent on X11. SDL 3.4’s X11 backend selects a 32-bit visual only for windows carrying SDL_WINDOW_OPENGL (SDL_x11window.c:601-651), and Goldberry’s windows do not. A dialog whose scrim is opaque is a grey rectangle over the application, which is worse than the thing being fixed — on the one platform where the problem exists.
  • It still needs a piece of this decision anyway, because the keyboard is WebKit’s while the page is up.
  • It would fork Dialogs.show into a popup path that ships and an in-frame path that every golden image exercises. That is the wrong way round for the most-used overlay in the toolkit, to fix one widget on one platform.

An offscreen engine, which dissolves this and every other item on ADR-0442’s list at once, is docs/servo-web-view-plan.md and is not started. It is a reason to keep this small, not a reason to wait: a dialog over a page is wanted now, and if Servo lands this is deleted along with the embedding path it belongs to.

Do nothing and document it, which is what WebScreen did. The documentation was good and the behaviour was still an application that appears to hang.

Consequences

A dialog over a page works, on every platform that can embed one. The showcase’s web tab is the demonstration and says what to look for.

Host gains a method, defaulted to false, so nothing that implements it breaks and an offscreen render — every golden image — answers correctly without knowing anything. PointerRouter.isModal() is the same answer the focus trap and the pointer rules already use, so there is one notion of modality rather than two that can drift.

One widget knows about this and nothing else does. Dialogs.show is still host.fill, ADR-0176’s closing animation is untouched, focus composites are untouched, and every golden image is still valid.

It is one frame late, like Host.anchor and for the same reason: the modal is found from the hit-test snapshot of the frame that was painted. The page parks on the frame after the dialog appears. At 60 Hz that is 16 ms of a dialog drawn behind a page, which is not visible and is not worth a second mechanism to remove.

A parked page is still mapped, and whether WebKit keeps painting, animating or playing video behind the parent’s clip is not known. If it does, the cost is a page’s worth of CPU for as long as a dialog is up. That is the measurement that would promote the gtk_widget_hide alternative above, and it has not been taken.

The end-to-end behaviour has no automated test, and cannot have one. No golden image can contain a page at all, which is the fact WebView has always documented. What is tested is the arithmetic — that parking moves and does not resize, that the parked origin is fully outside the parent’s box and inside an X11 INT16 at every scale — and that the router reports modality. That the widget calls it on the right frame is checked by running the showcase.

A finding fell out of this and is not fixed here. If the reading of SDL_x11window.c above is right, SDL_WINDOW_TRANSPARENT does nothing for a non-OpenGL window on X11 — which would mean every Goldberry menu, dropdown and tooltip has opaque square corners on an X11 session today, Popup’s frame.fill(0x00000000) notwithstanding. Wayland honours the flag, which is consistent with nobody having noticed. It is recorded in docs/dialog-over-web-view.md §7 as U1, it is a screenshot to confirm, and it is a separate defect from this one.

445. A page is not shown before it can be seen

Date: 2026-09-20

Status

Accepted. Builds on ADR-0444, whose parking mechanism this reuses for a second reason.

Context

Open the showcase’s web tab and the page is a white rectangle for as long as the network takes, and then the page appears in it. Nothing says it is loading, because nothing can.

The white is WebKit’s default document background. goldberry_webview_create_embedded maps the engine’s window and reparents it into the application’s inside one call — that is the order ADR-0442 requires, because GDK picks its backend during gtk_init and a page created before the backend is settled gets a wl_surface that cannot be reparented. So by the time Java has a handle, a window is already on screen, over the widget’s box, showing an empty document.

The obvious fix is not available. A spinner drawn over the page would be drawn underneath it: a page is a platform window above the frame, which is the whole of what ADR-0441 and ADR-0442 established and what ADR-0444 had to work around for dialog. There is no raster to composite into.

What ADR-0444 did leave behind is a mechanism: parking, which moves the page’s child window past its parent’s corner where the parent clips every pixel of it, without resizing it and therefore without reflowing the document. A parked page is invisible and the box it will occupy is ordinary frame that Goldberry paints.

Decision

A page stays parked until it has something to show, and a spinner is drawn in the box it will occupy.

Three parts.

The page is opened parked, not parked on the next frame. open hands Host.embeddedWebView the parked rectangle rather than the box, because the shim maps and reparents inside that call — a page created over its box is visible, and empty, before anything in the widget could move it. One frame of white is still a flash.

The widget asks, once a frame, whether the page is ready. goldberry_webview_load_state answers -1 unknown, 0 not started, 1 loading, 2 finished, and the widget is already calling the shim once a frame from its painter to keep the page over its box, so the question rides along.

A stack, so there is somewhere to draw. The surface is the first child and stays in flow, so it still sizes the box and still carries the id Host.anchor resolves; the spinner is absolute and covers nothing.

Polled, not signalled

WebKit has a load-changed signal and it is not bound. Binding it means an upcall stub whose lifetime outlives the Java object that owns it, a callback arriving from GLib’s thread into a widget confined to the UI thread, and a registration to unwind when the page closes. What it would buy over a poll is nothing: the consumer is a painter that runs every frame anyway, and one int per frame is not a cost anybody can measure against a frame budget of 16 ms.

The two facts are joined in C

webkit_web_view_is_loading and webkit_web_view_get_estimated_load_progress are combined in the shim rather than in Java, because the interesting state is the one neither reports alone. A page that has been created and never navigated — which is every page between create_embedded and the navigate after it — is not loading and has made no progress, and it is exactly as unready as one still fetching. Java would otherwise have to know that estimated-load-progress is 0 before the first load and stays at 1 after the last, which is WebKit’s business.

Unknown means show it

WebLoad.isReady() answers true for UNKNOWN, and that is the load-bearing default. A build whose engine will not say — Windows and macOS, where the embedding itself is unwritten — must behave as every build did before there was a question to ask. A page that never appears at all is a far worse failure than a white flash.

The flag is read in build, so only setState may write it

Stated because the first version of this got it wrong, and the way it failed is worth keeping. open set loading = true directly — the page had just been created parked, and the flag was true, so the code read correctly. But build had already run for that frame and nothing asked it to run again, so no spinner was ever put in the tree; and the next frame’s loadingIs(true) found the flag already true and skipped the setState that was the only thing left to do.

Both reported symptoms followed from that one line. No spinner, ever — and then, because a spinner is what asks for frames for ever, no frames: the loop went idle with the page still parked, and the page appeared only when something unrelated caused a repaint. Moving the pointer was what did it.

So there are two fields. loading is what build reads and changes only through setState; loadingWanted is what has been asked for, because the setState is requested from inside a paint and runs on the next turn of the loop — every frame in between would otherwise queue the same change again.

And the poll asks for its own next frame

Host.repaint() while the page is held off, rather than relying on the spinner’s own animation to keep the loop turning. A spinner does ask for frames for ever, so the loop would in fact stay awake once one existed — but that makes this widget’s correctness depend on which indicator it happens to draw, and it does not solve the first frame, which is a deadlock either way: no frame, no setState, no spinner, no frames.

The page’s progress changes with no tree change to notice it. Polling something means asking for the frame that looks again, and it stops the moment the page is ready, which is what keeps an idle application idle.

Alternatives considered

Set the page’s background colour with webkit_web_view_set_background_color, so the flash is the theme’s surface rather than white. Cheap, and it treats the symptom: the box is then a coloured rectangle with nothing in it and no indication that anything is happening. It is not exclusive with this decision and may still be worth doing; it is not a substitute for it.

Draw the spinner over the page. Impossible, and it is the premise of ADR-0441 rather than a thing that could be tried.

Hide the page with gtk_widget_hide instead of parking it. The same alternative ADR-0444 weighed and left as the designed fallback, for the same reasons: it costs an export and an ABI bump of its own, and whether gtk_widget_show restores an already-reparented window into the same X parent is the class of question that produced an empty rectangle the first time. Parking is already built and already tested.

Wait for LOADING before showing a spinner, rather than treating IDLE as unready. That is a spinner that appears a frame or two after the white it was meant to replace, which is the defect with an extra step.

Consequences

No white. The page is never on screen without content. What is on screen instead is an empty box and a spinner, which is what a user reads as “this is coming”.

The page loads while parked. A clipped-out child window is still mapped, so WebKit fetches, parses and lays out exactly as before — the page is finished when it appears rather than appearing and then finishing. Whether it also keeps painting while clipped is the open question ADR-0444 already records, and this decision makes it apply for longer: a slow page is now parked for the whole of its load rather than only for the length of a dialog.

A page that never loads shows a spinner for ever. WebKit substitutes its own error document for a page that fails, which ends the load and shows the error — so this is the case where the server accepts the connection and never answers. There is no timeout, and adding one would mean choosing a number on every application’s behalf.

stack leaves WidgetParityTest’s unstyled list, the way canvas did for ADR-0442 and for the same reason: the toolkit now builds one, so it needs one class-scoped rule to lay it out. A bare stack still gets nothing.

Webview ABI 5. One new export. A stale libgoldberry-webview is refused with the message it already had rather than called into.

Still no automated end-to-end test, for ADR-0444’s reason: no golden image can contain a page. What is tested is the arithmetic, that IDLE and LOADING are both unready, that UNKNOWN is not, and that stage puts a spinner in the tree exactly when it is loading. What is not testable is the half that actually broke — that the flag reaches build on the right frame — because that is a fact about frames and there is no frame in a test.

So the widget says so out loud instead: raising and taking down the indicator are logged at debug, which is how the fix above was confirmed and is the only evidence a running application can give.

22:49:17.930 INFO  web-view: a page is open inside the window over ...
22:49:17.953 DEBUG web-view "web-page": raising the loading spinner
22:49:20.659 DEBUG web-view "web-page": taking down the loading spinner
22:49:20.660 TRACE web-view "web-page" placed over ...

446. An embedded page is never the window manager’s

Date: 2026-09-20

Status

Accepted. Fixes a defect in ADR-0442’s embedding path.

Context

Open a page in the showcase and the desktop grows an extra entry — in the dock, in the taskbar, in the alt-tab list — that cannot be clicked, cannot be raised and cannot be closed. One per page opened, for the life of the session.

It is the page’s own window, and the sequence that produces it is goldberry_webview_embed_into:

gtk_window_set_decorated(GTK_WINDOW(window), FALSE);
gtk_window_resize(GTK_WINDOW(window), width, height);
gtk_widget_show_all(window);        // <- mapped as a TOPLEVEL, here
gtk_widget_realize(window);
...
XReparentWindow(display, child, parent, x, y);

webview_create makes an ordinary GtkWindow, which is a child of the root window. gtk_widget_show_all maps it, and for that instant the window manager adopts it: it is a managed client, so it goes in the window list, the pager and the switcher. XReparentWindow then takes it off the root — and what the shell is left holding is an entry for a window that has become somebody else’s child. Every EWMH operation on it is now meaningless, which is exactly what the user sees.

The show cannot simply be moved after the reparent. The comment above it was earned:

gtk_widget_realize alone creates the shell’s window without mapping the WebKit widget inside it, and the result is a correctly positioned rectangle with nothing in it — which is exactly what the first attempt produced.

So the window must be mapped while it is still a toplevel. The question is whether the window manager has to notice.

Decision

It does not, and it is told so twice.

gtk_window_set_skip_taskbar_hint(GTK_WINDOW(window), TRUE);
gtk_window_set_skip_pager_hint(GTK_WINDOW(window), TRUE);
gtk_widget_realize(window);                     // create, without mapping
gdk_window_set_override_redirect(gdk, TRUE);    // and do not manage it
gtk_widget_show_all(window);                    // now map it

The hints are the EWMH way to say it; override_redirect is the X way, and it is the stronger of the two — an override-redirect window is one the window manager is told not to manage at all, which is the literal truth here: this window is about to stop being a toplevel.

Both are set because they fail differently. A window manager that ignores _NET_WM_STATE_SKIP_TASKBAR still honours override_redirect, because honouring it is not optional; and the hints remain correct and readable for anything that inspects the window between the map and the reparent.

One reordering, and it is required rather than tidy: override_redirect is an attribute of the X window, so the X window must exist before it can be set, and it must be set before the map or the window manager has already seen it. gtk_widget_realize creates the window without mapping it, which is exactly the gap that was needed. The show_all stays, and stays before the reparent, for the reason the old comment gives.

Alternatives considered

The hints alone. Probably sufficient on GNOME and KDE, and it is the conservative change. Rejected as the only measure because a hint is a request: a window manager is free to ignore it, and the window really is not one for it to manage. Setting only the hints would leave the defect latent on whichever desktop ignores them.

gtk_window_set_type_hint(GDK_WINDOW_TYPE_HINT_UTILITY) or DOCK. Still a managed window, so still in the window manager’s client list — it changes how it is decorated and stacked, not whether it exists as far as the shell is concerned.

Reparent before mapping. The one fix that would remove the toplevel moment entirely, and the one the existing comment rules out: realize alone leaves the WebKit widget inside unmapped, and the result is an empty rectangle. That was the first attempt at this function.

gtk_window_set_type_hint plus unmapping and remapping after the reparent. More X round trips, and it reintroduces the empty-rectangle question for no benefit over the flag.

Consequences

The ghost is gone, and on every window manager rather than on the ones that honour hints.

An override-redirect window gets no window-manager services, and this one wanted none of them:

LostWhy it does not matter
DecorationsAlready off — gtk_window_set_decorated(FALSE) on the line above
Focus from the WMThe page’s keyboard comes from being a child of a window that has focus, which is ADR-0442’s arrangement and unchanged
PlacementIt is positioned by XReparentWindow on the next line and by XMoveResizeWindow after that

It is Linux-only, like the embedding itself. The Windows and macOS branches of goldberry_webview_create_embedded are still unwritten, and each will have its own version of this: WS_EX_TOOLWINDOW and SetParent on Win32, and on Cocoa the question does not arise because a WKWebView is an NSView and was never a window.

Webview ABI 5, shared with ADR-0445 — the two landed together.

No automated test. Whether a window appears in a dock is a fact about the running desktop’s shell, and there is nothing in this repository that can ask it. It is checked by opening a page and looking, which is what found it.

447. A spinner has a size, because a ring has a stroke

Date: 2026-09-20

Status

Accepted.

Context

docs/core-widgets.md §3 calls spinner “a small indeterminate activity indicator”, and the toolkit took the adjective literally: one size, 16px, from controls.css, with a 2px stroke written into the widget as a constant.

ADR-0445 gave it a second job. A web-view holds its page out of sight until it has loaded and draws a spinner in the box the page will occupy — a box that is a whole tab. A 16px ring in the middle of it reads as a decoration on something rather than as the subject, and there was no way to ask for a bigger one.

The obvious answer is a stylesheet rule, and it is not enough. A spinner is a ring: Box.Mark(ARC, colour, thickness), where the thickness is a number the painter is given. §8’s CSS subset has no property for the weight of a mark, so a 32px spinner styled only by width and height is drawn with a 16px spinner’s 2px stroke — a thin hoop. Nothing in a stylesheet could have fixed it.

Decision

spinner takes a size of small, medium or large, as a value the widget reads and not only a class.

That is the line ADR-0087’s badge and message draw between them, and this falls on message’s side of it. A variant that is only a skin should be a class rather than a second vocabulary only Java can write, which is why a badge’s variants are classes. A size is not only a skin: it decides something the stylesheet cannot say.

The diameter stays the stylesheet’s. The size puts a class on the node, controls.css gives that class a width and a height, and the widget reads the width the cascade actually resolved:

var diameter = style.width() instanceof Length.Points points && points.value() > 0
        ? points.value()
        : size.diameter();

So #busy { width: 48px } gets a stroke weighted for 48px, and the two numbers cannot drift. The diameter on the enum is a fallback for a width that is not a length a ring can be drawn from — auto, or a percentage of a parent the widget knows nothing about.

One ratio for every size, and it is exactly 2 / 16: today’s stroke at today’s diameter. A large spinner is a big picture of a small one rather than a different shape, and medium renders identically to every spinner drawn before this existed — which is why no golden image moved.

web-view asks for large.

Alternatives considered

A numeric size — spinner size=32. More flexible, and it invents a scale the design system does not have: §1.3 works in named tokens, and a control that accepted 17px would be the only one that did.

CSS only, with the thickness derived from the computed width and no widget change at all. This is half of what was built and is the half that works; what it does not give is a size a document can write, and §9 asks every widget to have a Java form, a KDL form and a CSS form. A spinner that could only be resized from a stylesheet would be the one control whose size is not in its markup.

A dense/compact pairing, reusing Density. That is a property of a screen rather than of one control, and it already means something else: a compact density is the whole window tightening up, not one indicator being asked to stand for a bigger region.

Consequences

Nothing that existed changed. medium is the default, its class is the only one with no rule, and the ratio makes its stroke the same number it always was. Every new Spinner(...) already written still compiles and still draws what it drew.

spinner gains a row in §3 and two rules in controls.css.

The stroke now depends on the cascade, which is a new coupling: a stylesheet that sets a spinner’s width to a percentage gets the size’s nominal stroke rather than one matching what it is finally laid out at. Reading the laid-out box instead would mean the mark could not be decided until after layout, and a render pass that needs its own output is not a thing this toolkit has.

Three sizes and no fourth. An application that wants 48px writes the width and gets a stroke to match, which is the escape hatch — but it does so without a name, and a fourth named size is a decision for whoever needs it.

448. A page calls back through a name it was given

Date: 2026-09-20

Status

Accepted.

Context

Everything between Goldberry and an embedded page has run one way. The application can navigate it, size it, park it and evaluate script in it; the page can do nothing but be looked at. That makes web-view a viewer, and the thing applications actually embed a page for — a form, a chart, a document editor that has to hand its result back — was not expressible.

webview/webview has the mechanism and nothing was bound to it. webview_bind makes a name a global JavaScript function, injecting the glue at document start; calling it in the page returns a promise, and the handler is given a request id, the arguments as a JSON array, and a void * it was bound with. webview_return resolves or rejects that promise.

Decision

A callback is declared on the page value, beside where it says what to load:

WebPage.of(url).on("save", arguments -> { store(arguments); return "true"; })
const ok = await window.save({title: "note"});

On the value rather than on the widget, because a page value is the whole description of a page and the standalone window form (WebViews.open) is described by the same value — so it gets callbacks for nothing instead of needing a second mechanism. Bindings travel WebPage → WebViewSpec → WebViewEngine, which applies them before the content, since the glue runs at document start.

What crosses is text, and it is not parsed

window.save({title: "note"}) arrives as the string [{"title":"note"}] — the arguments as a JSON array, exactly as the engine hands them over. The toolkit does not parse it. Goldberry ships no JSON reader and binding one for the sake of a callback would put a dependency in :core that every application pays for and few would use; a page that wants to send one value sends one value, and a page that wants structure brings a reader. The showcase’s demo takes the quotes off a one-string array and says in as many words that it is not a parser.

Throwing rejects the promise

The page is awaiting. A handler that cannot answer has still told the page something, and the exception’s message is what its catch receives — whereas a handler that swallowed its failure would leave a promise pending for ever, which is a hang with no error anywhere.

One upcall stub for every binding of every page

An upcall stub is executable memory in a global arena, so one per bound name would be one that is never freed per name. There is a single stub and a registry keyed by the number handed over as the callback’s void *arg — SdlFileDialogs’ idiom, a counter and a map and MemorySegment.ofAddress. A page drops its own entries when it closes.

On the UI thread

The engine’s loop is drained by Webview.pump(), which the frame loop calls, so a handler runs on the thread that paints. It may read and write state and call setState, like every other widget callback. It must not block: the page’s promise and the next frame are both waiting on it.

Alternatives considered

On the WebView widget — new WebView(page).on(...). Keeps WebPage a value with no behaviour in it, which is a real argument, and leaves the window form unable to have callbacks at all without a second mechanism for the same thing.

A handle handed back after opening, which the application calls bind() on. The familiar shape, and it fights the rule that a widget is a value rebuilt every frame: there would be a moment before the handle exists, an order to get right, and a registration to unwind.

webview_init as well, injecting script at document start. Useful and not needed by anything: a page that wants a helper defines one, and the export list’s rule is that a symbol nothing binds is dead weight.

Parsing the JSON. See above — a reader is a dependency, and the one shape this crossing has is “a string the page chose”.

Consequences

web-view is a two-way widget. An embedded page can hand results back, and the showcase demonstrates both directions: a resolved promise and a rejected one, from a document that greets Java on load so the mechanism shows itself before anything is pressed.

WebPage and WebViewSpec gain a component, with the seven-argument constructor kept so nothing that existed had to change.

Two pages are now rarely equals. A handler is a lambda and lambdas have no value equality, so two rebuilds of the same on(...) expression produce unequal pages. That is why ADR-0449’s showsSameAs exists: a widget navigating on equals would reload the page on every frame.

Bindings are read when the page opens. A handler added to a page that is already open does not take effect, because the glue has already been injected. The javadoc says so; the alternative is rebinding on every rebuild, which is a platform call per frame for a case nobody has.

Webview ABI 6, two exports. It is also the first ABI bump caught by its own guard: the shim was raised to 6 and the Java constant was not, and the page simply refused to open with a message naming both numbers — which is what that check is for.

A page can now run application code. The binding is the application’s own and is reached only by the document it loaded, but an application that binds a handler and then navigates to a page it does not control has handed that page a call into itself. Worth saying once, here.

449. A page follows the value that describes it

Date: 2026-09-20

Status

Accepted.

Context

A web-view loaded whatever its WebPage named when it opened, and then nothing could change it. An address bar — a field, a Go button, a page that follows — was not expressible, and neither was the ordinary case behind it: an application that shows a document chosen somewhere else on the screen.

The obvious answer is a handle to call navigate(url) on. The toolkit’s own rule points elsewhere: a widget is a value rebuilt every frame, and the widget already takes the page as a value. Nothing was reading it after the first frame.

Decision

The widget follows its page value. The application holds the page it wants, rebuilds, and web-view navigates:

private WebPage showing = WebPage.of(START);
// ...
new WebView(showing)
// and the Go button is:
setState(() -> showing = WebPage.of(typed));

No new API, no handle, nothing to hold on to, and no second way of saying what the page shows that could disagree with the first.

Compared by what it shows, not by equals

WebPage.showsSameAs compares the url and the document and deliberately nothing else.

It has to. ADR-0448 put callbacks on the page value, a callback is a lambda, and lambdas have no value equality — so two rebuilds of the same on(...) expression are unequal pages and a widget navigating on equals would reload for ever. Even without them the right comparison is this one: a title, a window size and the inspector are things about a page that do not change what is on it, and re-navigating to put a different word in a titlebar is a reload nobody asked for.

The spinner comes back, and only for this

ADR-0445 latched shown after the first load, because a page that re-parked on every navigation would blank itself when a site redirected — which is what a real run showed GitHub doing six seconds in. A browser keeps the old document up until the new one commits.

An application saying “show this instead” is a different event, and the latch is released for it. What is on screen is no longer what was asked for, and several seconds of stale content with no sign of life is worse than a spinner. That distinction is the whole reason navigation is driven from the value: the widget can tell the two apart because one of them arrives as a rebuild and the other does not.

Alternatives considered

An imperative navigate(url) handle. Direct, familiar, and it adds a second source of truth: after page.navigate(b) the widget’s own value still says a, and the next rebuild for any unrelated reason would navigate back. Making that safe means the handle writing into the widget’s state, which is the declarative design with extra steps.

Comparing with equals and telling applications not to use lambdas in a page that may be rebuilt. A rule nobody would remember, enforced by an infinite reload.

Re-navigating on any page change including the title. Simpler to describe and wrong: it reloads a document to change a window title.

Consequences

An address bar is fifteen lines of application code, and the showcase has one. Typing changes a field; pressing Go changes the page; the widget does the rest.

WebPage grows showsSameAs, which is a method that exists because equals cannot be used — worth saying plainly, since a reader will otherwise reach for equals first.

A page can be swapped for a document and back, because showsSameAs compares the html as well as the url.

Navigation is one frame late, like everything else web-view does from its painter. Nobody can see it.

Nothing observes the result. The application knows what it asked for and not whether it arrived: a url that 404s or never resolves looks the same from outside as one that worked, because the widget keeps the load state to itself. An application wanting to show “could not load that” needs something this decision does not provide, and the state is already there to expose when somebody needs it.

450. The WebView2 runtime ships with Windows; its headers do not

Date: 2026-09-21

Status

Accepted.

Context

ADR-0441 made libgoldberry-webview the one artifact in this build that an installation is allowed not to have, and wrote the rule for deciding whether to build it: Linux probes for WebKitGTK’s development headers and degrades when there are none, while macOS and Windows are always on. The line that put them there said:

WKWebView is a system framework and WebView2 ships with the OS, so there is no optional header to probe for.

Half of that is right. WebView2 is two things with one name, and the sentence conflates them:

  • the runtime is Edge. It does ship with Windows, and it is what WebView2Loader.dll eventually talks to;
  • the SDK header WebView2.h does not ship with anything. It is delivered exclusively in the Microsoft.Web.WebView2 NuGet package, and webview.h includes it by name.

So the Windows leg of the superbuild compiled every upstream, linked goldberry.dll, reached the one translation unit of the second library, and stopped:

webview.h(2788,10): error C1083: Cannot open include file: 'WebView2.h'

This broke master. publish / windows / libgoldberry (windows-x64) failed at the Build step, and publish / windows / Verify layouts (windows-x64) failed behind it at download-artifact, because the artifact it waits for is uploaded by the job that had just died. Two red jobs, one cause. Linux and macOS were unaffected: Linux probes, and on macOS WebKit really is a framework in the SDK.

webview’s own CMake solves this by downloading the package (WEBVIEW_USE_BUILTIN_MSWEBVIEW2, on by default). This build never runs it: FetchContent_Declare(webview ... SOURCE_SUBDIR do-not-add-this-subdirectory) takes the header and none of the project around it, on purpose, because the implementation is the header and there is nothing to link.

Decision

Fetch the WebView2 SDK headers, pin them in the catalog, and probe for the result.

webview2 = "1.0.1150.38" goes in gradle/libs.versions.toml beside every other upstream ref (ADR-0035) — including on the two platforms that never download it, because a ref that appears only inside a platform branch of a CMake file is one nobody reviews. It is the version webview 0.12.0 pins and therefore the version upstream tests against, which is why it is a 2022 date rather than the newest available.

CMake fetches the package on Windows only, then looks for WebView2.h inside it. Finding it turns the library on; not finding it leaves it off with a message saying so, exactly as a Linux machine without WebKitGTK behaves.

Headers only

Nothing from the package is linked and nothing from it is shipped. The six libraries on the Windows link line — advapi32 ole32 shell32 shlwapi user32 version — are Windows’ own, and webview’s built-in loader resolves WebView2Loader.dll with LoadLibrary at run time, against the Edge already on the machine. This is the same shape Linux has: headers at build time, the desktop’s own engine at run time, and a 70-odd KB shim in between.

A URL, and no hash

Every other upstream here is fetched by git tag, and a tag is mutable — it can be moved. A NuGet version cannot: the registry refuses to republish a version it has already served. The URL is therefore a stronger pin than the ones around it, and adding a URL_HASH would buy nothing except a second place to edit on a bump and a confusing failure for whoever forgot.

DOWNLOAD_NAME is set because the URL’s last component is a bare version with no extension, and CMake will not extract an archive whose name tells it no format.

Not fatal

A fetched package that holds no WebView2.h prints the reason and builds one library instead of two. This is ADR-0441’s rule, not a new one: everything else in this build is required, because a toolkit that silently drops its text shaping is the bug ADR-0325 exists about — and a web view is a feature an application opts into by calling for it. A layout change at Microsoft’s end should cost the second library, not the whole native build.

-DGOLDBERRY_WEBVIEW2_INCLUDE_DIR=<path> skips the download for a machine that already has the SDK installed.

Consequences

The Windows superbuild compiles again, and for the first time it produces goldberry-webview.dll rather than dying on the way to it.

Windows builds now download 1.7 MB from nuget.org. It lands in FETCHCONTENT_BASE_DIR with every other upstream, which lives outside build/ (ADR-0038), so clean does not throw it away and only the first build pays.

A Windows machine that cannot reach nuget.org fails to configure — the same way one that cannot reach github.com already fails, and for the same reason. The escape hatch is the include-directory flag.

The Goldberry web view: status line now names the engine on all three platforms rather than only the pkg-config module on Linux, because the reason a build has no web view is now platform-specific and the old message told a Windows reader to install a Debian package.

Still UNVERIFIED at run time. This is a compile and a link, which is more than Windows had, and it is not a page that has been opened. ADR-0441’s caveat stands for macOS and Windows both: the shim is written against webview’s C++ API, that API is the same on every backend, and nobody has run it here. natives’ layout tests do not reach it — they check libgoldberry, and this is the other library.

CI still does not ship it. upload-artifact names goldberry.dll alone on Windows, libgoldberry.so on Linux and libgoldberry.dylib on macOS, so the second library is built and discarded on every platform. That is a packaging decision this one does not make; it is written down in docs/todo-2026-09-21.md.

451. A shape is declared where it can be reached, not where it is linked

Date: 2026-09-21

Status

Accepted.

Context

The Linux showcase’s native image built, started, painted, and died on its first frame:

MissingForeignRegistrationError: Cannot perform downcall with leaf type (long,int,long)long
  ...
  at ...natives.desktop.calls.Bindings.link(Bindings.java:30)
  at ...natives.desktop.calls.PortalSettings$Call2.<init>(PortalSettings.java:252)
  at ...natives.desktop.DesktopMotion.linux(DesktopMotion.java:89)
  at ...render.backend.sdl3.Sdl3Backend.reducedMotion(Sdl3Backend.java:1250)
  at ...Window.reducedMotion(Window.java:257)
  at ...Launcher.renderer(Launcher.java:615)

The missing shape is dbus_bus_get: void* (jint, void*).

ADR-0339 exists to make exactly this impossible. It replaced a traced run — which records the screens the trace reached — with a generator that records every holder there is: Downcalls.link remembers each descriptor it is handed, ForeignSurface initialises every class in every …calls package, and ForeignMetadata writes the lot. Its own commit message names the case: “a ...Calls record binds on first use, so the Markdown parser’s holders were never initialised and never recorded”.

PortalSettings is in a …calls package and was initialised. It still went unrecorded, for a reason ADR-0339 did not have to consider, because at the time every binding was against libgoldberry.

ADR-0383 added the first bindings that are not. MacMotion, WindowsMotion and PortalSettings talk to libobjc, user32 and libdbus — libraries the toolkit does not ship and the machine may not have — so they bind through Bindings rather than Downcalls, with the opposite failure policy: a missing symbol is a missing feature, not a broken export list. That much is right and stays. The ADR then wrote:

…every descriptor it links is recorded for the native image’s metadata. Neither applies to a system library that is dlopened by name and may not be there at all.

The second half does not follow. Whether a library is on the machine that builds an image says nothing about whether it is on the machine that runs it. An unrecorded shape is not a shape that will not be crossed; it is a crash on the first desktop that has the library.

And there was a second layer, which is why simply recording in Bindings.link would have been a half-fix. The descriptor was constructed inside the constructor that links it:

Call2(SymbolLookup lookup, String symbol) {
    this(Bindings.link(FunctionDescriptor.of(ADDRESS, JAVA_INT, ADDRESS)),
         Bindings.symbol(lookup, symbol));
}

Calls.find() returns null when there is no libdbus, so that constructor never runs on a machine without one, so the descriptor is never built, let alone recorded. The generator runs on the build machine. A build machine without libdbus — which is the ordinary case, and is the machine this was developed on — produces metadata with the shape missing, silently, and the image is wrong in a way nothing on that machine can show.

That is why it survived: every gate passed. The JVM needs no metadata. The native image built. The failure needs a machine that has libdbus and an image built by a machine that might not — which is precisely CI, and the showcase is the only job that runs an image.

Decision

A foreign shape is declared where it can always be reached, and linked where the library is. Two different places, and conflating them was the bug.

Downcalls.describe(descriptor) records a shape and hands it straight back, without linking — the exact twin of Upcalls.describe, which exists because an upcall’s descriptor reaches Linker.upcallStub too late for an image to hear about it. A downcall against an absent library is late for the same reason, and now has the same answer.

Every descriptor in …desktop.calls is a constant declared through it:

private record Call2(MethodHandle handle, MemorySegment address) {

    /// `DBusConnection *dbus_bus_get(DBusBusType, DBusError *)`.
    private static final FunctionDescriptor FD = Bindings.describe(
            FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.ADDRESS));

    Call2(SymbolLookup lookup, String symbol) {
        this(Bindings.link(FD), Bindings.symbol(lookup, symbol));
    }

A static final on a holder runs when the class is initialised, and ForeignSurface initialises every class in a …calls package — nested records included, which they already were — on any machine. The link stays exactly where it was and stays conditional.

Downcalls.link is now LINKER.downcallHandle(describe(descriptor)), which is what it always did, spelled as the two steps it is.

It would fix the reported crash on a build machine that has libdbus and leave it in place on one that does not, which is the harder failure to find and the more common machine. A guard that works only where the bug is already absent is not a guard.

Consequences

The Linux showcase image can ask the desktop about reduced motion. So can any application built from a published goldberry-natives jar — the metadata travels inside it, so this was every native image, not just the showcase’s.

macOS and Windows were equally broken and equally fixed. Neither had reached its own holder yet: MacMotion’s objc_msgSend shapes and WindowsMotion’s SystemParametersInfoW were missing from the metadata for the same reason, and would have failed the same way on the first image that asked. The macOS showcase’s failure is unrelated and is not this.

The generated reachability-metadata.json goes from 77 downcall shapes to 82 — measured here, on a machine with no libdbus, by generating the file with and without the fix. The three holders declare twelve shapes between them; the other seven were already in the file by coincidence, having the same shape as some libgoldberry holder. That coincidence is why four rather than five of the shapes the new test names failed before the fix, and it is worth knowing about: it means a shape can be missing from the metadata and still work, until the day the holder it was borrowing from changes.

Two tests, and both fail on the old code. systemLibraryShapesAreDeclared names the five shapes and asserts they are reported whatever this machine has installed, which is the property that matters and the one that cannot be checked by having the library. systemLibraryShapesAreConstants reads the three sources and refuses a descriptor written inline at a Bindings.link(…) call site, so the next holder cannot reintroduce it. everyHandleIsCovered, the existing guard, cannot see these: it looks for a static final MethodHandle FD_…, and a system-library holder deliberately has none.

A holder against a system library now says its C prototype in a doc comment, like every libgoldberry holder already does (ADR-0173) — a side effect of having somewhere to write it.

452. A refresh budget nobody has measured is not a gate

Date: 2026-09-21

Status

Accepted.

Context

ADR-0342 gave the showcase a verdict. Each native image runs for three hundred frames with its window walked a pixel at a time, the launcher counts every display refresh that went by with a frame wanted and undelivered, and --late-budget=30 turns that count into a non-zero exit. showcase.yml ran all three platforms that way, under a comment that said:

The budget is a tenth of the frames, and it is a ceiling on a GPU-less virtual machine painting into a software X server, Cocoa or D3D — evidence about three platforms’ drivers, not a claim about hardware.

It was not evidence about any of them, and the first run to reach the check said so. macOS painted its three hundred frames and failed:

over the late-frame budget of 30: 300 frame(s) painted, 197 late;
paint mean 7.05 ms, worst 331.22 ms; display 60.0 Hz

The natural reading is a regression, and it is wrong. Re-running the last green showcase — run 20, at 64596a97, whose own logs had expired — showed what the macOS leg used to do:

./goldberry-showcase-macos-aarch64 --frames=3

Three frames, no walk, no budget. The 300-frame budgeted run arrived with ADR-0342 and after that green run, so run 21 was the first time macOS was ever asked, and it has never passed. Nor has anyone else: Windows has never got past building libgoldberry (ADR-0450), and Linux has never got past its own missing foreign registration (ADR-0451). No leg on any platform has completed the run. The number 30 was reasoned about, not measured.

So what went wrong is not a frame rate. It is that a ceiling was written for three machines on evidence from none, and then made a verdict.

Decision

No budget is asserted, on any platform. All three run the identical walk and report what it cost.

This is the second answer. The first kept the budget on Linux, on the grounds that showcase.yml pins that display and a refresh budget is a number about a display:

xvfb-run -a --server-args="-screen 0 1920x1200x24"

The first Linux run to get past its own missing foreign registration (ADR-0451) and its missing font (ADR-0453) printed the line that disproves it:

frames: 302 frame(s) painted, 75 late; paint mean 10.14 ms,
worst 1799.59 ms; display 0.0 Hz

display 0.0 Hz. Xvfb pins the geometry and reports no refresh rate at all, so adoptDisplayRate adopts nothing, the pacer keeps its default interval, and “late” counts missed ticks of a software timer. That is exactly what it counts on macOS. The carve-out was reasoning from a premise the evidence does not support, and it is left in this record rather than edited out because the premise was checkable before the run and was not checked.

What the legs actually measure, now that each has produced a number:

legframeslatepaint meanworstdisplay
linux-x643027510.14 ms1799.59 ms0.0 Hz
macos-aarch643002006.42 ms200.26 ms60.0 Hz

Both are far over 30, neither is a regression, and on each the worst frame is start-up rather than anything the walk did. Two samples locate a ceiling no better than none: a budget has to sit above what the machine does and below what a regression does, and nothing here says where either edge is.

What still fails the step

grep -q "painted 300 frame(s); exiting". A hang, a crash, an image that will not start, or one that dies part-way — as Linux did on the emoji font — all still turn the job red. That is most of what the step was ever catching; the budget caught nothing, because it had never once been under.

A --frames=300 run said “exiting” three times

Found in the same log and fixed here, because it is the sort of thing that makes a number untrustworthy:

painted 300 frame(s); exiting
painted 301 frame(s); exiting
painted 302 frame(s); exiting

Goldberry.stop() ends the loop; it does not unschedule the frames already in flight, and by then the resize walk’s zero-delay timer and an animating renderer have each asked for one. Those frames still paint, and each re-ran the painted >= frames branch. The launcher latches now, so the line is logged once. The count still reads 302 rather than 300, which is honest: three hundred and two frames were painted.

Unguarded by a test, deliberately. The only observable difference is the log line — the latch changes no frame, no count and no exit code — and :core has no log-capture harness and no logback on its test classpath. Adding one for a single assertion about a line of INFO is a worse trade than saying here that this one is held by review. LauncherEvidenceTest already covers the thing that could actually break, which is that a frame-limited run still terminates.

Consequences

The showcase can go green on all three platforms for the first time since the 300-frame walk was added.

No frame regression is caught automatically, and that is a real loss said plainly. What replaces it is the summary line in each job’s step summary, which a human reads. The honest position is that nothing was caught automatically before either — the budget had failed every single time it ran, on every leg that reached it, which catches nothing and hides everything behind a red tick that means “this runner is a VM”.

There is now a baseline. Two legs have reported, and the third will. Once there are several green runs the numbers can be looked at as a distribution and a ceiling set from the top of it — per platform, since 75 and 200 are clearly not the same machine. The note in showcase.yml says so and carries the numbers.

The ADR-0342 mechanism is untouched. --late-budget=N still exists, still throws FrameBudgetException, and is still what a developer runs locally against a real display, which is where it was always meaningful. What changed is that CI stopped asserting a number nobody had measured.

453. A face that moved module takes its declaration with it

Date: 2026-09-21

Status

Accepted.

Context

The Linux showcase’s native image started, opened its window, painted, and died on the first paragraph with an emoji in it:

java.io.IOException: /io/github/digitalsmile/goldberry/emoji/fonts/OpenMoji-color.ttf
is missing from goldberry-emoji, which means this jar was assembled without the asset step
  at ...emoji.OpenMojiFont.read(OpenMojiFont.java:62)
  at ...text.font.Fonts.emojiAt(Fonts.java:230)
  at ...widget.WidgetRenderer$1.paragraph(WidgetRenderer.java:141)

The message is OpenMojiFont’s own, and in this case it is wrong. The jar was assembled with the asset step; the font is in it. What was missing is the declaration that puts a resource inside a native image, so getResourceAsStream answered null and the only diagnostic the class has for null is the one about the asset step.

ADR-0160 settled how resources reach an image: declared by glob in a META-INF/native-image/**/reachability-metadata.json that travels in the jar, not traced from a run. :core, :widgets and :example each ship one. :emoji shipped none.

The best evidence that this was foreseen is in :core’s own metadata, in the comment explaining why globs beat traces:

The first image built here recorded nord-dark.css and not nord-light.css, and Inter but neither JetBrains Mono nor OpenMoji — because the run never switched theme and never drew mono text or an emoji. Each of those is a control the user can reach and an image that dies when they do.

At the time that was true and OpenMoji was covered, because it lived in :core under io/github/digitalsmile/goldberry/assets/fonts/ and the glob assets/fonts/*.ttf over module io.…goldberry.core caught it. ADR-0384 then moved the face into :emoji — an artifact an application opts into, because CC BY-SA wants credit where the work is seen — and ADR-0387 moved the resource under this module’s own package, because a resource directory is a package and the same one in two modules stops the application starting. Both moves were right. Neither took the declaration along, and there was nothing to notice: the glob still matched a directory, just not one that exists any more, over a module that no longer holds the font.

It then hid for the same reason the other two failures of this batch hid. The showcase’s image ran three frames, which is the first screen; nothing drew an emoji. The 300-frame walk of ADR-0342 reaches a screen that does.

Decision

:emoji ships its own reachability-metadata.json, declaring the one resource it has, over its own module, under the path the asset step actually writes to:

{ "module": "io.github.digitalsmile.goldberry.emoji",
  "glob": "io/github/digitalsmile/goldberry/emoji/fonts/*.ttf" }

A glob rather than the one file name, to match what :core and :widgets already spell and because the asset step’s output is the authority on what it wrote.

Three files have to agree, so a test says so

The path appears in three places that nothing connected: the --root the asset step is given in emoji/build.gradle, the glob in the metadata, and the RESOURCE constant OpenMojiFont reads. Any two can be changed without a build failing, and the symptom is an image that works until something draws an emoji — which, as this record shows, can be a long time.

DeclaredFontResourceTest holds all three together, and its last assertion is the one a text comparison cannot make: the font is really on the classpath under the name that was declared, so a declaration naming a file the asset step does not produce fails too.

Consequences

An emoji can be drawn in a native image. Not only the showcase’s: the metadata travels in the goldberry-emoji jar, so any application that takes the dependency and builds an image gets it without knowing it needed it, which is ADR-0160’s whole point.

OpenMojiFont’s diagnostic is still misleading in this case and is left alone. It names the likeliest cause of a null stream for a developer running on a JVM, which is the common case; teaching it to distinguish “not in the jar” from “not in the image” would mean asking whether it is in an image, and the answer would be wrong on the day the check is needed. The test above is the better guard: it makes the case impossible rather than better-reported.

The other modules were checked, and :html had the same hole. It ships markdown/view/markdown.css and html/view/html.css, reads both by name, and declared neither. It works today for a reason worth writing down: the showcase traces a run, that run opens the Markdown and HTML screens, and the traced metadata :example ships lists both files literally. So :html’s stylesheets reach the showcase’s image by luck and would not reach the image of anyone else who took the module and traced a run that did not open a document. That is exactly the failure ADR-0160 was written about, surviving inside the mechanism meant to prevent it. :html now declares its own.

:core, :widgets and :example were already right. :natives ships a metadata file for foreign calls (ADR-0339, ADR-0451) and no resource glob; its library is linked into the image rather than read out of a jar, so there is nothing there to declare.

A repository-wide guard, because per-module was what got forgotten twice. DeclaredResourcesTest in build-logic holds every module’s shipped resources to that module’s own globs. The “own” is the whole point and was learned the hard way: the first version of the test pooled every module’s declarations, saw :example’s traced list covering :html, and passed. A declaration that does not travel with the module it describes is not a declaration.

454. A force-link list belongs in the object, not on the link line

Date: 2026-09-21

Status

Accepted.

Context

With ADR-0450 in, the Windows superbuild compiled. It then failed at the last step but one, linking the library it had just built every object for:

[679/681] Linking CXX shared library goldberry.dll
FAILED: [code=1] goldberry.dll goldberry.lib
C:\Windows\system32\cmd.exe /C ""...link.exe" ... /INCLUDE:goldberry_abi_version
/INCLUDE:SDL_Init ... (253 of them) ... /DEF:.../goldberry.def"
The command line is too long.

Two numbers explain it. The command is 8541 characters, of which 7697 are the 253 /INCLUDE: flags. CreateProcess would take 32767 — but Ninja runs the link through cmd.exe /C, and cmd stops at 8191. We were 350 over.

The flags are not optional. Every exported symbol needs one, or the static archives contribute nothing: SDL_Init is referenced by no Goldberry source, so without /INCLUDE: the linker never pulls it out of SDL3-static.lib and the .def exports a name that is not in the image. That is the same force-link problem Linux solves with -u and macOS with -u _name.

What made this hard to see is that windows.yml was green on the same commit. It uses the Visual Studio generator, which drives MSBuild, which passes the link through a response file of its own; showcase.yml uses Ninja, which does not. One commit, two Windows jobs, opposite results, and nothing in the failure naming /INCLUDE: as the thing that was too long.

It had also been true for a while and only just crossed the line. eb30c1bf renamed 25 exported HarfBuzz symbols from hb_* to goldberry_hb_*, adding ten characters each, and ADR-0385 added six WebP animation entries. The list had been growing toward 8191 for months and the build that crossed it is not the build that caused it.

Decision

The MSVC force-link list is generated as #pragma comment(linker, ...) directives in a source file compiled into the library.

// Generated by CMake from exports/goldberry.symbols -- do not edit.
#pragma comment(linker, "/INCLUDE:goldberry_abi_version")
#pragma comment(linker, "/INCLUDE:SDL_Init")
...

The compiler writes each directive into the object’s .drectve section and the linker reads them from there. Nothing reaches a command line, so nothing can be too long for one.

The response file, which is what this record first said, does not work

It is the obvious answer, it fixed the Ninja build, and it broke the Visual Studio one:

LINK : fatal error LNK1104: cannot open file '@...\goldberry.force'

Response files do not nest. MSBuild already passes the whole link through one of its own, and an @file inside a response file is not expanded — link.exe takes it as the name of a file to link, @ and all, and cannot open it. Ninja does not use a response file for the options, so there the @file was expanded and worked.

That is the same disagreement between the two generators as the original bug, in the other direction. It is the reason the answer has to be off the command line altogether rather than merely shorter: any fix that is a link argument is a fix that one of the two generators will handle differently from the other, and there is no way to tell which from a machine that runs neither.

It is recorded here rather than quietly replaced because the reasoning that led to it was sound and still wrong — “every other platform writes its list to a file, so Windows should too” is a good argument that happens not to survive contact with MSBuild.

Linux and macOS are untouched

ld and ld64 are not invoked through cmd.exe, the limit there is ARG_MAX in the megabytes, and -u on the command line works under every generator. Changing a thing that works to match a thing that had to change is not symmetry worth having.

Not “drop the flags, the .def already forces them”

Plausible, and not taken. MSVC does resolve a .def export by pulling the defining object out of an archive, so most of the 253 would probably survive. “Probably” is the problem: the failure mode is a symbol silently missing from the DLL, which surfaces as an UnsatisfiedLinkError in Java on the first call through it, on Windows only. A response file changes how the flags are delivered and nothing about what they mean.

Consequences

The Windows link line loses 7.7 KB and does not grow with the export list again — which it does with every upstream symbol added, and nothing was watching.

Both generators agree. They are the same build twice, and this is the second time in one batch that a green Windows job beside a red one on the same commit turned out to be the two generators disagreeing rather than flakiness. That pattern is worth remembering: windows.yml uses Visual Studio, showcase.yml uses Ninja, and a Windows fix is not verified until both have run.

A test holds the shape, and it now names both wrong answers. msvcForceLinkListIsInTheObject refuses the inline /INCLUDE:${_symbol} and refuses the @${_force_file} — the second is in there because it is the mistake a reader is most likely to make again, having read the first half of this record. It reports the current inline size when it fires, 7950 bytes today, so whoever trips it learns the number without the test asserting one that moves.

The generated source was checked by generating it: the CMake block run standalone against exports/goldberry.symbols produces 253 pragmas for 253 symbols and compiles as C. What could not be checked here is that MSVC honours them, which is the whole point of the change and needs a Windows runner.

455. A page that will not animate is measured before it is blamed

Date: 2026-09-21

Status

Accepted.

Context

A page embedded through §9’s web-view animated visibly badly on the machine this was reported from — a 100 Hz panel, an NVIDIA GTX 1660 Ti on driver 610.57.04, and a GNOME Wayland session. The engine’s own logging said it had brought up its DRM path at 100 Hz. The animation was still slow.

Everything about the toolkit’s side of that boundary pointed at the toolkit. The reasoning was available, it was written down already, and it was wrong:

  • On Linux the engine runs on GLib’s main context, and nothing drives that context except Goldberry’s event loop. WebViewEngine#pump() is called once per turn of EventLoop#run.
  • That loop parks in SDL for up to WEB_VIEW_TIMEOUT, 8 ms, and GLib’s file descriptors are not in that wait. EventLoop says so itself: “Nothing wakes this loop when WebKit has work to do.” See ADR-0441.
  • goldberry_webview_pump then runs at most 16 g_main_context_iteration calls and breaks only when the context goes idle — so under an animation it always burns all sixteen and returns with work still queued.

Three real constraints, all of them genuinely in the code, and together they make a complete and plausible story: the page is serviced on a polling clock with a fixed dispatch quota, so of course it cannot keep up with a 100 Hz panel.

That story predicted 50–100 frames a second. The page was getting 0.3.

Being two orders of magnitude out is not a detail to reconcile. It means the mechanism is not the one in the story, and no amount of tuning the two constants would have found that out — both would have “improved” the number slightly and confirmed the wrong cause.

Decision

Measure the page’s own clocks before changing anything, and measure three of them.

:example’s webpump package opens a page whose document counts, and reports through a binding once a second:

clockwhat it iswhat a low reading means
timersetInterval(…, 4), a plain GLib timer sourcethe main context is not being drained — the embedder’s fault
timelinehow often document.timeline advances, sampled from the timerthe engine is not compositing
rafrequestAnimationFramethe engine composites and does not run the page’s callbacks

The middle one is load-bearing and is the reason this is written down. Sampling the rendering update from a timer rather than from a frame callback is what makes it readable when frame callbacks are the thing that is broken. With only raf, a stalled compositor and a broken animation controller are the same number.

What it measured

On the reported machine, in Goldberry and in stock MiniBrowser against the same webkit2gtk-4.1 build, identically:

median 1.0 raf/s, 63.2 timeline/s, 125.0 timer/s — RAF_STARVED
  • The pump is fine. 125 timer callbacks a second, every second, no drift.
  • The engine composites. The document timeline advances ~63 times a second, so CSS animations and transitions run.
  • requestAnimationFrame is dead. ~1 callback a second — the flat, perfectly repeatable rate of a fallback timer, not the noisy shape of starvation.

Three controls make that readable rather than merely suggestive:

  • Firefox, same machine, same document: 60 raf/s. The instrument works.
  • MiniBrowser, its own gtk_main(), no Goldberry anywhere: the same 1.0 raf/s. The embedding is not involved. Both the GTK3 (webkit2gtk-4.1) and GTK4 (webkitgtk-6.0) builds of 2.52.6 behave identically.
  • A document with no timers whatsoever, beaconing from inside the frame callback itself: 0.3 raf/s. So the probe’s own setInterval is not starving the frame callback — rAF is in fact worse without it, which is its own clue: the controller appears only to advance when something else churns the run loop.

What it is not

Swept, each against the no-timer document, each identical at 0.3 raf/s: WEBKIT_DISABLE_DMABUF_RENDERER, WEBKIT_DMABUF_RENDERER_FORCE_SHM, WEBKIT_DISABLE_COMPOSITING_MODE, WEBKIT_FORCE_COMPOSITING_MODE, WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS, LIBGL_ALWAYS_SOFTWARE, GSK_RENDERER=cairo, GDK_BACKEND of either x11 or wayland, and __EGL_VENDOR_LIBRARY_FILENAMES pointed at Mesa so that NVIDIA’s EGL is out of the process entirely.

That last one matters: this is not the NVIDIA driver. A GPU stack that has been replaced with llvmpipe and Mesa EGL and still produces the identical reading is not the thing producing the reading.

Nor is it the window. The page reports hasFocus() == 1, visibilityState == "visible" and a sane innerWidth, so it is not occluded, unmapped or throttled as a background tab. WebGL2 contexts are created successfully. The display is correctly configured — Mutter reports 2560x1440@99.946 as current and preferred. The engine’s stderr is silent.

The shape of the fault

Frame callbacks are run as part of the rendering update. A timeline that advances 63 times a second while the callbacks inside it run once says the rendering update is happening and the animation-callback step within it is not. That is not a missing vsync — a missing vsync would slow both equally, and neither lowering nor raising anything on the toolkit’s side of the boundary can reach it.

What upstream says

No matching report for 2.52.x, and nothing in webview/webview describing the 1 Hz symptom — #502 is Windows/Edge and about window moves breaking a frame loop.

The 60 Hz half of the reading is known and unfixed: #528 is exactly it, closed with the maintainer saying “I am not sure why it seems to be throttling… it might mean we have to dig into webkit core”, and its last comment (June 2025) reports the same control this ADR ran — both MiniBrowser builds, every environment variable, Wayland, no success. A different person on different hardware, a year earlier.

That thread proposes webkit_settings_set_hardware_acceleration_policy(…, ALWAYS), which webview 0.12.0 does not call — it sets only javascript_can_access_clipboard, enable_write_console_messages_to_stdout and enable_developer_extras. Deliberately not adopted here: MiniBrowser reproduces the fault with no webview code in the process, so the setting cannot be the cause, and adding it would look like a fix while changing nothing.

The venue for the 1 Hz bug is bugs.webkit.org, not webview/webview.

Consequences

This is not Goldberry’s bug and there is no Goldberry fix. An engine that composites at 63 Hz while declining to run the page’s frame callbacks is broken somewhere no embedder can reach. Lowering WEB_VIEW_TIMEOUT or raising the pump’s sixteen would have been a change with a rationale, a plausible story, and no effect — which is the expensive kind of wrong, because it ships.

The two constants stay as they are. ADR-0441’s reasoning about them is untouched: they are real ceilings and the fd-wakeup remains the honest fix for the cost of the 8 ms poll. They were simply not what was wrong here, and this ADR exists so the next reader does not re-derive the same convincing story.

A workaround exists and belongs in the docs rather than in the code. CSS animations, transitions and the Web Animations API all ride the rendering update, which works; only requestAnimationFrame does not. A page that must animate on this engine animates in CSS. docs/web-pump-probe.md says so.

The probe is not part of any check. It opens a real window and needs a real engine, and it answers a question about a machine rather than about the code. :example:test has to keep running where there is no WebKit at all, so it is its own task — :example:webPumpProbe.

Notes

The rendering update measured 63 Hz on a 100 Hz panel, which is a second and smaller discrepancy this did not chase. It is recorded here because it will look like a new finding to whoever reads the probe’s output next, and it is not.

456. The emoji face is Noto, drawn from its paint graphs

Date: 2026-09-23

Status

Accepted. Supersedes the choice of face in ADR-0384 and the choice of format in ADR-0393; the artifact boundary of the first and the itemization of the second stand.

Context

goldberry-emoji shipped OpenMoji’s COLRv0 build: flat-coloured layers, drawn a layer at a time. The request was to switch to Noto Color Emoji — the face Android and ChromeOS draw, whose pictures are the ones most readers expect — and to keep it in colour.

Google publishes Noto Color Emoji two ways, and the choice between them is the one ADR-0393 made for OpenMoji, with different numbers:

BuildFormatSizeAt 200%
NotoColorEmoji.ttfCBDT — 136 px PNG strikes10.7 MBscaled bitmaps, soft
Noto-COLRv1.ttfCOLR v1 — paint graphs of outlines5.0 MBoutlines, sharp

The bitmap build needs a PNG decoder in the font pipeline and blurs above the one size it was drawn at, on a toolkit whose claim is that it is crisp at every scale (docs/ARCHITECTURE.md §6.2 already rejected it once). The COLRv1 build is half the size and is outlines — but it is not a list of flat layers. Nothing the toolkit had could draw it: ColorLayers reads version 0 records and Noto ships none, so every emoji would have come out as its bare base outline.

What version 1 is, measured over the face (3,993 colour glyphs, 72,825 shared layers):

  • PaintGlyph — fill inside an outline — 66,000 times;
  • its fill is a solid 58,600 times, a linear gradient 3,000, a radial gradient 4,900, and a gradient under a transform of its own 4,200;
  • transforms wrap glyphs 12,400 times — affine, translate, scale about a centre;
  • PaintComposite 567 times, every one of them a flag: the stripes, masked SRC_IN, with a shading ramp laid over them SOFT_LIGHT so the flag waves.

Decision

Read the graph in Java, as a sealed tree

text.font.sfnt gains ColorPaints, the COLR version 1 reader, and ColorPaint, the tree it produces — a sealed interface of records, so the painter is an exhaustive switch and a node added later stops it compiling rather than falling through a default that draws nothing.

What is folded at read time, so the painter has fewer cases:

  • Eleven transform formats become one Transform matrix — translate, scale, rotate, skew, each with and without a centre.
  • Variable formats read as their defaults. Every PaintVar… is its static twin plus a variation index, and the toolkit draws the default instance.
  • Palette indices become colours, keeping 0xFFFF — “the text’s colour” — as a flag.

The index — base glyphs, layer offsets, clip boxes, palette — is read when the face is. A glyph’s graph is parsed on first use and kept, and layers are cached by index, because the same eyes recur across a dozen faces. Parsing all 3,993 graphs up front would be work and memory spent on emoji nobody sent; ColourEmojiTest.everyGraphReads parses them all anyway — the whole test class runs in well under a second — to prove none is malformed.

A malformed graph — an unknown format, an index past a list, a cycle deeper than 64 — answers null, and the pen draws the glyph as its own outline. That is ColorLayers’ rule and it stays.

Outlines are read too, because a glyph is now a clip

PaintGlyph fills inside an outline with a paint that may be a gradient, and the rasterizer’s glyph call fills with a colour and nothing else. So the painter needs the outline as a path, and GlyphOutlines reads glyf — simple glyphs with their implied on-curve midpoints, and composites placed by offset and matrix. It sends to an OutlineSink rather than returning a Path, because Path is in paint and paint reads sfnt. It is all-or-nothing: a glyph whose data runs off the table sends nothing, not half a shape.

GlyphFace reads outlines only for a face with paint graphs, and caches each glyph’s Path. A face of letters never builds one.

A clip is a fill, and a transform under a glyph is the gradient’s

ColourGlyphPainter concatenates one matrix — size / unitsPerEm, y flipped, at the glyph’s origin — and draws the graph in design units.

The rasterizer has no path clip, and the shipped face does not need one: filling an outline with a brush is the same picture as clipping the brush to the outline. So PaintGlyph over a solid or a gradient is one fillPath. A transform between the glyph and its gradient — 4,200 of them — must move the ramp and not the outline, so it becomes the gradient’s own matrix, which bl_gradient_init_as already took and nothing had passed.

The one case that is a real clip — a glyph whose fill is itself a picture — is drawn as the composite it means: the picture offscreen, kept SRC_IN to the outline.

Composites are two small layers

PaintComposite blends two finished pictures, so both are rendered into Layers sized to the glyph’s clip box on the device, snapped outwards to whole pixels, and the source is blitted onto the backdrop with the font’s operator. Noto’s flags nest one composite inside another; the inner one is drawn onto the outer one’s layer, so the offscreen area travels with the surface being drawn on rather than being fixed to the frame.

The native layer grows constants, not symbols

Everything needed was already exported: bl_gradient_init_as builds radial and conic gradients from a different values struct, and bl_context_set_comp_op takes any operator. What was missing is what the layout verifier guards:

  • BLRadialGradientValues and BLConicGradientValues rows, every field named — Blend2D’s radial puts the focal circle, where the first stop sits, second, and a COLRv1 radial puts it first. Swapped, both render.
  • Every BLCompOp a font may name — twenty-two constants, not the two the face uses, because a font is data the toolkit does not choose. The four HSL modes have no Blend2D operator and draw as source-over.

BlendGradient gains radial and conic and an extend mode and a BlendMatrix; BlendContext gains compOp. No export list changed.

The asset is a file, not an archive

Noto’s release has no assets, and its source zip is the whole repository — hundreds of megabytes of PNGs for one 5 MB font. Asset gains a Packaging: FILE pins the font itself, by the tag in its URL and by its checksum, which is the promise an archive’s checksum made.

The licence, and why the artifact stays

Noto Color Emoji is SIL OFL 1.1 — the licence Inter and JetBrains Mono are under. It asks for the licence text to travel with the font and for a modified font not to be called Noto; it does not ask for credit on screen, which is what put OpenMoji in an artifact of its own. The artifact stays for ADR-0384’s other reason: five megabytes is a lot to inherit for an application that never draws an emoji. NotoColorEmojiFont.CREDIT remains for an About box that wants it.

Consequences

Emoji are Noto’s, shaded and sharp at any scale, in every Paragraph drawn through a Fonts book, exactly as ADR-0393 routed them.

A colour glyph costs more than it did. A Noto glyph is a dozen filled outlines where an OpenMoji glyph was fourteen flat layers — comparable — but a gradient fill builds a native gradient per fill, and a flag allocates two small offscreen layers. None of it has been benchmarked yet; a wall of flags is the case to measure first if it ever shows. Caching a rasterized glyph per size is the optimisation, and it costs the crispness under a transform that this decision is for, so it is not taken now.

Written down rather than discovered:

  • A sweep gradient’s stops outside one turn are clamped into it, and a sweep that repeats or reflects is drawn padded. Noto uses no sweeps.
  • A radial gradient whose stops run past a circle of zero radius is clamped at zero.
  • PaintColrGlyph draws the glyph it names without that glyph’s own clip box, and stops after sixteen references.
  • A composite glyph placed by matching points is placed at its origin. No colour face uses the form.
  • CFF outlines are not read; a COLRv1 face with a CFF table draws its glyphs as outlines.

The version 0 reader stays, for any face that is still version 0, and the pen asks the graph first where a face carries both.

An emoji is as tall as Noto makes it. Noto ascends 950 and descends 250 in 1024 units, so at 14 px an emoji reaches 13.0 above the baseline against Inter’s 13.6 — it sits inside the line box now rather than three-quarters of a pixel over it, which retires one consequence ADR-0393 recorded.

457. A sheet is grouped by its upstream’s own categories

Date: 2026-09-23

Status

Accepted. Extends ADR-0386 and the icon sheet of ADR-0316; the virtualized grid of both stands.

Context

The showcase’s Icons and Emoji screens were one alphabetical (or code point) wall each: 1544 icons, 1212 emoji, a search field. Two things were asked for: the sheets grouped by category, with chips to choose one, and a tile that opens a dialog of the glyph at five sizes — with a line of text at several sizes for an emoji.

Decision

The categories are the upstreams’, compiled at build time

Neither list is invented here. Lucide files every icon under one or more of its own categories in the JSON beside each SVG — already inside the pinned archive :assets fetches. Unicode files every emoji under one of ten groups, in its own emoji order, in emoji-test.txt; the JDK carries every emoji property but that one. So :assets gains CatalogCompiler and PrepareCatalogs, which compile both into one line per entry, into :example’s own catalog package (ADR-0387), exactly as the icons themselves are compiled (ADR-0033).

emoji-test.txt is pinned as a single file by URL and checksum (Asset.Packaging.FILE, ADR-0456), and a single-file asset may now extract nothing, since this one is compiled rather than shipped. The Unicode licence is vendored beside the others.

A heading is a row of the same pitch

Both sheets are one virtualized list of equal-height rows, which is told one pitch and trusts it (ADR-0213). So a group’s heading is a row of that pitch with its label at the bottom, and the grid stays one list — forty lists in one viewport would each virtualize a window the others had moved. CategorySheet holds what both sheets share: the groups, the rows, the heading and the chip row.

An icon in three categories is under three headings, as on Lucide’s own site; the count beside the field counts it once. Emoji are shown in Unicode’s order within each group — the grinning face before the tears of joy — and code points Unicode groups nowhere go under “Other”, last.

Chips choose one; pressing it again is All

Single-choice chips, “All” first. Pressing the chosen chip takes the filter off, which is what a hand expects of a filter row. The choice is the screen’s own state, not the model’s: nothing binds to it.

A tile opens a dialog, and the dialog is the host’s

A tile is a PressableTile — click, Space or Enter, focusable, announced as a button. The screen answers with Dialogs.show on the host, because a modal needs the window and a tile is a value with none (ADR-0106). The icon dialog builds the icon at 16, 24, 32, 48 and 64 points; the emoji dialog styles the glyph at the same five sizes and sets a line of ordinary UI-face text at five text sizes with the emoji in it, which is the routed case of ADR-0393 — the one an application actually draws.

Consequences

The grouped icon sheet repeats icons — 3031 tiles for 1544 icons, about twice the flat sheet — and, being virtualized, builds the same window per frame.

A showcase built without the catalog step shows every icon under “other” and every emoji under “Other”, rather than refusing to open. CategorySheetTest asserts the real tables cover every bundled icon.

A dialog is invisible to a test that walks the element tree, so the screen tests build their trees with a RecordingHost — TourTestHost behind a proxy that records what is filled over the window.

458. A page on macOS is a view, not a window

Date: 2026-09-23

Status

Accepted. Amends the macOS row of ADR-0442, which said addSubview: and marked it unverified. It was never written: the shim’s goldberry_webview_create_embedded answered NULL on every platform except GTK on X11.

Context

The showcase’s Web view tab on macOS showed no page. What it showed instead was the Wayland notice — “This session cannot put a web page inside a window… Running under X11 or XWayland is what makes it work” — and the log said the same thing twice:

Webview - no page could be opened inside the window: this session does not permit
          embedding. Wayland has no cross-client surface embedding; running on X11
          or XWayland does
WebView - web-view: no page could be put inside the window. Embedding needs a
          native window handle to reparent into, which X11, Win32 and Cocoa give…

Two defects, one symptom:

  1. Nothing on macOS could embed. libgoldberry-webview.dylib was built and loaded, Capability.WEB_VIEW was reported, SDL handed over an NSWindow* — and the shim’s #else branch returned NULL with a comment saying the call had not been written because it could not be run.
  2. Every refusal was described as Wayland. Both messages were written on a Linux desktop, and a Mac, a Windows machine and a build with no library at all were all told to use XWayland.

Decision

The page is the engine’s WKWebView, added to SDL’s content view

macOS has no reparenting of windows and does not need it: a view is the unit of composition there. So an embedded page is a subview of the content view of the NSWindow SDL made, placed with setFrame:.

Not webview_create(debug, sdlWindow), although webview.h accepts a parent window on Cocoa. What it does with one is [window setContentView:webview] — it replaces the content view, and SDL draws and receives events through that view. Handing SDL’s window over would put a page where the whole Goldberry frame was.

So the engine is given a holder: a borderless NSWindow that is never ordered front, whose only job is to be somewhere webview.h can put the view it makes. The view is taken out of the holder and added to SDL’s content view. The holder lives until the page is destroyed, because the engine keeps it as m_window and reads it in its destructor. Destroying detaches the view from SDL’s window first — the content view holds a reference the engine does not know about — then destroys the engine, then releases the holder.

It is written in C++ against the Objective-C runtime, the way webview.h itself is, so goldberry_webview.cc stays one translation unit with no .mm beside it and no change to the build.

Placing it takes three conversions and one decision

goldberry_webview_set_bounds receives the same numbers on every platform: the parent window’s device pixels, origin top left. AppKit disagrees on all of it:

Java hands overAppKit wantsDone by
device pixelspointsdividing by the window’s backingScaleFactor
origin at the toporigin at the bottom, unless the view is flippedmeasuring y from the other edge
a box that moves only when the widget’s box doesa frame that stays put when the window growsan autoresizing mask whose bottom margin stretches

The last one matters because the widget re-places a page only when its own logical box changes. Making a window taller moves an unflipped view’s top edge without changing that box, so without the mask the page would drift down until something else caused a re-layout.

The decision: a parked page is hidden as well as moved. Parking (ADR-0444, ADR-0445) moves the page far past the parent’s top-left corner, and on X11 the parent clips a child there. An NSView does not reliably clip its subviews — clipsToBounds has defaulted to NO since macOS 14 — and a frame above the content view lies in the title bar. So a frame that does not intersect the content view is also setHidden:. Hiding keeps the document, its layout and its scroll position, which is everything parking by moving was for.

The page is also kept on top of SDL’s own subviews on every placement. SDL keeps a Metal view inside its content view, and one made after the page would draw the frame over it. Checking costs two messages.

Load state comes from loading and estimatedProgress

The same two facts the GTK branch reads, under WKWebView’s names, with the page’s URL as the tie-breaker for a document that loaded without a network — loadHTMLString: ends on about:blank. With that answering, the spinner of ADR-0445 works on macOS: the page opens parked and appears when it has loaded.

And a refusal says what applies here

Webview.embeddingRefused(kind) words the natives log line by the parent handle’s kind — X11, Cocoa or Win32 — because that is what the shim branched on. The widget’s own notice is WebViewRefusal, chosen from the two facts the widget can see: whether the library is loaded, and os.name. No library comes first on every platform, because nothing below it was asked. Then:

PlatformWhat the box says
LinuxWayland cannot embed; run under X11 or XWayland
macOSthe engine did not start inside this window; the log says why
Windowsembedding is not implemented on this platform yet

Alternatives considered

  • Pass SDL’s window to webview_create and put SDL’s content view back afterwards. Two setContentView: swaps under SDL’s feet, at a moment when its Metal layer and tracking areas are attached to that view. It might work; nothing about it would be obviously correct, and the holder costs one invisible window per page.
  • A .mm translation unit using AppKit directly. More readable, and a second compiler mode, a second file and a CMake change for about eighty lines. webview.h already shows how to do it from C++.
  • Rely on clipping for parking. Would need setClipsToBounds:YES on SDL’s content view, which is SDL’s to configure, and would still leave the frame under the title bar on a window with a full-size content view.
  • Park at 1×1. Rejected for ADR-0444’s reason: it reflows the document and loses the scroll position.

Consequences

  • A page shows on macOS. Built on macos-aarch64 and run in the showcase with -Pgoldberry.example.screen=web: the page opened inside the window at scale 2.0, bound both callbacks, raised the spinner, took it down about three seconds later when GitHub had loaded, and was destroyed cleanly when the window closed.
  • Keystrokes reach both. SDL3’s NSApplication subclass handles key events in Cocoa_DispatchEvent and passes them on to the window, which delivers them to the focused WKWebView. So typing into a page works, and Goldberry’s own key handling sees the same keys. On X11 it does not, because the page’s own X window has the focus. A shortcut that fires while the user types into a page is the visible form of this. Not solved here.
  • Clicks reach only the page. The WKWebView is the hit-tested view and does not pass a click to its superview, so SDL — and so the pointer router — never sees it, which is what ADR-0442 promised. Mouse motion still reaches SDL, so a hover over the page’s area is a hover over the canvas under it. That is harmless, since the canvas has no hover.
  • Windows is now the only platform that says “not implemented”. Its row in ADR-0442 is still unwritten, and the shim’s Win32 branch still answers NULL — but now the box says so, instead of sending a Windows user to XWayland.
  • The ABI does not change. Every exported function keeps its shape; only what four of them do on macOS is new. A stale library is therefore not caught by the ABI check, and it goes on showing the old refusal until it is rebuilt.

459. A key typed into a page is the page’s

Date: 2026-09-23

Status

Accepted. Amends ADR-0458, whose consequences recorded that on macOS a keystroke typed into a page reached the application as well, and left it unsolved. Writes the Windows row of ADR-0442, which ADR-0458 left as the only platform that said “not implemented”.

The Windows half is written and unverified. It was written on macOS and has not been compiled or run. Everything it calls is documented Win32 or WebView2, and webview.h’s own Win32 backend is the model.

Context

Keystrokes on macOS

SDL3’s NSApplication subclass overrides sendEvent:. It hands every key event to Cocoa_HandleKeyEvent first, and then to [super sendEvent:], which is what delivers the event to the window’s first responder. So with the page’s WKWebView focused, one keystroke:

  • types into the page, and
  • arrives in SDL’s queue as KEY_DOWN/KEY_UP, and as TEXT_INPUT when text input is on. Cocoa_HandleKeyEvent calls interpretKeyEvents: on SDL’s field editor itself, whether or not that editor is the first responder.

Typing in a page’s search box would therefore also fire the application’s shortcuts, move its focus with Tab, and type into whichever Goldberry field last had the focus.

The opposite direction was broken too. SDL’s content view does not accept first responder, so clicking back onto the Goldberry frame left the page as first responder. The keys kept going to the page.

X11 and Windows have neither problem. On X11 the focused page is its own X window, and the server sends its keys there and nowhere else. On Windows the focused window is WebView2’s, owned by the browser process, and its keys never enter SDL’s thread queue.

Windows

webview.h’s Win32 backend, given a parent HWND, does exactly what embedding needs. It creates a WS_CHILD window, webview_widget, inside the parent, hosts the WebView2 controller in it, and never touches the parent’s window procedure or content. Unlike Cocoa, nothing is replaced. What it leaves to an owner is COM, clipping, placement and load state.

Decision

The backend drops what was typed into a page

Two exports, goldberry_webview_has_focus and goldberry_webview_blur, bumping the shim’s ABI from 6 to 7. BackendWebView gains hasKeyboardFocus() and blur(), both defaulting to “cannot say / nothing to do”.

  • Dropping. Sdl3Backend drops KEY_DOWN, KEY_UP, TEXT_INPUT and TEXT_EDITING for a window while one of its embedded pages holds the keyboard. All four, not only presses: a release without its press is a stuck key, and text is the same keystrokes a second time. A window with no page costs one map lookup per key.
  • Giving the keyboard back. Every MOUSE_BUTTON_DOWN that SDL reports blurs that window’s pages. A press on a page belongs to the page and never reaches SDL — the WKWebView is the hit-tested view, and SDL hears buttons only through the responder chain — so every press SDL sees landed outside the page. No hit-testing is needed to know the user clicked back into the application.
  • What “blur” means on macOS. Focus goes back to SDL’s text-input field editor, an SDL3TranslatorResponder subview of the content view, if there is one; otherwise to the window. SDL makes that editor first responder only when it adds it, and does not add it again while text input is already on. So a Goldberry field that was focused before the page was clicked would otherwise lose IME composition until it was unfocused and refocused.

has_focus asks whether the key window’s first responder is the WKWebView or a descendant of it. WebKit keeps a private content view that is the real responder. On Windows it asks whether the foreground thread’s focus HWND is the child or inside it, using GetGUIThreadInfo rather than GetFocus, because the focused window belongs to the browser process. X11 answers no, which is true there.

Windows embeds by giving the engine the parent

webview_create(debug, sdlHwnd), and then:

Left to the ownerDone here
COMCoInitializeEx(STA). SDL’s WIN_VideoInit already did it; one more reference is taken so a page does not depend on that, and it is released on destroy. RPC_E_CHANGED_MODE — a multi-threaded apartment — refuses the page, because WebView2 would.
ClippingWS_CLIPCHILDREN on SDL’s window. SDL presents by BitBlt into the window’s DC, which without it paints over the child. WS_CLIPSIBLINGS on the child.
PlacementSetWindowPos(HWND_TOP, …, SWP_SHOWWINDOW) in client pixels, which is what Java already sends. The widget’s own WM_SIZE passes the new size to the controller. NotifyParentWindowPositionChanged, because WebView2 caches screen positions for its popups.
Load stateA COM object implementing both NavigationStarting and NavigationCompleted handlers, unregistered before the engine is destroyed. WebView2 has nothing synchronous to ask.

A parked page is moved off the parent’s top-left corner, where the parent clips it, exactly as on X11. The IIDs come from __uuidof against WebView2.h’s own declarations rather than GUIDs typed into this file: the Windows build is MSVC under both generators (ADR-0454), and a typo in a copied GUID fails silently.

The widget’s Windows notice is now the engine one — the log says why — instead of “not implemented”.

Alternatives considered

  • Filter in the launcher or the focus manager, not the backend. The backend is the layer that knows which window a page is in and holds the list, and what is being corrected is a platform quirk in how SDL receives events. The layers above never have to know it happened.
  • Hit-test presses against the page’s rectangle to decide on blur. Not needed: SDL never receives a press on the page, so “SDL saw it” already means “outside”.
  • Make SDL’s content view first responder on blur. It does not accept first responder, so the call would be refused and the page would keep the keyboard.
  • SetParent a toplevel webview window on Windows, as X11 reparents. It would show and then move a top-level window, with the flash and taskbar ghost ADR-0446 fought on X11. webview.h’s own child window has neither.

Consequences

  • A key typed into a page on macOS is the page’s alone, and clicking the application takes the keyboard back. Covered by unit tests of the backend’s decision, not by typing: this environment could not send keystrokes to the showcase. The showcase was run with the ABI-7 library, and the page opened, loaded and was destroyed as before.

  • The application sees nothing of what is typed into a page, including shortcuts. A Cmd-W typed while the page has the keyboard goes to WebKit, which ignores it, not to the application. That is what a focused browser control does elsewhere. An application that wants a global shortcut to work over a page needs a menu item, because AppKit routes key equivalents through the menu bar before any view.

  • The macOS blur knows the name of one of SDL’s private classes. If SDL renames SDL3TranslatorResponder, the lookup finds nothing, focus goes to the window, and the only loss is IME composition in a field that was already focused before the page was clicked.

  • Windows is unverified until a Windows build runs it. The likeliest surprises are

    • whether SDL’s BitBlt honours WS_CLIPCHILDREN through its cached DC, and
    • whether a WebView2 controller created inside a message loop that SDL pumps raises its navigation events without webview_run.

    A page that shows white and never takes down its spinner is the second one.

  • A stale ABI-6 library is refused by name. Unlike ADR-0458, this change is one the ABI check catches.

460. Media is FFmpeg driven from Java, not libVLC

Date: 2026-09-23

Status

Accepted. Supersedes the engine choice in docs/content-widgets.md §8, which named libVLC and gated the module on a media document. That document is docs/goldberry-media.md. This record is its §11, made a decision. The work is tracked in docs/media-plan.md.

Context

content-widgets.md §8 chose libVLC 3.x over libmpv. libVLC is LGPL by default, its C API has been stable for a decade, and vlcj has shipped VLC video inside Java applications for fifteen years through the same vmem callback that would hand frames to a BLImage. It never got further than that paragraph. The module was gated on a document about codecs and patents, and writing that document reopened the engine question.

Measured and read against libVLC:

  • Size. A pruned libVLC with only the plugins a player needs is 32–56 MB per platform. Its avcodec plugin alone is about 20 MB and cannot be split.
  • Build. VLC 3.x is autotools plus about a hundred contribs, builds on Windows only with mingw, and publishes no Linux binaries. It cannot be part of a superbuild the way everything else here is.
  • Duplication. Its subtitle renderer bundles its own FreeType and HarfBuzz, and its TLS bundles gnutls. Goldberry already has a text stack, and the JDK already has TLS.
  • Determinism. VLC owns its clocks and its threads. A golden test of video needs the presentation clock to be one a test can advance.
  • GPU. vmem hands over CPU frames. The zero-copy output exists only in libVLC 4, which is unreleased.

Decision

FFmpeg’s libraries, driven from Java. avformat, avcodec, avutil, swresample and swscale (plus dav1d for software AV1), built by a media superbuild of their own as shared libraries. The Engine’s threads, queues, clock and state machine are Java. FFmpeg demuxes and decodes and does nothing else.

Two scope decisions go with it, and they are what keep FFmpeg small:

  • Royalty-free codecs only. VP8, VP9, AV1, Opus, Vorbis, FLAC, MP3, PCM, and text subtitles. H.264, HEVC, AAC, AC-3 and the MPEG-TS demuxer are not built. An MP4 that carries them opens, and it reports UNSUPPORTED_CODEC naming the codec. A Decoder SPI exists from v1, so this is a default and not a ceiling. An application that needs a patented codec brings a provider, and the licence for it is the application’s.
  • No FFmpeg network layer. --disable-network and no protocols. Every byte arrives through a Java MediaIO over a custom AVIOContext. HTTPS, proxies, authentication and HTTP/2 come from the JDK, no TLS library ships anywhere, and the seek bar shows ranges that are really buffered, because the buffer is Java’s.

Pinned at FFmpeg n8.1.3 and dav1d 1.5.4 in gradle/libs.versions.toml.

Alternatives considered

  • libVLC 3.x. It is the context above. It would have been less code to write and more of everything else to ship.
  • libmpv. Its builds are GPL unless built with -Dgpl=false, and that build would then be ours to own. It also owns its clock and its output, as VLC does.
  • FFmpeg with its own network layer. Rejected for TLS. Each platform would need a TLS backend (schannel, SecureTransport, or mbedTLS on Linux), and mbedTLS takes FFmpeg to LGPL-3. It would also put HTTP retry and caching in C, where no test can fake a network.
  • Generated bindings (jextract). They would be thousands of lines covering every struct and function, for an Engine that touches about sixty functions and nine structs. ADR-0010 dropped jextract for libgoldberry for the same reason.

Consequences

  • The Engine (goldberry-media.md §3) and the FFM bindings (§2) are this project’s to write and maintain. That cost was accepted, not overlooked.
  • The natives are at most 6 MB per platform. The superbuild fails above 7 MB. A scratch build on macOS without dav1d is 4.2 MB.
  • Mainstream H.264/AAC content does not play out of the box. The error says so by name, and the SPI is the remedy.
  • RTSP is out. HLS and DASH have to be written in Java (post-v1), which in return lets segment selection, and so adaptive bitrate, be ours.
  • Moving the FFmpeg pin means re-reading the struct table. The layout probe fails the build until it has been re-read.

461. A media engine binds its own libraries

Date: 2026-09-23

Status

Accepted. Amends docs/ARCHITECTURE.md §3.1’s rule that raw MemorySegment never escapes :natives, for one module and for stated reasons. Follows from ADR-0460.

Context

Every native library Goldberry uses is bound in :natives, and every one but libgoldberry-webview is linked statically into libgoldberry. :natives exports its wrapper packages to named readers, and ADR-0280 and ADR-0290 sealed them so that no type of :natives appears in a signature an application can read. md4c (ADR-0294) and libwebp (ADR-0329) joined that library, because each is a few permissive C files.

FFmpeg cannot join it:

  • The LGPL wants the library replaceable. Linking FFmpeg statically into libgoldberry would make the whole toolkit’s binary the thing a user has to be able to relink. FFmpeg stays five shared libraries, loaded from a directory that -Dgoldberry.media.libdir can replace.
  • It is optional. An application that plays no media must not load FFmpeg, and must not need a toolchain that builds it.
  • Its surface belongs to one caller. About sixty functions and nine structs, and every call site is the Engine. :natives would be a pass-through that exports them to :media alone, and struct views and upcalls whose whole meaning is the Engine’s would live away from it.

Decision

:media binds FFmpeg itself, in io.github.digitalsmile.goldberry.media.ffi. That makes it the second module that holds a MemorySegment. The package is not exported.

The rules :natives keeps are kept here, in the same shapes:

  • Holders. Each function is a final class with a static final FD_<symbol> handle, grouped into …Calls records in a package of its own (…media.ffi.calls), so that a native image can initialise them at build time (ADR-0161, ADR-0173).
  • A layout probe. ffmpeg_layout.c prints sizes, offsets, library majors and constants for every struct and field the Engine touches. The superbuild packages the output beside the libraries. FfmpegStructs is checked against it by a unit test (against a committed copy) and again at start-up (against the packaged one), before any struct is read. Constants that differ between platforms, AVERROR(EAGAIN) for one, are read from it rather than written down.
  • One stub per owner. read_packet and seek are bound to one stream’s callbacks. They do not dispatch on opaque (ADR-0017).
  • Nothing leaks out. The exported packages (…media, …media.codec, …media.io) name no FFmpeg type. A codec is a CodecId, mapped by FFmpeg’s codec name, not its enum number, which changes between majors.

The one deliberate exposure is the Decoder SPI (phase 2), whose Packet and Frame wrap MemorySegments, so that a provider can decode without copying. That is goldberry-media.md §5’s design, and it is a java.lang.foreign type in an SPI signature. It is not an FFmpeg type, and not a struct layout.

Alternatives considered

  • Bind FFmpeg in :natives and export it to :media. This keeps the letter of §3.1 and loses the point of it. The wrappers would have one reader, and every Engine change would span two modules. :natives would also need FFmpeg’s headers to build, which ties the toolkit’s build to the media toolchain.
  • A C shim library over FFmpeg. It would narrow the Java surface to a handful of calls. But it would be a third native artifact per platform, C code this project owns for no gain FFM does not already give, and it would still need the layout check for the frames it hands back.

Consequences

  • ARCHITECTURE.md §3.1 names the exception, and module-info.java for :media says it.
  • Consumers pass --enable-native-access=io.github.digitalsmile.goldberry.media as well as the one for :natives.
  • The GraalVM reachability metadata for this module (three upcall shapes, the downcall descriptors that FfmpegDowncalls records) is this module’s to generate. It is an open item in docs/media-plan.md.

462. Media audio leaves through a sink, and the sink is SDL’s

Date: 2026-09-23

Status

Accepted. Refines docs/goldberry-media.md §3, whose audio decode thread wrote to SDL_PutAudioStreamData directly. Adds one package to :natives’ qualified exports, alongside ADR-0461.

Context

The design has the audio thread resample to interleaved f32 and hand the result to an SDL audio stream, and it takes the master clock from the same stream: the presentation time of the last sample written, minus what SDL still has queued.

Two things follow when that is built as written:

  • The Engine cannot be tested without a sound card. Every property worth asserting (the first sample after a seek, the position while paused, the backpressure) passes through the device. A test that plays through real hardware is slow and nondeterministic, and it cannot run on a CI runner.
  • SDL’s audio is in libgoldberry, and :media binds only FFmpeg. ADR-0461 gave :media its own libraries. It did not give it SDL, and a second copy of SDL would be a second audio stack.

Decision

An AudioSink interface between the Engine and the output, public in io.github.digitalsmile.goldberry.media.audio: open(AudioFormat) → AudioFormat, write, queuedSamples, clear, pause, resume, setGain, close. The Engine writes one format, interleaved f32 at one rate and channel count (AudioFormat), and the sink’s queue is the audio clock. A test’s sink controls that queue, and so controls what the Engine believes is playing: VirtualSink “plays” only when the test advances it, which is §9’s virtual clock for audio.

The desktop sink is SdlAudioSink, over one SDL audio stream on the default playback device. SDL converts from the stream’s format to the device’s, so the Engine’s format is always the one it asked for (48 kHz stereo), and the stream follows the default device when it changes. The eight SDL_*AudioStream* functions, SDL_AudioSpec and two constants join the export list and the layout table. The wrapper natives.sdl.audio.SdlAudioStream is exported to :media and to nobody else, md4c’s seal (ADR-0294). What crosses is a direct ByteBuffer in and frame counts out, so ADR-0280’s rule holds.

MediaPlayer uses SdlAudioSink unless told otherwise. The media tests run with SDL_AUDIO_DRIVER=dummy, which consumes audio at the real rate and plays nothing, so the one end-to-end test through SDL is silent and runs anywhere.

Alternatives considered

  • Write to SDL from the Engine, as §3 said. The tests of seeking and the clock would then need a device, or a mock of SDL’s C API. The interface costs one indirection per 20 ms block.
  • A second audio library in :media (miniaudio, or a separately built SDL). It would be a second stack, a second set of Linux backends to get right, and a second binary, for something libgoldberry already does.
  • Let the sink choose the format. It could skip SDL’s conversion when the device runs at 44.1 kHz. But the OS mixer converts anyway, and a sink that answers anything would make the Engine’s single-resample promise conditional.

Consequences

  • :media requires :natives. An application that plays media already has it.
  • The Engine’s behaviour is covered by tests that need neither a sound card nor timing: sample-exact accurate seeks, position under pause, backpressure at 200 ms, and the end.
  • On Linux, SDL’s audio backends are whatever the build machine had headers for. ALSA and PulseAudio are already required by LinuxDependencies, and PipeWire desktops play through PulseAudio compatibility.
  • The Clock SPI of §3 is, for audio, the sink. The video thread will present against the same clock in phase 3, and a monotonic clock takes over only for a source with no audio track.

463. Video is converted as it is decoded, timed by the master clock, and painted when a picture falls due

Date: 2026-09-23

Status

Accepted. Builds docs/goldberry-media.md §3’s “Presentation” and “Master clock” for phase 3 (CPU present), and refines them where building them showed something the design did not say. Its smoothing of the SDL sink is refined by ADR-0485: a drain re-anchored at each pull jumped by up to a pull when one came early, which passes over pictures at 60 fps. Follows ADR-0462, whose audio clock the pictures are timed against.

Context

§3 has a video decode thread feeding a small frame queue, and a video-view that “on each Goldberry frame tick takes the newest frame with pts ≤ master clock”. Four things were left open, and each decides whether video is correct, cheap or testable:

  • What the queue holds. A decoded frame is borrowed: its planes are the decoder’s until its next call (§5’s frame contract, as built in phase 2). A queue of decoded frames would pin the decoder, and a provider’s frames cannot be pinned at all.
  • Who moves through the queue. If only the view takes pictures, a player with no view on screen never reaches its end, and its decoder blocks for good.
  • When a view paints. “Each frame tick” is each display refresh: 120 times a second on a ProMotion display, for 25 pictures. Measured in the showcase, that was 107 frames a second of render work while a 320×180 clip played.
  • Which clock. The audio clock is the sink’s queue, which SDL drains in pulls of 1024 samples, so it moves in 21 ms steps. A picture timed against steps is shown up to a step late.

Decision

The video thread converts every picture it keeps, as it decodes it, to premultiplied BGRA (swscale, one context per stream, SWS_BITEXACT | SWS_ACCURATE_RND), into one of at most seven reusable direct buffers, and queues it with its time. The borrowed frame is done with before the decoder is called again, the paint only blits, and the bytes are the same on every CPU, so a golden of a decoded picture is exact (§7, S5). Scaling happens at the blit, where the size on screen is known.

The queue presents against the master clock, for whoever asks (FrameQueue.present): the newest picture whose time has come is shown and the ones it passed go back to the pool. The view asks when it paints, and the decode thread asks while it waits for room, so the queue drains with no view. A picture handed out to a view is kept from reuse until two newer ones have been handed out, which covers a view drawing what it asked for in the frame it asked in, even with two views on one player. The buffers are collected, not freed, so a view that holds a picture too long sees newer pixels and never freed memory.

A view paints when the next picture falls due, not every frame: MediaPlayer.untilNextPicture() says how long until a picture not yet due becomes due, and the widget’s state sets a timer for then. A 25 fps video costs 25 frames a second, and a paused one none.

The SDL sink drains its queue smoothly between pulls, never by more than the last pull took, and not at all while paused. The audio clock moves continuously, as ffplay’s does through its callback time. The virtual sink the tests use is unchanged, so the tests stay exact.

The master clock is the audio clock while there is audio, and a free-running clock over the MediaClock otherwise: for a source with no audio, and for a video that outlasts its sound, which hands over at the audio’s last position. MediaClock is §3’s Clock SPI, and a test that moves it by hand gets the same picture at the same time on every run.

Around those: the frame contract gains I010 (10-bit planar 4:2:0), which is what dav1d and VP9 profile 2 produce, so the common 10-bit case is lent without a copy; the built-in decoder converts anything else to I420. Pictures are dropped before conversion only when late by a whole picture with another packet waiting, so the last picture of a stream is always shown. Seeks come in two modes, accurate and keyframe, and a paused player still decodes one picture after each seek, which is what makes scrubbing show something (§7, S2).

Alternatives considered

  • Queue decoded frames and convert at paint time. The paint would do the conversion, on the UI thread, and the decoder could not reuse its buffers until the view had painted; a provider’s frames cannot be held at all.
  • A presenter thread of its own, sleeping until each picture is due. It would pace pictures, but it would still need to wake the UI to draw one, and the UI’s own timer does both.
  • Keep the canvas-style “animating” loop. Simplest, and 107 frames a second of render work for 25 pictures.
  • Smooth the clock in the Engine rather than in the SDL sink. The Engine cannot tell a sink that pulls in steps from one that does not, and smoothing a virtual sink would make every clock test depend on timing.

Consequences

  • CPU present is phase 3’s only path. GPU present (phase 4) uploads planes instead, and will want the queue to hold YUV rather than BGRA; the queue’s slots are the place that changes.
  • One swscale pass per shown picture at full size. At 1080p that is a few milliseconds on the video thread, which is where it belongs.
  • SWS_BITEXACT turns off swscale’s fastest paths. Exact goldens were judged worth it; a player that cannot keep up drops conversions, not decodes.
  • The paced timer and the position poll are the only frames a playing view asks for. Measured in the showcase: 33 frames a second while a 25 fps clip plays, at about 1.5 ms each.
  • A file whose chosen audio or video track has no decoder fails naming every such codec, before anything plays (§7, S7), rather than playing half of it.

464. A slider says when a gesture ends

Date: 2026-09-23

Status

Accepted. Adds one hook to slider in :widgets (docs/core-widgets.md §3), asked for by goldberry-media’s seek bar (docs/goldberry-media.md §3, “Seeking”).

Context

slider reports every step of a drag through onChange, snapped and clamped, which is what a thumb that follows the finger needs. A media seek bar needs a second fact: when the user lets go. §3 scrubs to keyframes while the bar is dragged, because an exact seek per step would decode from the keyframe every time, and asks for one exact seek on release. With only onChange, every drag step was an exact seek (the phase 2 audio-player did exactly that, and the Engine coalesced them), and there was no way to resume a player paused for the drag at the right moment.

Decision

Slider gains onCommit, a twelfth component and a wither (slider.onCommit(value -> …)), and commit= in markup. It is told the same snapped, clamped value onChange would be:

  • once when a press or a drag on the slider is released, from the release’s position, which is where the last move put it;
  • after every key step, since a key press is a whole gesture.

The eleven-component constructor stays, so no existing slider changes.

Alternatives considered

  • onChangeStart and onChangeEnd, as Flutter has. The start is the first onChange of a gesture, which a caller can tell for itself; one hook is enough.
  • Commit only on pointer release. Then a keyboard user would never commit, and a seek bar driven by arrows would scrub for ever.
  • Let the media widget watch the pointer itself. It would duplicate the slider’s own capture and travel arithmetic, and get the release position slightly differently wrong.

Consequences

  • media-controls, audio-player and media-player scrub while dragging and seek exactly on release (§7, S2).
  • A slider in a form can save on commit rather than on every step.
  • The record’s equality now includes one more lambda, which a markup-built and a Java-built slider only share when both have none: §11’s parity comparison still holds for every slider that does not set it.

465. Network media is read through a cache, and buffered to a high water mark

Date: 2026-09-23

Status

Accepted. Phase 6 of goldberry-media (docs/media-plan.md), building docs/goldberry-media.md §4 and scenarios S3 and S6.

Context

FFmpeg is built with no network layer (ADR-0460), so every byte of an http: or https: source has to come through a MediaIO written in Java. §4 asks for four things from it: Range requests for seeking, a read-ahead cache that the seek bar’s buffered ranges come from, reconnects that resume at the last byte, and ICY radio metadata. It asks two things of the Engine: water marks that move playback between BUFFERING and PLAYING, and a live source that the widgets show as LIVE with what is playing.

Three questions had to be settled while building it:

  • How a cache, a fetcher and a demuxer that seeks at will share one stream without a connection per seek.
  • What the water marks are measured in, and what the low one is.
  • Whether ICY needs a MediaIO of its own, as §4’s table has it (IcyIO).

Decision

HttpIO is one MediaIO with a fetcher thread and a cache of extents.

  • The first request asks for bytes=0-. A 206 makes the stream seekable and says its length; a 200 means the server ignores Range.
  • A virtual thread fetches into a ReadAheadCache: disjoint extents of 64 KB chunks, which merge when one grows into the next. The thread never calls native code, which is the only reason the Engine’s own threads are platform threads (§3).
  • The fetcher always fetches the first byte the reader lacks: the end of the extent holding the read position. It reads at most readAhead (8 MB) past the reader and then waits, which is the backpressure. When the reader moves so that the connection in hand no longer brings that byte (a seek outside the cache, or into a cached extent with a gap after it), the connection is abandoned and a Range request opens at the new place. A seek inside the cache opens nothing.
  • Eviction keeps the cache under cacheSize (32 MB): other extents first, farthest from the reader first, then what the reader has already read of its own. Never the bytes ahead of it.
  • A connection that fails, closes short of the length, or delivers nothing for stallTimeout (10 s) is made again after a doubling backoff, from the byte it broke at, up to maxReconnects in a row. A 4xx other than 408 and 429 is final at once. A seek resets the count.
  • A read waits no longer than the Source’s timeout.

ICY is a property of the response, not a protocol. A radio station is an ordinary HTTP server that adds icy-metaint when asked with Icy-MetaData: 1. HttpIO asks by default and, when the header comes back, reads the body through IcyStream, which strips the metadata before the cache. Titles are kept by the byte offset they took effect at, so nowPlaying() answers for the demuxer’s position rather than the fetcher’s, which may be megabytes ahead. There is no IcyIO class.

MediaIO grows three default methods: isLive(), buffered() (byte ranges) and nowPlaying(). FileIO and every application protocol keep working unchanged.

The water marks are measured in demuxed time. Each packet queue knows the end time of the latest packet queued since the last flush. Less the clock, the least of these over the playing tracks is bufferedAhead: what plays on if the source stops delivering now.

  • High water mark, MediaPlayer.Builder.highWaterMark, 1 s by default. BUFFERING becomes PLAYING only when every track is demuxed that far ahead, or the source has ended, or the queues are full and could not hold more. The same rule holds at the start and after a stall.
  • The low water mark is empty. A track stalls when its decoder runs out of packets with the source not at its end. Only then does playback go back to BUFFERING, pausing the sink and holding the free-running clock.

bufferedRanges maps bytes to time in proportion to the source’s length and duration.

Alternatives considered

  • One connection per seek, and no cache. FFmpeg seeks to a container’s tail on open (Matroska’s Cues, MP4’s moov) and back, so every open would cost two or three connections, and the seek bar would have nothing to show as buffered.
  • A cache of fixed blocks keyed by index. A Range request lands at any offset, so its first and last blocks would be partial and need their own bookkeeping. Extents that start where the request did avoid it.
  • An IcyIO wrapping HttpIO. A wrapper sees the bytes after the cache, so it would have to strip metadata from cached data on every read, and a seek would lose track of where the metadata falls. Stripping before the cache keeps offsets in audio bytes.
  • A low water mark above zero. Pausing with media still in hand trades one long silence for a later one, and after every seek the queues are empty for a moment, so a local file would flicker through BUFFERING on each one.
  • Water marks in bytes. Bytes do not say how long they play: 2 MB is two minutes of Opus and a second of 4K VP9.
  • Mapping bytes to time through the container’s index. Exact, but it would need av_index_search_timestamp and a per-format fallback. A proportion is exact for constant bit rates and close for the rest.

Consequences

  • S3 and S6 pass: against a local server that drops connections, stalls and speaks ICY; and against a fake MediaIO that stalls at an exact byte, so the BUFFERING, PLAYING and BUFFERING sequence is checked sample for sample.
  • PlayerStatus gains bufferedAhead, bufferedRanges, nowPlaying and live(); MediaInfo gains live. The widgets show LIVE only for a live source. One that cannot seek but ends shows what remains, and the title is a line over the controls.
  • FFmpeg’s probe reads about 4 s of raw PCM before anything plays, and only its first 32 KB of Opus, MP3 or FLAC (measured on 30–60 s files). A smaller probesize for network sources was measured and not kept: it helped WAV alone.
  • The seek bar did not draw bufferedRanges at first: slider in :widgets had no second range to show. ADR-0466 gave it one.

466. A slider marks spans of its range

Date: 2026-09-23

Status

Accepted. Adds one component to slider in :widgets (docs/core-widgets.md §3), asked for by goldberry-media’s seek bar, which shows what a network source has buffered (docs/goldberry-media.md §4, S3; ADR-0465).

Context

A media seek bar shows two things along one groove: where playback is (the fill and the thumb) and what is already fetched, so that a seek there is instant. PlayerStatus.bufferedRanges has carried the second since phase 6, and slider had nowhere to draw it. The fill cannot carry it: a buffered stretch can start after the thumb and there can be several.

The groove places its thumb by flex ratio (fill, thumb, rest), so that nothing in Java learns the groove’s width (ADR-0079). Whatever marks a stretch must not disturb that ratio.

Decision

Slider gains spans, a thirteenth component: a list of Slider.Span(from, to) in the slider’s own units, with a wither (slider.spans(...)). The twelve-component constructor stays, so no existing slider changes, and markup has no attribute for it, since spans are live data rather than a document’s.

Each span is a slider-span part, the first children of slider-groove, positioned absolutely and inset by percentages of the groove: its fractions along the axis, zero across it. It is painted under the fill and the thumb, and takes no part in the ratio. Spans go through the slider’s Scale, are clamped to its range, and one that is empty or wholly outside it is not drawn. A vertical slider insets from the bottom.

The theme colours them with --gb-slider-span-bg: an alpha over the groove, the --gb-border-strong technique, a step from the groove and well short of the accent.

Alternatives considered

  • Flex spacers, as the thumb is placed. A second row of grow factors laid over the groove would need an absolute layer anyway, and a spacer per gap. Percentage insets say the same with one box per span.
  • A progress under the slider. Two widgets for one groove, aligned by hand, and only one stretch.
  • Spans along the thumb’s travel rather than the groove. Exact where the thumb’s centre is, but it needs the travel’s width, which only layout knows. Along the groove the two agree in the middle and differ by at most half a thumb at the ends, where a buffered edge is not something anyone reads to the pixel.

Consequences

  • media-controls, audio-player and media-player show a network source’s buffered stretches, and look again every quarter second while the fetch goes on, paused or not. A local file reports none, so nothing it draws changed.
  • Two new goldens, slider-spans and slider-spans-light; every other slider golden is unchanged.

467. An audio track is switched by retiring its thread and seeking

Date: 2026-09-23

Status

Accepted. Phase 7 of goldberry-media (docs/media-plan.md): the audio track menu of docs/goldberry-media.md §6.

Context

A source with several audio tracks (languages, a commentary) plays its default one. Choosing another while it plays has to change which packets the demuxer hands over and which decoder turns them into samples, and bring the new track in at the position, in step with the picture. The audio thread owns its decoder (the Decoder SPI promises a decoder one thread) and its packet queue, and the queue carries Serials that tie every packet to a seek.

A menu also has to name the tracks, and FFmpeg keeps a track’s language and title in the stream’s metadata dictionary, which the bindings did not read.

Decision

A switch retires the audio thread and seeks. MediaPlayer.selectTrack hands the track to the demux thread, which:

  1. checks that the track has a decoder, and keeps the playing one if not;
  2. retires the audio thread (a flag every one of its waits checks), aborts its queue so a blocked wait ends, and joins it; the thread closes its own decoder on the way out;
  3. selects the new stream in the demuxer, and starts a new audio thread on a new queue;
  4. requests an accurate seek to where playback is.

The seek is the whole of the synchronisation: every queue is flushed at one Serial, the new thread discards to the target like after any seek, and the sink starts over at it. Until the flush reaches the sink, it plays what the old thread wrote, so a switch is a change of track and not a gap.

Tracks carry their language and title, read with av_dict_get from AVStream.metadata (a field and a struct, AVDictionaryEntry, added to the layout probe and check). A language of und is no language.

The menu names languages through ISO 639-2/T as well. Containers mostly write three-letter terminology codes (fra, deu), which the JDK does not name on its own. They are mapped to their two-letter codes first.

Only audio is switched. Choosing a video track is refused for now.

Alternatives considered

  • Reopen the whole playback on the new track. Simple, and it restarts the sink and the picture, loses the read-ahead cache of a network source, and shows OPENING for a menu choice.
  • Keep one audio thread and swap its decoder. The thread would need to know which Serial each packet’s stream belongs to, and a provider’s decoder must be opened and closed on the thread that uses it anyway; retiring the thread is that rule, followed.
  • Decode every audio track and mute all but one. Instant switching, at the cost of decoding what nobody hears.

Consequences

  • media-controls, audio-player and media-player show an audio track menu (.media-audio-track) for a source with two or more audio tracks, and nothing more for one, so no golden changed.
  • PlayerStatus.audioTrack and videoTrack report what plays. PlayerStatus.videoTrack() was derived from the source’s default before, and is what the Engine chose now.
  • Switching video tracks and subtitles remain open.

468. Text subtitles are read in Java

Date: 2026-09-23

Status

Accepted. Phase 7 of goldberry-media (docs/media-plan.md): the subtitles of docs/goldberry-media.md §6.

Context

§6 asks for text subtitles drawn by Goldberry’s own text stack over the picture, from a container’s subtitle tracks and from external .srt and .vtt files, with the formats’ markup taken out. Bitmap subtitles are post-v1.

FFmpeg has subtitle decoders, reached through avcodec_decode_subtitle2 and the AVSubtitle and AVSubtitleRect structs, which would join the layout table. What they produce for a text codec is an ASS event whose markup would still have to be stripped in Java.

Decision

No FFmpeg subtitle decoder is bound. A container’s text subtitle packet is one cue: its times are the packet’s, and its payload is text.

  • SubRip and WebVTT: the payload is the cue’s text.
  • ASS: FFmpeg’s demuxers hand over ReadOrder,Layer,Style,Name,MarginL, MarginR,MarginV,Effect,Text, and the text is the ninth field on.
  • MP4’s mov_text: a 16-bit length, the text, then style boxes, passed over.

Subtitles parses these and external SubRip and WebVTT files into Cues of plain lines. Tags, entities, WebVTT’s inline timestamps and ASS’s override blocks go; \N is a break.

The demux thread collects cues. A chosen subtitle track’s packets are not queued for a decode thread. The demux thread turns each into a cue for a SubtitleTimeline, kept once read, so a seek back finds them. Choosing a track selects its stream and seeks to the position, so that the cues around it are read. A file loaded beside the source (MediaPlayer.loadSubtitles) replaces the timeline at once. A view asks MediaPlayer.currentSubtitles() for the cues at the clock, as it asks for the picture.

media-player draws them as lines over the foot of the picture, out of flow and outside the overlay, so they stay when the controls fade, and lower while the controls are hidden. media-player and media-controls have a subtitles menu: off, each track, and a loaded file. audio-player, which draws no subtitles, has none.

Alternatives considered

  • Bind FFmpeg’s subtitle decoders. Three structs and two functions more in the layout table, to arrive at the same ASS text, then strip it in Java anyway.
  • A decode thread and packet queue per subtitle track, as for audio and video. A subtitle packet needs no decoding and no pacing: the clock decides what shows, and a list of cues answers that directly.
  • Draw cues with their styling. Positions, colours and fonts belong to the subtitle author’s player; §6 draws them in the theme’s type, which is what makes them legible on every picture and consistent with the rest of the window.

Consequences

  • Text subtitles show from Matroska, WebM and MP4 tracks and from .srt and .vtt files, over any protocol a source can use.
  • Bitmap subtitle tracks (PGS, DVB, VobSub) are listed and show nothing.
  • A cue that started before the keyframe a choice seeks to is not read until the source is sought back past it; the next cue shows on time.
  • New golden media-player-subtitles; no other golden changed.

469. A video track is switched over the same frame queue

Date: 2026-09-23

Status

Accepted. Phase 7 of goldberry-media (docs/media-plan.md): the video track menu of docs/goldberry-media.md §6. It extends ADR-0467 from audio to video.

Context

A source with several video tracks (camera angles, a signed version, a different cut) plays its default one. ADR-0467 switches an audio track by retiring the audio thread, starting one on the new track, and seeking to the position. It refused video, because a video thread does not only own a decoder and a packet queue: it fills the FrameQueue that views present from. That queue also holds the picture on screen and the buffers handed out to views (VideoPicture stays valid until two newer pictures are handed out). A new queue per switch would blank every view until they asked again, and would lose that promise. The old thread might also be blocked in FrameQueue.obtain, waiting for room, and the only way to wake it was abort(), which ends the queue for good.

Decision

A video switch is an audio switch, over the same frame queue. The demux thread:

  1. checks that the track has a decoder, and keeps the playing one if not;
  2. retires the video thread (a flag its waits and reports check), releases the frame queue’s waiters (FrameQueue.releaseWaiters(): a waiting obtain returns null, and the queue carries on working), aborts the thread’s packet queue, and joins it. The thread closes its decoder and converter on the way out and hands back its pending buffer;
  3. selects the new stream in the demuxer, and starts a new video thread on a new packet queue and the same frame queue;
  4. requests an accurate seek to where playback is.

As for audio, the seek does all the synchronising. It flushes the frame queue to a new Serial, which drops what the old track queued and keeps the picture shown up until the first picture of the new position replaces it. So a switch is a cut from the old track’s last picture to the new track’s picture covering the position, with no black frame between. A size change needs nothing more: obtain already replaces a buffer that does not fit.

A retired thread reports nothing: no videoReady, videoDone, videoUnderrun, picture length, or failure. A failure that comes from being retired is not a failure of playback.

Playback.select now accepts a video track. It refuses cover art (an attached-picture track), which the Engine never plays as video, and the attachment and data kinds.

The track menus are shared: Transport.trackMenu builds both, and media-player and media-controls, the widgets that go with a picture, add .media-video-track for a source with two or more video tracks. audio-player does not.

Alternatives considered

  • A new frame queue per track. It is simpler for the Engine, but every view would lose its picture at the switch, a handed-out picture’s promise would end early, and step would find no picture to step from until the first arrives.
  • Keep one video thread and swap its decoder. This was rejected for ADR-0467’s reason: a provider’s decoder is opened and closed on the thread that uses it. The thread would also have to tell the two tracks’ packets apart across a Serial.
  • Switch without a seek, from the next keyframe of the new track. The audio would not be touched. But the demuxer reads every selected stream from one position, and the new track’s packets before the next keyframe have been read and discarded already. Waiting for its next keyframe could leave the old picture up for seconds.
  • Abort the frame queue and unabort it. This would turn a one-way state into a two-way one that every other waiter would have to reason about. A release only wakes the waits in progress when it is called. A later obtain waits as before.

Consequences

  • MediaPlayer.selectTrack takes an audio, video or subtitle track, and PlayerStatus.videoTrack reports the switch.
  • As with an audio switch, the audio restarts at the position when the seek’s flush reaches the sink. Until then the sink plays what it held, so a few milliseconds of sound may be heard twice. This is the price of one seek being the whole of the synchronisation.
  • A test that moves a VirtualSink by hand has to wait for the flush’s clear() before it plays on, or it plays samples the flush then discards.
  • The fixture clip-two-angles.mkv carries VP9 at 160×90 and VP8 at 96×54, so a switch shows in the picture’s size alone.
  • Fullscreen is now the only item of §6 still open, and it waits on :core.

470. Hardware decode is a rung of the built-in decoder, copied back

Date: 2026-09-23

Status

Accepted. Phase 5 of goldberry-media (docs/media-plan.md): hardware decode, with copy-back and the fallback ladder of docs/goldberry-media.md §3.

Context

VP9 and AV1 at 4K are more than a laptop’s CPU should spend its battery on, and every platform Goldberry targets has a video engine that decodes them: VideoToolbox on macOS, D3D11 on Windows, VAAPI on Linux. FFmpeg drives all three through hwaccels. Each is a decoder that is handed a device in AVCodecContext.hw_device_ctx, asks for a surface format through the get_format callback, and returns frames that are surfaces on the device rather than pixels.

There is no GPU present yet. Phase 4 waits on M4, so a surface cannot be drawn where it is. Hardware is also unreliable in ways software is not. A device opens and then cannot decode the codec: an M1 has VideoToolbox and no AV1 engine. Drivers fail mid-stream. And a device’s output need not be the same bytes as software’s, which is what the goldens compare.

Decision

Copy-back, always. Every hardware frame is copied into system memory with av_hwframe_transfer_data. That gives NV12 for 8-bit and P010 for 10-bit, both already in the frame contract, with av_frame_copy_props for the timestamps and colour. From there it is lent and presented exactly as a software frame is. CPU present needs nothing new. GPU present (phase 4) will be able to skip the copy when it exists.

One more rung on the ladder, the built-in decoder’s. The ladder becomes providers → FFmpeg on the device → FFmpeg in software. The hardware rung exists for a video track when hardware decode is on and this build has a hardware path for the codec on the platform’s device type. A mid-stream failure on the device walks one rung down, like a provider’s failure.

The hardware rung chooses its own decoder. avcodec_find_decoder(AV1) is libdav1d, which has no hardware path. FFmpeg’s own av1 decoder is hardware only. HardwareDecoder.choose therefore looks through every decoder of the codec for a HW_DEVICE_CTX configuration of one of the wanted device types. The software rung stays avcodec_find_decoder’s.

get_format is an upcall that asks for the device’s format, and otherwise asks FFmpeg. When the device’s surface format is offered it takes it. When it is not, because FFmpeg could not start the device for this stream and has offered again without it, the upcall hands the list to avcodec_default_get_format. So VP9 on a device with no VP9 engine decodes in software inside the same decoder, and nothing fails. An empty list is answered NONE without asking, because FFmpeg’s default reads the last entry. Nothing is thrown into C. The stub is bound to its decoder (ADR-0017) and freed after the codec context.

Failures on the device are thrown for the ladder, not as playback errors. On the hardware path, send, receive and copy-back throw FfmpegException, not MediaException. The video thread then reopens the track on the next rung. The new decoder has none of the pictures the next packets refer to, so the thread drops the queued packets and makes an accurate seek. If no picture has been shown since the last seek, the target is that seek’s target (or the start). Otherwise it is the position. A device that cannot decode the codec fails on its first packet, before anything has played, so the seek back to the start is not heard.

What failed is remembered, per process. A codec and device type that failed before giving a picture, or that FFmpeg declined in get_format, are recorded in the Hardware policy. The next decoder of that codec goes straight to software. HardwareDecoding.AUTO is one policy per process. The rung itself is counted whether or not it failed before (it then opens in software), so the ladder’s positions do not move between the first open and a fallback.

HardwareDecoding.AUTO by default, OFF for determinism. An application gets hardware decode without asking. Tests that compare pictures byte for byte ask for OFF.

On where the OS provides it. The media superbuild enables VideoToolbox on macOS and D3D11VA on Windows by default: system frameworks, nothing to install or ship. It stays off on Linux. VAAPI is libva, which libavutil would then link directly, and a machine without it could not load FFmpeg at all, not even to play audio. -Pgoldberry.media.hwaccel overrides either way.

The decoder names itself after what it does: ffmpeg (videotoolbox) while its pictures come from the device, and ffmpeg once they do not. PlayerStatus.videoDecoder follows it.

Alternatives considered

  • Present surfaces directly (zero-copy). This needs GPU present and platform interop (IOSurface, D3D11 shared handles, dma-buf), which is post-v1 in the design. The copy costs about a frame’s worth of memory bandwidth, which is small next to the decode it saves.
  • Hardware as a DecoderProvider. The SPI’s request does not carry AVCodecParameters, and the device, the get_format stub and the codec context would all have to be rebuilt outside the built-in decoder. The design keeps OS decoders (Media Foundation, VideoToolbox proper) for an optional provider module post-v1. FFmpeg’s hwaccels belong to the built-in decoder.
  • Probe the device’s codec support before choosing. FFmpeg has no portable query, and VideoToolbox’s is behind the hwaccel’s own initialisation. The first packet is the probe, and the record keeps it to once per process.
  • Fail the playback when the device fails. This contradicts S4. The application sees no difference between software and hardware decode except in the CPU meter.
  • Link VAAPI on Linux and load libva lazily. FFmpeg links it at build time. Making it lazy means patching FFmpeg, which the LGPL allows and the pin discipline (ADR-0030) does not want. This is left open.

Consequences

  • MediaPlayer.Builder.hardwareDecoding(HardwareDecoding), AUTO by default.
  • Seven more FFmpeg functions (avcodec_get_hw_config, avcodec_default_get_format, av_hwdevice_find_type_by_name, av_hwdevice_ctx_create, av_hwframe_transfer_data, av_frame_copy_props, av_buffer_unref), one more struct (AVCodecHWConfig), two constants, and one more upcall shape in the native-image metadata. The macOS libraries grow by 43 KB and link VideoToolbox, CoreMedia and CoreVideo.
  • On an M1, VP9 decodes on VideoToolbox, 8-bit and 10-bit. Its luma is the software decoder’s byte for byte, and its pictures pass the software goldens. AV1 falls to dav1d on the first packet, once per process.
  • A mid-stream fallback seeks, so for a moment the audio restarts at the position, as with a track switch (ADR-0469).
  • The design’s exit criterion for phase 5, 4K60 without dropped frames on GPU present, waits on phase 4. What can be measured without it, CPU time with the device against without, is recorded in the plan.
  • Linux hardware decode is open: an opt-in build flag today, and a decision about libva before it is on by default.

471. AVI is demuxed, and a container with no demuxer is named

Date: 2026-09-24

Status

Accepted. Follows S7 of docs/goldberry-media.md §7 (“an unsupported file errors, naming what it could not play”).

Context

A user opened an Xvid and AC-3 AVI, the most common shape of a film rip, and got not playable media: Invalid data found when processing input. The file is not damaged. The build has no AVI demuxer, so FFmpeg’s probe had nothing that recognised it, and avformat_open_input failed as it does for random bytes.

The codec policy is unchanged by this: MPEG-4 Part 2 and AC-3 are on the “not built, by decision” list, so the file cannot play either way. What was wrong is the answer. An H.264 and AAC MP4 opens, lists its tracks, and says no decoder for h264, aac. An AVI of the same kind said the file was broken.

The same holds for every container the build does not demux, and MPEG-TS is not demuxed by decision. A transport stream, an FLV, or an ASF file all read as invalid data.

Decision

The AVI demuxer is built. AVI is a container with no patents of its own, and FFmpeg’s demuxer adds 17 KB to libavformat. An AVI now opens, and S7 applies to it as to MP4: an Xvid and AC-3 rip reports no decoder for mpeg4, ac3, and an AVI of codecs the build has (MP3, PCM, VP8) plays.

A container with no demuxer is recognised by its first bytes, and named. IoCallbacks keeps the first kilobyte of the source as FFmpeg reads it, from position 0 and for live sources too. When avformat_open_input fails with AVERROR_INVALIDDATA, ContainerSniffer matches that head against a short list of well-known signatures: MPEG-TS and M2TS (sync bytes a packet apart), MPEG program and elementary streams, raw H.264, FLV, ASF, RealMedia, MXF, IVF, AIFF, CAF, AMR, WavPack, Monkey’s Audio, Musepack, DSF, ADTS AAC and AC-3. The error is then MediaError.UnsupportedContainer: no demuxer for MPEG-TS in this build.

A signature is reported only when this build has no demuxer for it, read from the loaded libraries (FfmpegCapabilities.demuxers). A container the build does read, and that failed anyway, is damaged, and stays InvalidData. So does a head that matches nothing.

Alternatives considered

  • Only the better message. AVI would then say no demuxer for AVI. That is true, but less useful than naming the codecs, and it hides the AVIs this build could play.
  • Build every demuxer, so FFmpeg names the format itself. MPEG-TS is excluded by decision, and the demuxer set is part of the size budget and the attack surface. A signature table costs neither.
  • Ask FFmpeg’s av_probe_input_format with every demuxer compiled in. This is the same as the previous option: the probe only knows demuxers that exist.

Consequences

  • MediaError gains UnsupportedContainer(format). Code that switches over MediaError exhaustively needs a case; MediaErrorTest holds that switch.
  • The superbuild’s demuxer list is matroska,mov,avi,ogg,flac,mp3,wav,srt,webvtt,ass.
  • The showcase’s Video file dialog offers .avi.
  • Test fixtures: clip-xvid-ac3.avi, tone-mp3.avi, clip-mpeg2.ts and clip-flv1.flv.

472. The platform decoders are the system’s own, bound with FFM

Date: 2026-09-24

Status

Accepted. Implements the first of the “intended providers” in docs/goldberry-media.md §5: operating-system decoders in an optional goldberry-media-platform module.

Context

The published natives decode royalty-free codecs only (§2, §11). H.264, HEVC, AAC, AC-3 and E-AC-3 open, list their tracks, and fail with UNSUPPORTED_CODEC. That is most of the video people actually have. The Decoder SPI exists so that whoever holds the licences can bring the decoders, and an operating system that ships decoders for these codecs holds them.

Other ways to use “codecs already on the system” were weighed first:

  • Load the system’s FFmpeg instead of ours. The loader pins library majors and needs a layout file generated against the same headers. Homebrew’s FFmpeg on the development machine is libavcodec 63, and we pin 62. §5 already rules out swapping the natives, and distributions differ in what they compile in.
  • FFmpeg’s own hardware paths (VideoToolbox, D3D11, VAAPI) for H.264. Those run inside FFmpeg’s software H.264 and HEVC decoders, which would then be compiled into the published natives. That is the patent exposure the policy exists to avoid.

Decision

A new module, :media-platform (goldberry-media-platform), provides DecoderProviders over the system frameworks. It starts with macOS:

ProviderCodecsFramework
videotoolboxH.264, HEVC; 8- and 10-bit 4:2:0VideoToolbox
audiotoolboxAAC (LC, HE, HEv2), AC-3, E-AC-3AudioToolbox

The frameworks are bound with FFM, the way :media binds FFmpeg. No native code is built or shipped. The frameworks are opened by their install path, which dlopen resolves from the dyld shared cache. Each function’s unbound handle is a static final constant (ADR-0173, ADR-0161). Struct layouts were measured against the SDK with clang, not guessed. The module is found by ServiceLoader, and PlatformDecoders.providers() lists the providers for an application that builds its own list. On other systems the providers support nothing, so a file fails as it would without the module.

Video. Each track’s format description is built with CMVideoFormatDescriptionCreateFrom{H264,HEVC}ParameterSets, from the parameter sets in the container’s avcC or hvcC. A description built from the raw record (SampleDescriptionExtensionAtoms) decodes the same pixels, but Core Media does not read the VUI from it: VideoToolbox then attaches ColorInfoGuessedBy = VideoToolbox and guesses the matrix from the picture size. That was measured: a BT.709-tagged 160×90 clip came out as BT.601. Packets are already length-prefixed, so they are copied into a sample buffer unchanged. Decoding is synchronous.

VideoToolbox emits pictures in decoding order. This was measured too: with the reorder buffer at depth 0, B-frame clips come out of order. The provider holds pictures in a ReorderBuffer and releases the earliest one once more than the stream’s reorder depth are held. The depth comes from the SPS, derived the way FFmpeg derives has_b_frames:

  • H.264: the VUI’s max_num_reorder_frames;
  • otherwise 0 for picture order type 2, no reference frames, or an intra-only profile;
  • otherwise the level’s MaxDpbMbs divided by the picture size, at most 16;
  • HEVC: sps_max_num_reorder_pics of the highest sub-layer.

Decoding times are not used, since Matroska stores none.

Pictures are lent, not copied. The provider hands over the CVPixelBuffer itself, locked for the CPU, as NV12 or P010. It asks for both range variants, so VideoToolbox keeps the stream’s range. The buffer is unlocked and released at the decoder’s next call. The matrix comes from the buffer’s attachment. An untagged stream gets VideoToolbox’s guess by size, which is the built-in decoder’s default rule: BT.709 from 720 rows up, BT.601 below.

Audio. AAC is configured by a magic cookie, the ES_Descriptor built around the AudioSpecificConfig, as FFmpeg’s audiotoolboxdec builds it. The decoder uses the first entry of kAudioFormatProperty_FormatList, which for HE-AAC is the full-rate layer. Output is interleaved f32. For 3–8 channels the converter is given an output channel layout in FFmpeg’s default order, because the Engine’s resampler reads frames that way. Each frame is timed by counting samples from the first packet after an open or flush. A packet more than 200 ms off that count starts the count again from its own time, since that means the stream has a gap.

Failures. One packet the decoder rejects is dropped, as FFmpeg’s decoders drop one. After 30 in a row the decoder throws, and the Engine walks its fallback ladder. An invalid VideoToolbox session (after sleep, or a GPU change) is replaced, and decoding resumes at the next keyframe. Nothing is thrown back into native code from a callback. An error in a callback is kept and rethrown on the decode thread.

Alternatives considered

  • Parse the VUI ourselves and attach the colour. HEVC’s VUI sits behind scaling lists and short-term reference picture sets. Core Media already parses both codecs when handed the parameter sets.
  • kVTDecodeFrame_EnableTemporalProcessing instead of a reorder buffer. It is documented as a hint (“may delay”), not as a guarantee of display order.
  • A native shim in C. It would add a build per target and a library to ship. It would buy nothing FFM does not do, and FFM already carries the struct-by-value CMTime the output callback receives.

Consequences

  • Tests use media-platform/src/test/fixtures/make-fixtures.sh. Every H.264 and HEVC fixture ships with FFmpeg’s framemd5 in NV12 or P010. The VideoToolbox tests require every picture to match byte for byte, in order and at its time, for MP4 and Matroska, 8-bit and 10-bit. Audio is checked by frequency, level, channel order and continuous timing. A MediaPlayer plays H.264 with AAC, and HEVC, to the end on the two providers.
  • -Pgoldberry.platform.required=true turns the macOS skips into failures. The Media workflow runs :media-platform:check on both of its targets and requires the decoders on the macOS one.
  • Not done yet:
    • Windows (Media Foundation, a COM API that needs its own binding layer) and Linux (VAAPI decodes on hardware only, and has no audio). Done (2026-09-28), ADR-0489: GStreamer on Linux, built and tested; Media Foundation on Windows, written and not yet run on Windows.
    • Native-image metadata for the upcalls. Done (2026-09-24): :media-platform:foreignMetadata writes it from the bindings into the jar, as :media does. PlatformForeignMetadata initialises every binding class and names the two callbacks, and its test fails when a class in the package links a downcall or declares a callback descriptor that is not listed.
    • Bitmap output for 4:2:2 and 4:4:4 streams. The provider does not claim them now, so they stay UNSUPPORTED_CODEC.

473. A window is fullscreen when the platform says so

Date: 2026-09-24

Status

Accepted. Unblocks F in docs/goldberry-media.md §6 and the last item of phase 7 in docs/media-plan.md. Applies ADR-0252’s rule to the other window state a platform owns.

Context

media-player was designed with a fullscreen button and an F key, and both waited because :core had no way to make a window fill its display: nothing in BackendWindow, nothing bound in SDL, no event plumbed. docs/core-widgets.md §6 lists a fullscreen toggle among the window chrome helpers for the same reason.

Fullscreen is also a window state the platform owns, and it behaves the way maximizing does, only more so:

  • It is asynchronous. On macOS SDL moves the window to a Space of its own, animated, and the state lands several frames after the ask. SDL’s own header says the request “can be denied by the windowing system”.
  • The user can change it without the application. The green button on macOS, a window manager’s key on Linux.
  • A hidden window defers it. Goldberry creates windows hidden until their first frame. SDL keeps a fullscreen request made on a hidden window as a pending flag, applied and reported when the window is shown. A test on the dummy driver found this: the request was accepted and no event came back until a frame had been presented.

A media player adds a second question the window cannot answer: fullscreen video in a browser means the element fills the screen, not the page. A window made fullscreen with the player still one pane of a layout shows the player at the size it had.

Decision

In :core, fullscreen is maximizing’s shape.

  • BackendWindow.setFullscreen(boolean): a request, a default no-op. Sdl3Window calls SDL_SetWindowFullscreen with no display mode set, so it is borderless fullscreen on the desktop’s own mode, never an exclusive mode change. HeadlessWindow agrees and reports, and reportFullscreen drives the user’s route in a test.
  • BackendEvent.FullscreenChanged(window, boolean), from SDL’s ENTER_FULLSCREEN (0x217) and LEAVE_FULLSCREEN (0x218), checked against the compiled header by the constant probe.
  • Window.isFullscreen() answers what the platform last reported, and between the ask and the event it still answers the old state. Window.setFullscreen asks. Window.onFullscreenChanged is a Subscription with any number of listeners, told of changes only.
  • Host gains canFullscreen, isFullscreen, setFullscreen and onFullscreenChanged, defaulting to “no window”. A widget asks its host, not host.window(): Host’s own docs say reaching for the window means the call belongs on Host, and a test host with no window has an honest answer.

In media-player, fullscreen is the element’s, as in a browser. Entering lays a full-window copy of the player over the window with Host.fill (the same player, fit and classes, .is-fullscreen, no id) and then asks the window to go fullscreen, unless it already is. Leaving (the button, F, Esc) takes the copy away and gives the window back as it was. When the window leaves fullscreen by the platform’s own button, the copy goes too. While the copy shows, the player in the layout stops rebuilding for pictures nobody can see.

The button is offered only where Host.canFullscreen() is true. Every golden image is taken with no window, so none of them changed.

Alternatives considered

  • isFullscreen() set when asked. That’s wrong the moment a window manager refuses, and wrong for several frames on every Mac. ADR-0252 already rejected this for maximizing.
  • Window fullscreen only, the player left in its layout. This is simpler, and on a showcase with tabs and cards it shows a small video on a big black screen. It isn’t what anyone means by fullscreen video.
  • A CSS rule that takes the player out of its layout. §8’s subset has no position: fixed, and an absolute box is placed against its own parent. The overlay layer is the one place a widget can cover the whole window (ADR-0100).
  • Exclusive fullscreen at a chosen display mode. SDL supports it (SDL_SetWindowFullscreenMode), but a video wants the desktop’s mode: a mode switch blanks every monitor for a second and scales the picture twice.
  • Media-player listening on host.window() directly. TestHost.window() throws, so every widget test would need a real window.

Consequences

  • One more SDL export (SDL_SetWindowFullscreen) and two more checked constants. The ABI version is unchanged, as it was when the ninth SDL audio export was added.
  • Between the ask and the event, isFullscreen() is stale, and a toggle driven by it can ask twice during macOS’s animation. That is harmless: the second ask repeats the first.
  • A window manager that refuses leaves the player’s copy filling the window without the window being fullscreen. F and Esc still leave, so nobody is stuck, but the player is only “full window”.
  • Keyboard focus stays where it was when the copy opens. The copy and the player in the layout drive the same player, and either one’s F or Esc leaves, so the keys work whichever one has focus. Moving focus into the copy would need an id and a frame’s wait, and nothing needs it yet.
  • A fullscreen request made before the first frame is applied as the window appears. That makes “open straight into fullscreen” an ordinary call at start-up, and is documented on Window.setFullscreen.

474. The audio clock is what is heard

Date: 2026-09-24

Status

Accepted. Closes the “− device latency” correction in docs/media-plan.md, open since phase 3. SDL’s buffers are counted in typical pulls, the median of the last fifteen, since ADR-0485: two pulls seen at once no longer double the latency.

Context

The master clock is the audio clock: the sample just past the last one written, less what the sink still holds (§3, ADR-0463). That is when a sample leaves SDL’s queue, not when it is heard. The design said “− device latency”, and it was left out because SDL 3 does not report any.

Between leaving and being heard there are two stretches:

  • SDL’s own buffers. The sink’s smoothing (ADR-0463) drains the next pull between pulls, so by construction it runs one pull ahead of what SDL has handed over. On macOS, SDL’s CoreAudio backend (SDL_coreaudio.m) then keeps three AudioQueue buffers, and a pull fills the one that has just finished, behind the other two. That is three pulls in all: 64 ms at 48 kHz.
  • The operating system’s. CoreAudio reports it as four properties of the output device: its latency, its safety offset, its IO buffer, and its first stream’s latency. Bluetooth reports its radio link in the first of these.

Measured on the development Mac, whose default output is a Bluetooth headset: device 84 (Bluetooth) at 44100 Hz: 11166 + 0 + 512 + 0 frames = 264 ms. With SDL’s ~70 ms, pictures were about a third of a second ahead of the sound, more than the “up to ~200 ms” the plan had guessed.

Decision

The audio clock is what is heard: what has left the queue, less the sink’s latency, less the application’s delay.

  • AudioSink.latencyNanos(), 0 by default, is wall-clock time from leaving the queue to being heard. The Engine takes it off the clock at the current rate, since in that time the device plays rate times as much stream.
  • SdlAudioSink reports SDL’s buffers, as pulls of the measured pull size (three on macOS; elsewhere only the pull the smoothing runs ahead by, until SDL’s WASAPI and PulseAudio backends are read as closely), plus the system’s latency from an OutputLatency.
  • OutputLatency is an SPI in :media, found by ServiceLoader and asked about the default output device, which is the one SDL’s stream follows. The sink asks on open and then at most once a second from the audio thread, so a headset connected mid-song is in the clock within a second. goldberry-media-platform provides CoreAudioLatency, one binding (AudioObjectGetPropertyData) and PortAudio’s sum of the four properties.
  • MediaPlayer.setAudioDelay(Duration), within ±2 s and kept across sources, is the correction by hand. Positive means the sound is heard later than reported (a receiver, a device that under-reports, a system with no provider yet). Negative means the picture is the late one, as on a television. MediaPlayer.audioLatency() reports the total being taken off.

Two consequences had to be designed:

  • A seek does not step back. Just after a seek, left − latency is before the target. The clock is floored at the last seek’s target, so the position holds there while the first samples travel.
  • A track ends when its last sample is heard. Once the queue is empty, nothing leaves it, so the clock would stop a latency short and the audio would be reported done while its tail was still in the headset. A player that opens the next track at ENDED would cut that tail off. An AudioTail counts the latency down in wall time from the moment the queue empties: the clock runs on to the end, and the audio thread reports done when the tail has lasted the latency. Paused, the tail stands still.

Alternatives considered

  • A user offset only. That’s simple and portable, but every Bluetooth user on every system has to find and turn a knob, and the right value changes whenever the headset does. The offset is kept, as the correction on top.
  • The latency query in libgoldberry. The toolkit’s natives would take on a media concern, and each platform’s C would need building on three platforms. The frameworks are the operating system’s, and :media-platform already binds them with FFM (ADR-0472).
  • SDL’s device buffer size read from SDL (SDL_GetAudioDeviceFormat). It needs two more exports for a number the sink already measures: the size of a pull.
  • A property listener on the default device. It would be exact at the moment of a switch, but needs an upcall and its lifetime. A read once a second costs a few system calls.

Consequences

  • On this Mac, over Bluetooth, the pictures are now held back by about 330 ms, and the position shown is what is heard. This hasn’t been checked with a microphone: the numbers are the system’s, and the sum is PortAudio’s. CoreAudioLatency logs every change at debug level, so a report from another headset comes with its numbers.
  • Windows and Linux count only SDL’s one pull until their providers (WASAPI, PulseAudio) and SDL’s buffering on those backends are written; the delay is the workaround there.
  • ENDED arrives a latency later than it did: 264 ms here over Bluetooth, and tens of milliseconds on built-in speakers. That is when the sound ends.
  • After a pause, the pictures replay up to a latency of what was already heard, while the resumed sound travels. The device’s buffer at the moment of a pause is the platform’s, and nothing reports what became of it.
  • Every clock reading now reads the sink’s latency: a synchronized read of two numbers, with the system asked only from the audio thread.

475. SDL_GPU is bound for :core and :gpu, and tested on the first thread

Date: 2026-09-24

Status

Accepted. The first decisions of M4 (docs/gpu-plan.md): D6 as built, and what phase 1 found about where a GPU device can exist. Amends ADR-0280 as ADR-0461 did.

Context

M4 needs SDL_GPU from Java. SDL_GPU is already inside libgoldberry, because SDL 3.4.16 is built with its defaults (ADR-0003). But none of its 97 functions was exported, and :natives had no binding for any of them.

Phase 1 of the plan binds the first surface: a device, textures, transfer buffers, command buffers, a render pass that clears, a copy pass that uploads and downloads, and fences. Its exit criterion is a texture cleared to a known colour that downloads byte for byte.

Three facts were found in SDL’s source on the way, and each changes something:

  1. A device needs the video subsystem, and a video driver that can make a Metal view or a Vulkan surface. SDL_CreateGPUDeviceWithProperties asks the current video device (SDL_gpu.c). The Metal driver’s PrepareDriver needs Metal_CreateView and the Vulkan driver’s needs Vulkan_CreateSurface. SDL’s dummy video driver, which every headless test in the project runs under, has neither, so under it there is no device at all. The offscreen driver has a headless Vulkan surface, so lavapipe works under it with no display. On macOS, only cocoa has a Metal view.
  2. cocoa starts only on the process’s first thread (Cocoa_CreateDevice returns NULL otherwise). That is ADR-0039’s failure, “No available video device”, met again in a place where the flag cannot be passed: Gradle’s Test task runs tests on a worker thread however its JVM is started.
  3. The exports cost almost nothing. libgoldberry for macos-aarch64 was 6,100,832 bytes without the 29 new symbols and 6,102,848 with them: 2 KB. The GPU drivers were already linked, reached through SDL’s renderer, which backs the window surface where a platform has no framebuffer (ADR-0046). The size risk in the plan’s phase 0 is answered for this target.

Decision

Bind SDL_GPU in :natives by the rules every other library follows, and seal it to the two modules M4 builds on it.

  • The functions are holders in natives.sdl.calls (ADR-0173): SdlGpuDeviceCalls, SdlGpuResourceCalls and SdlGpuCommandCalls, plus SdlPropertiesCalls for the property groups a device is configured with. They are exported in goldberry.symbols only as they gain a caller, so ExportListTest holds.
  • Their six structs and 23 enumerators are on the shim’s layout table and in Layouts/NativeConstants, so the C compiler checks every offset and value. The ABI version is 16.
  • The wrappers are in natives.sdl.gpu: SdlGpuDevice, SdlGpuTexture, SdlGpuTransferBuffer, SdlGpuCommandBuffer with its CopyPass, and SdlGpuFence.
    • No public signature carries a MemorySegment.
    • SDL’s rules are checked in Java before SDL is called: one pass at a time, a command buffer used once, unmapped buffers in a copy pass, regions inside their texture, and resources of the same device.
    • A closed resource fails in Java, naming itself. A device releases whatever is still open when it closes.
    • Mapped memory is a ByteBuffer scoped to an arena that unmap closes, so holding it too long throws rather than reading freed memory.
  • natives.sdl.gpu is exported to :core and :gpu, and to nobody else. :core will claim windows and present; :gpu is the public API. It is the first package sealed to two readers, and ExportedSurfaceTest now holds a sealed package to a set of readers.

Run the GPU tests on the first thread. They are tagged gpu, and the ordinary test task leaves them out. :natives:gpuTest runs them instead: a JavaExec with -XstartOnFirstThread on macOS, whose main (GpuTestLauncher) asks JUnit to run them on the calling thread. It is part of check.

  • Without a device they skip. -Pgoldberry.gpu.required=true makes that a failure, which is ADR-0016’s rule applied to a device.
  • -Pgoldberry.gpu.videoDriver names the video driver: offscreen for lavapipe on a runner with no display.
  • A launch that finds no tests fails.

Alternatives considered

  • Vulkan through MoltenVK under offscreen on macOS. It needs no first thread, but it tests a driver no Mac user runs, and needs MoltenVK installed. Rejected: the macOS path is Metal.
  • Dispatching SDL’s video initialisation to the main queue from the test thread. The java launcher parks the first thread in a run loop, so a block sent there would run. But SDL’s video would then belong to a thread the tests never run on, and every later call would cross threads. Rejected for what it would teach the code under test.
  • One export per SDL_GPU function up front (the plan’s ~60). Rejected by ExportListTest’s rule: a symbol nothing binds is dead weight, and the rest arrive with the phases that call them.

Consequences

  • Phase 1’s exit is met on Metal: 16 device tests pass under Metal’s API validation. They cover the clear and download, a region uploaded and read back, 16-bit planes, and every rule above. Layouts and constants are verified by LayoutVerificationTest. Lavapipe waits for the GPU lane.
  • A readback in a headless test needs offscreen or cocoa, not dummy. The plan’s “headless GpuSurface in readback mode” (D5) is corrected: the golden tests that render GPU content run in a gpuTest-style task under a GPU-capable driver. The CPU goldens keep dummy and Offscreen, unchanged.
  • The size gate needs no allowance for GPU exports on macOS. Linux and Windows are measured when their builds run.

476. Shaders are HLSL, compiled by DXC and SPIRV-Cross, and committed

Date: 2026-09-24

Status

Accepted. D7 of docs/gpu-plan.md, as built, with the pipeline surface of SDL_GPU that phase 2 needed to draw with the shaders.

Context

SDL_GPU takes a different bytecode on each backend: SPIR-V for Vulkan, DXIL for Direct3D 12, and MSL (or a metallib) for Metal. SDL cannot read what a shader declares from any of them, so the samplers and uniform blocks are stated when a shader is created. The plan said to author each shader once and cross-compile it with SDL_shadercross, offline, and commit the output.

Three things were found:

  1. Shadercross is two engines. It runs DXC (HLSL to SPIR-V and DXIL) and SPIRV-Cross (SPIR-V to MSL). Both ship with the Vulkan SDK, and this Mac had them (1.4.328), while shadercross itself would have to be built.
  2. DXC signs DXIL off Windows. Since 1.8 its validator is built in: the container’s digest is non-zero without -Vd, and zero with it. Direct3D 12 refuses unsigned DXIL, so this was the one reason to fear compiling off Windows, and it does not apply.
  3. SDL’s binding conventions fix the registers. A vertex shader’s uniform blocks are (b[n], space1). A fragment shader’s textures and samplers are (t[n], space2) and (s[n], space2), and its uniforms (b[n], space3). DXC maps spaces to SPIR-V sets. Vulkan wants combined image samplers, which DXC writes only when told ([[vk::combinedImageSampler]], guarded by __spirv__ so the DXIL compile does not warn). SPIRV-Cross then gives MSL [[texture(0)]], [[sampler(0)]] and [[buffer(0)]], as SDL’s Metal backend expects, and renames the entry point main0.

Decision

Author in HLSL, compile with DXC and SPIRV-Cross directly, commit the output, and check it.

  • Sources are gpu/src/main/shaders/<name>.vert.hlsl and .frag.hlsl. :gpu:compileShaders is run on purpose, not in build. It writes .spv, .dxil and .msl for each source under the module’s resources, and shaders.properties with each source’s SHA-256 and the tools’ versions. The output is deterministic: a second run gives the same bytes.
  • ShaderManifestTest fails when a source’s hash differs from the manifest, when a source has no bytecode or bytecode no source, and when BuiltInShader and the sources disagree. It needs no DXC and no device, so every leg runs it.
  • BuiltInShader states each shader’s stage, samplers and uniform blocks. ShaderLibrary loads the format the device takes (MSL, SPIR-V, DXIL, in that order) with the right entry point. The directory is declared to native-image by glob, which DeclaredResourcesTest holds to.
  • The first three shaders are quad.vert, texture.frag and solid.frag. quad.vert makes a quad from the vertex id and one uniform block (destination in normalised device coordinates, source in texture coordinates). Quad turns pixels from the top left into that block, in one place.

The pipeline surface, bound as phase 2 needs it. 13 more exports: shaders, samplers, graphics pipelines, and what a render pass records (pipeline, viewport, scissor, fragment samplers, vertex and fragment uniforms, draws). Their 13 structs are on the layout table, SDL_GPUGraphicsPipelineCreateInfo among them at 168 bytes with five structs by value, and so are 13 enumerators. In natives.sdl.gpu:

  • SdlGpuShader, SdlGpuSampler and SdlGpuGraphicsPipeline. A pipeline draws triangles made from the vertex id into one colour target, with no culling and no depth, and one of two blends: REPLACE, or PREMULTIPLIED_OVER for the UI.
  • beginRenderPass takes a sealed SdlGpuLoad (keep, clear, don’t care), and clear is one such pass.
  • The RenderPass refuses, in Java: a draw with no pipeline, or with fewer textures bound than the pipeline samples; a pipeline for another format than the target’s; and a scissor outside the target.

Alternatives considered

  • Build and pin SDL_shadercross. It adds reflection (the counts BuiltInShader states), but that is three numbers a shader, checked by the device’s validation. It would also add a C build of shadercross to a machine that already has both engines. Rejected for now, and nothing here prevents it later.
  • Write MSL by hand for Metal. Rejected: three sources per shader, which drift apart.
  • Compile at build time on every leg. Rejected, as the plan said: it would need the Vulkan SDK on every runner and every contributor’s machine.

Consequences

  • Phase 2’s exit is met on Metal for these shaders. A solid quad fills exactly its pixels, the right way up. A texture drawn 1:1 with a nearest sampler reads back byte for byte, which is what a composited UI rests on. Premultiplied “over” lands within 2 in 256, and a scissor clips.
  • DXC and SPIRV-Cross are in THIRD-PARTY-NOTICES.md under “Not distributed”: build-time tools whose output is ours.
  • The DXIL has not run on a Direct3D 12 device yet; that waits for a Windows host. The SPIR-V waits for the lavapipe lane.

477. The GPU composites with SDL_GPU directly, and converts Y’CbCr itself

Date: 2026-09-24

Status

Accepted. D1 of docs/gpu-plan.md, taken on phase 0’s measurements. The colour half of media phase 4 (GPU present) is built here. Its chroma siting is corrected by ADR-0484: swscale centres chroma rather than siting it left, and the shaders now do the same.

Context

The composition pass could be written against SDL_GPU itself, or against SDL 3.4’s SDL_CreateGPURenderer. The renderer is an SDL_Renderer on an SDL_GPU device. Its textures take NV12 and P010 with a colorspace, and SDL_GPURenderState gives it custom fragment shaders. The plan leaned to SDL_GPU, and set a condition for the renderer: choose it only if it is clearly smaller at equal fidelity.

Phases 0 to 2 built the direct path far enough to measure it (ADR-0475, ADR-0476):

  • Fidelity. Two shaders, yuv2.frag (NV12, P010) and yuv3.frag (I420, I010), with one uniform block. The block holds the bit-depth scale, the range offset and gain, the matrix coefficients, and a left-siting offset for chroma. Against a Java reference (YuvConversion), every layout × matrix (BT.601/709/2020) × range × eight colours read back exactly: worst difference 0 in 255, on Metal. Chroma stays on its own side of an edge.

  • Cost (:gpu:gpuVideoProbe, M1 Pro, 2560×1600 window, 120 Hz). Each frame uploads a new 3840×2160 picture, converts it into a letterboxed quad, uploads a caret’s worth of UI damage, and draws the UI over it:

    LayerCPU per frame (copy + record + submit)frame interval, median / p95
    4K NV12 (12 MB)0.94 ms8.32 / 8.93 ms
    4K P010 (24 MB)1.37 ms8.31 / 8.95 ms

    The display’s rate held with a 4K picture every frame, which is twice what 4K60 needs.

Decision

Composite with SDL_GPU directly, and convert Y’CbCr in the toolkit’s own shaders.

The renderer fails the plan’s condition on fidelity before cost is reached. It has no I010, the 10-bit format dav1d and VP9 profile 2 decode to, so that format would need a conversion of its own before upload, the step the GPU path exists to remove. Its colour pipeline is also SDL’s, and can change with an SDL release. The direct path’s is ours, and a test holds it to a reference.

The direct path keeps what D1 listed: frames in flight, cycling transfer buffers, readback through the same device, and one abstraction for the UI, canvas3d, video and the goldens. The composition itself is two draws.

Alternatives considered

  • SDL_CreateGPURenderer. Rejected, as above. It was not built to be measured: at unequal fidelity its cost does not decide anything, and binding the renderer’s API only to compare would have been work thrown away.
  • Converting on the CPU and uploading BGRA. What CPU present does today (ADR-0463), with swscale on the decode thread. A 4K BGRA frame is 33 MB to upload against NV12’s 12 MB, and swscale’s conversion costs far more than the shader’s. Kept as the fallback ladder’s last rung, not as the GPU path.

Consequences

  • YuvLayout, YuvMatrix and YuvConversion are in …gpu.render for phase 6 to use. BuiltInShader has YUV2_FRAGMENT and YUV3_FRAGMENT. The shared yuv.hlsli is hashed into the shader manifest like a source.
  • D8’s arithmetic holds on this machine: a 4K P010 picture copies into a transfer buffer in about a millisecond on the UI thread. Transfer buffers written by the decode thread are not needed here.
  • Parity with swscale’s SWS_BITEXACT output (goldberry-media.md §8) is still phase 6’s to show, with real pictures. What is shown here is parity with the arithmetic swscale implements, for flat colour and one chroma edge.
  • Lavapipe waits for the GPU lane; D3D12 for a Windows host.

478. The GPU API is confined to one thread, scoped by pass, and checked in Java

Date: 2026-09-24

Status

Accepted. The public half of docs/gpu-plan.md’s phase 2, over the SDL_GPU wrappers of ADR-0475 and ADR-0476.

Context

Phase 2 asks for “a safe layer an application can use without knowing SDL_GPU’s structs”. canvas3d renderers are its first users (phase 5), then the composited window’s layers and video (phases 3, 4 and 6). They need textures, buffers, samplers, shaders, pipelines, a command scope per frame, uploads, and readback.

The wrappers in natives.sdl.gpu already check SDL’s rules in Java: one pass at a time, a draw with everything bound, resources of the same device. But they are :natives types, and ADR-0280 keeps those out of every signature an application can read. They also cover only what the toolkit’s quads needed. Vertices came from the vertex id, there was no depth, and a pipeline was always a quad pipeline.

SDL_GPU has sharp edges a Java API can blunt:

  • A command buffer cannot be cancelled with a pass open.
  • A debug group begun in a pass has to end in that pass on Metal.
  • A texture cycled on upload loses whatever the upload did not write.
  • A transfer buffer mapped for writing makes the CPU wait if the GPU still reads it, unless it is cycled.
  • Nothing is thread-safe.

Decision

A public package, io.github.digitalsmile.goldberry.gpu, whose types wrap the natives wrappers. No SDL type, handle or MemorySegment appears in it.

  • Resources. GpuTexture, GpuBuffer, GpuSampler, Shader and GraphicsPipeline extend a sealed GpuResource. Each is AutoCloseable and made from a record: TextureSpec, SamplerSpec, ShaderCode, or PipelineSpec with its builder. The enums those records name map onto SDL’s by exhaustive switch, and a test holds each mapping to be one to one. A use after close, or with another device, throws in Java and names the public type.
  • One thread. A GpuDevice belongs to the thread it was handed out on. Every call from another throws WrongThreadException, as a confined Arena does, before the driver is reached.
  • The device is not an application’s to make or close. GpuDevice.wrap is package-private. The backend will own the one device of the process (D2) and hand it out through gpuSurface() (phase 3).
  • Passes are scoped by lambdas. GpuFrame.copyPass(body) and renderPass(target, load, [depth,] body) open the pass, run the body, and end the pass whether it returns or throws. Passes do not nest, and a pass kept past its body throws. A GpuFrame is AutoCloseable: closing an unsubmitted frame discards it, and closing a submitted one does nothing, so try-with-resources is always right. debugGroup(name, body) is scoped the same way, and the wrapper refuses a group left open across a pass’s end.
  • Uploads go through one staging buffer per device. It starts at 64 KiB and at least doubles when an upload does not fit. Each map is cycled, so writing never waits for the GPU and never overwrites bytes it has yet to copy. An upload of regions records the texture upload uncycled, so the pixels outside the regions stay as they were; that is the composited UI’s damage-only upload. An upload of the whole texture or buffer is cycled.
  • Readback is recorded in the frame and awaited after it. GpuFrame.readback(texture, region) records a download. Submitting a frame with readbacks takes one fence, which they share and release. Readback.await() is the only blocking call in the API. awaitPixels() gives a PixelBuffer of premultiplied BGRA, swizzling an RGBA texture.
  • Driver refusals are GpuException. What the API can see for itself is IllegalArgumentException or IllegalStateException.

Under it, the natives wrappers grow what canvas3d needs, each piece checked the same way:

  • vertex and index buffers, and their uploads and downloads;
  • indexed and instanced draws;
  • a SdlGpuPipelineDescription with vertex input, primitive type, culling, front face and a depth test;
  • depth targets for render passes;
  • sampler address modes;
  • uniforms pushed as bytes;
  • balanced debug groups.

That is ten more exports (SDL_CreateGPUBuffer … SDL_InsertGPUDebugLabel) and seven more verified structs. The enumerators are verified through nine new enums, so the names and values are checked against the compiled library.

Alternatives considered

  • Exporting the natives wrappers. They are already safe, but it would reverse ADR-0280 for one module. It would also make SdlGpu… names, and every change to them, part of the published surface.
  • Passes as AutoCloseable objects returned to the caller. Safe inside try-with-resources, but nothing makes a caller use one, and a pass left open locks the command buffer. With a lambda there is nothing to forget. The cost is that state a body computes has to leave through a captured variable.
  • One transfer buffer per upload. Simpler, but a 4K frame would allocate 33 MB every frame. The staging buffer is sized once, and SDL’s cycling keeps the few copies frames in flight need.
  • await returning the transfer buffer’s mapped memory. It would need no copy, but the memory would stop being valid at unmap, the lifetime ADR-0019 keeps raw memory from having. The copy goes to the heap, and only readback pays for it.

Consequences

  • canvas3d (phase 5) has what its cube needs: vertex and index buffers, depth, culling and a uniform block. The tests draw a mesh with shaders of their own, which :gpu:compileShaders now compiles from src/test/shaders into test resources that do not ship. ShaderManifestTest holds them to their sources too.
  • Phase 2’s exit is met on Metal: a triangle from a vertex buffer reads back as a reference rasterized in Java draws it, every pixel but those on an edge. Lavapipe waits for the GPU lane.
  • The composited window (phase 3) builds on GpuFrame. Its swapchain becomes a second RenderTarget (the interface is sealed and permits only GpuTexture today), and CopyPass.upload(texture, pixelBuffer, damage) is its UI upload. SDL_CancelGPUCommandBuffer is refused after a swapchain acquire, so a composited frame will have to submit rather than discard.
  • Vertex-buffer and sampler bindings are reset whenever a pipeline is bound. SDL keeps them on some backends. The stricter rule is the same on all of them.
  • Not in this cut, and recorded so it is not rediscovered:
    • PresentMode, which has no consumer before phase 3;
    • resource names (SDL_SetGPUTextureName);
    • mip levels, multisampling and stencil;
    • compute (phase 7).
  • The culling test checks winding in normalised device coordinates, y up, as the API documents. If lavapipe disagrees with Metal, that is SDL’s backends disagreeing, and the test is where it will show.

479. A window is composited through a seam :core declares and :gpu provides

Date: 2026-09-24

Status

Accepted. docs/gpu-plan.md’s phase 3, and D3 on macOS. It corrects D5. Its default policy (auto, compositing nothing until GPU layers exist) is superseded by ADR-0480: windows are composited by default. Its rule that a window with an embedded page stays on the CPU is amended by ADR-0491: under X11 the window keeps the GPU.

Context

Phase 3 makes a window present through the GPU. The window’s painted frame is uploaded, damage only, as a texture, then composited onto the window’s swapchain. Phase 0 measured the switch on macOS: it is reliable and cheap, and compositing costs less CPU than the window surface (docs/gpu-plan.md §4.1).

The two halves live in different modules:

  • :core’s half: the window, the frame loop, pacing, and when to switch.
  • :gpu’s half: the device, the shaders and the draws.

:gpu requires :core, so :core cannot call into :gpu. D5 sketched GpuLayer.render(GpuFrame …) in :core’s SPI, which cannot compile for the same reason: GpuFrame is a :gpu type (ADR-0478).

Nothing needs composition yet either. GPU layers are phase 4. Until then, a composited window is one that is asked to be.

Decision

:core declares a seam, render.composite, exported to :gpu alone. :gpu provides it, and the sdl3 backend finds it with ServiceLoader.

  • The seam. It has three types:

    • Compositor, one per backend: claim(SdlWindowHandle) returns a window or empty, unavailable() says why when empty, and close();
    • CompositedWindow: present(PixelBuffer, damage), lastPresent() and close();
    • PresentTimings: the upload, acquire and submit times, the bytes uploaded, and whether anything was shown.

    The signatures name :natives’ window handle, so the export is qualified. :core uses the service and :gpu provides it (SdlCompositor). With no :gpu on the module path there is no provider, and every window presents on the CPU exactly as before. An application needs no requires for this; being on the module path is enough.

  • The policy. It comes from two properties:

    • goldberry.gpu=off|auto;
    • goldberry.gpu.composite=never|auto|always.

    The default, auto, composites a window when a GPU layer needs it, so until phase 4 it composites nothing and no window changes. always composites every window from its first frame, which is how the path is run and measured now.

  • The window (Sdl3Window) enters the composited mode in acquireFrame, the one call made before a frame is painted. It gives up its surface, is claimed, and from then on lends no surface. The frame loop paints into its own buffer, which it keeps between frames, so partial repaint goes on working. present hands the frame and its damage to the compositor.

    • A claim that is refused is remembered, and the window stays on the CPU.
    • A present that fails gives the window back, presents that same frame whole on the CPU, and stays there.
    • A window with an embedded page stays on the CPU, and leaves the composited mode if a page arrives. Whether a page’s native view or the swapchain shows on top was left unmeasured in phase 0, and on the CPU the answer is known.
  • The compositor owns the process’s one device (D2). It is made at the first claim, with goldberry.gpu.driver and goldberry.gpu.debug, and a failure to make it is remembered.

    • Two frames in flight. VSYNC, or MAILBOX then IMMEDIATE when goldberry.backend.vsync=false.
    • One staging buffer (ADR-0478’s, moved to gpu.render so both share it) and one composite pass, shared by every window.
    • Each window has a B8G8R8A8_UNORM UI texture at the frame’s size. The first frame, and the first after a resize, goes up whole and cycled. After that, only the damage goes up, uncycled, keeping the rest.
    • The composite pass clears to opaque black and draws the UI texture 1:1 with quad.vert and texture.frag, sampled nearest, with premultiplied “over”. The UI’s pixels are premultiplied, so over black they keep their colour bytes, and the alpha goes opaque. That is what the window surface, which ignores alpha, showed. D7’s separate ui.vert/ui.frag are not needed.
    • A swapchain format the bindings do not model is blitted to instead.
  • Pacing. FramePacer stays on for composited windows. Phase 0 found the swapchain paces a composited frame, and the first cut stepped the pacer aside for them. Measured on the showcase (M1 Pro, 120 Hz, 240 frames):

    timelatepaint mean
    composited, pacer aside0.93 s02.88 ms
    composited, pacer on2.9 s143.83 ms
    CPU2.8 s135.23 ms

    With the pacer aside, a frame with no damage presents nothing and waits for nothing, and the loop painted such frames about a millisecond apart. With it on, a frame that did present has already waited out its interval in the acquire, so the pacer holds nothing back; it only stops the frames nobody sees.

Alternatives considered

  • The composite written in :core against the natives wrappers. :core reads natives.sdl.gpu already. But the shaders are :gpu’s resources, and so is the arithmetic that places a quad. Moving them would put GPU code in every application, used or not.
  • A registration call instead of ServiceLoader. Something would have to call it, which means an application naming :gpu. ServiceLoader needs only the module path, and :core already finds the emoji face that way.
  • Deciding the mode at window creation (D3’s fallback). Not needed on macOS, where the switch is reliable. Deciding at the first frame keeps the door open for phase 4’s attach-time switch.

Consequences

  • D5 is corrected. GpuSurface, GpuLayer and CompositionMode come with phase 4, their consumer, and not before (ADR-0019’s rule). The layer interface will be :gpu’s, since it renders with GpuFrame. :core will record opaque layer slots in paint order and ask the compositor to draw them.
  • Hysteresis (D3) comes with phase 4 too. Until layers attach and detach there is nothing to switch back from, except a failure, and that is for good.
  • PresentTimings is kept per window but does not reach FrameStats or the hud yet.
  • Parity is proven at the composite pass. Random premultiplied frames, which cover every byte value and every alpha, keep every colour byte exactly. A damage-only upload keeps every pixel outside the damage. The tab-by-tab comparison of the showcase in both modes is still to be done by eye, and so is a gallery parity test against the CPU goldens.
  • Untested platforms. Wayland, X11, Windows and lavapipe are untried: claiming after SDL’s hidden Wayland renderer, in particular. The GPU lane runs the backend end to end under offscreen.
  • Native image. An image needs the service registered for ServiceLoader. That is phase 7’s native-image row.

480. Windows present through the GPU by default, and on the CPU where it cannot

Date: 2026-09-24

Status

Accepted. It supersedes ADR-0479’s default policy, and ADR-0002’s promise that an application without GPU content never touches a driver. Rasterization is unchanged: the UI is still painted by Blend2D on the CPU (ADR-0002). Only the last step changes, how the painted frame reaches the screen.

Context

ADR-0479 built the composited window and left it off by default: goldberry.gpu.composite=auto composited a window only when a GPU layer needed one, and there are none before phase 4. That followed docs/gpu-plan.md’s D2 and ADR-0002: no driver is loaded until something needs one.

The measurements since say the GPU path is the better default where it works. On this Mac (M1 Pro, 120 Hz, the showcase’s 240-frame run):

first frame on screendevicepaint meanlate
through the GPU1016.9 ms (median of 3)19.5–21.3 ms at the first frame3.67 ms10
through the window surface1013.4 ms (median of 3)none5.34 ms13
  • The first frame reaches the screen about 3.5 ms later, which is about 0.3%.
  • Frames paint faster, because the frame loop’s own buffer replaces SDL’s window surface.
  • A present costs less CPU than SDL_UpdateWindowSurfaceRects (phase 0, §4.1).

And the path fails safe. Every way it can fail already ends with the window presenting on the CPU, as it did before there was a GPU path.

Decision

Composite every window by default, and present on the CPU wherever the GPU cannot be used. Log both: the outcome for every window, and why when it is the CPU.

  • goldberry.gpu.composite defaults to always. auto keeps its meaning, composite only for GPU layers, and never and goldberry.gpu=off still keep every window on the CPU. A value that is not understood is the default.

  • Fallbacks. Each is logged once, where it is known:

    What happensWhere it is loggedLevel
    the backend startsthe policy, and how to change itINFO
    no :gpu on the module paththe backend: “add goldberry-gpu”INFO
    the device is madethe compositor: driver, formats, timeINFO
    no device can be madethe compositor: why, and the two propertiesWARN
    a window is claimedthe window: “presents through the GPU”INFO
    a window is refusedthe window, with the compositor’s reasonINFO
    a page is embedded in a composited windowthe window, from now onINFO
    a composited present failsthe window, with the exceptionWARN

    A claim now answers with a sealed Claim, either Claimed(window) or Refused(reason), so the window’s line carries the driver’s own reason rather than a generic one.

  • Popups are not composited. They are transparent windows, which SDL will not claim, and asking would tear a menu’s surface down and rebuild it, only to be refused.

  • The device is made at the first frame of the first window, not before, and its creation is on the start-up timeline (“GPU device created”).

Alternatives considered

  • Keep auto as the default until GPU layers exist. What ADR-0479 did. It keeps ADR-0002’s promise, but then the composited path runs only when someone asks for it, which is the reason it has run so little. It would also first meet real users only once layers exist, with two new things arriving at once.
  • Decide per platform. Composite by default only where it has been measured, which is macOS. That would be more conservative, but every other platform already falls back per window with a logged reason, and a default that differs by platform is one more thing to explain. Where the GPU lane or a user finds a platform that composites badly rather than failing, goldberry.gpu=off is the switch, and the default can be narrowed then.

Consequences

  • Every application with goldberry-gpu on its module path loads a GPU driver at its first frame. One without it is unchanged, and says so once at INFO.
  • The composited path now runs by default on this Mac for the showcase, the GPU tests and anything else with :gpu on its path. The CPU path stays covered by every backend test in :core, which has no :gpu, and by goldberry.gpu=off, which CompositedBackendTest drives.
  • Untried platforms. Wayland, X11 and Windows now take the GPU path by default without having been tried. A refused claim or a failed device falls back cleanly. What would not fall back by itself is a platform that claims, presents without error, and shows the wrong thing. The GPU lane and the first runs there are what find that.
  • What a first frame costs: about 20 ms of device creation, measured here. ADR-0002’s millisecond start-up claim is about painting, which is unchanged.

481. GPU layers are placed in paint order, and shown through a hole or read back

Date: 2026-09-24

Status

Accepted. docs/gpu-plan.md’s phase 4, D4 as built, and the rest of D5. It brings the GpuSurface ADR-0479 moved to this phase, in a different shape: there is no attach or detach, and no CompositionMode enum.

Context

A GPU layer is something the GPU draws into a window among what the CPU paints: a canvas3d, a video on the GPU. Phase 3 composites a window with no layers, and phases 5 and 6 need layers to exist. The plan’s D4 chose how they sit in the z-order: the layer’s element clears its box in the CPU frame, a hole, and the compositor draws the layer under the frame, so what was painted after the layer is above it.

Four things the plan left open had to be settled against the code:

  • Who knows the clip. A layer is scissored by the clips it is painted under, a scroll view’s viewport above all. Blend2D will not hand its clip back, and a canvas painter, which is what places a layer, only has the frame.
  • Partial repaint. A frame that repaints only its damage walks only what the damage touches. A video beside a blinking caret is not painted on the caret’s frames, and places nothing, but it is still on screen. And a video the damage cuts across is painted under a clip that includes the damage, which is not a clip the video is seen through.
  • The type split. :core places layers and cannot name :gpu’s GpuFrame, which is how a layer renders (ADR-0479).
  • Where a layer renders. A layer with depth and passes of its own cannot draw inside the window’s composite pass.

Decision

:core records opaque layers in paint order and punches their holes; :gpu renders each into a texture of its own, and either composites that texture under the frame or reads it back into it.

:core

  • render.GpuContent is an empty interface, an identity. :gpu’s GpuLayer extends it and says how it renders.

  • Frame.gpuLayer(content, x, y, width, height) places one, in logical coordinates under the transform in force, and returns false when the frame cannot show it, so the painter paints a fallback. The box goes through the transform to its bounding box in physical pixels, each edge rounded to the nearest pixel. Its scissor is that box cut by the clips in force and the frame. What is placed is a render.GpuPlacement(content, target, scissor), and Frame.gpuPlacements() lists them in paint order.

  • The frame mirrors its clip in Java, as it already mirrored its transform (ADR-0390): clipTo, resetClip, save and restore keep a physical-pixel rectangle beside Blend2D’s. A clip under a rotation is its bounding box, which is what Blend2D clips to as well.

  • Frame.repaintOnly(x, y, width, height, body) confines body to the region being repainted. It is a clip as far as pixels go, it outlasts resetClip (Blend2D’s reset returns to the last saved clip, and the region is saved), and it is not in the mirror, so it scissors no layer. RenderTree’s partial paint uses it, and keeps the damage out of the clips its walk sets: the damage is only what the walk culls against.

  • render.window.GpuSurface is sealed, with two non-sealed kinds, which are the window’s composition mode:

    • Composited: the frame clears the layer’s box to transparent black (SRC_COPY, in physical pixels), and the window’s next present draws the layer there;
    • ReadBack: render(content, size) gives the layer’s pixels, and the frame draws them over the box, replacing what was there as the compositor’s quad would. Heap pixels are copied to direct memory and held until the frame ends, since a threaded context blits after the call returns.

    Both are told, once a frame, what is on screen: placed(layers).

  • BackendWindow.gpuSurface() gives the surface for the frame about to be painted; Window.paint asks for it after acquireFrame. Empty by default, with goldberry.gpu=off, and with no :gpu on the module path. Asking makes no device.

  • Partial repaint keeps layers it did not reach. After each frame the window merges: a layer from the last frame that the damage does not touch is kept, one the damage touches and the frame did not place again is gone, and one placed again is where the frame put it. Paint order is kept on both sides.

  • The seam (render.composite, :gpu alone) gains Compositor.readback(), a ReadbackSurface per window, and CompositedWindow.present(frame, damage, layers).

The backends

  • sdl3. A composited window gives a Composited surface and passes what the frame placed to its present. A window on the CPU gives the compositor’s read-back surface: a popup, a window with a page in it, one whose claim was refused, and every window under goldberry.gpu.composite=never.
  • goldberry.gpu.composite=auto now means something: a window is composited from the frame after it first shows a layer, and goes back to its surface two seconds after it last showed one (D3’s hysteresis; leaving costs 6 ms on macOS, a visible hitch at 120 Hz). Until then its layers are read back. Leaving on a timer is not for good, as leaving on a failure is.
  • goldberry.gpu=off is a policy of its own now, OFF, rather than NEVER: no window is composited and no layer is rendered, where never still reads layers back.
  • headless windows give a read-back surface when :gpu is on the module path. Its device needs a video driver the GPU can use: offscreen under lavapipe, cocoa on macOS.
  • Offscreen.gpu(surface) takes a surface, so a render with a layer in it can have the layer.

:gpu

  • GpuLayer.render(GpuFrame frame, GpuTexture target), public. target is a B8G8R8A8_UNORM colour target exactly the layer’s physical size, the toolkit’s, kept for the layer while it is placed at that size. The layer covers it whole, with passes of its own. GpuLayer.painter(fallback) is the painter that places it, or paints fallback where it cannot be shown.
  • A composited present renders every placed layer, in one frame submitted before the composite (one queue runs its submissions in order), then records the composite: clear to black, each layer’s texture drawn 1:1 at its place, scissored, with a replacing pipeline, then the UI texture over them all with premultiplied “over”. With layers on screen a window is composited on every present, damaged or not, since a layer may have changed with no damage at all. Layer work is counted in the present’s submit time.
  • A read-back surface renders the layer into the same kind of texture and downloads it: the same pixels, bought with a wait.
  • A layer that throws is logged once and shows as black where it throws. Content that is not a GpuLayer shows as black too.
  • GpuDevice.wrap and a texture’s SDL handle stay package-private in the public package. gpu.render reaches them through ApiAccess, which GpuDevice registers when it is initialised.

The rules

Written into GpuLayer’s doc, and each one shown by a golden:

  • Layers are opaque. A layer replaces what is under it. A translucent one does not show the frame through, in either mode.
  • Layers are axis-aligned rectangles of whole pixels. Clips are rectangles too: a rounded clip scissors a layer to its bounding box, and rounded corners are drawn by UI painted over the layer.
  • No layer in a group. A frame nested in an opacity group or a promoted layer has no surface, so the layer’s painter paints its fallback there.
  • Frost blurs the UI only in composited mode (D4, unchanged).

Alternatives considered

  • attach and detach on the surface (D5’s sketch). A layer’s presence is a fact about the frame just painted, and the frame already records it in paint order. A second registry would have to agree with it every frame.
  • Rendering layers straight into the swapchain’s composite pass. It saves a texture per layer, but a layer could then have no depth buffer and no pass of its own, and read-back would need a different path. The texture is what makes the two modes one render.
  • Scissoring by the Blend2D clip as it stands. It holds the damage, so a video half inside the damage would show half.
  • A full repaint whenever a window shows a layer. It would make the keep-or-drop merge unnecessary, at the cost of repainting the whole UI for every caret blink in a window with a video.
  • A CompositionMode enum beside the surface. The sealed kinds already say it, and an enum could disagree with them.

Consequences

  • Phase 4’s exit is met on Metal. Six z-order goldens (a popup over a layer, a layer in a scrolled list at 100% and 150%, two overlapping layers with translucent UI between them, a layer under a rounded clip, and the popup at 200%) are each rendered composited and read back through the production passes. The two ways agree to within two levels in 256, and both match one golden. The lavapipe lane has not run them yet.
  • What a layer costs, composited: a texture of its size and a render every present it is on screen. There is no on-demand cache yet: a static canvas3d re-renders on a caret’s frame. Phase 5 decides whether it needs one.
  • What read-back costs: a blocking download per layer per painted frame. It is the mode for what cannot be composited, not the one to choose.
  • Untested here: leaving auto’s composited mode after the hold (it waits two seconds of wall time), and layers under Wayland, X11 and Windows.
  • RenderTree culls against the damage and clips by the tree alone, which changed no existing golden or test.

482. canvas3d is a GPU layer an application renders into

Date: 2026-09-25

Status

Accepted. docs/gpu-plan.md’s phase 5, built on ADR-0481’s GPU layers.

Context

Phase 4 made GPU layers: content the GPU draws, placed in paint order, and composited under a window’s frame or read back into it. canvas3d is the first layer an application writes, and the consumer D5 was waiting for.

Five questions came with it:

  • What the application writes. It needs a device to make pipelines and buffers on, a frame to record into, a target to draw into, and a place to release what it made.
  • When it is drawn. A spinning model is drawn every frame. A model viewer whose camera moves once a minute should cost nothing between moves. Phase 4 rendered every layer on every present, so a still 3D view was drawn again for a caret blinking beside it.
  • How a frame is asked for. The render tree repaints a canvas box only when its painter changes, and compares painters with equals. A read-back canvas has to be repainted to be drawn again. A composited one does not, but its window must still present.
  • What it shows with no GPU. Plan D5 said “the widget paints --gb-canvas3d-unavailable and the reason, as web-view does.” web-view gives its reason as a message child, which is chosen at build time. A canvas learns it has no GPU only when it is painted.
  • Where its module sits. :gpu had no widgets and did not depend on :widgets.

Decision

canvas3d is a stateful widget that keeps one GPU layer per renderer. The layer drives the application’s Canvas3dRenderer through its lifecycle, and says when it has nothing new to show.

The renderer

gpu.view.Canvas3dRenderer:

  • init(GpuDevice): once, before the first render, on the window’s device.
  • resize(PhysicalSize): before the first render, and whenever the canvas’s physical size changes.
  • render(GpuFrame, Canvas3dTarget): for each frame the canvas is drawn.
  • dispose(): when the canvas leaves the tree, and before init on a new device.

All four run on the UI thread. Canvas3dTarget holds three things:

  • the colour texture, which is the layer’s texture at the canvas’s size and belongs to the toolkit;
  • the depth texture, when the canvas asked for one (depth=d16|d32), kept by the canvas and remade on resize;
  • the frame’s time in nanoseconds since the canvas was first drawn, taken from the window’s clock. With a virtual clock, such as Offscreen’s, a frame is therefore exact.

Drawing only what changed

  • GpuLayer.needsRender(), true by default. When a layer says false and the compositor still holds its texture at the same size, the compositor shows the texture again without rendering. A new texture is always rendered into, and so is one whose last render threw. False is therefore never wrong; it only promises that the last picture is still right.
  • A canvas is continuous or on demand.
    • continuous: the canvas needs a render on every frame, and its leaf reports isAnimating, which keeps frames coming.
    • On demand: it needs one on its first frame and when revision changes. Canvas3d.revision(n) is what an application rebuilds with, and markup’s revision= sets it.
  • The painter is a record, Canvas3dPainter(layer, stamp, nanos, …), so the render tree’s equals decides the damage:
    • on demand, the stamp is the revision, so an unchanged canvas is not repainted;
    • continuous, the stamp is the frame’s time, so the canvas’s box is repainted every frame. That is what read-back needs; composited, it costs a hole’s upload.

No GPU

  • The painter fills the box with --gb-canvas3d-unavailable, a token in both Nord themes.
  • In a running window, a deferred rebuild adds a message over the canvas (class canvas3d-notice) saying why. This is web-view’s zero-delay rebuild from inside a paint. There are three reasons:
    • the GPU is off (goldberry.gpu=off);
    • there is no GPU here: an opacity group, or a window with none;
    • the GPU failed.
  • Frame.hasGpu() is what tells the first two cases from the third.
  • Offscreen has no host to rebuild through, so an offscreen canvas without a GPU shows the fill alone. Its golden, canvas3d-unavailable, runs on every lane.

The module

:gpu now does what :media does for its widgets:

  • applies the catalog weaver;
  • takes :widgets as api;
  • requires transitive it;
  • exports gpu.view;
  • uses WidgetCatalog.

The woven catalog is gpu.view.GoldberryCatalog, one widget.

The showcase

The GPU tab has three cards:

  • a spinning cube in a continuous canvas, with a chip painted over it;
  • the same cube on demand, turned by a slider that bumps the revision;
  • a hud with the present readings.

The cube is written as an application’s renderer. Its HLSL is the showcase’s own (example/src/main/shaders), compiled by :gpu:compileShaders into the showcase’s resources, loaded with ShaderCode.load, and checked fresh by ShowcaseShadersTest.

Alternatives considered

  • init(Canvas3dHost) with device() and requestRedraw(). A renderer asking for its own redraw is imperative. A revision on the widget is how every other widget here changes: rebuild with a new value.
  • A painter that is a new lambda every build, as video-view’s is. That repaints an on-demand canvas on every rebuild of anything around it.
  • The reason drawn as text by the painter. The painter would need a font and a paragraph of its own, and would draw words the stylesheet cannot style. The message widget is the toolkit’s.
  • A runtime switch between composited and read-back in the showcase (the plan’s row). The mode is the window’s, set at launch. Switching it at run time needs a window-level composition API, and its only consumer would be this switch (ADR-0019). The tab instead says which launch properties show which mode.

Consequences

  • Phase 5’s exit, on Metal:
    • the cube at a fixed angle is a golden in :gpu, the same composited and read back;
    • the GPU screen is a gallery golden in :example: drawn on the GPU through a headless window’s read-back surface (gallery-gpu-drawn, on the new :example:gpuTest), and without a GPU on every lane (gallery-gpu);
    • the showcase runs composited on Metal with both cubes.
  • A new GPU lane. :example:gpuTest runs on the first thread like :natives’ and :gpu’s, and is part of check.
  • A continuous canvas’s box is uploaded every frame when composited: it is a hole, but it is damaged. A signal of “layer only, no UI damage” could skip that. It is not needed at the sizes measured here.
  • The GPU tab’s notice needs a window, so no golden shows it.
  • Not tested: the device-loss path. A renderer disposed and initialised again on a new device is tested; a device lost mid-run is phase 7’s.

483. Video pictures wait as planes for a view that uploads them, and every view says which form it draws

Date: 2026-09-25

Status

Accepted. docs/gpu-plan.md’s D8 and the first item of phase 6 (media phase 4, GPU present). It refines ADR-0463, whose consequences said the queue’s slots are what GPU present changes, and it uses ADR-0469’s seek.

Context

CPU present converts every kept picture to premultiplied BGRA on the video thread (ADR-0463). GPU present uploads the Y’CbCr planes and converts them in yuv2.frag or yuv3.frag (ADR-0477), so a view that uploads planes has no use for BGRA. The conversion also costs the most. Measured on an M1 Pro with PlaneCopyBenchmark, one 3840×2160 picture, median of 30:

LayoutBytesPlanes copiedRow by row (padded rows)Converted to BGRA (SWS_BITEXACT)
NV1212.4 MB0.35 ms0.46 ms12.22 ms
I42012.4 MB0.34 ms0.43 ms12.00 ms
P01024.9 MB0.80 ms1.46 ms13.60 ms
I01024.9 MB0.80 ms1.28 ms12.78 ms

At 60 fps a picture has 16.7 ms. The conversion alone takes three quarters of that, on the thread that also decodes. The copy takes a twentieth.

D8 left three things open:

  • What a picture is, once there are two forms.
  • Who chooses the form. A player can be shown by more than one view, and a view that draws on the CPU cannot draw planes.
  • What the change costs. D8 said “a flush plus an accurate reseek”, in both directions.

Decision

A picture is one of two forms. Picture is a sealed interface over VideoPicture, which is BGRA and unchanged, and VideoPlanes, which is new. PictureForm names the two forms, CONVERTED and PLANES.

VideoPlanes holds:

  • the frame contract’s layout (NV12, I420, P010 or I010);
  • the size;
  • one read-only, little-endian plane per layout plane, with its stride;
  • the matrix and the range;
  • the time.

Its bytes are the decoder’s, copied and not changed, so a shader’s output can be compared with swscale’s from the same input. It is borrowed under VideoPicture’s rule: a picture handed out keeps its bytes until two more have been handed out.

The queue’s slots have a shape. FrameQueue.Shape is the size plus either “BGRA” or a plane layout. A slot is one direct buffer, and each plane starts at its own offset with 64-byte-aligned rows. When a free slot has the wrong shape, the collector takes it, just as it already did when a stream changed size. The pool is still seven slots. A 4K P010 slot is 25 MB, where a BGRA slot is 33 MB.

The video thread reads the form for each picture. For CONVERTED it runs swscale, as before. For PLANES it copies each plane: one copy when the strides match, otherwise one copy per row. The late-picture drop and accurate seeks work the same way in both forms.

Views attach, and the player decides.

  • MediaPlayer.attachView(PictureForm) returns an Attachment. The attachment can change its form (setForm) and closes idempotently.
  • The pictures are PLANES only while at least one view is attached and every attached view asks for planes. With no view attached, or with any CONVERTED view, they are CONVERTED.
  • MediaPlayer.pictureForm() reports the form in use, and it carries over to the next source opened.
  • video-view and media-player attach as CONVERTED while they show pictures, from FollowingState. A view that uploads planes therefore cannot leave a CPU view on the same player with nothing to draw.
  • MediaPlayer.shownPicture() hands out whichever form is shown.
  • currentPicture() still hands out BGRA only. It is empty while the picture shown is planes.

The two directions of a change differ. This corrects D8.

  • To planes: nothing else happens. The converted pictures already queued play out, because a view that asks for planes must also draw a VideoPicture (PictureForm.PLANES says so). No seek and no flush means no gap in the sound when a GPU view attaches in the middle of playback.
  • Back to converted: an accurate seek to the position (Playback.setPictureForm). This is the track switch’s seek. It flushes the planes, which a CPU view cannot draw, and the picture that covers the position comes back converted.

Alternatives considered

  • A flush and a reseek in both directions, as D8 wrote it. Going to planes would then cost a sound flush and a decode from the keyframe, to replace pictures the new view can already draw.
  • Converting the queued planes in place when the form goes back to converted, on the video thread. This would avoid the seek, but it needs a path for the paused and ended thread, and a swap of slots under the queue’s lock while the UI thread presents. The case it serves is a GPU view detaching while a CPU view stays, or a device lost (phase 7), and neither needs to be seamless.
  • A form set on the player by the application, not by views. A player shown by two views, or by a view whose window has no GPU, would then be the application’s problem to get right.
  • currentPicture() returning Optional<Picture>. That changes a signature every CPU caller uses, and gains them nothing.
  • The UI thread copying planes out of the decoder’s frame. Frames are borrowed until the decoder’s next call (ADR-0463), so the copy must happen where the decode happens.

Consequences

  • The video thread’s cost for 4K drops from 12–14 ms a picture to 0.3–0.8 ms while every view draws planes. This is the headroom phase 6’s 4K60 measurement needs.
  • D8’s other half is still open. The UI thread uploads each shown picture’s planes through staging memory, 25 MB a picture at 4K P010, which is 1.5 GB/s at 60 fps. It is measured when video-view becomes a GPU layer (phase 6’s next item). If it is too slow, the slots become mapped transfer buffers written by the video thread.
  • Going back to converted costs a seek. The sound is flushed. The picture shown stays up until the covering one replaces it, but a CPU view draws its background until then, because the picture it holds is in planes. A player at its end plays its last picture again and ends again.
  • Parity is testable without a GPU. VideoPlaybackTest converts the planes handed out with swscale and compares them byte for byte with the CPU goldens, including the 10-bit I010 golden. The GPU parity tests (phase 6) compare their shader against those same planes.
  • Hardware decoders’ copy-back is copied a second time, from the copied-back frame into the slot. Zero-copy (D9, phase 6b) is where that goes away.

484. video-view shows its pictures through a GPU layer when :gpu is present

Date: 2026-09-25

Status

Accepted. docs/gpu-plan.md’s phase 6 (media phase 4, GPU present): the video layer, the parity tests and the first rung of the fallback ladder. It builds on ADR-0481’s GPU layers and ADR-0483’s frame queue of planes. It corrects ADR-0477’s chroma siting.

Context

ADR-0483 made the frame queue hold a picture’s planes while every view attached to the player draws planes. Nothing drew planes yet. The plan’s phase 6 left five things open:

  • Where the layer lives. The Y’CbCr shaders and their arithmetic are in :gpu’s unexported render package. video-view is in :media. :gpu must not depend on :media, and :media must play video without :gpu.
  • Who letterboxes. video-view places a picture by Fit over a box of the stylesheet’s size. A GPU layer is an opaque rectangle.
  • When a view asks for planes. Only a paint knows whether its frame can show a GPU layer (Frame.gpuLayer returns false without one), and a window can lose the ability to show layers while a view is shown.
  • What parity means. Parity is “within tolerance of swscale’s SWS_BITEXACT picture”, and nothing had measured the gap.
  • The upload’s cost to the UI thread, D8’s second half.

Decision

The layer is :gpu’s, exported to :media alone

io.github.digitalsmile.goldberry.gpu.video contains:

  • VideoLayer, a GpuLayer;
  • VideoImage, sealed over Planes and Bgra;
  • PlaneLayout, the four layouts of the frame contract;
  • ColorMatrix.

The package’s vocabulary is its own, because :gpu does not know :media. :gpu exports it with exports … to io.github.digitalsmile.goldberry.media under @SuppressWarnings("module"), as :natives exports to its readers. :media requires static :gpu and has it compileOnly.

The layer shows one image, stretched over its whole box. show(image, source) takes the picture and the part of it Fit keeps. The caller letterboxes by where it places the layer: over Fit’s rectangle only. The rest of the box is the stylesheet’s background, painted on the CPU as before.

How the layer draws each form:

  • Planes: each plane is uploaded into a texture of its own (R8, R8G8, R16, R16G16), then yuv2.frag or yuv3.frag draws them with YuvConversion’s uniforms, sampled linearly.
  • BGRA: uploaded into one texture and drawn by texture.frag. This form covers the converted pictures still queued after a switch to planes, and the case where another view keeps the player converted.

The shaders are the toolkit’s own, loaded through the public ShaderCode.load. Images are compared by identity. A picture is uploaded once, on the frame it is first shown. A frame repainted for something beside the video renders nothing new.

:media finds :gpu once, and names its types in one class

GpuVideo probes once:

  • it looks up gpu.video.VideoLayer with Class.forName(…, false, …);
  • it then checks that :media’s module reads that class’s module.

GpuVideoPresenter is the only :media class that names :gpu types, and it is loaded only when the probe says yes. It maps a VideoPicture or VideoPlanes to one VideoImage per picture. The view sees it through VideoPresenter, an interface written in :media’s own types.

A view asks for planes when its paint placed the layer

FollowingState, the state behind video-view and media-player:

  • attaches to the player as CONVERTED;
  • holds a presenter when :gpu is here;
  • when the presenter reports that a paint placed the picture on the GPU, changes the attachment to PLANES;
  • when a paint could not place it, changes it back to CONVERTED.

The presenter reports only changes.

This makes the fallback ladder’s first rung a matter of each paint:

  • A frame that cannot show a layer (no GPU, goldberry.gpu=off, a group): the picture is drawn on the CPU, and the view asks for converted pictures. ADR-0483’s seek brings those back.
  • A view that has not been painted yet asks for nothing it may not be able to draw.
  • Without :gpu nothing changes from phase 3’s behaviour.

Chroma is centred, as swscale centres it

The first parity run was 113 levels from swscale at worst, at 27 dB. The GPU matched its own Java reference exactly, so the gap had to be a difference in what was computed, not a fault in how.

CPU present converts with SWS_BILINEAR | SWS_FULL_CHR_H_INT. Fitting references with several chroma positions to swscale’s picture found that swscale reads chroma centred horizontally. The reference is within one level of swscale’s picture, with no pixel over two. ADR-0477 had sited chroma left, “as swscale assumes”, a quarter of a chroma texel off.

The fix:

  • YuvConversion.uniforms now sites chroma centred (Siting.CENTRED, an offset of 0).
  • Siting.LEFT remains for the day a frame carries its own chroma location.
  • The shader is unchanged; its comment is rewritten, and the committed hashes with it.

Parity is then pixel by pixel. Every fixture with a golden is drawn through the layer, composited and read back, and compared with CPU present’s golden. That is VP8, VP9 and AV1, 8-bit and 10-bit, BT.601, 709 and 2020, limited and full range. The bound is 2 levels; every fixture measures 1. Three tagged fixtures were added for the matrices and range the old ones lacked: clip-vp9-709, clip-vp9-2020-10bit and clip-vp9-full. The layer is also held to PlanesReference, a Java model of the same sampling and conversion: 0 levels.

Alternatives considered

  • A public video API in :gpu. Its only caller is video-view, and an application shows video with that. A public API would have one caller and an obligation to keep it (ADR-0019).
  • :gpu providing a service that :media finds. The service interface would have to live in :media, which :gpu cannot depend on, or in :core, which knows nothing of video.
  • A layer over the whole box, letterboxed by the shader. The layer would have to clear to the box’s background. That colour is the stylesheet’s, and the CPU already paints it.
  • Point-sampled chroma, to match a replicating conversion. swscale does not replicate. The measurement showed that, and linear sampling is also what scales the picture.
  • A statistical bound, such as PSNR, against swscale. The first run suggested one. The siting fix removed the reason for it.

Consequences

  • GPU present works through video-view. It is composited or read back like any layer, byte-exact for BGRA and within one level for planes. It falls back to CPU present where a frame has no GPU.

  • Two new test runs in :media:

    • gpuTest: decoded pictures on a real device, run on the first thread;
    • testWithoutGpu: the whole suite with :gpu off the class path, as an application without it runs.

    Both are part of check.

  • D8’s second half, measured by :gpu:videoLayerProbe on an M1 Pro, median per 4K picture. The UI thread copies each new picture into staging memory in 2.5 ms (NV12), 2.9 ms (I420) or 5.2 ms (P010). That is about 5 GB/s, because SDL’s Metal backend makes upload buffers write-combined. Submitting costs 0.1 ms. At 4K60 that is up to a third of a frame on the UI thread. D8’s second step, mapped transfer buffers written by the video thread, would move that cost off the UI thread. Whether it is needed is decided by phase 6’s 4K60 run.

  • A device that fails mid-render shows black where the layer is: the compositor catches the throw (ADR-0481). The view does not learn of it, so it does not fall back. Device loss is phase 7’s.

  • A native image finds :gpu through a registration. The probe class is declared for reflection in :media’s metadata. The showcase’s image with the GPU tab is phase 7’s.

  • Chroma location is not carried. Both paths site chroma centred, whatever the stream says. MPEG-2, H.264 and HEVC content sited left is a quarter of a chroma texel off on both, identically. Carrying AVFrame.chroma_location through the frame contract would fix both paths together.

485. The audio clock never jumps, and 4K60 plays every picture

Date: 2026-09-25

Status

Accepted. Closes docs/gpu-plan.md’s phase 6 measurement and docs/media-plan.md phase 5’s exit criterion: “4K60 VP9 without dropped frames on GPU present”. It refines ADR-0463’s smoothing of the SDL sink, and ADR-0474’s latency counted in pulls.

Context

The exit criterion needs a count of dropped pictures, and nothing counted them. The Engine dropped pictures in two places:

  • the video thread, which skips preparing a picture late by a whole picture (ADR-0463);
  • the frame queue, which passes over a picture the clock has moved beyond before any view asked for it.

The first run of a minute of 4K60 VP9 was:

  • decoded on VideoToolbox;
  • shown by a video-view composited through the GPU (ADR-0484);
  • in a 2560×1440 window at 120 Hz.

The video thread dropped nothing and the UI painted no late frame, yet 117 of 3599 pictures were passed over. Recording each pass showed the reason: between two asks by the view about 9.5 ms of wall time passed, and the stream clock moved 21.3 ms. That is 1024 samples at 48 kHz, exactly one pull of SDL’s audio device.

Three faults were behind it:

  • The smoothing re-anchored at every pull. ADR-0463 drained the queue from the moment the last pull was seen, capped at that pull. CoreAudio’s pulls do not come evenly. When one came early, the clock jumped forward by the part of the last pull not yet drained, up to 21 ms. A picture lasts 16.7 ms at 60 fps. At 25–30 fps the jump hid inside a picture.
  • Latency counted the last pull, not a typical one. ADR-0474 counts SDL’s buffers as three pulls of the last pull’s size. A reading that saw two pulls at once doubled that for a moment, and the clock swung by 64 ms and back.
  • The audio end and the queue were read apart. The audio thread writes to the sink, then records the end it wrote to. A reading between the two found the queue grown and the end not yet moved: the clock went back by a packet, and the view’s timer, set from that reading, woke a picture late.

Decision

The drain is a line that never jumps. It lives in DrainEstimate, which takes the time as an argument so that it is testable:

  • The measurement is ADR-0463’s: samples taken up to the last pull, plus what has played of that pull since.
  • The estimate runs at the stream’s rate. It is steered toward the measurement, faster or slower by at most 10%, in proportion to the error, and it is at full slew at 10 ms off.
  • It never goes back, and it never gets more than a pull past what the device has taken, so a late pull stalls it rather than making it overshoot.
  • It is set outright at the first pull after an open or a clear, and whenever it is more than 100 ms off, as after an underrun or pulls nobody read.
  • Punctual pulls make the two the same line, so the latency still holds.

Latency counts a typical pull: the median of the last fifteen seen (typicalPull).

The audio end and the queue are written and read under one lock. Playback.writeAudio writes to the sink and records the end under the lock that audioClockNanos reads both under. The sink’s write does not block.

The Engine counts its pictures. MediaPlayer.videoStatistics() returns VideoStatistics:

CountWhat it counts
decodedpictures decoded
latedropped before being prepared
passedqueued, due, and replaced before any view was handed them
shownhanded out to a view, each counted once

A seek’s flushed pictures and an accurate seek’s run-up to its target count only as decoded. dropped() is late + passed.

The run is a probe, :media:videoPresentProbe:

  • it plays the clip media/src/test/fixtures/make-4k60.sh makes: a minute of testsrc2 at 3840×2160 and 60 fps, VP9 at about 17 Mb/s, with an Opus tone so the audio clock is the master;
  • the clip is not committed, and the script writes it into build/;
  • the probe reports the counts and each thread’s CPU time per picture shown;
  • the launcher’s frames: and presents: lines follow;
  • it logs through Logback, which only it has on its class path.

Measured

On an M1 Pro, macOS, a 120 Hz display, a 2560×1440 window. VideoToolbox decodes in another process, so its CPU is not counted here.

One minute of 4K60 VP9GPU present, 8-bitGPU present, 10-bitCPU present, 8-bit
decoded / shown3600 / 36003600 / 36003598 / 2018
dropped late / passed over0 / 00 / 01581 / 0
video thread, a picture shown1.4 ms (9% of a core)2.0 ms (12%)13.9 ms (47%)
UI thread, a picture shown2.8 ms (17%)3.1 ms (19%)17.6 ms (59%)
process7.5 ms (46%)8.5 ms (51%)66.6 ms (224%)
frames painted, late4077, 04031, 05286, 0

GPU present, 8-bit, was clean on five runs after the fixes; the counts in the table are the last. Before them it passed over 107–117 pictures a run. After the first two fixes it still passed over 1–4, which the lock removed. The only pass seen since came with a late UI frame (a 19 ms gap between paints), on one 10-bit run of three.

Alternatives considered

  • Smoothing in the Engine rather than the sink, as ADR-0463 already argued against: the Engine cannot tell a sink that pulls from one that does not, and the virtual sink the tests use would stop being exact.
  • SDL’s own playback position. SDL 3.4 reports how much is queued, not a timestamped position. CoreAudio’s AudioQueueGetCurrentTime would give one, but only on macOS, and bound beside SDL’s own backend.
  • Waking the view on every display refresh, at 120 Hz. That would hide a jump of the clock by asking more often, at twice ADR-0463’s cost in frames. The clock is what was wrong.
  • D8’s second step, the video thread writing into mapped transfer buffers. At 2.8–3.1 ms of UI time per 4K picture, 17–19% of a core, nothing is dropped and no frame is late. It stays unbuilt until something shows a need.

Consequences

  • Phase 5’s exit criterion is met on this Mac, and phase 6’s measurement with it. Windows and Linux are measured when a host exists.
  • Every clip plays more smoothly, not only 4K60. Pictures at any rate are timed against a clock without 21 ms steps.
  • The audio clock trails an early pull by up to about 100 ms before it has caught up at 10% faster. On average it is where it was, and an A/V offset of a few milliseconds for a moment is below what anyone hears.
  • VideoStatistics is public API, for a hud or an application’s own diagnostics.
  • A regression test for the race. It reads the position while a sink lingers a millisecond after each write. It fails 3 of 3 runs without the lock and passes with it.

486. FFmpeg and dav1d are built at -O2, to fit the size gate

Date: 2026-09-27

Status

Accepted. Closes the linux-x64 half of docs/media-plan.md phase 1’s “Linux and Windows” row: the media superbuild is now built and measured on a Linux host. It amends the build of ADR-0460’s superbuild and leaves the configure line of docs/goldberry-media.md §2 unchanged.

Context

The size gate of docs/goldberry-media.md §2 was set and first passed on macos-aarch64: 4989 KB against a 6 MB target and a 7 MB failure. The first linux-x64 build, with the same pins and the same configure line, failed it:

librarylinux-x64, configure’s -O3
libavutil1002 KB
libswresample174 KB
libswscale2026 KB
libavcodec, dav1d inside5163 KB
libavformat612 KB
total8980 KB

The gate’s message blames something the configure line did not mean to enable. Nothing was. config.h enabled exactly the demuxers, decoders, parsers and filters the line lists, with hardware decode off (ADR-0470). The growth was spread over FFmpeg’s C code: swscale’s output.c and swscale_unscaled.c templates, VP9’s inter_pred and reconstruction, and the tx transforms in avutil. dav1d was about 2 MB of the 5163 KB, about 870 KB of it C and the rest x86 assembly, SSE2 up to AVX-512, that arm64 does not have.

FFmpeg’s configure compiles at -O3, and meson’s release buildtype does the same for dav1d. At -O3, GCC 15 unrolls and vectorises those templates far more than Apple’s clang did on the Mac. Each change below was measured by rebuilding the same pins and stripping the result the way package.cmake does:

buildtotal
-O3, as configure chooses8976 KB
FFmpeg --optflags=-O26952 KB
… and dav1d -Doptimization=26696 KB
… and --enable-lto6608 KB

The build through Gradle then gave 6708 KB. The last 12 KB is the install prefix and the configure line, which end up as strings in the libraries.

Decision

FFmpeg is configured with --optflags=-O2, and dav1d with -Doptimization=2, on every target. dav1d keeps --buildtype=release, because meson ties NDEBUG and trim_dsp to the buildtype, not to the optimisation level. Both were checked in the result: -DNDEBUG on the compile lines and TRIM_DSP_FUNCTIONS 1 in config.h.

The flag is part of GOLDBERRY_FFMPEG_CONFIGURE, so ffmpeg-NOTICE.txt quotes it with the rest of the configure line, as the LGPL’s relinking promise needs. FfmpegSuperbuildTest reads both flags out of CMakeLists.txt, and fails on every machine if they are removed. The gate itself fails only on a machine that builds FFmpeg, and only after several minutes.

Speed

Most of FFmpeg’s decoding runs in hand-written assembly, which -O does not touch. To check that -O2 costs no speed, a C harness decoded 3 s clips of 4K60 through a custom AVIOContext, as MediaIO does, against both builds. It converted each picture with sws_scale the way VideoConverter does, and ran each case three times on an 8-core linux-x64 with GCC 15.2:

case-O3-O2
VP9 8-bit, one thread, decode60.4–60.7 fps60.7–62.1 fps
AV1 10-bit (dav1d), one thread, decode54.4–56.4 fps56.2–57.6 fps
VP9 8-bit → BGRA (CPU present)5.68–6.08 ms a picture5.71–5.78 ms
VP9 10-bit → BGRA14.5–17.1 ms13.8–14.5 ms
AV1 10-bit → BGRA13.0–13.3 ms13.1–13.6 ms
VP9 10-bit → P0108.1–8.4 ms9.9–10.2 ms
Opus, decode31.8–32.6 k packets/s30.5–31.6 k packets/s

Only the conversion from I010 to P010 is slower. It is swscale’s C repacking of planar 10-bit into semi-planar 10-bit, which GCC vectorises only at -O3. Nothing on the playback path does that conversion. FfmpegDecoder lends I010 without a copy, the GPU uploads I010 planes, and hardware decode copies back P010 straight from the device (ADR-0470, ADR-0483).

On the other targets

  • Windows: FFmpeg’s MSVC toolchain already compiles at -O2, since cl has no -O3. The flag changes nothing there, and dav1d’s -O2 is the only difference.
  • macOS: the flag applies there too. One set of flags for every target matters more here than 20 KB on the Mac. The macOS figure in docs/media-plan.md is from -O3 until the Mac or CI measures it again. It can only shrink.

Consequences

  • linux-x64 builds 6708 KB, under the 7 MB gate but over the 6 MB target. The target remains a goal, and the gate is what fails a build. What is left over the target on x64 is mostly dav1d’s assembly. meson_options.txt has no option to drop AVX-512, only enable_asm, which would drop all of it.
  • -Pgoldberry.media.required=true :media:check against the linux-x64 build: 460 tests, none failing. The 10 skipped are the VAAPI tests, and hardware decode is off on Linux (ADR-0470).
  • LTO was measured and not kept: 88 KB for a slower build, and one more toolchain requirement on every runner.
  • A JVM warning seen in this run, “You have loaded library libavutil.so.60 which might have disabled stack guard”, is not about the shipped libraries. All five have a non-executable GNU_STACK and load without it. FfmpegLibrariesTest writes a text file under that name to test a failed load. HotSpot reads the ELF stack note before dlopen, finds none in text, and warns.
  • linux-aarch64 has still not been built. Its dav1d assembly is the arm64 kind, so it should land nearer macOS than linux-x64.

487. With no audio device, media plays silently

Date: 2026-09-27

Status

Accepted. Fills a gap in docs/goldberry-media.md §3, which did not say what happens when a source has audio and the machine cannot play it. Builds on ADR-0462’s sink.

Context

The first run of the showcase on Linux failed every video with an audio track:

playback of showcase:///mandelbrot.webm failed: not playable media:
SdlException: SDL_InitSubSystem failed: No available audio device

That machine’s libgoldberry had been built without ALSA’s and PulseAudio’s headers. The natives build warns about this and carries on, so SDL had only its dummy and disk drivers, and SDL never picks either of those by itself. The same happens on a headless machine, in a container, or on a desktop whose sound server SDL has no backend for.

Two things were wrong:

  • SdlAudioSink.open threw, and Playback reported the throw as MediaError.InvalidData: “not playable media”. The media was fine.
  • A video failed because its sound could not be heard. A browser plays the same video silently.

Decision

When SDL cannot open a device, SdlAudioSink plays into a SilentAudioSink, and logs why. SilentAudioSink plays what is written into silence, at the sample rate times the rate, in wall time. Like a device, it stands still once its queue runs dry. The Engine does not know the difference. The audio clock still comes from queuedSamples(), so pictures keep their times, seeks, pause and rate apply, audio tracks can still be switched, and a source with only audio ends when its last sample would have been heard.

  • Only SdlException falls back. That is what SdlAudioStream.open documents for “no device, or a refused format”. Any other exception is a bug, and still fails the source.
  • One warning per process: “no audio device, so media plays without sound”, with SDL’s reason. Every later source logs the same line at debug, so a playlist on a headless machine does not repeat the warning for every track.
  • Every open tries the device again. A sink is made for every source opened, so the first source opened after a device appears is heard. A source already playing silently stays silent.
  • Only the default sink falls back. A sink an application passes to MediaPlayer.Builder.sink is the application’s, and its failures are its own.
  • SilentAudioSink is public, for an application that wants silence on purpose, such as a thumbnailer or a test that should not reach SDL.

Consequences

  • Nothing in the public API tells an application that playback is silent: no MediaError variant and no status flag. The log is the only record. SdlAudioSink.silent() exists for tests and is package-private. If an application needs to show “no sound device”, that is a new PlayerStatus field and a decision of its own.
  • SdlAudioSink gained a package-private constructor that takes the device opener and the clock for the silence, so the fallback is tested without SDL: SdlAudioSinkFallbackTest and SilentAudioSinkTest, 9 tests.
  • Checked from start to finish on the Linux machine that failed, with the test task’s SDL_AUDIO_DRIVER=dummy removed for one run, so that SDL really found no device. clip-vp9.webm, a second of VP9 and Opus, played to ENDED in 1.28 s with no error.
  • The machine still has no sound. That is a toolchain fix, not a code fix: install libasound2-dev and libpulse-dev, and rebuild libgoldberry. Since ADR-0488, a build without them fails, where before it only warned.

488. The Linux build fails without the audio headers

Date: 2026-09-27

Status

Accepted. Follows ADR-0487, which made a machine with no audio device play silently. That decision is for a machine with no sound. This one stops a build from producing a library that has no sound on a machine that does. It moves two rows of ADR-0082’s table, and fixes the cross-check of ADR-0325.

Context

LinuxDependencies listed alsa and libpulse as OPTIONAL, “a feature the toolkit does not use”. That was true until goldberry-media began playing its sound through SDL’s audio stream (ADR-0462). SDL loads libasound and libpulse at run time, but compiles the two drivers in only when their headers are there at build time. Without them it builds with dummy and disk only, and never picks either by itself. The configure succeeds, the toolchain check warns, and on that libgoldberry every source with audio plays silently. On one machine the warning went unread until the showcase had no sound.

Making the configure fail on that exposed a second defect. The superbuild cross-checks SDL’s decisions (docs/gaps.md G32) by reading include-config-<config>/build_config/SDL_build_config.h. SDL writes that file with file(GENERATE), and generation runs after the whole configure. During the configure that checks it, the file holds the previous configure’s answer, or nothing on a clean build directory. So after the audio headers were installed, the first build failed. SDL had found ALSA, but the header still said it had not. Read that way, the D-Bus cross-check would also pass a build that had just lost D-Bus, and never ran at all in CI’s fresh containers.

Decision

alsa and libpulse are NEEDED. Both are needed, not either one: PulseAudio is what a desktop answers on, PipeWire’s included, and ALSA is what a machine without a sound server has. checkToolchain fails and names libasound2-dev libpulse-dev (alsa-lib-devel pulseaudio-libs-devel with dnf). Neither row names a capability, so -Pgoldberry.allowDegradedPlatform=true, which waives only capability rows, does not waive them. A library built without them has nothing to report through Goldberry.capabilities(). It is simply wrong.

The superbuild fails when SDL did not compile in SDL_AUDIO_DRIVER_ALSA and SDL_AUDIO_DRIVER_PULSEAUDIO, with the install line for both package managers. The match ends at the 1, because SDL_AUDIO_DRIVER_ALSA_DYNAMIC shares the prefix. This is the only guard the manylinux release job meets, since it runs CMake directly and never runs checkToolchain. linux.yml already installs both packages, and LinuxDependenciesTest now fails if it stops.

SDL’s answer is read from CMakeFiles/SDL_build_config.h.intermediate, which SDL’s configure_file writes during the configure, before FetchContent_MakeAvailable returns. The generated header stays as a fallback, in case a future SDL stops writing the intermediate file. The fix applies to every check that reads the file: D-Bus, IBus, udev, Wayland, libdecor and audio.

Consequences

  • Checked on this machine, both ways:
    • With a PKG_CONFIG_LIBDIR that hid only alsa.pc and libpulse.pc, checkToolchain failed, even with allowDegradedPlatform, and named the two packages.
    • With the headers installed, :natives:cmakeBuild passed. SDL’s header has both drivers, and libgoldberry.so carries libasound.so.2 and libpulse.so.0 to load.
    • Reconfiguring with -DSDL_PULSEAUDIO=OFF failed on SDL_AUDIO_DRIVER_PULSEAUDIO, reading the intermediate file. It was then set back.
  • A machine without the headers can no longer build libgoldberry at all. This is the point: the silent library was the worse outcome.
  • The memory of this repo’s local build, “use allowDegradedPlatform when D-Bus is missing”, no longer covers audio. Install the packages.
  • Tests: LinuxDependenciesTest.requiresAudio for both rows, theContainerWorkflowInstallsAudio, and in PlatformIntegrationBuildTest audioStopsTheConfigure and sdlAnswerIsReadFromThisConfigure.
  • ADR-0487’s silent sink is still needed. A machine with the libraries but no device, such as a headless box or a container, still has no device to open.

489. The Linux and Windows platform decoders are GStreamer and Media Foundation

Date: 2026-09-28

Status

Accepted. Continues ADR-0472, which built :media-platform for macOS and left Windows (Media Foundation) and Linux open. Linux is built and tested here. Windows is written, and not yet run on Windows.

Context

The published natives decode royalty-free codecs only. H.264, HEVC, AAC, AC-3 and E-AC-3 are left to whoever holds their licences (docs/goldberry-media.md §5). On macOS that is Apple, through VideoToolbox and AudioToolbox. Windows and Linux ship decoders too.

Windows has one media framework: Media Foundation. Its decoders are MFTs (IMFTransform), COM objects found with MFTEnumEx. Microsoft’s H.264, AAC, AC-3 and E-AC-3 decoders come with the system, and its HEVC decoder with the HEVC Video Extensions.

Linux has no decoder in the system itself. Three ways were weighed:

  • VA-API, which ADR-0472 had pencilled in. It decodes on hardware only, and has no audio. It takes pictures parsed into its own parameter buffers, so Goldberry would parse H.264 and HEVC slice headers itself. The machine this was written on exposes no H.264 or HEVC decode through it at all.
  • The distribution’s libavcodec. ADR-0472 rejected this. Its major and struct layouts differ between distributions, and the loader pins both.
  • GStreamer. It is Linux’s media framework: what WebKitGTK, GNOME and KDE play through. Every desktop distribution installs it. Its decoders are whatever the distribution chose to ship and license: gst-libav’s avdec_*, openh264dec, faad, a52dec, and VA-API or NVIDIA decoders where the machine has them. It handles audio as well as video, and it ranks its decoders, so it knows which one to use.

Decision

Linux: GStreamer, bound with FFM. It lives in …platform.linux, with providers gstreamer-video and gstreamer-audio that claim exactly what the macOS providers claim. Windows: Media Foundation, in …platform.windows, with providers mediafoundation-video and mediafoundation-audio. Neither builds or ships native code. PlatformDecoders lists all six providers, and asks only the current system’s package whether its decoders are available.

GStreamer

One pipeline per track, built from a description string, with the decoder’s position filled in:

appsrc caps="<the container's form, codec_data included>" ! parser ! decoder
  ! videoconvert (or audioconvert) ! appsink
  • The parser (h264parse, h265parse, aacparse, ac3parse) converts between the form MP4 and Matroska store the stream in and the form the chosen decoder wants.
  • The converter passes the frame contract’s formats through untouched: NV12, I420, P010, I010, and interleaved f32. It converts anything else.
  • The Engine’s decode thread drives the pipeline with no callback. It pushes packets into appsrc, at most eight queued. It pulls whatever appsink has, and waits only when the queue is full or the stream has ended.

The decoder is the one GStreamer ranks highest for the stream at its size, as decodebin chooses. Size matters. This machine has an NVIDIA GTX 1660 Ti, and GStreamer ranks nvh264dec and nvh265dec (257) above gst-libav (256). NVDEC decodes no HEVC under 144×144, and the fixtures are 160×90. Asked for video/x-h265 alone, the registry offered nvh265dec, which then failed negotiation. Asked with the width and height, it offers avdec_h265.

A decoder is replaced if it fails before its first frame. One that cannot build is passed over. One that starts but then fails is replaced by the next candidate, which is given every packet sent so far; those are kept until the first frame comes out. A hardware decoder without the stream’s profile fails exactly this way. GStreamerTest reproduces it portably with funnel, which starts and then cannot hand H.264 to videoconvert.

The stream’s VUI decides the colour, before the decoder’s caps. nvh264dec and openh264dec both report the full-range BT.709 fixture as limited range; avdec_h264 gets it right. ParameterSets already walked the H.264 VUI to find the reorder depth. It now also records video_full_range_flag and matrix_coefficients as a Signal. GStreamer’s caps are used only when the stream signals nothing, which is every HEVC stream, since the HEVC VUI lies past what ParameterSets parses. The default by height comes last.

Times:

  • An hour is added to every time going in and taken off coming out. Matroska starts AAC at −21 ms to cover the encoder’s priming, and a GstClockTime is unsigned.
  • Audio frames are timed by counting samples from the first frame, as on macOS. The decoders pass on Matroska’s whole milliseconds: 21 ms apart for a frame of 21.33.

A flush tears the pipeline down and builds it again. It costs milliseconds, and it is what a seek is.

The struct offsets are measured at load. GStreamer’s buffer times, map info, video info and colour are fields and macros, with no function to reach them. No machine that ran this had the development headers, so the offsets were derived from the 1.x headers. GstLayout.check makes GStreamer write values it defines, and reads them back at those offsets before any decoder opens. If they disagree, the providers are unavailable, with the difference as the reason. GStreamer 1.28 here agreed on every offset.

Media Foundation

  • The decoder: the first synchronous software MFT MFTEnumEx sorts for the input type.
  • Video: packets are rewritten from length-prefixed to Annex B, with the parameter sets before each keyframe (bitstream.AnnexB).
  • Output: NV12 or P010, lent from the locked output buffer, cropped to MF_MT_MINIMUM_DISPLAY_APERTURE.
  • Audio: AAC is configured with HEAACWAVEINFO user data around the AudioSpecificConfig, and output is float in the speaker-mask order, which is FFmpeg’s.
  • COM is bound as vtable slots over unbound downcall handles.
  • Threads: every thread that calls into Media Foundation joins the multithreaded apartment first.

What only Windows can confirm. It was written without a Windows machine, from the SDK headers. CI on Windows must confirm:

  • every vtable slot;
  • the 31 GUIDs;
  • the layouts of MFT_OUTPUT_DATA_BUFFER, MFT_OUTPUT_STREAM_INFO, MFT_REGISTER_TYPE_INFO and MFVideoArea;
  • the message and flag constants;
  • the call sequence of an MFT: enumerate, set the input type, choose an output type, begin streaming, process, drain, flush;
  • that the H.264 MFT reports the aligned frame size with the crop in the aperture;
  • that the AAC MFT offers float output.

The Windows decode tests (23) mirror the macOS and Linux ones, and skip off Windows. The pure parts (GUID encoding, picture layout and crop, AAC user data, claims, the binding surface) are tested here: 57 tests.

Shared

  • …platform.bitstream holds ParameterSets (moved from macos), BitReader and the new AnnexB. ParameterSets.of(request) is the one video claim all three systems make.
  • The test fixtures (Fixtures, Tones, BindingSurface) moved to a shared test package. Fixtures.md5 hashes I420 and I010 as their NV12 and P010 twins, so the fixtures’ framemd5 files check planar decoders too.
  • The native-image generator moved to …platform.nativeimage, and writes the union of MacBindings, LinuxBindings and WindowsBindings.
  • goldberry.platform.required fails a test only on its own system. Each job requires its own system’s decoders, so the Media workflow now requires them on Linux too, and installs GStreamer’s runtime plugins there.

Consequences

  • On this Linux machine, :media-platform:check with FFmpeg and the platform decoders required: 240 tests, none failing, 55 skipped, the macOS and Windows decode tests.
    • H.264, in MP4 and Matroska, decodes on nvh264dec, and every picture matches FFmpeg’s framemd5.
    • HEVC, 8- and 10-bit, decodes on avdec_h265, again matching every picture.
    • The crop, the colour of all four tagged cases, and flush and seek pass.
    • Audio passes on tone, level, 5.1 order for AAC and AC-3, and continuous timing.
    • MediaPlayer finds the providers through ServiceLoader and plays H.264 with AAC, and HEVC, to the end.
  • A Linux system plays these codecs only if its distribution installed the decoders: gstreamer1.0-libav (or openh264, faad and a52dec) and the parsers in plugins-good and plugins-bad. Without them the providers support nothing, and the file is UNSUPPORTED_CODEC, as it would be without the module.
  • Every packet is copied once into a GStreamer buffer, since the pipeline decodes after send returns.
  • Loading our FFmpeg broke GStreamer’s avdec_* in the same process, because both libraries answered to one soname. ADR-0490 fixes that, and the Linux providers depend on it.
  • Still open: output latency on Linux (PulseAudio) and Windows (WASAPI), and running the Windows providers on Windows.

490. Goldberry’s FFmpeg has sonames of its own

Date: 2026-09-28

Status

Accepted. Changes the file names of ADR-0460’s natives, and what docs/goldberry-media.md §2 asks of a user who replaces them.

Context

The five FFmpeg libraries were named as FFmpeg names them: libavcodec.so.62 on Linux, libavcodec.62.dylib on macOS, avcodec-62.dll on Windows. The loader opens them in dependency order and relies on the dynamic linker to satisfy each one’s dependencies from those already loaded. That comment in FfmpegLibrary states it outright: glibc matches an already-loaded soname, and Windows an already-loaded module name.

A distribution’s FFmpeg of the same major answers to the same names. Ubuntu 26.04’s FFmpeg 8 is libavcodec.so.62 too. Any process that loads both gets one of them twice:

  • Ours first, the case the GStreamer providers (ADR-0489) found. GStreamer’s libgstlibav.so needs libavcodec.so.62, and glibc satisfied it with ours, which decodes VP8, VP9 and AV1 and nothing else. So avdec_h264 could not be created. It was created when GStreamer loaded first, and not when our FFmpeg had loaded first; one run of each showed it. A web view plays media through GStreamer too, so a Goldberry application with the web view and :media was exposed.
  • The system’s first. Our libavformat then links against the system’s libavcodec: another build, other options, and a layout the probe never measured.

Decision

FFmpeg is configured with --build-suffix=-goldberry. Every library’s file name, soname and dependencies carry the suffix: libavcodec-goldberry.so.62, libavcodec-goldberry.62.dylib, avcodec-goldberry-62.dll. No other FFmpeg has these names, so neither can stand in for the other.

  • Two copies of the suffix, each checked against the other. GOLDBERRY_FFMPEG_BUILD_SUFFIX in the superbuild sets the configure line. FfmpegPlatform.BUILD_SUFFIX is the one the loader looks for. FfmpegSuperbuildTest fails if they differ, if the suffix is empty, or if the configure line stops using it.
  • package.cmake names the libraries with the suffix. It also empties its output directory of libraries first: the natives jar packs the whole directory, and the libraries named before the suffix were still there.
  • The NOTICE tells a user who replaces the libraries (the LGPL’s relinking promise) to configure theirs with the same suffix. The configure line it quotes includes it.

Consequences

  • Checked on linux-x64. Every soname and NEEDED entry carries the suffix, and the unsuffixed files are gone from the output. The size is unchanged at 6708 KB. With our FFmpeg loaded first, GStreamer’s avdec_h264 and avdec_h265 now decode in the same process, which ADR-0489’s tests depend on.
  • :media:check with FFmpeg required passes: 473 tests. FfmpegPlatformTest names the suffixed files on all three systems.
  • macOS and Windows take the same configure option. Their names follow FFmpeg’s own rules for --build-suffix and were not built here. The macOS install names become @loader_path/libavcodec-goldberry.62.dylib.
  • A replacement FFmpeg built without the suffix is refused as missing. The error names the file the loader looked for, so it says what to build.

491. A page under X11 keeps its window on the GPU

Date: 2026-09-30

Status

Accepted. Amends ADR-0479’s rule that a window with an embedded page stays on the CPU. Relates to ADR-0442, ADR-0445 and ADR-0046.

Context

Opening the showcase’s web tab on Linux ended the process:

Sdl3Window - "Goldberry — showcase on Linux / amd64" presents on the CPU from now on: a page is embedded in it
WebView - web-view: a page is open inside the window over 2496.0x1107.0 logical at (32.0, 232.0) (scale 1.0)
(java:9977): Gdk-WARNING **: The program 'java' received an X Window System error.
The error was 'BadDrawable (invalid Pixmap or Window parameter)'.
  (Details: serial 252 error_code 9 request_code 14 (core protocol) minor_code 0)

Request 14 is GetGeometry. GDK’s X error handler exits, status 1. It reproduced on every run.

What happened. The window was presenting through the GPU (ADR-0480). The page was reparented into it, then Sdl3Backend moved the window to the CPU, as ADR-0479 said a window with a page must. The next frame made the window’s first surface. On X11 that surface is SDL’s texture framebuffer, and SDL builds it with its OpenGL renderer. The Vulkan claim had taken the window’s SDL_WINDOW_OPENGL flag away, so GL_CreateRenderer called SDL_ReconfigureWindow. X11 has no ReconfigureWindow hook, so SDL fell back to SDL_RecreateWindow: it destroyed the X window and created another with a new id. X destroys a window’s children with it, and the page was one. GTK’s next request about its own window was BadDrawable.

WindowIdentityTest shows it without a page: an X11 window claimed, released and then given a surface changes id (29360179 → 29360196 here).

Why the window was on the CPU at all. ADR-0479 left it unmeasured whether the page or the swapchain shows on top. On X11 that is settled by the protocol: a child window is stacked above its parent’s contents, and the server clips the parent’s presents against it, the Vulkan swapchain’s included.

Keeping the GPU exposed two more defects that the CPU path had hidden:

  • The page never left its parking place. web-view opens its page parked off the side of the window (ADR-0445). Its canvas painter polls the load state and brings the page over its box, and it asks for the next poll with Host.repaint(). A GPU window paints into a buffer it keeps, so its frames repaint in part, and a frame calls a painter only where it is damaged. repaint damages nothing, so after the page opened the painter never ran again. On the CPU the surface was new after leaving the GPU, and a new buffer is a whole repaint, which started the polling.
  • Stale pixels where a page had been. Wherever the page covers the parent, the parent’s presents are clipped, so the parent’s pixels there keep what was last shown before the page arrived: the loading spinner. When the page moves away (parked for a modal, scrolled), X exposes the region and the parent must present again. SDL reports that as WINDOW_EXPOSED, and it became window.repaint(): no damage, so the composited present returned early and showed nothing new.

Decision

Under X11 a window keeps the GPU with a page in it. PageStacking decides by the window system of the page’s parent handle. X11 is AboveTheSwapchain. Cocoa and Win32 are NeedsTheCpu, with the reason logged, until someone measures them: a Metal view added after the page would cover it, and WebView2 over a flip-model swapchain is untested. Sdl3Backend.createEmbeddedWebView calls stayOnTheCpu only for NeedsTheCpu. wantsComposited no longer excludes windows with pages, since stayOnTheCpu already keeps those on the CPU for good.

On X11 a window surface is the X server’s framebuffer. Sdl3Backend.keepWindowsAcrossTheGpu sets SDL_FRAMEBUFFER_ACCELERATION=0 after SDL_Init, under the x11 driver and a policy that claims windows (always, auto: Composition.claimsWindows). The native framebuffer touches no graphics flag, so a window given back from the GPU keeps its id. This still matters for pages under X11: auto gives a window back when its last layer goes, and always gives one back when a present fails. An SDL_FRAMEBUFFER_ACCELERATION already in the environment wins, and the log warns.

A Vulkan renderer for the framebuffer (vulkan,opengl) was tried first. It changes no flags either, but claiming a window after it segfaulted inside libnvidia-glcore (driver 610.57.04) in SDL_ClaimWindowForGPUDevice.

web-view polls with a rebuild. WebViewState.pollAgain schedules a zero-delay setState. A rebuild mints a new painter, and damage compares painters by identity, so the box is damaged and the painter runs however the window presents. It is used after opening and on every frame while the page loads. It stops once the page has shown, so an idle application stays idle.

An expose re-presents a composited window. CompositedWindow.exposed() marks the window stale. SdlCompositedWindow.present then draws its kept UI texture to the swapchain even with no damage, uploading nothing, and clears the mark once a swapchain texture was actually acquired. Sdl3Backend calls it on WINDOW_EXPOSED, before the event’s repaint.

Consequences

  • Checked on linux-x64 (XWayland under GNOME, NVIDIA 610.57.04). The web tab opens with the window on the GPU and no X error. The page draws above the swapchain, the spinner goes up and comes down, and the page is placed over its box. Closing the window exits 0. With goldberry.gpu.composite=auto the window presents on the CPU through the X framebuffer, page on top.
  • The CPU path on X11 loses SDL’s renderer, which waited for vertical blank. The frame loop’s own pacer paces it. On the showcase’s animated home screen under auto over 600 frames: 19 and 16 late with the X framebuffer, against 22 and 29 with SDL’s renderer. Mean paint went from 8.4 ms to about 9.6 ms.
  • Popups share the process-wide hint. The X framebuffer’s formats for 24- and 32-bit visuals are XRGB8888 and ARGB8888, both ones Goldberry paints into. A transparent popup on this path was not looked at on screen.
  • Tests: PageStackingTest, CompositionTest.claimsWindows, Sdl3BackendTest.SurfaceKeepsTheWindow, WebViewPollingTest (two of its three fail with host.repaint() put back), CompositorTest.exposed, and WindowIdentityTest (fails without the hint; it needs -Pgoldberry.gpu.videoDriver=x11 where SDL would choose Wayland).
  • Left as found: in :gpu:gpuTest, GpuLayerBackendTest segfaults in VULKAN_DestroyDevice when it closes its device, which ends the run. With it disabled, Canvas3dGoldenTest.cube fails its golden (52% of pixels, delta 1), and under Wayland CompositorTest.givesTheWindowBack and oneDevice fail to re-claim their window. All four fail the same way without this change. An interactive showcase run that closed the window after visiting the media tabs exited with SIGABRT, which may be the first of them. It was not reproduced.
  • macOS and Windows keep the CPU rule. Measuring them is the way to lift it: PageStacking.of is the one line to change.

492. A window says whether it presents through the GPU

Date: 2026-09-30

Status

Accepted. Builds on ADR-0480, which promised that “the log says which and why”, and ADR-0491.

Context

Whether a window presents through the GPU or on the CPU depends on the machine as much as the application. There may be no :gpu on the module path, no device, or a driver that refuses the window. A property may say so, a page may be embedded where stacking is unmeasured, or a GPU may fail mid-run. ADR-0480 logged it, but a reader could not easily find it:

  • Six phrasings in two classes. “presents through the GPU”, “presents on the CPU: …”, “presents on the CPU from now on: …”, “presents on the CPU again: …”, from Sdl3Window. “GPU device ready …” and “no GPU device …”, from SdlCompositor. Nothing common to search for.
  • Silent cases. A window with no GPU compositor on the module path set its reason and logged nothing. Neither did a popup, nor a policy of never or off.
  • A misleading total. The exit line “presents: N frame(s) composited” counts only GPU presents that carried new pixels. A still window on the GPU presents with nothing to upload, so ADR-0491’s web tab read “2 frame(s) composited” after 400 frames on the GPU.
  • Nothing an application could ask. The showcase could not show which path it was on, and no test could assert it.

Decision

Presentation is a public value in render.window: sealed, Gpu(driver) or Cpu(reason). path() is GPU or CPU, describe() is the log’s long form and label() a status bar’s short one. Presentation.Cpu.UNDECIDED stands before a first frame.

  • BackendWindow.presentation() defaults to the CPU, “this backend has no GPU path”.
  • Sdl3Window answers from what it does. While composited, it is the GPU driver (CompositedWindow.driver()). Otherwise it is its own reason when something put it on the CPU (a refused claim, a failed present, a page). When nothing did, it is the policy’s (Composition.whyOnTheCpu) or a popup’s.
  • HeadlessWindow is on the CPU, “headless: nothing is shown on a screen”. presentAs lets a test change that.

Window watches it, after every present. Window.presentation() is the last frame’s. onPresentationChange hears each change, the first frame’s included. presentations() counts every presented frame by path. Asking after the present is what makes it cheap: a field compare per frame, and no event from the backend.

One log line per change, tagged. Window logs each change at INFO:

[GPU] "Goldberry — showcase on Linux / amd64" presents through the GPU (vulkan)
[CPU] "Goldberry — showcase on Linux / amd64" now presents on the CPU: no GPU layer on screen (goldberry.gpu.composite=auto)

Sdl3Window’s transition lines are DEBUG now, so each change is said once. Its warning for a failed GPU present stays, with the exception. The device lines are tagged [GPU] device ready … and [CPU] no GPU device …, and the backend’s policy line starts presentation policy:.

The exit summary says where frames went. A new line, on screen: 385 frame(s) through the GPU (vulkan), 0 on the CPU, comes before the cost line, which now reads 385 GPU present(s) with new pixels; upload mean ….

The showcase’s bar shows it. A badge, class="info", bound to app.presentation and set from onPresentationChange: GPU · vulkan, or CPU · and the reason.

Consequences

  • Checked on linux-x64 on the showcase’s web tab. Under always: [GPU] at the first frame, and on screen: 385 frame(s) through the GPU (vulkan), 0 on the CPU. Under auto: [CPU] at start, [GPU] when a layer showed, [CPU] again six seconds later, and 370 … through the GPU (vulkan), 473 on the CPU, with the page open and exit 0. That last switch is ADR-0491’s.
  • The bar changes 21 gallery goldens by 16 pixels each: the badge shows …, since an offscreen render never presents. ShowcaseDocumentsTest names the new badge.
  • CompositedWindow has one more method to implement, driver(). :gpu’s is the only implementation.
  • Tests: PresentationTest, PresentationTallyTest, WindowPresentationTest (decided at the first frame, heard once per change, counted every frame), and CompositedBackendTest asserting Gpu with a compiled-in driver and Cpu("goldberry.gpu=off").
  • A long CPU reason makes a long badge. Under auto it is the property’s whole explanation. The bar has the room, and the reason is the useful part.

493. The platform decoders are part of media

Date: 2026-09-30

Status

Accepted. Supersedes the module boundary of ADR-0472, which put the system decoders in a module of their own, goldberry-media-platform. What ADR-0472 and ADR-0489 decided about the decoders themselves still stands.

Context

:media-platform held the DecoderProviders that hand H.264, HEVC, AAC, AC-3 and E-AC-3 to the operating system: VideoToolbox and AudioToolbox on macOS, GStreamer on Linux, Media Foundation on Windows. ADR-0472 made it optional, like every content module, so an application that did not want the system’s decoders would not get them.

It was optional in name only.

  • It ships no native code. It binds libraries the system already has with FFM. An application that does not decode H.264 loses nothing by having it: a provider is asked only about a track in one of its codecs, and binds its system’s libraries on that first question.
  • Each provider supports nothing off its own system. On Linux the VideoToolbox provider answers “no” to everything and opens nothing. Being on the path is inert until a file in a patent-pool codec is opened, and then it is the difference between a picture and UNSUPPORTED_CODEC.
  • Nothing used :media without it. The showcase depended on both. CI ran both in the same job, and :media-platform’s tests demuxed with :media’s FFmpeg through :media’s test classes.
  • It cost a module’s worth of wiring. It had its own build script, test task and coverage floor. It had a second foreignMetadata generator with a second copy of the metadata grammar, kept apart only because the first sat in an unexported package of another module (ADR-0280). It had its own --enable-native-access flag, its own CI step and its own paragraph in NOTICE. And it was one more thing for ADR-0495 to publish.

Decision

The system decoders are part of :media, in the packages they already had.

  • io.github.digitalsmile.goldberry.media.platform and its bitstream, macos, linux and windows packages move into :media unchanged. The descriptor exports …media.platform (for PlatformDecoders) and nothing under it. The provides lines for the six DecoderProviders and CoreAudio’s OutputLatency move with them.
  • One metadata file. …media.nativeimage is new and unexported. MetadataGrammar is the tracing agent’s grammar, written once, with the sealed switch over ValueLayout the platform copy had. FfmpegDescriptors (in …media.ffi) and PlatformDescriptors give each family’s shapes. MediaForeignMetadata writes both into the module’s single reachability-metadata.json. :natives’ ForeignMetadata keeps its own copy, for ADR-0280’s reason, which still holds across modules.
  • The coverage floor counts what runs. :media’s floor stays at 88% of lines and 77% of branches. Another system’s decoder package is never counted. This system’s is counted only under -Pgoldberry.platform.required=true, which is when the job has its decoders installed. That is the rule :media-platform’s own floor kept.
  • One CI step. media.yml runs :media:check with both required flags.
  • The fixture script is media/src/test/fixtures/make-platform-fixtures.sh, beside the one that makes FFmpeg’s fixtures.

OutputLatencyTest asserted that no latency provider was on :media’s path. CoreAudio’s now always is, so the test asserts what holds on every system but macOS: the installed provider answers nothing.

Consequences

  • An application with goldberry-media plays the patent-pool codecs wherever the operating system can, with no second dependency and no second --enable-native-access flag. One that wants FFmpeg only lists its own providers with MediaPlayer.builder().decoderProviders(…), which is how it always opted out of ServiceLoader’s list.
  • goldberry-media-platform was never published, so no coordinates are withdrawn.
  • The licence position does not change. The jar carries no copy of any system decoder, and NOTICE and THIRD-PARTY-NOTICES.md now say so for all three systems. Before, they named only macOS’s frameworks.

Alternatives considered

  • Keep the module and publish it too. That means two artifacts, two natives-access flags and two metadata generators for code that is inert where it is not wanted. Being optional protected nothing.
  • Merge it and rename the packages to …media.decode.*. The names are already role-shaped (ADR-0172): platform is “the system’s decoders”, and the three below it are split where each system’s foreign memory stops. Renaming would churn seventy files and every ADR that cites them, and buy nothing.

494. The QR encoder is an image format

Date: 2026-09-30

Status

Accepted. Moves the package ADR-0391 created and changes nothing else it decided.

Context

ADR-0391 put the QR encoder in :core “beside image.gif and image.png and for their reason”. Each is a specification small enough that owning it costs less than linking it, with nothing in it that names a widget. The package it was given was not beside them, though. It was io.github.digitalsmile.goldberry.qr, a top-level package among css, layout, paint and render, which are the toolkit’s subsystems. The encoder is not a subsystem. It takes a payload and returns a QrMatrix, the same shape of thing as the GIF decoder and the PNG encoder.

ADR-0172 says a package is named for the role its contents play. The role is “an image format the toolkit owns”, and that role already has a parent package.

Decision

…goldberry.qr is …goldberry.image.qr. All eleven types, the five tests and libqrencode-vectors.txt move with it, unchanged. :core exports the new name in place of the old. :widgets’ qr-code and the showcase’s Canvas screen import it from there.

tools/refactor/move_package.py made the move. On the way it was found to walk into .claude/worktrees/, which holds agents’ whole-repository worktrees, and would have rewritten another agent’s copy. It now skips .claude.

Consequences

  • A source-incompatible change for an application that imported io.github.digitalsmile.goldberry.qr. Nothing has been released, so there is no deprecation shim. The snapshot is the only place the old name ever shipped.
  • image now holds every format the toolkit owns: gif, png, anim, qr.

Alternatives considered

  • image.codec.qr, with gif and png moved under codec as well. It would be tidier, but it would churn two more exported packages for a word that adds nothing. The parent is already image.

495. Media is published, and snapshots publish again

Date: 2026-09-30

Status

Accepted. Extends ADR-0334 and ADR-0336 to :media. Changes where FFmpeg’s libraries ship: docs/goldberry-media.md §2 named a separate goldberry-ffmpeg-natives artifact, and they now ship as classifiers of goldberry-media.

Context

The request was for html, emoji, media and gpu to be snapshot artifacts an application can add as optional dependencies. Three of the four already were on paper. PublishedModules listed html, emoji and gpu as OPTIONAL, and the umbrella’s POM named them <optional>. :media was not published. Its build script said why: “a goldberry-media on Maven Central with no FFmpeg to load is a jar that fails on every machine that adds it”, and the natives jar that would carry FFmpeg existed for no target outside a developer’s machine.

On paper was all it was. Snapshot runs 32 (2026-09-27) and 33 (2026-09-30) failed before the Maven job started, so no snapshot of anything had gone out since run 31. Every per-OS Java job stopped about thirty seconds in, in :natives:gpuTest. The Java jobs build with -Pgoldberry.skipNative=true, so there is no libgoldberry, and GpuDeviceRequirement correctly skips each GPU test class from its @BeforeAll. The classes’ @AfterAll then called Sdl.get().quit() regardless. That loaded the library that was not there, and the UnsatisfiedLinkError counted as a failure of the class. The launcher fails on any failure, and build depends on it. Eight classes in :natives and :gpu share the pattern. WindowIdentityTest, added in ADR-0491’s commit, has not been pushed yet, so the next run would have been red for the same reason.

Decision

A teardown returns when its setup was skipped. Each of the eight classes (SdlGpuDeviceTest, WindowIdentityTest, GpuApiTest, DrawTest, YuvDrawTest, CompositorTest, LayerTexturesTest, and GpuLayerBackendTest’s @AfterEach) returns early when the field the requirement fills is still null. With -Dgoldberry.native.library pointing nowhere, :natives:gpuTest, :gpu:gpuTest and :media:gpuTest now pass: 28, 67 and 2 tests found, and every one that needs the library skipped. With the library and -Pgoldberry.gpu.videoDriver=x11, :natives:gpuTest runs all 28 on the device, as before.

:media is published, optional, as goldberry-media. It is one line in PublishedModules and goldberry.publish in its build script, so the BOM pins it and the umbrella lists it <optional> without anybody editing either.

FFmpeg ships as goldberry-media’s classifiers, ffmpeg-<target>. That is io.github.digitalsmile:goldberry-media:<v>:ffmpeg-linux-x64 and its three siblings, the same shape as goldberry-natives:<v>:linux-x64. What the spec wanted from a separate artifact still holds. The libraries are in jars of their own, replaceable, carrying the LGPL text and the configure line. They are never resolved unless an application names them. What the separate artifact would have added is a second coordinate to model. PublishedModules has no kind for “natives of another module”, and goldberry.publish makes one publication per project. A publication of classifier jars and nothing else could only be checked against Central itself, on the one workflow that uploads.

A snapshot carries what was built, and a release carries all four or fails. publish.yml now calls media.yml, which builds and tests FFmpeg on every target it has a runner for (macos-aarch64 and linux-x64) and uploads each install. The Maven job hands them over with -Pgoldberry.media.artifactsDir. media/build.gradle attaches a classifier for each target present, and warns which ones are missing. For a release version it throws when any of the four is missing, because a release on Central cannot be withdrawn and one missing a target is a player that cannot load FFmpeg there.

Verified locally: publishToMavenLocal with a linux-x64 FFmpeg handed over produces goldberry-media-2026.1-SNAPSHOT.jar, its sources and javadoc jars, the POM, the module file, and goldberry-media-2026.1-SNAPSHOT-ffmpeg-linux-x64.jar. :media:javadoc passes doclint, and PublishedModulesTest and PublishWorkflowsTest hold the list and the workflow to it.

Consequences

  • An application adds media with implementation 'io.github.digitalsmile:goldberry-media' and one runtimeOnly '…:goldberry-media::ffmpeg-<target>' per target it ships to. The loader’s message when FFmpeg is missing names that coordinate.
  • A snapshot run now also compiles FFmpeg, on two more runners. The FFmpeg and dav1d checkouts are cached by the catalog’s hash, as in the Media workflow’s own runs. A media failure now blocks every snapshot, which is the same rule the three per-OS workflows already follow.
  • Before the first release: FFmpeg for windows-x64 and linux-aarch64, which media.yml does not build yet. The release refuses without them. Also the LGPL corresponding-source offer that docs/media-plan.md lists as open.
  • Snapshots of goldberry-html, -emoji and -gpu go out again with the next green run. None has since run 31.

Alternatives considered

  • A goldberry-ffmpeg-natives artifact, as the spec named it. It would need a second publication in :media or a Gradle project of its own, and a fourth kind in PublishedModule. It would buy a name. The name can still be changed before the first release, which is the point after which it cannot.
  • Publish goldberry-media without natives for now. That is the jar the build script warned about, which fails on every machine.
  • Wait for all four targets before any snapshot. A snapshot exists so that work in progress can be tried. Two targets that work are worth more than none.

496. Eleven packages split by role

Date: 2026-09-30

Status

Accepted. Applies ADR-0172 again, a month and six modules later.

Context

ADR-0172 split :core, :natives and :widgets by role, but the modules built since then (:gpu, :media, the showcase) had grown their own folders. An audit went over every main package, 215 of them. It started from the largest and the ones that mixed roles, and counted for each candidate split the package-private members it would break. The count came from CodeGraph’s edge table cross-checked by grep, and the compiler had the last word. The rule was ADR-0172’s. A split that needs more than about three promotions is probably the wrong split. A split that would leak internals into an exported package is not made.

Decision

Eleven moves, made with tools/refactor/move_package.py:

ModuleFromToTypesPromotions
:gpugpu.rendergpu.compositeSdlCompositor, SdlCompositedWindow, SdlReadbackSurface, UiComposite, LayerTextures, DeviceOptions, ApiAccess and their 7 testsnone
:nativessdl.gpusdl.gpu.enumsthe 17 SdlGpu… enumerationsSdlGpuShaderFormat.createProperty()
:exampleexample.uiexample.ui.sheetthe Icons and Emoji screens, tiles, catalogs and specimens, and 2 testsnone
:exampleexample.uiexample.mediaShowcaseMedia, ShowcaseServer, JavaPcmDecoder and 1 testnone
:widgetswidgets.datawidgets.data.plotScale, Ticks, LogTicks, TimeTicks, Lttb, Gaps, Curves and 7 testsnone
:mediamediamedia.picturePicture, VideoPicture, VideoPlanes, PictureForm and 2 testsnone
:mediamedia.viewmedia.view.gpuGpuVideo, GpuVideoPresenter, VideoPresenter and 3 testsVideoPresenter, GpuVideo, GpuVideo.available(), GpuVideo.presenter(…)
:corepaintpaint.strokeStroke, Cap, Join, Dashnone
:corerenderrender.clipboardClipboard, UriList and 1 testnone
:widgetswidgets.corewidgets.core.presencePhase, Departure and 1 testnone
:assetsassetsassets.preparethe whole build tool, and 4 testsnone

The reasons, one line each:

  • gpu.composite. gpu.render’s own package comment said it held the shaders and the quad arithmetic. The compositor, which claims a window, composites it and reads it back, is a different role. The new name mirrors the render.composite SPI in :core that it implements.
  • sdl.gpu.enums. ADR-0172’s rule: split where the foreign memory stops. These are tables of C constants beside the wrappers that own handles. The name is the one blend2d.enums, harfbuzz.enums and md4c.enums already use.
  • example.ui.sheet, example.media. Forty-two types in one showcase package. The glyph sheets are a closed group. The media sources, a loopback server and a decoder are not UI at all.
  • widgets.data.plot. The deterministic arithmetic between a series and a plot, which every chart shares and none owns. The widgets stay behind.
  • media.picture. What a view draws, apart from the player that makes it.
  • media.view.gpu. The only code that touches the optional :gpu. The package boundary makes that isolation visible, and GpuVideoPresenter, the one class that names :gpu’s types, stays package-private inside it. That took three promotions, all in an unexported package, which is at ADR-0172’s limit and is recorded here for that reason.
  • paint.stroke, render.clipboard. Values and an SPI that sat inside larger packages and used nothing package-private from them.
  • widgets.core.presence. An enter/exit lifecycle that nine packages share. It is not a structural primitive like column or row.
  • assets.prepare. The build tool shared its package name, io.github.digitalsmile.goldberry.assets, with :core’s exported runtime package, which is also a resource directory (ADR-0387). It was harmless only because the two never met on a module path.

Every new package has a package-info.java. All but assets.prepare, which is in a build tool without NullAway, are @NullMarked. Four NullAway findings surfaced in the showcase’s sheets and were fixed: two @Nullable subscription fields, and a map lookup rewritten as computeIfAbsent.

Three things the move tool did not do were done by hand:

  • the :gpu service file
  • the :assets mainClass strings in :core, :emoji and :example
  • six test doc comments that [linked] a package-private test class now in the other package, which the tool had turned into imports. They are plain code mentions now.

The tool gained two fixes on the way. It skips .claude/ (ADR-0494). It also rewrites a qualified name with a type-use annotation in it, …widgets.core.@Nullable Phase, which it used to leave behind.

Consequences

  • Source-incompatible for anyone who imported the moved types from paint, render, widgets.core, widgets.data or media. Nothing is released, and each new package is exported where the old one was.
  • The test suites pass after the moves: :core 2823, :widgets 2893, :media 714, :natives 569, :html 296, :example 236 and the rest, with 0 failures. The exception is :gpu:gpuTest, where GpuLayerBackendTest crashes in VULKAN_DestroyDevice on this machine’s NVIDIA driver. That crash was recorded as found and left in ADR-0491’s commit, before any of this.

Alternatives considered

Eleven splits were looked at and not made. They are recorded because a refactor that keeps only its successes cannot be argued with.

  • gpu’s root enums and specs into gpu.spec. About 30 package-private sdl()/of() mappers would become public, putting natives.sdl.gpu types in the exported gpu API.
  • media.ffi into stages (demux, decode, hardware, resample, convert, AVIO). About 60 view-accessor promotions. The views sit beside the one binding that calls them.
  • media.ffi’s loader (FfmpegLibraries, FfmpegLibrary, FfmpegPlatform). It would need Ffmpeg’s constructor to be public, and that constructor bypasses the layout verification.
  • paint’s box painters. It would publish Frame’s path-pool protocol and Path.replayInto in an exported package.
  • paint’s glyph painting. It would make Frame.drawGlyphs public again, undoing ADR-0290.
  • paint.canvas (Painter, StyledPainter, CanvasStyle). There are no promotions, but it makes a package cycle with paint.
  • widgets.core.image’s loader. The internal sealed ImageLoad would become exported API.
  • :core’s root package, including Placement to render.popup. ADR-0172 already rejected the root split (21 Window promotions). Placement is application-facing, while render.popup is the backend SPI.
  • css’s root. There are no promotions, but Stylesheet, ComputedStyle and Theme have about 600 importers, and css/ is a resource directory.
  • example.ui’s documents. They load resources by relative name, and the paths appear in native-image metadata globs and opens rules.
  • markdown.model into block and inline. It is one sealed AST, which is one role.
  • SdlFileDialogs/SdlLog into sdl.dialog/sdl.log. They hold the arena and the upcall stubs. ADR-0287 put only the plain values in those packages, on purpose.

Left alone as already single-role: natives.sdl.calls, the Blend2D and Yoga binding packages, one-control packages in :widgets (scroll, image, timeline, tabs, calendar, menu, slider, form.parts, per ADR-0091/0065), layout, widget, stats, bind, media.codec, media.io, and the :html, :common, :weaver, :emoji and build-logic packages.

497. Every package says what it is, and is null-marked

Date: 2026-09-30

Status

Accepted. Finishes the JSpecify adoption docs/testing.md §2 began one package at a time. It decides Q1 of docs/static-analysis-plan.md, how a record that accepts null for a default says so.

Context

Of 215 main packages, 122 had no package-info.java. Six more had one without @NullMarked. That mattered for more than documentation. NullAway runs in OnlyNullMarked mode, so a package without the annotation is not checked at all. Nothing required a new package to be marked, and the adoption stopped at 103. The Qodana triage of the same day found the result: most of its 408 findings were nullness in the packages NullAway never saw.

Three tools that were meant to make the sweep mechanical did not work, and each failure looked like success:

  • tools/nullness/sweep.sh compiled in warn mode and counted error: lines. It reported “clean” after one pass on a module with a hundred findings.
  • It compiled with Gradle’s up-to-date check. A cached compile prints no warnings, so an already-built module looked clean even with the first fix.
  • javac prints at most 100 warnings and then stops, silently. Every warn-mode count was capped. :core‘s “95” was 154 and :widgets’ “89” was 353.

Decision

Every package that has a class in it has a package-info.java whose doc comment says what the package is for. That covers src/main/java, and src/testFixtures/java where the package is not also a main one. The comments describe roles, per ADR-0172, and say whether and to whom the package is exported.

Every package of a module under the conventions is @NullMarked. The build tools (:assets, :weaver, build-logic) run no Error Prone, so their package-info files document without marking, and do not claim a check that never runs.

PackageInfoTest (build-logic) keeps both true. It fails on a package with no package-info.java, on one without a doc comment, and on a package in a module under NullAway that is not @NullMarked. On its first run it found the six unmarked files nobody had listed.

A record that takes null for a default says so in an explicit canonical constructor (option A of Q1). The component stays non-null, because the field never holds null, and the constructor’s parameter is @Nullable:

public Toast(String text, @Nullable String label, @Nullable Runnable onPress, @Nullable Duration timeout) {
    Objects.requireNonNull(text, "text");
    timeout = timeout == null ? DEFAULT_TIMEOUT : timeout;
    …
    this.timeout = timeout;
}

A component that stores null and returns it (Toast.label, Progress.source) is @Nullable on the component itself. The compact-constructor form had to pick one lie. Either the parameter was non-null, so every new Card(…, null) and every null from markup was a finding and the default was dead code by contract, or the component was nullable, so every reader was told to check for a null that could not arrive. The explicit constructor is javac’s way of declaring the two sides separately. It was spiked on Toast against javac and NullAway before the rule was adopted.

A :natives parameter that takes null is @Nullable there, although :natives runs no Error Prone (its build script says why). The annotations are its contract for the modules that do. :core’s NullAway read YogaNode.setMeasureFunction, SdlTray.open/icon, SdlTrayItem and SdlFileDialogs.show as non-null and needed seven suppressions. With the parameters annotated upstream the suppressions are gone. :natives now requires transitive static org.jspecify, as every other module does, so -Xlint:exports accepts an annotation on an exported signature.

The sweep works. sweep.sh normalises NullAway’s warnings before counting and compiles with --rerun. The conventions raise -Xmaxwarns and -Xmaxerrs in -Pgoldberry.nullaway=warn mode.

Consequences

  • NullAway now checks every package it can see. The findings marking surfaced were resolved without behaviour changes, module by module, upstream first. :core had about 154:

    • about 69 parameters and 29 returns annotated @Nullable where the code already handled null
    • about 11 fields made @Nullable
    • 27 requireNonNull calls stating an invariant. Thirteen of them go through a new RenderObject.appliedBox().
    • seven wrong annotations from the auto-annotator corrected, among them Paragraph.layout, which never returns null
    • NullAway.Init on Launcher’s six fields that run() creates

    No real bug could happen today. One latent one was made explicit: Window.repaintIfRestyled assumes a pointer router, which both callers install first.

  • :widgets had about 353 findings, :example 42, :html 5 and :media 1. They were resolved the same way. docs/refactor-2026-09-30.md has the breakdown. One real bug came out of it: Accelerators.unbind passed a null owner down, which the router reads as “only keys nobody owns”, although its contract is “whoever holds those keys now”. It removes the key outright now.

  • Popup.focusOn, Attributes.id, Box.painting and TraySpec.tooltip documented or stored null and did not declare it. They do now, and the last suppression that stood in for them is gone. What remains is NullAway.Init on fields a lifecycle method sets: Launcher’s six, two text states, and the showcase’s screens.

  • Public signatures that now say @Nullable are listed in the sweep’s record in docs/refactor-2026-09-30.md. Each widens a contract the code already honoured. Host.popup’s fit is one of them, so an implementation must match.

  • docs/static-analysis-plan.md Q1 is decided. Its batch 5 is now a sweep with a known rule rather than a spike.

498. Qodana reads a reviewed profile, and a bound value may be null

Date: 2026-09-30

Status

Accepted. Carries out docs/static-analysis-plan.md, item 6 of docs/refactor-2026-09-30.md. Applies ADR-0497’s rule for records everywhere Qodana found the old form, and adds to ADR-0341’s table rather than replacing it.

Context

The plan was written against 5878e577, where Qodana reported 408 findings. By the time it was carried out, item 5 of the same refactor had marked every package @NullMarked, and a run at 6dd0cde9 reported 683. The growth was entirely ConstantValue (127 to 345) and DataFlowIssue (107 to 163). Under @NullMarked every parameter is non-null by contract, so every x = x == null ? DEFAULT : x in the 128 newly marked packages became a check IntelliJ calls dead. 351 of the 508 nullness findings were inside 114 compact record constructors.

Three other things the plan found needed a decision rather than a fix:

  • qodana.yaml asked for an inspection that does not exist. It included UnusedDeclaration, and the Java inspection’s ID is unused. It had never run. Turned on as written, it reported 445 findings, 242 of them public methods of a toolkit whose public API mostly has no caller in its own repository, and 70 of those were the inflate method the woven catalog calls.
  • 101 AutoCloseableResource findings were one false positive. A getter hands out a Font, a Backend or a MediaPlayer that its owner closes, and the inspection reads every such call as a leak.
  • Observable<T> said its value was never null. A model field that is not loaded yet is null, and so is a Property made empty. Five case null arms and a dozen value == null checks on bound values were reported as dead, and they are exactly the checks a reader of a binding needs.

Decision

Qodana’s profile is a file in the repository, config/qodana/profile.yaml, based on qodana.starter and named by qodana.yaml. It changes four things, each with its reason beside it:

  • unused is on, limited to private and package-private declarations. Public API is the toolkit’s product, and “no caller here” is not “dead”. The entry points the build reaches without a Java call are declared in .idea/misc.xml, where the IDE reads them too: @Bind and @Action members, and every widget’s inflate.
  • AutoCloseableResource ignores the types a window, a backend, a player or a tree owns, or that live as long as the application. The list keeps IntelliJ’s own defaults, because setting the option replaces them. Subscription is not on it: the plan listed it as owned, and one of its two findings was a real leak, below.
  • OptionalUsedAsFieldOrParameterType is off. It is a style opinion this codebase decided against.
  • EmptyStatementBody counts a comment as content.

The dead exclusions LongMethod, OverlyComplexMethod and NonBooleanMethodNameMayNotStartWithQuestion are gone. None of them is in qodana.starter.

A false positive is answered where it is, never in the profile. A //noinspection comment or @SuppressWarnings on the narrowest declaration, with the reason in a sentence beside it. IntelliJ’s suppression ID is not always the rule ID the SARIF shows: MismatchedArrayReadWrite is suppressed as MismatchedReadAndWriteOfArray, and AutoCloseableResource as resource.

Observable<T extends @Nullable Object>, and Property and BoundField with it. Validator.of takes a predicate over @Nullable T, because a validator is asked about a field with nothing in it. Validator.parsing takes a parser that may answer null, as its documentation already said. NullAway does not check type-argument nullness in this build, so no caller had to change. IntelliJ does check it, and in three places it reads the nullable bound instead of a declared non-null argument (Property<List<Overlay>>). Those three carry a suppression that says so.

Consequences

  • Qodana went from 683 findings, 676 of them high, to 249, none of them high: 247 unused declarations at weak-warning severity and two while loops the plan leaves alone. The baseline was regenerated from that run, so the gate starts clean.
  • ADR-0497’s record rule is now applied everywhere, not only where NullAway asked for it. 121 files were rewritten by a script, then formatted and compiled with NullAway on. The script handles a qualified type (Outer.@Nullable Inner), an array or varargs, and a component comment that contains a comma.
  • Three real bugs came out of it:
    • A Content-Range or Range with more digits than a long or an int holds threw NumberFormatException out of HttpIO and the showcase’s server. The reader now fails with an IOException, and the server reads an over-long bound as past the end, which is what RFC 9110 means by it.
    • The launcher subscribed every model’s restyle and repaint listeners and never closed the subscriptions. A model that outlived one launch went on asking a closed window for frames. They are closed in shutDown, and Models.frameListenerCount lets a test say so.
    • An @Action taking a double and handed null threw a bare NullPointerException out of Double.valueOf, while one taking an int refused it by name. Both now refuse it by name.
  • Downloader and AssetCache are AutoCloseable, so the standard downloader’s HttpClient is closed. PngEncoder closes its Deflater with try-with-resources, which JDK 24 made possible.
  • Subtitles.parse lost its unused format parameter. :media is published as a snapshot only, so no release had it.

499. Every way through restyle is listed with its reason

Date: 2026-09-30

Status

Accepted. Closes book/src/TODO.md’s “Styled.restyle is an escape hatch with nine overrides now”. Keeps ADR-0099’s rule and says what it has come to mean.

Context

ADR-0099 added Styled.restyle, the widget’s last word on its own style. It runs after the cascade and the style cache and before the transitions observe the result. That order is what lets a widget-computed value animate. It is also why whatever is written there is unthemeable and unoverridable. The ADR gave one rule, “a widget may write here only what a stylesheet could not have written”, and one warning: the toolkit had one caller, and a second that was not a count would be the signal to look again. Styled’s own note still said the toolkit used it for “exactly two values, both derived from a count”.

There were nine overrides, all in :widgets, and nothing had read them against the rule since the first:

OverrideWhat it writes
ColorSwatchbackground: the colour it shows
SegmentedDividerinset at boundary / count; opacity: 0 beside the pill
SegmentedIndicatorwidth of 1/count; a translation of index cells; which corners stay round
Tabcolor: the tab’s own colour; a transform while it is dragged
TabIndicatorbackground: the tab’s colour when selected; the displacement it slides out of
ScrollContentflex-shrink: 0; padding plus the gutter; the scroll offset as a transform
ScrollThumbits length and its travel
ScrollViewportthe caller’s height, when one was given
AffixContenthow far the pinned box is shifted from its hole

Most of the nine are not counts, and most of those are still right. A swatch’s colour is the application’s data, a thumb’s length is a measurement, and a scroll offset is input. No stylesheet can know any of them. The warning was narrower than the rule. The question is whether a stylesheet could have written the number, not whether it is a count.

Read that way, two of the nine wrote something a stylesheet could have written:

  • SegmentedDivider’s opacity: 0. No selector can tell which lines are beside the pill. Once the widget says which, opacity: 0 is an ordinary declaration, and a theme might reasonably want to dim the lines instead. scroll-thumb.dragging already has this shape: the widget supplies a fact as a class, and the sheet decides what the fact means.
  • ScrollContent’s flex-shrink: 0. controls.css already declares it. The widget wrote it again so that no later rule could set it back to 1, which would squash the content into its viewport and leave nothing to scroll. That is a pin against the stylesheet, not a number the stylesheet could not have written. The comment said it was in restyle because Box had no wither for it. Box.shrink has existed since ADR-0076. Every other pin in the catalog is applied in render after .style(style), for example the segmented indicator’s position: absolute.

Decision

restyle writes five kinds of number and nothing else. Every override is listed, with its reason, in RestyleSweepTest, and an override that is not on the list fails the build.

The five kinds, as Styled.restyle’s note and the test’s Because enum now name them:

  • count: derived from how many of something there are.
  • data: the application’s own value.
  • measurement: what only layout can say.
  • input: where the pointer or the wheel has taken the widget.
  • arithmetic over the cascade’s own values. §8 defers calc(), so no rule can write it.

A fact a selector could match on is a class. A property the widget must hold against the stylesheet is a pin in render. Neither goes in restyle.

The verdicts:

OverrideVerdictKind
ColorSwatchKept. The colour is the model’s value. Written here so the closed control fades between colours rather than jumping.data
SegmentedDividerThe place is kept. The opacity moved. The widget reports beside-selection as a class, and controls.css has segmented-divider.beside-selection { opacity: 0 }.count
SegmentedIndicatorKept. Width, travel and which corners stay round all depend on which cell of how many. The radius it squares stays in the sheet, and §8 has no per-corner longhand a rule could use instead.count
TabKept. The colour is application data (ADR-0107). The drag offset is the pointer’s (ADR-0372).data, input
TabIndicatorKept. The tab’s colour, and a displacement that is the difference between two painted rectangles (ADR-0377).data, measurement
ScrollContentThe offset and the gutter are kept. The flex-shrink pin moved to render, as .shrink(0) after .style(style). The sheet still declares it, so the sheet still reads true. The gutter is the author’s padding plus a token (ADR-0364), which needs calc().input, arithmetic
ScrollThumbKept. The proportion of the content on screen, from measured extents (ADR-0117).measurement
ScrollViewportKept. The caller’s height, which in the toolkit is Fitted capping a menu at the screen it opens on.measurement
AffixContentKept. The shift comes from the rectangles the box and its scroll container were painted in.measurement

The guard is widgets/src/test/.../arch/RestyleSweepTest. It imports every Goldberry class on :widgets’ test classpath, excluding tests, the same import BoundaryTest uses. It finds every concrete Styled that declares restyle(ComputedStyle) and compares that set with a list of entries. Each entry has a type, at least one Because, and a sentence saying what it writes and why. Three checks:

  • An override missing from the list fails, and the message quotes the rule and gives the two ways out: a class, or a pin in render.
  • An entry for a type that no longer overrides restyle fails, so the list does not become a history.
  • A sanity check requires the sweep to find SegmentedIndicator. An import that found nothing would otherwise pass vacuously.

Consequences

  • No picture changes. The divider’s opacity comes from the cascade instead of from restyle, at the same point before the transitions observe it, so it still fades on fast. The content box still does not shrink. The segmented and scroll goldens pass unchanged, as do SegmentedTest’s hairline tests, which read the fill that reaches the screen.
  • A theme can restyle the lines beside a segmented control’s selection. This is the first thing moved out of restyle.
  • Changing the selection now invalidates two dividers’ cached styles, the same way scroll-thumb.dragging does. Before, restyle rebuilt the style on every frame anyway, so nothing is slower.
  • A tenth override is a failing test until someone writes down which kind of number it is, which is the question the rule asks. The test cannot tell whether the reason is true. Reviewing the entry does that, and the list keeps every entry on one page for that review.
  • The sweep sees :core, :common and :widgets, not the modules downstream. :html, :media and :example implement Styled and none of them overrides restyle. If one does, the same test belongs in that module’s arch package. A source scan from :widgets would not be enough, because Gradle would treat :widgets:test as up to date when only another module’s sources had changed.
  • Styled’s note no longer claims two callers. It names the five kinds and the test, so the rule and its list are one hop apart.

500. A drag held at the edge carries the viewport on

Date: 2026-09-30

Status

Accepted. Closes the goldberry-html entry “dragging a selection past the edge of a viewport does not scroll on”. Builds on ADR-0301’s selection, ADR-0439’s ScrollScope and ADR-0081’s frame clock.

Context

Both content views select, copy and highlight. A drag to the bottom of the scroll around one stopped selecting. The entry read this as “does not scroll on”, and there were two faults behind it, not one:

  • Nothing moved the viewport. A pointer held still sends no events, so a drag has no way to say “keep going” unless something runs on its own clock.
  • The selection itself stopped at the edge. Every word is clipped to the viewport. WordGeometry.at skips a word whose clip does not contain the pointer, so a pointer below the pane is over no word and at answers nothing. Selection.extendTo ignores nothing. So a drag that went past the bottom and came back into the pane’s width froze the selection wherever it had last been inside.

text-area had a different version of the same want. It scrolls its own offset rather than living in a scroll. A drag past its bottom selected the line under the pointer outside the control, and then the caret chase in laidOut scrolled to it. So it did scroll, but only while the pointer moved, and by a jump as far as the pointer had gone past the edge. Held still, it stopped.

The entry named the touchpad as “the interesting part”: what an edge scroll should do with a touchpad’s fractional deltas.

Decision

One mechanism, EdgeScroll, in :widgets’ core.scroll package, beside the viewport it moves. Both content views and text-area hold one. It is a timer plus a clamp, as the entry said:

  • The timer is the frame clock. The widget that owns the drag calls tick(nowMillis) from its render and answers isAnimating() with isScrolling(). That is how ScrollGlide and ScrollFade already move. A frame moves by speed × the time since the last frame. The first frame of a run only starts the clock, a late frame moves at most 50 ms worth, and a target that refuses a step (the end of the document) stops the frames until the pointer moves again.
  • The clamp is x() and y(): the pointer pulled back inside the viewport, a pixel short of its far edge. What is selected is what is under that point. This is the fix for the second fault above, and it applies whether or not anything scrolls.
  • The speed is linear in the distance past the band’s inner edge: 10 px/s per pixel, capped at 2400 px/s. The band is 16 px inside the viewport, or an eighth of it for a small one. The band is there because a pane that fills a maximised window has nothing below it for the pointer to reach. At the viewport’s own edge the speed is 160 px/s, about eight lines a second.

The touchpad answer: the speed is the pointer’s, never the wheel’s.

  • A wheel or a two-finger scroll during a drag scrolls the way it always does, one-to-one and in the viewport’s own units. The selection follows what it brings in: the document re-asks what is under the held point every frame of a drag, and text-area re-asks after the wheel moves it. The wheel does not start the edge, stop it, or change its speed. Feeding fractional wheel deltas into a velocity would add momentum to a precise gesture.
  • The speed comes from a position, so a mouse, a pen and a touchpad behave the same.
  • The steps are fractional and are applied unrounded. At 144 Hz the viewport’s edge moves 1.1 px a frame and the band’s inner part much less. Rounding would stop a slow edge dead, and saving the fractions up for a whole pixel would move it in jerks. The offsets are already doubles.

The viewport is found, not wired. On a press the document asks ScrollScope.enclosing(event.target()) and holds scope::nudge. The selection-host becomes Located so that it learns the viewport’s rectangle, which is its clip. ScrollScope.nudge(dx, dy) is new: it moves at once, along the viewport’s axis only, and reports whether anything moved. It is not ScrollController.scrollBy, which glides for 240 ms. A glide restarted on every frame of a drag would never arrive anywhere, and this is direct input.

A text-area drag stops at the lines wholly on screen. The new AreaEditor.dragTo clamps the row to the fully visible lines. Without that, the caret would land on the half-shown line at the bottom, the chase would scroll a whole line into view, and an edge moving a pixel a frame would move a line a frame. A press still takes the line it was on, half-shown or not. The edge’s step is assigned to the offset inside render, before laidOut reads it, so the frame that takes the step draws it.

Consequences

  • A drag held below or above a pane carries it on at a speed the reader controls with the pointer, and it stops on the release, when the pointer comes back inside, or at the end. For text-area it also stops on focus loss.
  • A drag past the edge of a document now selects to the word at the edge even when the viewport cannot move. The selection no longer freezes at the last word the pointer was over.
  • A text-area drag far past its bottom no longer jumps. It selects to the last line on screen and scrolls from there. That is a behaviour change, and it is the intended one.
  • The document’s selection lags the scroll by one frame. The geometry is the last paint’s, so what is selected is what the reader saw under the pointer. A text-area’s wash lags the same frame, because its edit is rebuilt next frame.
  • A drag re-hit-tests the document once per frame while it is held. That is the same linear scan a pointer move already costs, and only during a drag.
  • EdgeScroll is public in an exported package. Anything else that drags a selection, a list’s rubber band or a table’s range, can hold one.
  • Tests: EdgeScrollTest covers the speed, the band, the clamp, the stepping and nudge. SelectionTest$HeldAtTheEdge drives both views through a real frame loop on a virtual clock and checks the offset per frame against speed × time. TextAreaTest$HeldAtTheEdge does the same for text-area, plus the no-jump rule and a wheel mid-drag.

501. A tray icon follows the desktop’s theme, and stops when it closes

Date: 2026-09-30

Status

Accepted. Builds the “theme-aware light/dark variants” in docs/core-widgets.md §9’s tray-icon line, which ADR-0191 left as a mechanism without a user. Changes what ADR-0322’s Host.onSystemThemeChanged returns.

Context

The TODO entry said nothing paints a tray icon for you and nothing swaps it on a theme switch. The parts were there: TrayIcon.icon(pixels), BackendTray.icon, and SDL_SetTrayIcon, bound with a comment saying it is what a theme switch calls. Nothing called it. An application that wanted §9’s variants had to hold two PixelBuffers, listen for the setting and rebuild the tray itself.

Writing the swap exposed three facts the entry did not mention:

  • The listener could not be released. Host.onSystemThemeChanged returned nothing, and its documentation said a listener lives as long as the window. That holds for an application’s own listener. It does not hold for a tray. A tray menu cannot change while the icon is up, so an application that changes its menu closes the tray and shows it again. Each showing that listened would leave one listener behind, holding two pictures and a closed tray. It is the same leak ADR-0498 found in the launcher’s model subscriptions, at a smaller scale. onFullscreenChanged already returns a Subscription, and for the same reason: whatever listens is gone long before the window.
  • The setting is not always the panel’s shade. SDL reports the desktop’s application theme. On Windows that is AppsUseLightTheme. The taskbar has its own SystemUsesLightTheme, which SDL does not read. On Linux it is the portal’s color-scheme, and GNOME’s top bar is dark whichever way that is set. The toolkit can pick a variant only by the signal it has.
  • A desktop may say nothing. Host.systemTheme() is empty on a session with no setting, and a pair has to show one of its two pictures there too.

Decision

TrayIcon holds a Picture, sealed, and it is one of two things. A Single is one buffer, shown whatever the desktop says. A ThemePair is two: forLightShell and forDarkShell, set by TrayIcon.icons(forLightShell, forDarkShell). Each is named for the background it sits on, not for its ink. “The light icon” can mean dark ink for a light panel or a light mark for a dark one, and the two readings are opposite. icon(pixels) still makes a Single. A tray with no picture asks for the platform’s default, as before.

Where the desktop says nothing, a pair shows forLightShell. That is how CSS reads “no preference”: Media Queries 5 removed prefers-color-scheme: no-preference, and a user agent with no setting matches light.

Trays.show keeps a pair matched to the setting. It starts on the picture for host.systemTheme(), and on each change it calls BackendTray.icon with the other one. The menu is not touched, so the rule that a menu cannot change while the icon is up still holds. The swap is skipped when the picture is already the one shown, compared by identity, because PixelBuffer is a record and on Linux every SDL_SetTrayIcon writes a PNG for AppIndicator to read back.

The handle Trays.show returns owns the listening. For a pair it is a ThemedTray wrapped around the backend’s tray. Three things stop it:

  • closing the handle;
  • setting an icon through it, because an application that sets a picture of its own (a badge, a busy state) has taken the icon over, and a swap at dusk would undo it;
  • the backend closing the tray underneath it, as HeadlessBackend.close does. The next change finds the tray closed and closes the subscription.

A Single tray, or one with no picture, gets the backend’s handle unwrapped and listens to nothing.

Host.onSystemThemeChanged returns a Subscription. The launcher wraps each registration in an object of its own and removes it by identity. Closing one of two registrations of the same listener leaves the other, and closing it twice is harmless. A lambda would not do, because the JLS does not promise a fresh instance for one.

The toolkit ships no default mark. A tray icon names the application. Goldberry’s own mark on every application that forgot to supply one would misname all of them, which is worse than the desktop’s generic picture. With no icon the platform shows its own: IDI_APPLICATION on Windows, a blank indicator on Linux, an empty status item on macOS. An application that wants a picture has one line to write, and it is the line that decides what the picture is.

Consequences

  • An application gets §9’s variants by writing TrayIcon.of(tooltip, menu).icons(darkInk, lightMark). No listener or rebuild is needed.
  • Host.onSystemThemeChanged changed its return type from void to Subscription. Callers that ignore the result compile unchanged. Anything that implements Host must return one: the launcher, TestHost and TourTestHost here.
  • TrayIcon’s first component is now picture, a Picture, where it was icon, a PixelBuffer. TrayIcon.spec() still describes the tray for a desktop that says nothing, and spec(Optional<SystemTheme>) describes it for a given setting.
  • The pair follows the application theme, not the panel. On GNOME, whose top bar is dark in both settings, a light setting shows forLightShell on a dark bar. On Windows it follows the app mode, not the taskbar mode. Neither can be fixed from here without platform code SDL does not have. An application that knows its panel better sets one picture and follows nothing.
  • Tested headlessly in TraysTest: a pair starts on the desktop’s setting and on forLightShell when there is none; a change swaps the icon on the same tray and leaves its rows alone; closing the tray removes its listener; rebuilding the tray five times leaves one listener; an explicit icon ends the following; a tray closed underneath lets go; a single-icon tray is shown as described and registers nothing. SystemThemeTest checks through the real launcher that a closed subscription is not told and that closing one of two registrations of a listener leaves the other.
  • The showcase’s tray has no icon, so it does not use the pair. Giving it one would mean inventing an asset.

502. A node copies custom properties only when it changes one

Date: 2026-09-30

Status

Accepted. Answers the TODO.md entry “customPropertiesFor still walks to the root”, which cited ADR-0070. Keeps ADR-0152’s cache and changes only what a node does on a miss. Measured first, per ADR-0045.

Context

StyleResolver.customPropertiesFor gets a node’s parent’s custom properties by recursing to the root, and then adds the node’s own. The TODO entry read that as a full cascade at every ancestor, O(depth × rules) per node, paid on a first frame and on every invalidated subtree. It proposed computing each node from its parent’s resolved map instead.

That was already the algorithm. ADR-0152 caches each node’s map against its parent’s map by identity, and the renderer resolves top down, so by the time a node asks, every ancestor’s entry is valid. The walk costs one cache probe per ancestor, and none of them cascades. What the measurement found instead was a cost with nothing to do with depth.

DeepTreeStyleBenchmark (:widgets, tagged benchmark) builds a chain of panel > column > (text, button, next level) to element depth 51, 101 and 201, styled by Controls.stylesheets(Theme.NORD_DARK) plus one hover rule on the middle panel. Every node is styled. The Nord theme puts 146 --gb-* properties on :root, and controls.css declares 35 more, 33 of them on :root too. Temporary System.nanoTime probes inside resolve split one first-frame pass (every styled node resolved against a resolver it has no cache entry for):

Depth (nodes)PassCascadeCustom propertiesof which: copy and compareSubstitution
51 (103)1288 µs348 (27%)815 (63%)799109 (8%)
101 (203)2822 µs990 (35%)1567 (56%)1510241 (9%)
201 (403)6867 µs3063 (45%)3271 (48%)3064502 (7%)

The walk triggered no ancestor cascades at any depth. With every level a cache hit, the walk alone costs 7, 34 and 97 µs a pass, 0.5–1.5% of the pass. It does grow with depth squared, but it is small. The term that mattered was the line after the cache check: new LinkedHashMap<>(inherited), a put for each of the node’s own custom properties, and own.equals(inherited). That is roughly 7.5 µs a node, the same at every depth. It copies and compares 180 entries to learn, at almost every node, that the node declares none.

Decision

A node builds a map only when it changes a property. Its own cascade’s --* winners are compared with the inherited values first. When none differs, it hands down the parent’s map itself, which is the instance ADR-0152’s cache already keyed on. When one does, it copies the parent’s map, puts the changes, and freezes the result with Map.copyOf. The result is the same as before in every case. Before, the node kept inherited exactly when the copy equalled it, and that is exactly when every own value equals the inherited one. Token equality includes source position, so “equal” in practice means the same rule matched both parent and child, group { --b: … } for example.

The walk and the cache are unchanged. So are var() substitution at use time, the raw storage of a custom property’s tokens, and the invalidation paths in Element. CustomPropertiesCacheTest (:core, widget package) checks the cached path through real Elements against an uncached stand-in that re-cascades every ancestor on every ask. The cases are an override at depth, a var() in an inherited property that resolves differently below each of two overrides, a middle ancestor’s hover that changes its own map, one that reaches a descendant’s custom property only through .mid:hover .deep, and a deep node asked before anything above it is cached. The tests pass on the old algorithm as well, which is the point of them.

Measured as a paired A/B in one JVM, with the old merge behind a temporary switch, alternating ten passes of each for forty rounds after five warm-up rounds. The figures are medians over five runs at depths 101 and 201 and three runs at depth 51:

DepthFirst-frame resolve, before → afterFirst render()render() after the middle panel’s hover
511337 → 505 µs (−62%)1765 → 836 µs (−53%)1000 → 497 µs (−50%)
1012651 → 1168 µs (−56%)3383 → 1805 µs (−47%)1949 → 1130 µs (−42%)
2017796 → 4098 µs (−47%)10035 → 5786 µs (−42%)5938 → 3823 µs (−36%)

After the change, the probes put the merge at about 60 µs a pass at depth 101, down from 1510. That is 0.3 µs a node.

Consequences

  • A first frame’s style resolution roughly halves at any depth, and so does a subtree re-resolving after an invalidation. The saving is per node, not per level, so a shallow wide window gets the same share as a deep one.
  • The walk stays, at one identity probe per ancestor per node. At depth 201 it is 1.5% of a first frame. Removing it would mean trusting a parent’s entry without checking the chain above it, which is the check that makes ADR-0070’s inheritance invalidate itself. That is not worth 97 µs.
  • The real depth term is selector matching. The cascade’s cost per node grows from 3.4 µs at depth 51 to 7.6 µs at depth 201, because a descendant combinator that fails walks every ancestor. That is O(depth × rules) per node, the shape the TODO entry feared, in SelectorMatcher rather than here. Browsers answer it with an ancestor Bloom filter. It is recorded rather than scheduled, because nothing shipped nests 200 deep and at the showcase’s depth the cost is a few microseconds a node.
  • The benchmark is kept as DeepTreeStyleBenchmark and listed in docs/testing.md §1.5. It prints the first-frame pass, custom properties alone, the warm walk, a first render(), the hover and an unchanged frame at each depth. It asserts nothing.

Machine caveat. The measurements ran on the 8-core development machine while other worktrees’ Gradle builds were running. The load average was about 3 for the probe breakdown and 7–15 during the A/B runs. Absolute numbers from separate runs moved by up to 2× under that load, and that is why the before/after figures come from interleaved passes inside one JVM rather than from two runs. The ratios held within ±5 points across runs. The absolute microseconds should not be quoted as this machine’s idle figures.

503. The GPU lane finds lavapipe, a device goes before SDL does, and a GPU golden has its own tolerance

Date: 2026-09-30

Status

Accepted. Repairs the GPU lane docs/gpu-plan.md phase 2 wrote before it could be run. Adds a second tolerance beside the one ADR-0050 set, for pictures a GPU draws, and leaves every existing golden on the first. Corrects the record in ADR-0495 and in book/src/TODO.md that the lane “has never run”: it had run twice, and failed both times before a single GPU test.

Context

The lane in linux.yml installs Mesa’s lavapipe, points the Vulkan loader at it alone, and runs :natives:gpuTest and :gpu:gpuTest with a device required. It was written without a host to run it on, and its comment said its first run would be its first test.

It had two first runs. In Snapshot runs 32 and 33 both Linux legs stopped at the lane’s own first check, no lavapipe ICD in /usr/share/vulkan/icd.d (the runs’ public annotations). The runners’ red was put down to ADR-0495’s teardown failures in the Java jobs, which were real and were in a different job; nothing had looked at this one.

Running the lane here, on lavapipe, answered the rest in three steps.

  1. The glob. Mesa’s packaging names the file lvp_icd.json now; older releases wrote lvp_icd.x86_64.json, and the lane globbed only for lvp_icd.*.json. This machine’s Mesa 26 has the new name.
  2. A crash behind it. With the ICD found, :gpu:gpuTest took the JVM down in VULKAN_DestroyDevice, from GpuLayerBackendTest’s @AfterEach. That is the crash docs/refactor-2026-09-30.md §4 and ADR-0491’s commit recorded as this machine’s NVIDIA driver’s. It is not the driver’s. The test made a device in @BeforeEach to prove one could be made, kept it, and closed it after the test body — where an Sdl3Backend had already been closed, and Sdl3Backend.close() ends in SDL_Quit. Its own comment says a device has to be destroyed ahead of that. Every driver would have crashed; the one here was the only one anybody had run.
  3. A golden that cannot hold across drivers. With the crash gone, 66 of 67 passed on lavapipe. canvas3d-cube, blessed on Metal, drew two of its 32,000 pixels 157 levels apart: silhouette pixels, covered on one driver and not on the other. On NVIDIA’s Vulkan driver the same golden differed by one level on 52% of its pixels — the lit faces, rounded differently — and by nothing more. The six z-order goldens, which are screen-aligned quads, matched on both.

Decision

The lane accepts both spellings of the ICD, lvp_icd.json first.

A GPU test proves a device can be made and lets it go at once. GpuLayerBackendTest closes the device GpuDeviceRequirement.enforce() gives it inside @BeforeEach, keeps SDL’s video initialised for the headless backend, and quits SDL in @AfterEach only if it reached SDL. The backends make their own devices and destroy them before their own SDL_Quit.

A picture a GPU rasterized and shaded is compared under Tolerance.GPU, and nothing else is. A golden’s tolerance is now a value, Tolerance(channel, differing, stray):

channeldifferingstray
RASTER — every Blend2D golden, and every golden before this22%0
GPU — canvas3d-cube2100%0.1%

stray is the share of pixels allowed beyond channel. RASTER is exactly the rule ADR-0050 wrote, restated: nothing beyond two levels and at most 2% within them, because Blend2D’s coverage is analytic and only antialiased edges round differently. Both halves of that reasoning fail on a GPU, in opposite directions. A fragment shader’s arithmetic is not specified to the last bit, so a lit fill may round a level apart on every pixel. And Vulkan, Metal and D3D12 each leave the tie-break for a sample exactly on an edge, and the sub-pixel precision a vertex snaps to, to the implementation — so an aliased edge pixel can flip, and a flip moves it by the edge’s whole contrast. One pixel in a thousand may. A cube’s silhouette is several hundred pixels, so a vertex in the wrong place or a reversed winding still fails, and so does a colour that moved a fill by tens of levels.

GoldenImage.assertMatchesAtOneScale gains an overload that takes a Tolerance. The existing entry points use RASTER.

Consequences

  • The lane passes here, on lavapipe, as CI will run it: :natives:gpuTest 27 of 28 with one skipped by its own condition, and :gpu:gpuTest 67 of 67, with -Dgoldberry.gpu.required=true and the offscreen driver.
  • :gpu:gpuTest also passes on this machine’s NVIDIA driver under -Dgoldberry.gpu.videoDriver=x11, 67 of 67. It was recorded as a known-red segfault. Under SDL’s default Wayland driver here, CompositorTest’s claimed window and GpuLayerBackendTest.autoCompositesForLayers still fail with This surface does not support presenting and VK_ERROR_SURFACE_LOST_KHR: a Wayland surface the NVIDIA driver will not present to, which is a property of the session and not of the code.
  • :example:gpuTest’s gallery-gpu-drawn was stale, and not by a driver. It failed identically on lavapipe and on NVIDIA — 344 pixels, all in one 20×20 box — which two drivers do not do by rounding. The box is the status bar’s presentation badge, which 5878e577 added and re-blessed into every gallery golden except this one, because only the GPU lane draws it and nobody ran the lane. It is re-blessed, with only that box changed, and stays under RASTER: its picture is Blend2D’s except for the cubes, and the cubes match. The memory of it as “the same class as the cube” was wrong.
  • Nothing about the lane on CI is proven until it runs there. It is answered by the next push, like the rest of ADR-0495.
  • A golden that opts into GPU is weaker than one that does not, and the overload’s documentation says a Blend2D golden has no business with it: a Blend2D edge does not flip, so admitting one that did would hide the regression the golden exists for.

Alternatives considered

  • A golden per driver. Exact on each, and three references that rot separately — ADR-0050’s own reason for a tolerance, and worse here, because the drivers a user has are not the three a runner has.
  • Multisampling the test’s render target so edges stop being aliased. It would hide the ownership flips, and it would also stop the golden describing what canvas3d draws, which is single-sampled: :gpu has no multisampled target to ask for.
  • Widening RASTER until the cube passed. Every Blend2D golden would then admit a pixel off by 157 levels, which is a regression, not a rounding.

504. A selection is published where the platform has a primary selection, and a field never asks which platform that is

Date: 2026-09-30

Status

Accepted. Closes book/src/TODO.md’s “No primary selection” under The clipboard. Adds a second text buffer beside ADR-0286’s clipboard and leaves the clipboard exactly as it was. Bumps libgoldberry’s ABI from 16 to 17.

Context

X11 has two buffers where other desktops have one. Ctrl+C fills the CLIPBOARD selection; selecting text fills PRIMARY, and a middle click in any other application pastes it, with no key pressed in between. Wayland carries the same thing through zwp_primary_selection_device_manager_v1, which GNOME, KDE and wlroots compositors all advertise. SDL3 wraps both as SDL_SetPrimarySelectionText, SDL_GetPrimarySelectionText and SDL_HasPrimarySelectionText, and none of the three was bound.

The entry named why: it is one platform’s idea, and the widgets that would fill it — a text field on X11 — “would have to know they are on X11”. That is the part that needed deciding, and it has a trap under it. SDL answers the three calls on every video driver. On x11 and wayland they talk to the window system; on everything else — Windows, Cocoa, offscreen, dummy — SDL keeps the text in a buffer of its own inside the process and reads it back faithfully. A binding that trusted the calls would work in every test and offer Windows users a middle click that pastes something nobody on Windows selected.

Decision

Three symbols, bound like the clipboard’s text. SdlClipboardCalls gains a holder for each, SdlClipboard gains hasPrimaryText(), primaryText() and primaryText(String) with the same call-copy-SDL_free read, and goldberry.symbols lists them in the clipboard section. GOLDBERRY_ABI_VERSION and GoldberryShim.SUPPORTED_ABI_VERSION go to 17. The three descriptors are ones the clipboard already links, so the native-image metadata gains nothing.

The capability is optional, and the backend is the only thing that knows. render.clipboard.PrimarySelection is text only — hasText(), text(), text(String) — because nothing in the toolkit selects anything but text. Backend.primarySelection() and Host.primarySelection() return an Optional, empty by default:

  • Sdl3Backend answers present only when the driver SDL chose is x11 or wayland — hasPrimarySelection(String), decided once after SDL_Init and unit-tested over driver names with no display.
  • HeadlessBackend answers with an in-memory HeadlessPrimarySelection, present by default so the widgets’ behaviour is testable, with primarySelection(false) to model a platform without one, and primaryBuffer() to prove nothing was written while it was off. It counts writes and can refuse them, as HeadlessClipboard can.
  • Launcher forwards the backend’s answer. A Host that says nothing has none.

An Optional where clipboard() is deliberately not one, because absence here changes what a widget does and not only what it reads. A clipboard that always reads empty is honest; a middle button that moves the caret and pastes nothing is a gesture from the wrong platform. So without a primary selection, nothing is published and a middle click is not consumed.

A finished selection is published, once. text-input, text-area, core’s Editor and the content views’ SelectableDocument publish a non-empty selection when it is finished:

  • a pointer selection on the release — a drag, a double click, a triple click — and not on every move, because every write is an ownership change the whole desktop is told about, and a clipboard manager that mirrors PRIMARY would hear one per pointer event;
  • a keyboard selection when its key lands — Shift with a movement, and Ctrl+A, which publishes even when everything was already selected;
  • not the select-all a text-input does when Tab arrives. Arriving is not selecting, and a form tabbed through would otherwise overwrite PRIMARY once per field.

A collapsed selection publishes nothing and clears nothing: what was last selected stays pasteable, which is what xterm and every browser do.

A middle click pastes at the point. In a field or an Editor, a middle press moves the caret to where it landed and inserts the primary selection’s text there. The move changes no text, so the history records one step and one Ctrl+Z takes the paste back, leaving the caret where the press put it. It is Ctrl+V’s insertion — the same maximum length, a single line’s newlines flattened, a text-area’s kept — and a read-only or disabled control, an empty primary selection, or none at all make it a no-op the event is not consumed by. A drag after a middle press selects nothing: the fields now track whether the primary button started the gesture, which also stops a right-button drag from extending the selection, as it did before.

A password never publishes. text-input is the only masked field in the catalog (code-input’s mask has no selection at all). A primary selection is readable by every client on the desktop with no action from the user, and every X11 toolkit refuses it for a secure field. Pasting into a password by middle click is allowed, as Ctrl+V is: the ban is one-way.

Consequences

  • No widget and no Editor names a platform. Each asks its host whether a primary selection exists, and the whole of the platform decision is one predicate over the driver’s name in Sdl3Backend.
  • SelectableDocument publishes and never pastes: a document is not a target. Its tests reach a primary selection through a Host proxy, because a press finds one only through the element that heard it.
  • The showcase’s canvas sticky wires its Editor to the host’s primary selection, which is the one Editor in the tree.
  • A Wayland compositor without the primary-selection protocol is not told apart: SDL refuses the write and reads empty there, which every caller already handles as a refusal.
  • The primary selection is not watched, for the reason the clipboard is not (ADR-0286’s entry, still open): a middle click asks when it happens.
  • natives’ SdlClipboardTest round-trips the three calls against the real library, and proves the two buffers are separate.

Alternatives considered

  • PrimarySelection.none() on a non-Optional accessor, the clipboard’s shape. Every widget would then need a second question — does this do anything? — to know whether a middle click is a paste, which is the question the Optional already answers.
  • Offering it on every driver and letting SDL’s in-process buffer stand in. Middle-click paste between two fields of one application on Windows, which no Windows user expects, and a HeadlessBackend that proved nothing about the platforms where it matters.
  • A method on Clipboard — primaryText() beside text(). A backend with a clipboard and no primary selection would have to implement it anyway, and every caller of the clipboard would see a buffer that exists on two desktops.
  • Publishing on every selection change, as GTK does. Correct and chatty: a drag across a paragraph becomes dozens of ownership changes, each a round of messages to every other client. Qt publishes on release, and so does this.

505. A border has four sides, and takes no room

Date: 2026-09-30

Status

Accepted. Adds per-side borders to docs/ARCHITECTURE.md §8’s CSS subset — the change ADR-0215 declined for one table’s underline and said was “worth doing when something needs an edge the subset cannot fake”. Closes the table-rules half of book/src/TODO.md’s goldberry-html entry.

Context

border has been one width and one colour for the whole box since ADR-0064. Five rules reached for a side and got nothing: border-bottom under a tab (ADR-0107) and under table-head (ADR-0215), border-left on a quotation in both document views, border-right down text-area’s gutter (ADR-0331), and a rule between a document table’s cells, which TODO.md records as “border is uniform, so there is no border-left”. Each was answered with a node — tab-rule, table-rule, html-quote-bar — or with nothing, and group-box-title’s comment has described a rule under the header since the widget shipped while no declaration drew one.

A node stops being an answer at the table. A document’s table has as many columns as its author wrote and the builder does not lay out a grid, so a line between each pair of cells is a border on one side of every cell or nothing. markdown-view had already put a first class on each row’s leftmost cell for the rule it could not write.

Three facts decided the shape.

  • A border has never taken layout room here. BoxPainter strokes it inside the box’s edge, over the padding, and RenderObject.apply never calls YGNodeStyleSetBorder — bound per edge in YogaNode.setBorder, and unused. Every bordered widget’s padding assumes this: segmented’s padding “is that edge’s own width”, and TextAreaBox insets its gutter strip by the border because the border is drawn over its padding.
  • Every golden with a border is the one stroked rounded rectangle. A per-side model that changed how a uniform border draws would move every one of them.
  • The painter strokes, and a stroke has one width. Sides that differ are a different drawing, not a different number — ADR-0215’s own objection.

Decision

The subset has CSS’s side properties. border-top, -right, -bottom and -left take border’s grammar over one side; border-width and border-color take CSS’s 1-4 side form in padding’s order; border-{side}-width and border-{side}-color set half of one side. ComputedStyle.with handles all of them, so StyleLint and SupportedPropertyTest, which ask the real engine, learnt them with no edit.

Style stays a word inside a shorthand. border has always read a style keyword and drawn it solid, logging anything but solid, with none and hidden as a zero width. The side shorthands do the same, and there are no -style longhands — border-style included, which was never a property here either. A -style longhand that set nothing would be a declaration the lint passes and the painter ignores. StyleLintTest’s example of an unsupported property is border-style now, since border-bottom stopped being one.

Later wins, per side. The cascade applies declarations in the order they won (ADR-0311), so border: 1px solid; border-left: none is three sides and border-left: 2px solid; border: 1px solid is four equal ones. A more specific rule’s border is applied after a less specific rule’s border-left, whatever the source order. A side’s shorthand resets what it does not name on that side: border-left: red is a 0px left side, as border: red is a 0px border.

The value is Border, four Lines, and Decoration carries one. It replaces Decoration’s borderWidth and borderColor components. The accessors went with them rather than being kept as derived answers, for ADR-0216’s reason: with four sides, “the border’s width” has no correct return value. Decoration.border(width, argb), borderWidth(double) and borderColor(int) still set all four, and the old seven-argument constructor is kept as a uniform-border convenience.

A border takes no room, and a side takes none either. Nothing reaches Yoga. A cell with padding: 6px 8px and border-left: 1px has its rule over the first pixel of its padding and its text where it was. In CSS’s words this is border-box sizing with the border laid over the padding rather than inside it; the subset has no box-sizing and gains none. The per-edge binding stays bound and unused.

Four equal sides are the old drawing. Border.isUniform() compares four lines, and a uniform border goes down the same stroke BoxPainter always drew: one rounded rectangle inset by half the width. That is decided from the value, so a border spelled as four longhands takes it too, and BorderPaintTest checks the two spellings pixel for pixel.

Sides that differ are filled, one region each (BorderPainter). A side is the band between the outer edge and the inner edge — the outer edge inset by each side’s own width — cut off at each end where it meets its neighbour.

  • Square corners are mitred as a browser mitres them: the boundary runs from the outer corner to the inner one, so a 1px top against a 4px left meets it on the diagonal from (0, 0) to (4, 1).
  • With a radius, it is an approximation. The inner corner is a circle of the outer radius less the wider of the two sides beside it, where CSS makes it an ellipse with each side’s own width taken off its own axis; Corners are circles. The corner’s arc is split between its two sides in proportion to their widths, by parameter along the cubic, with de Casteljau so the pieces are exact pieces of the arc. Equal widths split at the arc’s midpoint, which is exactly where the mitre of a concentric corner falls; a zero on one side gives the other the whole corner, tapering into it, which is what a browser draws for a lone border-left on a rounded box. The error is bounded by the difference between the two widths.
  • One fill per colour. Two anti-aliased fills meeting on a diagonal each cover part of the pixels along it, and part over part is not all, so a border in one colour with two widths would have a faint seam down every mitre. Every side of one colour goes into one path and is filled once; between two different colours there is a seam, as in every browser.

border-color transitions every side. Its animated value is the Border itself, each side’s colour mixed through OKLCH like every colour transition, and only the colours are written back, so a width is never animated. A width change with every colour held starts nothing.

The consumers.

  • Document tables are ruled. In html.css and markdown.css a row draws the line above it and a cell the line before it, and first — on the top row and each row’s leftmost cell, set by the builder because the subset has no :first-child — stops either drawing over the table’s own border, where a square line would also show outside the rounded corner. A caption is the first thing in an HTML table when it has one, so the head under it is ruled off from it.
  • And their columns are equal. Each row is a flex row of its own and nothing shares a column between rows, so while a cell’s share followed its content a column started a few pixels further along in one row than in the next. Space hid that; the rule before a cell drew it as a line with a step at every row, which the first render showed. Cells are flex-basis: 0 now, an equal share of the row — what both stylesheets’ comments said they wanted while flex-basis was missing, and it has been in the subset since ADR-0373.
  • A quotation’s bar is its border-left. The 2px widget and the 12px gap beside it became border-left: 2px and padding-left: 14px, and the goldens with quotations in them did not move, which is the evidence that the side drawing is the fill it replaced.
  • group-box-title has its rule. border-bottom: 1px solid var(--gb-border), inside the header’s own padding.
  • The other nodes stay nodes, each for a reason that is not the subset any more, and their comments say so. table-rule sits under table-head’s 36; as a border it would sit inside it and move every row up a pixel for a picture that is already right. tab-rule has the travelling indicator drawn over it, and a border on the list would be painted before the tabs. tab-indicator travels by a transform (ADR-0377), which a border on one tab cannot do. segmented-divider fades on its own when the pill reaches it. text-area’s gutter is told from the text by a step of surface, which was a choice and not only a workaround. menubar still wants no rule.

The specification says all of this first: docs/ARCHITECTURE.md §8 has a “Borders are per side” entry beside the paint properties, and the parenthesis that said border was “one width and one colour, not per-side” is gone.

Consequences

  • Seven golden images changed, each by the rule it gained, and nothing else: html-light, html-dark, markdown-light, markdown-dark and markdown-selection by the table’s inner rules (and, in the Markdown ones, a right-aligned 400 that moved one pixel to line up with the 600 under it now that the columns are equal); group-box-dark and gallery-panels by the line under a group box’s title. One golden is new, borders: a uniform border, a lone border-bottom, two widths in one colour, four colours and four widths, and the same rounded. Every other golden in the four modules matches its reference, and re-blessing the four golden classes these belong to rewrote no other file in them — which is what says the uniform drawing did not move.
  • A table’s columns are equal shares now, not shares of their content. A table with one long column and one short one wraps the long one sooner than it did. That is the trade for rules that line up, and it is the one the stylesheets were written for.
  • Decoration.borderWidth() and borderColor() are gone. Four call sites moved: TextAreaBox reads each side it insets against, Animatables animates the Border, and two widget tests compare a Border.
  • Only a non-uniform border costs anything new: a handful of small arrays and up to four fills per box per frame. A document table’s cells are that case, all square, all one colour, so each is one fill of one or two rectangles.
  • An author coming from CSS will find a border that does not push content in. It never did here, for border; the specification now says so in so many words, beside the properties that make it more likely to be noticed.

Alternatives considered

  • Handing the widths to Yoga, as CSS does. It is the binding’s purpose and would make a side push content in like a browser’s. It would also move every bordered box in the catalog by its border’s width, double-count the edge in every widget whose padding already includes it (segmented, text-area), and change every golden with a border — a box-model change across the toolkit, riding on a feature that needs none.
  • Keeping one stroke and drawing a side by clipping. Blend2D clips to rectangles, not paths, and a rectangular clip cannot give a mitre; four clipped strokes would also put a seam wherever two of them met.
  • Stroking each side as a line. Right for a lone side on a square box and wrong everywhere else: two strokes of different widths meet in a notch or an overlap rather than a mitre, and neither follows a corner’s curve.
  • Elliptical inner corners, as CSS specifies. Exact, and a second kind of curve in a toolkit whose Corners are circles (ADR-0216). No consumer draws a rounded box with sides of different widths; the golden that does is there to pin the approximation, not because anything ships one.
  • A border-style longhand that accepts solid and refuses the rest. It would make border-style: none a way to turn a border off, which border: none already is, and every other value would be a declaration the lint passes and the painter cannot honour.
  • Horizontal rules only between a table’s rows. No layout change and no golden but the rules — and TODO.md’s entry is about border-left, which is the rule a reader of a table with left-aligned columns misses. With the columns equal, the vertical rule is right; without them, it was the thing that showed they were not.
  • Converting table-rule and tab-rule to borders for tidiness. Both draw correctly, and each would move pixels or lose an ordering the node gives for free; a node that is right is not a workaround to be retired.

506. Start-up is timed from the kernel’s clock, and a native window is up in a tenth of a second

Date: 2026-09-30

Status

Accepted. Answers the book/src/TODO.md entry “‘Starts in milliseconds’ is still unproven”, which asked for the example launched directly rather than under gradle run. Corrects ADR-0028’s timeline, whose zero was wrong on Linux, and with it every Linux number that timeline has printed. Measures, and qualifies, the claim docs/ARCHITECTURE.md §1 and README.md open with.

Context

ADR-0028 built Startup: named marks from process start to the first frame, printed at trace. Its numbers were taken under gradle run, so the entry asked for the showcase launched the way a user launches it. There are two such ways — the JVM, through the start script installDist writes, and the GraalVM native image, which is what a release ships (ADR-0340).

The first measurement looked like a finding and was a bug. A native image, whose process has no JVM to start, reported 275–292 ms before the toolkit’s first line. Timed against a clock read just before exec, the same line came at 52–60 ms. Under strace the process reached libgoldberry 66 ms after execve.

The timeline’s zero was ProcessHandle.current().info().startInstant(). On Linux the JDK builds that instant from /proc/self/stat’s starttime, in clock ticks since boot, plus /proc/stat’s btime — the boot time, in whole seconds. The fraction btime drops is an error of up to a second, the same for every process on a boot. On this one it was about 218 ms, and every “runtime starting” row since ADR-0028 carried it.

Decision

The zero comes from the kernel’s clock at both ends

ProcessAge reads starttime from /proc/self/stat and the uptime from /proc/uptime. Both count from the same boot on the same clock, so their difference is the process’s age with no wall clock in it, to the kernel’s 10 ms tick. USER_HZ is the kernel’s user ABI constant, 100, rather than a native call from :common, which makes none. The command in stat’s second field may contain spaces and parentheses, so fields are counted from the last closing one.

Where /proc is absent or unreadable, ProcessHandle answers as before: macOS and Windows report a start time to the microsecond, and the fault was Linux’s.

After the change the timeline agrees with the external clock to within 10 ms on every run below, where it had been 218 ms late.

What the claim is, measured

The showcase, on this machine (8 cores, NVIDIA, GNOME on XWayland), a real X11 window, three frames, timed from exec by an outside clock and by the corrected timeline, which agree:

LaunchFirst frame, medianRange
JVM, cold1980 ms1948–2187 (5 runs)
JVM, JDK 25 AOT cache1340 ms1220–1406 (5 runs)
Native image, GPU on519 ms491–557 (7 runs)
Native image, GPU off365 ms351–460 (7 runs)

A native run, phase by phase:

   70.1ms   runtime starting            (about 50 ms of it an mDNS host lookup)
   86.6ms   SDL video subsystem up      (14.1ms)
  113.1ms   libgoldberry ABI 17 verified (26 ms of loading WebKitGTK before it)
  116.2ms   window "Goldberry — showcase" open
  365.3ms   GPU device created          (190.1ms on NVIDIA's driver)
  511.0ms   first frame presented

So the window is up in a tenth of a second, and the first frame in half a second, in what a release ships. “Starts in milliseconds” is true of the window and generous about the frame. On the JVM it is not true: two seconds cold, most of it before the toolkit’s first line and in building the showcase’s first frame. SDL’s video subsystem, which dominated ADR-0028 at 99 ms, is 14 ms now.

The JVM’s answer is JDK 25’s AOT cache, and it is the application’s to train

-XX:AOTCacheOutput=app.aot on one training run and -XX:AOTCache=app.aot afterwards (JEP 483, JEP 514, JEP 515) took a third off the JVM’s first frame: the classes arrive loaded and linked and the hot methods profiled. The cache is 37 MB, specific to the JDK build and the module path, and trained by running the application. That is why it is documented, not built in: the toolkit is a library, and the cache belongs to an application’s own start-up and its own screens. book/src/applications.md says how. The showcase ships as a native image, so its start script gains nothing.

Consequences

  • Startup’s numbers are right on Linux, and ADR-0028’s table should be read as about 200 ms early at every row; its deltas stand.
  • docs/ARCHITECTURE.md §1 and README.md carry the numbers beside the claim. §1’s “the device costs about 20 ms at the first frame, measured on macOS” is 190–320 ms on NVIDIA’s Vulkan driver here.
  • Four things were found and are not fixed here; each is a TODO.md entry:
    • A native image resolves a host name before main, through libnss_mdns4_minimal, about 50 ms. The JVM does not: its only nsswitch.conf read is for the user’s name. Logback resolves HOSTNAME lazily and this configuration never asks for it, so the caller is not logback’s ContextBase. A stripped image did not say whose it is.
    • libgoldberry-webview is opened at start-up, to answer Capability.WEB_VIEW, and it brings WebKitGTK and GTK 3: 26 ms before a page is asked for. docs/content-widgets.md §11 calls the library “opened on demand”; the capability question is the demand.
    • The native image cannot install the GLib log handler: “no handle to bind a GLib callback to”, so GLib’s messages go to stderr there, which ADR-0443 exists to prevent. An upcall the image does not register.
    • The GPU device is a quarter of a native start on this driver. That is ADR-0480’s default meeting a slow driver; goldberry.gpu=off is the measured alternative.

Alternatives considered

  • Correcting ProcessHandle’s instant by a measured offset. The offset is a property of the boot, not of the machine, and there is nothing to measure it against inside the process except /proc/uptime — which, once read, makes the instant unnecessary.
  • Timing from the toolkit’s first line. It would have hidden the bug and the host lookup together, and ADR-0028’s reason stands: a user waits for the process, not for the toolkit.
  • An AOT cache built into the showcase’s start script. A cache missing or trained on another JDK is a warning at every launch, and the start script is not what ships.

507. A page is taken down on WebKit’s thread, and its context outlives exit()

Date: 2026-10-01

Status

Accepted. Changes how libgoldberry-webview closes a page on Linux (ADR-0441, ADR-0442). The macOS and Windows paths are unchanged.

Context

The showcase, run with gradle run on the GPU, printed its last summary line — every window closed, SDL_Quit called — and then died with exit code 134, SIGABRT, and no JVM crash log. Not every run: an idle showcase, and fourteen runs through the heavy screens, exited 0. Separately, Ubuntu’s crash dialog reported WebKitWebProcess — WebKit’s out-of-process renderer — dead of SIGSEGV in NVIDIA’s EGL driver.

Two things were wrong with how a page was closed on Linux:

  • goldberry_webview_destroy called webview_destroy and returned. The message that tells the renderer its page has gone is sent from GLib’s main context, and nothing iterated it again: the next thing the backend does is destroy the SDL window the page was reparented into, and the X server takes the page’s own window with it. A renderer still drawing then draws into surfaces that are gone, and NVIDIA’s EGL answers with a segfault rather than an error. Nothing noticed: there was no handler for WebKit’s web-process-terminated.
  • WebKit’s exit-time teardown runs on the wrong thread. Stopping the renderer first made the abort happen on every run, which made it debuggable: under gdb, with Ubuntu’s debug symbols, exit() on the launcher’s first thread runs the C++ static that holds WebKit’s default WebKitWebContext; finalising it releases the website data manager, whose ~WebsiteDataStore reaches allDataStores() — main-thread-only state — and WebKit crashes on purpose, WTFCrashWithInfo at WebsiteDataStore.cpp:124. WebKit’s main thread is the one that started GTK, Goldberry’s UI thread; the stock java launcher runs main on a thread of its own and calls exit() from its first. Whether the abort happened depended on whether anything still held the context at exit, which is why it came and went. A native image runs main on the first thread and never met it.

Decision

On Linux, a page is closed in three steps, on the UI thread, before its window can go: the renderer is stopped (webkit_web_view_terminate_web_process, WebKitGTK 2.34 and later), the page is destroyed, and GLib’s main context is drained — bounded, never blocking, for goldberry_webview_pump’s reason. Termination rather than a polite close, because a close is a message the renderer handles when it gets to it, and the window goes in the next call. An embedded page’s unload handlers do not run; closing a window under a browser tab costs the same.

WebKit’s default context is pinned. The first page takes one reference to webkit_web_context_get_default() on the UI thread and never gives it back, so the static’s release at exit() only counts down and nothing is finalised off WebKit’s main thread. The context lives until the process ends, which is the moment it was being destroyed in.

A renderer that dies is said out loud. web-process-terminated with WEBKIT_WEB_PROCESS_CRASHED or EXCEEDED_MEMORY_LIMIT is a GLib warning in the goldberry-webview domain, which ADR-0443 routes to the logger: WARN goldberry-webview - the page's web process crashed; the page is blank until it is navigated again. Termination by the API — the first step above — is silent.

Consequences

  • The showcase on the web screen exits 0, and the three “WebKit encountered an internal error … internallyFailedLoadTimerFired” lines it used to print after its last summary are gone: those were the renderer’s loads failing as the process left under it.
  • WebviewExitTest runs WebviewExitProbe in a child JVM — open a page, let it load, close it, return from main — and asserts exit code 0. With the pin removed it fails with 134; it skips without a display or a library.
  • Killing the renderer from outside while a page is open now logs the warning above, and the process still exits 0.
  • The renderer’s SIGSEGV in NVIDIA’s EGL is not reproduced on demand. The teardown above removes the path by which a closing page could leave it drawing into a destroyed surface; a crash during ordinary rendering would be WebKit’s and NVIDIA’s, and would now at least be logged. WebKit’s own WEBKIT_DISABLE_DMABUF_RENDERER=1 is the known workaround for that driver, and is the user’s to set, not the toolkit’s.

Alternatives considered

  • Exiting the JVM from the UI thread. System.exit there would run the exit handlers on WebKit’s main thread. It would also make Goldberry.launch end the process for every application, including those that do work after it returns — a contract change to dodge one library’s static.
  • Releasing the context ourselves before exit. The static still holds its reference and drops it at exit(); there is no WebKit API to clear it, and unreferencing an object the toolkit does not own is a double free waiting for a WebKit release that changes the count.
  • Leaving the renderer running and only draining. The drain lets WebKit send its close, but the renderer handles it when it gets to it, and the X window is destroyed microseconds later.

508. FFmpeg’s source is published beside its binaries, from the same place

Date: 2026-10-01

Status

Accepted. Closes book/src/TODO.md’s “The LGPL corresponding-source offer for FFmpeg is not decided”, and with it the open row in docs/media-plan.md. Completes what ADR-0495 started when it put FFmpeg on Maven Central, and adds a second refusal beside its missing-target one.

Context

goldberry-media carries FFmpeg’s five shared libraries, with dav1d linked statically into libavcodec, as ffmpeg-<target> classifier jars on Maven Central (ADR-0495). Snapshots carry them as well as releases: every push to master puts them in Central’s snapshot repository, where anyone can download them.

FFmpeg is LGPL-2.1-or-later. Those jars contain the Library itself in object form, so the section that applies is §4:

You may copy and distribute the Library […] in object code or executable form […] provided that you accompany it with the complete corresponding machine-readable source code […]. If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code.

The TODO entry cited §6, which is about a work that uses the Library: an application linked against it. That is the application’s obligation, not this build’s. What this build does is §4’s case, and §4 offers two ways to comply. It does not offer a written offer.

§0 says what “complete source code” means for a library: “all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library”. So the source alone is not enough. The scripts are the media superbuild, because that is what turned the source into these files. dav1d is a module the shipped libavcodec contains. Its own licence, BSD-2, asks only for the notice. Its source is still part of FFmpeg-as-shipped’s complete source.

A snapshot is not an exception. The licence concerns distribution, not version labels, and a -SNAPSHOT jar on Central is downloadable object code like a release is. The binaries that have already gone out as snapshots had the tags and configure line in ffmpeg-NOTICE.txt, but no source.

The source also has to be exactly what was built. The superbuild clones FFmpeg and dav1d from git with ExternalProject at the tags pinned in gradle/libs.versions.toml. A tag can be moved, and nothing checked that one had not been.

Decision

A classifier jar, ffmpeg-sources, goes beside the binaries under the same coordinates. That makes it §4’s “equivalent access … from the same place”: io.github.digitalsmile:goldberry-media:<version>:ffmpeg-sources, in the same repository and with the same version, published by the same Gradle invocation. There is one jar per version, not one per target, because the source is the same for every target. It is about 24 MB compressed: 10,664 entries and 100 MB unpacked.

README.txt              what this is, which binaries it is the source of, how
                        to rebuild offline and how to relink
ffmpeg-n8.1.3/          FFmpeg at the tag, as `git archive` writes it, + VERSION
dav1d-1.5.4/            dav1d at the tag, as `git archive` writes it
recipe/
  media/src/main/cmake/ the superbuild: CMakeLists.txt, package.cmake,
                        checkout.cmake, ffmpeg_layout.c
  gradle/libs.versions.toml   where it reads the tags and commits from
notices/<target>/ffmpeg-NOTICE.txt   each published target's exact configure line
licenses/ffmpeg.txt, licenses/dav1d.txt
META-INF/               Goldberry's LICENSE, NOTICE and third-party notices

The trees come from git, checked against pinned commits. :media:ffmpegSourcesJar uses UpstreamSource in build-logic for each upstream. It shallow-fetches the tag into a bare repository under media/.deps/sources, which outlives build/ so that later archives need no network. It refuses the tag unless it names the commit pinned beside it in the catalog (ffmpegCommit, dav1dCommit). Then it runs git archive on that commit. The clone’s info/attributes turns off export-ignore and export-subst, so a future upstream attribute cannot leave a file out or rewrite one. The superbuild runs the same check: checkout.cmake, as an ExternalProject step between download and configure, refuses a clone that is not at the pinned commit. The binaries and the jar are each checked against the same commit, so the published source is the built source.

Two inputs were possible, and both meet “reproducible”. git archive of a commit is a pure function of the commit, and the jar’s timestamps, order and modes are fixed: the archive uses tar.umask=0022 and the files that do not come from git are 0644. Two builds of the jar, the second from a fresh clone, gave the same SHA-256. Only the git trees met “exactly what was built”. FFmpeg’s release tarball is made by FFmpeg’s release process, not from the checkout this build compiles. It contains a VERSION file the tag does not, and nothing guarantees the rest is identical. A checksum committed for it would prove that the tarball is the tarball, not that it is our source. A pinned commit id is the same kind of integrity check as a checksum: it is a hash of the content and its history, and it is the id the build itself checks.

The jar adds one file to the trees, ffmpeg-n8.1.3/VERSION, which holds the tag. Outside a checkout, FFmpeg’s version.sh falls back to that file, as its release tarballs do. With it, a rebuild reports n8.1.3 as the shipped libraries do, and not the 8.1.3 in RELEASE. The README says this is the one file that does not come from git.

The superbuild builds from the jar with nothing fetched. -DGOLDBERRY_FFMPEG_SOURCE_DIR and -DGOLDBERRY_DAV1D_SOURCE_DIR replace each clone with a tree on disk. The commit check is skipped there, because that check was made when the jar was. The README gives the whole command line, and the recipe is the same code the published builds ran, so it can rebuild them. On linux-x64, an unpacked jar rebuilt with nothing fetched. The five libraries came to 6696 KB, against 6708 KB from the git checkout, ffmpeg-layout.properties was identical byte for byte, and libavutil reported n8.1.3.

Publication refuses the binaries without the source. goldberry.publish calls CorrespondingSource.require for every AbstractPublishToMaven task in the graph. It runs when the task graph is ready, before any task runs, and covers mavenLocal and Central alike. Any ffmpeg-<target> classifier without ffmpeg-sources fails the build, for snapshots and releases alike, with a message that names this ADR. The check is placed there and not in :media’s publish task because modules upload one after another. A refusal inside the ninth module would leave eight on Central, which is what happened with the javadoc in ADR-0405. media/build.gradle attaches the jar wherever it attaches a target, and removing that line is what the check catches.

The jar needs git and the network, not FFmpeg. It fetches and does not compile, so publish.yml’s Java-only maven job builds it with no change to the workflow. The fetch reaches git.ffmpeg.org and code.videolan.org, the hosts the Media workflow already clones from.

The notices say where the source is. ffmpeg-NOTICE.txt, written by package.cmake into every binary jar, now gives each upstream’s commit and names the ffmpeg-sources classifier of the same version. NOTICE, which every Goldberry jar carries, and THIRD-PARTY-NOTICES.md point to it too.

Consequences

  • Every version on Central carries about 24 MB more. A snapshot replaces the previous one, so the snapshot repository does not accumulate copies.
  • The maven job now depends on two more hosts being up. A failed fetch fails the publication, which is the right result: publishing the binaries without their source is what this decision rules out.
  • Moving the ffmpeg or dav1d tag also means moving its commit pin (git ls-remote <repository> 'refs/tags/<tag>^{}'). If it is forgotten, the superbuild’s first clone fails, before anything is compiled.
  • A rebuild from the jar is not byte-identical to the published libraries. FFmpeg stores its configure command in libavutil, including the install prefix and the pkg-config path (avutil_configuration()). dav1d names its version 1.5.4 rather than 1.5.4-0-g54706fc when there is no .git, and the build directories end up in the files. The README says so. What Goldberry reads the libraries by, the layout file, comes out identical.
  • Only the linux-x64 rebuild from the jar has been run. The Windows and macOS steps are the superbuild’s own steps, with the same caveats: the Windows superbuild has not been built yet (ADR-0495).
  • An application that redistributes these binaries takes on §4 itself. Shipping the ffmpeg-sources jar beside them meets it, and THIRD-PARTY-NOTICES.md says so.
  • UpstreamSourceTest makes a repository with git, archives a tag through export-ignore and export-subst, refuses a moved tag, and archives again offline. CorrespondingSourceTest refuses binaries without the source and checks that the convention, :media, the catalog and the three notices are wired to it.

Alternatives considered

  • Upstream release tarballs, verified against a committed checksum. This is reproducible, but not of the source that was built. The superbuild compiles a git checkout, and FFmpeg’s tarball is a separate product of FFmpeg’s release process, with at least a VERSION file the tag does not have. Changing the superbuild to build from tarballs would make the two match, at the cost of a second fetch path for the build and a checksum to maintain beside a tag. The commit pin already gives a hash, and it is the one the build checks.
  • B: link to upstream (ffmpeg.org/releases, git.ffmpeg.org). §4 accepts access “from the same place” as the object code. Another organisation’s server is not that place, and it can drop or move a tag without notice. A link also gives neither the recipe §0 counts as part of the source nor a single pinned dav1d. FFmpeg’s own legal checklist asks distributors to host the source themselves.
  • C: a written offer. §4 does not offer this option to someone distributing the Library itself. The offer is a §6(c) mechanism for works that use it, and the GPL’s version has to be honoured for three years by whoever receives a request. That makes it a process someone has to keep running, which costs more than 24 MB on Central.
  • D: don’t ship FFmpeg on Central. ADR-0495 put it there because a goldberry-media with nothing to load fails on every machine that adds it. Leaving the binaries out to avoid publishing their source would bring that back, for snapshots too.

ADR-0509: goldberry.dev is the landing page, and the book is its /docs/

  • Status: Accepted
  • Date: 2026-10-01
  • Relates to: docs/site.md, site/README.md, .github/workflows/pages.yml

Context

The project has a domain, goldberry.dev, and nothing on it: the registrar’s parking page answers there. Everything a newcomer can read is in the repository itself: the README, and this book read as Markdown on GitHub, where the cross-links between records work and the table of contents does not.

A landing page was written outside the repository: a static site/ folder (HTML, one stylesheet, content in content.js and news.js, a Node script that prerenders it for crawlers), and a Pages workflow that builds it together with this book. Three things had to be settled to bring it in:

  • Where it lives. A separate repository, a gh-pages branch, or a folder in this one.
  • How the domain reaches it. .dev is on the HSTS preload list, so a browser never speaks plain HTTP to it. The page is unreachable, not merely insecure, until GitHub has issued its certificate.
  • The book was not buildable. SUMMARY.md had the [Template] suffix chapter in the middle of the decision log, with ADR-0441 onwards listed after it. mdBook refuses a numbered list after a suffix chapter. Nothing built the book, so nothing noticed.

Decision

The landing page lives in site/ in this repository. pages.yml deploys it with GitHub Actions as the Pages source: site/ at /, and the mdBook in book/ at /docs/, as one artifact from one job. The page’s version is read from goldberryVersion in gradle.properties, its links into the book are checked against the built book before deploying, and a pull request builds without deploying.

The domain is the apex, goldberry.dev. It carries GitHub’s four A and four AAAA records, and www is a CNAME to digitalsmile.github.io so GitHub redirects it to the apex. The domain is set in the repository’s Pages settings. site/CNAME carries it too, for a reader of the repository, though an Actions deployment ignores the file. HTTPS is enforced as soon as the certificate exists.

mdBook is pinned (0.5.4) and downloaded as a release binary. The prerender is plain Node with no dependencies, so there is no package.json and no lock file to keep current. SiteTest in build-logic checks the same links against book/src without building anything. It also checks that the template stays the last line of SUMMARY.md.

Alternatives considered

  • A gh-pages branch, with mdBook’s cname option. It puts a second history beside master that nobody reviews, and the option writes CNAME into the book’s output root, which would be /docs/CNAME, not the site root.
  • A separate DigitalSmile.github.io repository. That would separate the version, the screenshots and the book links from the code they describe. The version would then be typed twice, once in each repository.
  • Netlify or Cloudflare Pages. Both work. Either is another account, another set of credentials, and another dashboard for a site that needs no server-side behaviour.
  • A static site generator (Hugo, Astro). The page is one HTML file and two content files. A generator would bring in a toolchain and a lock file to render them, when 120 lines of Node already do it.
  • The book at the root and the landing page somewhere else. The landing page is what a link preview and a search result show. The book is for a reader who has already decided to read.

Consequences

  • Every push to master that touches site/, book/ or gradle.properties deploys. A record added to the decision log is live a minute later. There is no separate release step for the site, and none for the book.
  • A weekly scheduled build keeps the stamped GitHub star count from going stale. It costs one cheap workflow run per week.
  • The book’s search index is 19.5 MB, almost all of it the decision log. That is acceptable for a reference and too much for a user guide that is loaded on every page. The next step, the book as documentation, has to settle it (docs/site.md, “Next: the book”).
  • The deployed artifact is about 43 MB, well inside the 1 GB Pages limit.
  • The domain is outside this repository’s control. Changing the DNS records, and verifying the domain on the GitHub account against takeover, are manual steps listed in docs/site.md. The page is not live until they are done.
  • book.toml now gives every page an “edit this page” link to its source on master.

ADR-NNNN: Title

  • Status: Proposed | Accepted | Superseded by ADR-NNNN
  • Date: YYYY-MM-DD
  • Relates to: docs/ARCHITECTURE.md §N

Context

What forces are at play? What makes this a decision rather than an obvious call? State the constraints honestly, including the ones that are about time or taste rather than engineering.

Decision

What was decided, in the active voice. One paragraph if possible.

Alternatives considered

What else was on the table, and the specific reason each was rejected. “It was worse” is not a reason.

Consequences

What becomes easy, what becomes hard, and what is now expensive to reverse. List the costs, not only the benefits — this is the section future readers come for.