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
| Document | Answers |
|---|---|
docs/ARCHITECTURE.md | What the system is, layer by layer |
book/src/adr/ | Why each significant choice was made, and what it costs |
README.md | How 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.
| Milestone | State | In one line |
|---|---|---|
| Foundation | done | The build, the module graph, the toolchain and the decision log |
| M0 — Skeleton | done | One native library on four targets, two backends, a window at the right fractional DPI |
| M1 — Vertical slice | built, unproven | Blend2D 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 & style | done | CSS, KDL, the three trees, input, motion — and every §3 control, select included |
| M3 — Shell | started | The 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 seal | done | Drawing, 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 — GPU | started | Windows 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 — Hardening | started | Text 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 modules | started | The 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
libgoldberryexporting exactly the symbols on the export list and nothing else — both Linux targets in CI’s manylinux containers,macos-aarch64on an Apple Silicon runner, andwindows-x64under MSVC. The layout probe passes against the real library, and Yoga’s measure callback crosses in both directions including theYGSizestruct-by-value return (ADR-0017), so the hand-written binding mechanism is proven end to end. - Windows closed the milestone:
goldberry.dllbuilds,:natives:testpasses against it withgoldberry.native.required=trueso nothing skips, and the golden images match — which answers the MSVC/INCLUDE:and.defbranch of the export machinery and Win64’s 4-bytelongat 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
headlessbackend and thesdl3backend 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
Windowfront 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.
Frameno longer writes pixels by hand: it wraps the platform’s own buffer in aBLImagewithout 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 JavaString, so the cluster indices point back into the caller’s own text (ADR-0032). - Text draws. Blend2D’s font chain is bound and a
GlyphRunreaches the rasterizer:Fontin:coreowns 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:
BoxPainterlays 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 intogoldberry-core(ADR-0033)
Text in a layout
- Text takes part in layout. A
Paragraphshapes once and wraps with arithmetic over that oneGlyphRun, so its measure function answers Yoga from inside a layout pass without shaping again (ADR-0036). ABoxwith 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 benchmarkmeasures the text path, and the numbers say the upcall crossing is ~0.3 µs, a memoised wrap 0.02 µs, and shaping 56 µs — soParagraphCachecaches 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.SvgPathDataanchors every subpath before the join andIconCompilerrefuses 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:FontFaceholds 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.updatethrew a node’s whole subtree’s cached styles away on every re-description — so ascrollmoving 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 leavestype,idandclassesalone cannot change what a selector matches anywhere below it, because those are three of the five questionsStyleElementlets one ask and the other two live on the element; and what is left isStyled.restyle, which the whole catalog overrides twice, asked once per widget class through aClassValue. 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.StyleCacheTestasserts each guard againstElement.cachedStylerather 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
fillRectand expensive for a stroked icon path and a shaped glyph run. Each render object now carries theInkits 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 atransformmoves 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 atransformon 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/boxesCulledare counted forlayersRepainted’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 atflex-wrap,align-self,limits,overfloworelevated, so a row that starts wrapping moved every child without being called changed; all five are compared now, which also givesoverflowandelevatedthe 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_dandbl_context_set_global_alphafor layers, thenbl_context_clip_to_rect_dandbl_context_restore_clippingfor the partial repaint. Nothing else was needed — the offscreen pixels are aPixelBufferallocated in Java and wrapped with the already-exportedbl_image_init_as_from_data, which is the principle the export list states in its own comment.BlendLayerTestis seven pixel assertions that cannot pass unless both really exported, and the ELF, MSVC.defand 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.ymlopens 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.capturetook a frame and a box tree and built a whole second Yoga tree to answer.HitTest.capture(RenderTree)reads the passupdatealready 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_GetWindowSurfacefalls back to a hiddenSDL_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_GetDisplayForWindowandSDL_GetCurrentDisplayMode, withSDL_DisplayModeverified 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’sthread_countis 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’sTaken. Up to four workers, on any surface over 400×300. — ADR-0037, ADR-0031, ADR-0042thread_countwas parked in ADR-0031 as “only matters if paint ever becomes the bottleneck”; on these numbers it has.
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 aComputedStylethat 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
:hoversurvives a parent re-describing its child; state lives on the element,setStatemutates 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 aBoxtree 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,outlineandopacityreachBox, and a rounded rectangle is built from four cubics through the already-exportedbl_path_cubic_torather 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
StyleResolverinherited custom properties and nothing else: the label is atextchild element no rule names, so it resolved toComputedStyle.INITIAL’s black.buttonhad never shown it, because it copiesstyle.color()onto its child boxes by hand and bypasses the cascade.colorand the typography now inherit down the element tree — andcursordeliberately 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.WidgetRendererresolves 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-weightandline-heightreachComputedStyle; aFontsbook 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
wghtneeds 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). positionandinsetreached the cascade — §8 has listed them andYogaNodehas bound them since the beginning, and nothing had needed a box that sits over its siblings rather than beside them.flex-basiswas implemented and taken back out:flex-basis: 0gives 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-*tokensbutton.ghostuses 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:selectand custom image cursors
Binding — §9’s other half
bindis done, which closes the second half of §9’s wiring. AProperty<T>is a cell with listeners and nothing else —get,set,subscribe— andsetdoes nothing when the value is unchanged, which is what makes two properties mirroring each other settle instead of recursing.Bindingsis the third registry besideActionsandIconsand is deliberately the same shape: markup names a path, the registry resolves it, strict by default.- A path is
prefs.frostand nothing else — the §17 fork is settled at dotted paths, enforced by the registry, sobind="!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 andpanel > textstyles it exactly like an unbound one; a change marks the element dirty by the same routesetStatedoes, 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
Observablehalf 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(),:hovermoves along the whole ancestor chain and only where it differs,:activefollows the press, and focus walks up to the nearest focusable ancestor with:focusand:focus-visiblekept distinct (ADR-0054). The sdl3 backend translates all of it — motion, buttons, wheel, keys and committed text — andGoldberryRuntimedrives 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
:activecannot get stuck; an explicit capture outlives the release, for a gesture that does (ADR-0058). - The cursor rides on the painted box:
cursor: pointerresolves 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 ownCtrl+A; letters and digits joinedKeyfor 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 CSStransitionresolved 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— andtransition: width 200msis 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:activeand 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.pngis 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-densityships, 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.cssis a three-token:rootblock 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.REGULARships no stylesheet: a default is the absence of an override, and adensity-regular.cssrestating 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: 1and awidth: 36pxwas a preferred width a cramped row could take back. §8 listsflex-grow/shrink/basisand 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 sinceborder-radiusfollows 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-shrinkis implemented now with no native symbol and no new binding —YGNodeStyleSetFlexShrinkwas already exported and bound, so the gap was in the CSS engine alone — and the controls declareflex-shrink: 0once 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
textthat 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.
buttonships 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 onSpace/Enter, ignoring repeats. Theactionhalf of §9 is wired: markup names an action and anActionsregistry resolves it, strict by default so a typo fails at inflation rather than producing a button that silently does nothing.paddinggrew CSS’s 1–4 value shorthand and its four longhands on the way, becausepadding: 0 12pxis the button’s own metric. buttonis finished, not started: label, icon, or both — an icon is aBoxnow, 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.disabledrefuses 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: anIconowns 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.
buttoncomplies with its own metrics row (§3): radius 8, the design system’s focus ring — 2px--gb-focusat a 2px offset, following the radius, written once for every control rather than per control — and:disabledas 45% opacity rather than a colour remap (§2.1), so a disableddangerbutton 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):hoverand:not()is not in §8’s subset, soPointerRouterrefuses to set:hoveror:activeon a disabled widget — one choke point, every control, forever.buttonis now fully compliant with its §3 row:body-strongwas the last of the four thingscontrols.csssaid it could not express. The theme tokens were also wrong and are now §1.4’s exactly —headingwas 16 where the table says 15,bodywas 14 where it says 13, there were no line-height tokens at all, anddocs/ARCHITECTURE.md§10.1 carried a different table with alabeltoken at weight 500 that no shipped face can draw; §1.4 won and §10.1 records that it did.
checkbox
checkboxships: three states with:indeterminateas its own pseudo-class, because two cannot describe three and folding mixed into:checkedmakes every rule that meant “the tick is showing” silently wrong; a tick and a dash drawn by the painter rather than by anIcon, since a widget is a value and anIconowns native memory; a click target that includes the label;Spaceand deliberately notEnter, which belongs to a dialog’s default action. Its glyph is the first part —check-indicatoris 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 oneComputedStylecannot 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.
radioandradio-groupare the third and fourth controls, and the first widget that is a set rather than a control — so three things that were trivially true forbuttonandcheckboxstop 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, sincemoveFocuscollected 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
selectedis 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
fromKeyboardhalf of the newonFocusChangedis 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.Actionsgains a valued binding, the first action told which one —Consumer<String>over thevaluethe document already wrote, with a plainRunnablestill resolving against it and a valued action refused for apress=rather than called with an invented argument.radio-indicatoris 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: 8pxon a 16px box is one, through the four cubics ADR-0064 already ships, so no native symbol was added andBox.Mark.DOTfinally 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.inlinekept hugging its label — the same widget with two hit targets depending on a class, which no value assertion would have shown (ADR-0073). radiois 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 whichcheckboxshared — §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-controlborder-radius: 4px, which §2.2’s ring follows rather than drawing a square one besidebutton’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.inlinetakes 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::activewas 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
toggleships, 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 theTogglethat 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 isNaNand not zero with no button held, because zero is a real answer (a press that did not move) andMath.abs(NaN) >= 8isfalse, 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-trackandtoggle-thumbare the fifth and sixth parts, the thumb by ADR-0073’s argument that the unit of independent movement is a node — atransformapplies 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.
nord0was identical to--gb-bg;nord3was 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 isnord10rather 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
sliderships, withfaderas 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 — andtransformis not merely awkward here but unable: CSS percentages insidetranslateare a proportion of the moving box, sotranslate(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 isPointerEvent.local(), the direct sibling of ADR-0075’sdragX(): 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 frommin(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 ownsRightand letting it through would move focus off the control being adjusted (ADR-0079).slideris finished against §3 rather than merely shipped. Its three optional halves — “optional tick marks and value label”, andfader’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 ──── ] 40is 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-trackis now the full-height box the value is measured along and the 4px channel isslider-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-ticksisheight: 0and each mark is moved clear by atransform, 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 perstep(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). formatis 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%dagainst a double fails at inflation rather than out of a paint, and formatted inLocale.ROOT, because the default would draw0,5on ade_DEmachine and the golden that failed would be unreproducible anywhere else.Scaleis a sealed interface of records —LinearandDecibels(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.SliderGeometryTestis 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 explicitheight: 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
progressandspinnership, 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@keyframesand is not going to grow one. §1.7 namesAnimationControllerfor 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 theopening → open → closing → removedsequence 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, andPaints.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 atransform(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 needsoverflow: hiddenand 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 aBox.Markand its arc is three cubics through the already-exportedbl_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
badgeships, 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-warningis 1.35:1, on--gb-success1.77 and on--gb-info2.34, so three of the four hues need the opposite end of the palette from the one the dark theme is built on —--nord0text 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-dangerneeds something that is not in the palette at all: it is 3.55:1 under--nord6and 3.05 under--nord0, legible against neither, so the badge’s fill is--nord11derived 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 abadgerow before a single number reachedcontrols.css(Principle 3), and every one of them is derived rather than picked: 20 is on §1.3’s ramp and is the heighttoggle-trackalready uses, soborder-radius: 10pxis §1.5’sfullspelled the way that part already spells it.ContrastTestresolves 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
knobships, which is the tenth control and the first whose drag is a rate. It looked like a slider bent into a circle – samemin/max/step, same keyboard map, samebindandchange– 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 asPointerEvent.anchor().NaNoutside a gesture, which isdragX()’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, soGestureAnchorTestis 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_GetModStatejoins the export list, the first new symbol since ADR-0086, because pointer events carried no modifiers anywhere – not inPointerEvent, not in the SPI, not from SDL, whose mouse events have nomodfield 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.Markgainedstartandsweep, makingARCthe 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, becauseArc.addTowas already general and already fed by ADR-0064’s cubics; the rule holds for the sixth time.- Detents are magnetic and
stepis 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 ownbackgroundand 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.KnobDialexists because of it, and the knob went intocontrols-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.Markgained aPOINTERkind, a radial line at the value’s angle, drawn as a mark onknob-dialrather than as a part of its own — the first time that has been the right answer sinceCheckMarkwent 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
knobnamesknob-dialas itslocalPart()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 onCLICKEDand notPRESSED, 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 isToggle’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
segmentedships, 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 sharesradio-group’s model and invariant exactly and “isradio-groupwith 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 arithmetictoggle‘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 indicatortranslate+width between segments”:widthis not on §1.7’s whitelist and never will be, and thetranslatewould 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, onfast, which is whatlistselection already does — and the travelling version waits fortabs, 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). Bothdesign-system.mdrows were amended rather than left describing something that does not exist. What is new in Java is one line:focusScope()isHORIZONTALwhereradio-group’s isBOTH, and that single difference is the whole of why these are two widgets rather thanradio-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, soUp/Downare 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 isoption— the node §3 writes for this control and forselect— 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:hoveris 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 —checkboxandtogglespend a descendant selector on the identical problem because their fill is on a part. Andflex-grow: 1on a segment is the same questionradio-groupanswered withalign-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 atransformis 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 inrenderarrives 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. SoStyled.restyleexists: §8’sinlinecascade 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 issegmented → 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 isslidergrowing 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.
exportsgoverns types; a file inside a package of a named module is invisible to other modules unless the package isopens. So the toolkit could not read the showcase’s ownshowcase.css, and the error blamed the file. The message now checks whether the owning package is open and names the missingopensline when it is not. The showcase opens its package to:coreonly — 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:hoverthat repaints itself. -
An application is a root widget, and the showcase’s
mainis 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 trailingGoldberry.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 isGoldberry.launch’s now: an application implementsApplication— one required method,root()— and gets back aHostwithrepaint,restyle,title,shortcut,fontsand a named escape hatch to the window.restyle()is separate fromrepaint()and is the one piece of state the launcher keeps for the application: re-readingstylesheets()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 —Attributedgivesid,styledandkeyed,Bindablegivesbound, both self-typed sonew Badge("3").styled("danger")is still aBadge— and a widget supplies the one line only it can,withAttributes. Containers take children as varargs, soList.ofis gone from the showcase entirely. And an application’s CSS and markup are resources now:Stylesheet.resourceandKdlParser.resourceread 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
newsurvived 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 nowShowcase(theApplication: lifecycle, stylesheets, registries, accelerators),ShowcaseModel(properties, the methods that change them, and the two registries markup resolves against), andui.Screen/ui.Panes/ui.Content.titlebar.kdlandsidebar.kdlcarry everything declarative, which is the first time §9’s markup path has run in a window with all three registries live:bind=,change=,press=andicon=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.Contentstays 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 evaluateclicks == 0would be code in a data file with no stack trace.ShowcaseDocumentsTestasserts the shape rather than trusting the window: an emptysidebar.kdlinflates 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 (abind=resolving to nothing still renders a control that never moves). OnColumn.of()againstnew Column():newstays, 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 — soof()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,new45.2 ms againstof45.1 ms, identical within noise, because-XX:+PrintInliningshows the factory inlined (Box::of (10 bytes) inline (hot)) — the first attempt at that benchmark said 87 against 46 and was wrong, with aString.equalsinside the loop. What did get named is the ambiguous overload:Sliderhad two five-argument constructors differing only in whether the fourth parameter was adoubleor anObservable, andKnob,ToggleandProgresshad the same shape — nowSlider.of,Knob.of,Toggle.of,Progress.of, following theof= 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 theDouble.parseDoublea 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#handlermagic”) — rightly, since it would need the application’s packageopens, cost start-up, and leave the same silent control. So@Bind,@Actionand@Registryare 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 — aprivatemember the generated code cannot see (with the fix in the message), a@Bindon something that is not aProperty, two members claiming one path, an@Actiontaking 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 areSOURCE-retained so nothing at run time can be tempted to read them — ADR-0096 -
A shortcut is built from enums, and
Modifiersis 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.Modifiershad 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 aModenum now with a real bitmask, composed asMod.CTRL.and(Mod.SHIFT).and(Key.Z).Mod.CTRL | Key.Ais not reachable:|is defined for the integral types andbooleanand Java does not allow overloading it, and the spelling that would compile —Mod.CTRL.bit() | Mod.SHIFT.bit()into a method taking anint— is a mask with nothing checking it, whereKey.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, andandcan only ever produceModifiersor aShortcut.Modifiersis oneintwithhas/only/seton 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 -
:coreships no widgets, and its own tests stopped needing any.text,row,column,panelandspacerwere nested records inside aWidgetsclass 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:widgetsreached thirty types with a package per control, they were the only widgets in a module that is not a widget toolkit — andcore-widgets.mdhad 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.Attributesstayed, 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:StyleCacheTestandBindingTestreach intoElement’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 patternDragOriginTestalready established, and nothing inStyleCacheTestany longer looks like a fact aboutpanel.BindingTestsplit along a seam that turned out to be real: readingbind=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.
ImmutabilityTestasserts 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, thatAttributescopies 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 anObservableand 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@Actiononly the markup calls has no business being part of a model’s API. A private member now gets aVarHandleor aMethodHandle, looked up once in the generated class’s static initializer throughprivateLookupIn— which needs noopensand nosetAccessible, because the generated class is in the target’s own package and a module always opens its packages to itself. This is not theMethodHandles.Lookupalternative 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, andShowcaseModel’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)isclicks++with three extra tokens and a heap object, and because the field was aProperty, 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;andclicks++— and the build rewrites that oneputfieldinto 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:putfieldis not virtual, so no subclass or proxy can see the write. The@Actionhalf moved with it — oneinvokedynamicper action, bootstrapped byLambdaMetafactory, written into the model’s own class, which is byte for byte the call sitejavacemits formodel::click. That deletes both the:processormodule and the generated…Registrysource file, and it deletes ADR-0098’sprivateLookupInalong 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 aLambdaMetafactorycall 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 aProperty<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: astaticorfinal@Bindfield, an array (only assignment is observed, sovalues[0] = xwould notify nobody), a malformed path, two members claiming one name, an@Actiontaking two arguments or one the toolkit cannot parse, an abstract or empty@Model, a@Modelextending 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, andLambdaMetafactoryis used as aninvokedynamicbootstrap rather than as a method call — the one form the image builder resolves when it builds the image.NativeImageComplianceTestparses the woven bytecode and asserts it: noClass.forName,setAccessible,privateLookupIn,findVarHandle,defineHiddenClassorMethod.invoke; every bootstrap isLambdaMetafactory.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;:nativesand 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@Bindfield changing is the frame request now, subscribed to withModels.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:onRestylebecame two subscriptions to the two paths a stylesheet depends on, which is what madedensityworth binding even though nothing displays it. The second: ninepublic 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 lookupbind="app.tab"does, and the weaver now caches theBindingsit builds so a path lookup while building a widget costs a map get.Actionsis 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.ShowcaseModelwent 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.inflaterwas 300 lines ofinflater.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, theString.valueOfchange adapter three. Each widget now has astatic Widget inflate(KdlNode, List<Widget>, Wiring)beside its record,Wiringcarries the three registries and the readings that were repeated, andInflatable.Catalogbinds one wiring so the table iscatalog.add("button", Button::inflate). A class rather than aMap, because the registration order is the order an unknown node is reported against.Primitivesuses 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, sojava -jar goldberry-weaver.jar target/classesis a complete integration — verified end to end against a class compiled outside this build. Gradle gets thegoldberry.weaveplugin, which hangs the weave offclassesandtestClassessojar,runand everyTesttask reach through it and unwoven output cannot be consumed. Maven has no first-class plugin:exec-maven-pluginbound toprocess-classesruns 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 writeMETA-INF/maven/plugin.xmlitself — not built, and said plainly rather than implied -
A widget package announces itself, and a model wires itself. The catalog ADR-0130 left in
Controlswas still nineteen hand-written lines naming exactly the widgets:widgetshappens 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 aWidgetCatalog, patchesprovidesinto the module’s ownmodule-info.class, and writes aMETA-INF/servicesentry for the class-path case.ServiceLoaderfinds 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 —Showcaseis itself a@Modelnow, and theShowcase.actions(model, openMenu, toggleHud)static that used to merge them by hand is gone.Controlsis 136 lines and has noinflaterat all;Primitives.inflateris gone entirely, because the structural widgets carry@Markuplike 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 readingActionsas 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 afterstart, so an application says nothing about either.@Model(repaint = false)turns the frame request off for a model the UI does not show. APropertyfield 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@Bindfield, 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@Bindfield 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 anIllegalAccessErrorat the first click. The bug that found the implementation was mine: composing twotransformingMethodBodieswith 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.
ShowcaseModelis 125 lines of fields and four projections;ShowcaseActionsis 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 costsprivateon 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.startbuilt its inflater from a hand-written list of models whilemodels()returned a different one, soapp.toggle-themewas 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:startbuilds the inflater frommodels(), 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 anActionsrecord nested inside the values reads aprivatefield with an ordinarygetfield— 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 bothprivateagain. The second:@Modelsat on top ofimplements 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 aWindowActionsrecord ofRunnables now, so it knows what they are called and nothing about who performs them (ADR-0137, ADR-0138) -
Actions are annotated as actions.
@Modelmarked two different things: a class of@Bindvalues, and a class of@Actionmethods that operates on somebody else’s values and holds nothing at all. ADR-0138 had just made that mislabelling more visible by extracting aWindowActionsrecord whose entire content is actions and whose annotation said “model”. There is an@Actionsmarker now, with three build-time rules — a@Bindfield on one is refused (“a class that holds values is a @Model”), an@Actionswith no@Actionis refused, and carrying both markers is refused. A@Modelmay 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, soBindingsandActions— the two runtime registries — becameBindingRegistryandActionRegistry: a rename made to free a name, which is a bad reason, and an improvement for a better one, since one package heldBind,Bindings,ActionandActionswhere 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 calledActionsshadows the annotation, so the showcase writes the fully-qualified name, and the guide recommends naming the type for its domain instead (ADR-0139) -
selectis built, and it is the last control in §3. The value model needed nothing new — it issegmented’s, which isradio-group’s, which §3 says outright — and everything else it needed had arrived in the last month:scrollfor a list longer than the screen,host.popupfor 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, soMenus.open(host, …)is right (ADR-0106); opening a dropdown is something the control does, and there is no application code on aselect bind="app.theme"line to hold a window with.BuildContext.host()is the door — Flutter’sOverlay.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 anOptionalbecause 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) -
optionmoved, and the move cost one flag. §3 givessegmentedandselectthe 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 optionagainstselect-list option— and which of §3’s two keyboards the set has, which the specification states in as many words: aradio-grouphas “arrow keys move selection (roving focus)” and aselecthas “arrows, Enter/Esc”.Option.inAList()is that, and it also unlocksEnter, 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 firstDownin 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
selectat 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. AndPopup.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 answerDownwith 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.
SelectFieldisLocated, so it is told where the last frame painted it and the state opens the popup there. Anchoring byidwas the alternative and is worse: aselecta document gave noidwould 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 throughchange. The gaps are stated rather than implied: typeahead works closed and not open, because aTextEventgoes 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; andmultiple,autocompleteandtreeare unbuilt, two of them waiting ontext-inputandtreerather 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
renderof 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:restyleruns after the cache, by design, and every widget that writes an inline value allocates a freshComputedStyleevery frame whether or not anything moved —ScrollContent’s isresolved.flexShrink(0), unconditionally. So every node under ascrollre-resolved every frame, and in the showcase every screen is inside ascroll. 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 whoserestyleallocates, 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
selectstretched 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 — sohost.popuptakes 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 ofPlacement, 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’scontrolsimages 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-leadis 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. Sohudgrew four readings —build,style,layout,raster, one word for the set of them (readings="stages") — timed by fivenanoTimecalls in the painter and kept in the same 60-frame ring as the rate. They deliberately do not add up topaint: 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, because0.0 mscannot 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.
FrameBudgetTestmeasures 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 —stylemeasures 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 aroundpaintmeasures submitting a frame, which is how the first run reported a 4K raster as cheaper than an 800×600 one.FrameBenchmarkstays: 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: centerputs 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 isoption’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
stylesat at 12 ms a frame.:hoverand:activeapply to the whole ancestor chain —.card:hover .titlehas 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.StyleResolvernow indexes, once, which pseudo-classes appear to the left of a combinator and on what type, socheckbox:hover check-indicatormakes:hoveron acheckboxreach down and nothing makes:hoveron acolumndo 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 msreads 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, withframebesidepaintand a caption under both. Every reading carries a budget — shares of a 60 Hz frame — and reportsok,nearoroveras 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 itsrenderruns and the statistics only arrive inrender, 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=trueis 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 — onestatic final booleanthe JIT folds away — and a system property rather than a log level, because anisTraceEnabled()per element per frame is a diagnostic measuring itself.-Dgoldberry.trace.input=trueis 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 andstylewas 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 atextnode as readily as for abutton. 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, becauseresolveasked 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, andresolvecascades 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.
framewas 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_GetCurrentDisplayModereports what the display does, and what a loop achieved is not a thing any platform knows. Sorefreshis asked for rather than counted:SdlVideo.refreshRatewas 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.fpsstays 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 onedisplay, and.displayis §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
hudwas 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 ismin / mean / maxnow, 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.FrameStatsgrew aSpan, 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— andthis hud includedis 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 judgedstyleMillis()while the row printedstyle(), and the over-budget golden came out withstyle 4.80 / 9.60 / 38.40 msdrawn 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
hudis its first occupant. Every window’s element tree is rooted at awindow-rootwhose 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 aCornerwith 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 aPropertythe launcher owns and the root watches through thebinding()every widget already has (§9’sbind, 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
hudis §7’s first widget and the first in the catalog that is about the toolkit rather than the application:60 fpsandpaint 2.1 ms, read off a 60-frame ringWindow.paintnow writes unconditionally — twonanoTimecalls a frame, where before every timing was behindLOG.isTraceEnabled()and watching a rate meant measuring a loop that was also writing a line per frame. The numbers travel downPaints.Contextbeside the frame clock, which is what lets a barehudnode 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
HUDbutton orCtrl+F, off by default, which is also what keeps a machine-dependent number out of §14’s image corpus.
Popup windows
- 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 — soSdl3Windowbecamesealed … permits Sdl3Popuprather than growing a boolean, and popups are inwindows()because shutdown enumerates windows. - It returns an
Optionaland 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’sdummy, 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_TOOLTIPalone does not stop a popup taking focus —NOT_FOCUSABLEis a separate flag, and §7’s “shows on keyboard focus, never focusable itself” is false without it; and0x80000000turned out to be the first constant in the toolkit with the top bit set, which the layout probe read into a signedintand 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, andHeadlessPopupdefers 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
Popupis an element tree, a render tree and a pointer router of its own, in a window of its own — wrapped in the sameWindowthe 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
Escapebelongs to no control in particular.Windowgrew one package-privateInputWatcher, called before routing; the launcher watches the owner window and the popup watches its own forEscape, 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 sameHitTestcapture 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’stourasks for it and because an application holds ids; apopoveranchoring 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,headlessincluded, because that is where the bug would otherwise pass. - The showcase demonstrates both, one button each:
Menuopens a real platform popup under its own button and is free of the window’s bounds, andHUDfloats ahudin 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.measurelays a tree out with no surface — two floats rather than aLogicalSize, because “undefined” is what has to be expressible and a size refusesNaN. 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, then960×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.rendergrew to fill its window, and a growing root fills a definite available size (ADR-0104). Placementis 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_GetDisplayUsableBoundsexcludes whatever the desktop reserved, and the difference between the two rectangles is exactly the taskbar a menu would otherwise open underneath.BackendWindowgainedworkArea()andposition(), bothOptionalbecause some drivers will not say; the launcher translates the first by the second so placement works entirely in the window’s own coordinates.HeadlessBackendhas 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.keyPressedreturns a boolean now, andtruetakes 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. popoveris 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 isHost.popup, which servestooltip,selectandmenuequally and is not a popover. The showcase’sMenubutton opens one withhost.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
Attributesbesideid,classand 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 abuttonhas 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
Attributesbroke every wither, silently.id(),classes()andkey()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,
pointingChangedfound it unchanged and returned early, and the tooltip sat there until something else took the focus. The fallback now asksPointerRouter.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.pointingChangedis the only caller ofhideTooltip, 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 forhoveredthe rule ADR-0180 already enforced forfocused— 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).
menu, item and separator
- 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 aHost, 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 whatradio-groupdoes to itsradiochildren (ADR-0106, ADR-0073). - A nested
itemis the submenu syntax, so there is nosubmenunode 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.anchoranswers from the main window’s geometry and knows nothing about what is in a popup, soPopupgained one that translates by its own offset — without which a submenu opens at the right place relative to the wrong origin. - A
menuis a vertical focus scope.UpandDownmove between rows;LeftandRightare deliberately not traversal, because in a menu they mean “close this submenu” and “open that one”.Escapewas 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
menubarneeds 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
:corecan 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. SoHost.onContextMenuhands over the name and the point, andMenus.contextMenus(host, map)is the line an application writes (ADR-0108). - The name rides on
Attributes, besideid,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.pressedreturns 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
bindand reports three things —change,close,new— and the application answers all of them, which isradio-group’s shape extended to a set whose membership changes. A strip whoseclosehandler does nothing keeps its tab, which is the visible form of “the model did not change”. There is noaddTab, 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 throughrestyle, so the stylesheet still decides what the colour means:controls.cssputs 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 newCssColor.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-bottomandcurrentColor; §8’s subset has onebordercovering all four edges and nocurrentColor, 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 issegmented-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;
Deleteon 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 —
CROSSandPLUS— 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 aPropertynow 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. Themarginthat 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
Tabshas 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, becauseTabis 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
Tabsstateful put twotabsnodes 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.
The showcase is a gallery
- 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.mdasks 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=andchange=. - The gallery’s own selection is an ordinary bound value, so
Ctrl+1…Ctrl+5and 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 aspinneron 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.
BoxPainterdrew 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— forOption’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_TRANSPARENTnow and their frame is cleared to transparent — the surface format was checked rather than assumed, and X11 hands backARGB8888for 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
pointercursor, 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-outis not one of §1.7’s easings (they areease-enterandease-exit) andbackgroundis not transitionable (background-coloris). Two rules got both wrong and the engine said so once per node per frame. align-selfis 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
onOpenSubmenuonly 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 handedonHoverednow 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 —onOpenSubmenudescribed what the caller wanted,onHovereddescribes 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:focuslit it. Focus and the highlight are two things:moveFocustakes afromKeyboardflag now, and the highlight isitem:focus-visible, which is what §2.2 defined that pseudo-class to mean. Open a menu with the mouse and nothing is picked out; pressDownand the row it lands on lights up. - A tooltip takes
bodyrather thancaption, 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
checkedto 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
CROSSandPLUSrather than Lucide’schevron-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
listfor amasonrybecause 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;columnsForis, from a width the screen measures itself. It was doing the chunking, which is four lines. The sheet is aListViewover 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 aYGNodeand 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, andModelsthrew 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 — aVarHandleper@Bindfield, aMethodHandleper@Action— andModelspicks 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@Actionsrecord writes to the model beside it), at the top of every frame over the modelsApplication.models()named, and whereverModels.refreshis called. The showcase needed exactly one of those calls — a background job’s continuation — and it is the one visible cost.RuntimeAgreesWithWovenTestis 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:@Markupwidgets have no runtime equivalent — finding them means scanning the path, which is what aprovidesexists to avoid — soWeaverMaingrew--modelsand--catalog, and the catalog stays hung offclassesfor 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 toopensits package to the toolkit, the registry listing order differs between the two forms becausegetDeclaredMethodspromises none, and an image now carries annotation metadata it does not read. And measured rather than asserted:BindingSchemeBenchmarkruns 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.
BindingCodegenBenchmarkbuilds the option for real — a hidden class defined as a nestmate of the model, reading its private fields with a plaingetfield— and it does the sweep’s read-and-compare in 0.60 ns against the boxed reflective 15.7. But asking the sameVarHandlefor anintand comparing twolongs 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, theListwalks with arrays, and aList.copyOfper 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 makingjava.lang.classfileanddefineHiddenClassreachable 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:nativeImageis the attempt. The obstacle was never the binding — it is:natives, where a binding class takes aSymbolLookupobtained 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:nativeImageMetadataruns the showcase headless under GraalVM’s tracing agent and writes what it saw intosrc/main/resources— source, because it is reviewed in a diff and packaged into the jar — andnativeImagebuilds over that, afterweaveModels, which the build orders beforejarso 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 theWidgetCatalogservice all surviving the closed world. Neither task is in CI and neither is wired intobuild; 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.invokebeside 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 needszlib1g-devrather than thezlib1ga desktop already has, or a minute of analysis ends incannot 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 aClassLoaderforlogback.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.
opacityis the only thing the stylesheets set on a disabled control (ADR-0077) andopacityis 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, andbl_context_blit_image_ddraws 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 isLayerTest’s newScalednest 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
Windowrather 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.soplus 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-lmnative-image does not pass), but Goldberry resolves every native function by name at run time, so the symbols must reach the dynamic symbol table — andnative-imagelinks with its own--version-scriptmaking everything unlistedlocal.--export-dynamic-symboldoes 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 branchNativeLibraryalready 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; anddeleteOnExitdrains 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.ttfandOpenMoji-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,:widgetsand:exampleeach ship areachability-metadata.jsondeclaring 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 becausenative-imagereadsMETA-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
hudon the first properly exercised image readpaint 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. AMethodHandleis 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 untillibgoldberryisdlopened. SoDowncallsholds one unbound handle per signature (134 bindings share 56 of them), each binding keeps theMemorySegmentit looked up, and:nativesships thenative-image.propertiesthat initializes that class in the builder — beside the--initialize-at-run-time=…NativeLibrarythat moved there fromexample/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 twoinvokeWithArguments(Object...)paths inSdlVideoandSdlCursorsthat boxed every argument becameinvokeExacton 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 — soDowncallsTestpins the naming scheme,DowncallBenchmarkprints both numbers, and the control is written down (ADR-0161, the native-image page) -
-Dgoldberry.trace.frames=allprinted nothing at all. Found while measuring the above.ENABLEDwasBoolean.getBoolean, which is false for anything buttrue, whileALL_FRAMESlooked forall— so the setting that asks for more output turned tracing off entirely and every counter guarded byENABLEDwas skipped.allnow impliestrue, and both readings are pure functions of the property value so thatFrameTraceFlagsTestcan check them without setting it — which is the only way to test a flag read once into astatic finalfield (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
assertMatchescalls 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,TransformPaintTestandIconPaintTestget 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:ScaleInvarianceTestrebuilds 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)
menubar, and the accelerator that was waiting on it
- 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
Menuis arecord: an ordinary value, andMenus.openbuilds 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. Somenubarholds its menus,Acceleratorswalks them, andCtrl+Oruns 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 anitemcontainingitems 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+Kclicks the counter with the bar shut. - A heading is not a menu row, and the keyboard is why.
Downopens where anItemmoves;Rightmoves where anItemopens. Neither arrow is the widget’s —menubaris a horizontal focus scope wheremenuis 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. F10and a bareAlt, and the difference between them is in the type. §8 asks for “Alt-style keyboard activation”. A bareAltis a modifier released with nothing in between, and aShortcuthere is a key plus modifiers —Keyhas noALTto name, becauseShortcut’s own constructor refuses one that can never fire. So theAlthalf is not an accelerator at all but a gesture, recognised at the window from the raw keycode (ADR-0223);F10is the companion binding on every platform that has theAltone, and the one that survives a compositor which eatsAltfor its own window switcher. Both open the first heading rather than focusing it, because there is noHost.focusand 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.
HostgrewremoveShortcut, and the map is keyed by the shortcut and not by who bound it — so a bar going away takes whatever is onCtrl+Owith 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
Hostfound a third hand-written stub.SelectTestandTourTesteach carried a near-identical one, so the interface change would have meant editing both and writing a third.TestHostis the shared one, both extend it, and — like the real thing under SDL’sdummydriver — 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:
.openstarted as--gb-overlay-activeagainst: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
Alttap, andLeft/Rightmoving 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 keptLeftfrom closing a submenu since ADR-0112.
Five of §5’s seven containers
card,group-box,statistic,skeletonandcollapseship, 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 nobox-shadowand nothing in this toolkit paints outside a box’s own rectangle, so a card is raised by contrast —--gb-surface-2against the page, plus a border — which is the answerpopoverreached 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;carddoes 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 closedcollapsedescribes 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
skeletonshimmer and nothing else, and a transition runs between two states where a skeleton has one — so the pulse is computed fromnowMillis(), which isspinner’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
childrenwhenchildren()is overridden:GroupBoxdescribed 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 iscontentnow. And the skeleton goldens could never have matched: a widget drawing from the frame clock renders differently every run, so they needClock.virtual()the wayProgressGoldenTestalready 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. statisticnever 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. Anddirectionnames 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 oncanvas, andcollapse’saccordion=is a rule about siblings and therefore the containingcolumn’s.
split-pane and carousel, and §5 is complete
- 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 agestureAnchorand the new offset isanchor + 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-growshares 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 noflex-basisto say it with instead. - A rotation has three brakes and only two of them work. §5 makes
intervaldefault 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-withinand 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,
buildscheduled 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 inbuildwas missing. - Two small costs, both stated.
EventLoop.Timer’s constructor is package-private rather than private soTestTimerscan hand one to a stubHost—TestFrameshas the same privilege overFrame, for the same reason — and the divider’s thickness is written inSplitPaneViewand incontrols.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.SplitPaneTestpins them together. - The showcase’s Panels screen demonstrates all seven, still with no Java
behind it: a
split-paneand acarouselthat keep their own state need no more wiring than acarddoes (ADR-0165)
Five reports from looking at it, and two of them were decisions being wrong
panelhad 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 acard, 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-2was 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-2is a step down from--gb-surface— which is#ffffffthere — 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-raisedand--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-boxholds 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 afieldset’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 apanel, 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. carouselandcollapseanimate, andTabPhasebecamePhase. It was written fortabsand 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 acollapseunmounts 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=#trueinflates to a widget. §5 puts the flag on the containingcolumnand is right to, since “one open at a time” is a rule about siblings. Butcolumnis 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 aStateit never uses. It inflates to anAccordioninstead, which reportscolumnas 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=. Abind=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 ownchangehandler would reset the caret to the end on every letter — and it must have changed since the last build, or an unbound field’s constantvalue=would overwrite whatever had been typed. It also has to be inbuildrather than indidUpdateWidget, because a binding firing does not replace the widget. - The editing rules are a value, and forty-five tests need no window.
TextEditis(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 oneCtrl+Zby 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
passworddraws bullets, so the caret and selection it draws are offsets into those, and a field applyingedit.backspace()to what it was drawing deleted a bullet and left the password a row of them. The seam passesmove(LEFT, byWord, extend)instead, and the one rule a masked field has lives in one place: a row of bullets has no words, soCtrl+Leftgoes 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.
isAnimatingasks 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_StartTextInputwas never called. It was not on the export list, so on a real SDL window theTEXT_INPUTevent had never once arrived —SdlEventBuffercould read it,Windowrouted it andKeyboardTestexercised 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_freeis bound besideSDL_GetClipboardTextbecause that string is the caller’s to free with SDL’s allocator. A backend without one reportsClipboard.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, andPointerRouter.pointerMovedbuilds 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 isdragX(), which isNaNwhen 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-itemsexactly 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 forcard, in a new place, found the same way.--gb-surface-sunkenis--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:--nord2on 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-mutedis 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-placeholderis 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.ContrastTestcannot measure either token — both are translucent, which is the trap it keepsbutton.ghostout 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 iscarousel, whose third brake ADR-0165 recorded as a gap needing “:focus-withinin 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 —requiredincluded — 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.TabsStatelooked at it and said it “looks the wrong way”, which was right for tabs and is exactly right here. :invalidis a real pseudo-class, which is the one addition §1’s list of states asks for by name — unlikeselect.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
andreports the first failure because a message slot is one line. submitcarries 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 — andbinding()is anObservablerather than a path, so the toolkit cannot name them anyway. AFormControllersubmits, 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-labelbeside afield-bodythat is always a column. The second falls out of the same idea: an action row is afieldwith no label, so the empty label still occupies the column and nothing has to know how wide it is.align-items: baselineon 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;FieldGoldenTestis 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=andvalidator=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-strongisrgba(…)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.
TextEditwas 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 alltext-input’s unchanged. - A column is an x.
Upkeeps the column, a column is a position rather than an offset, and a run ofUp/Downhas 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: aTextEdithas 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.
renderruns 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-inputrecords its width and requests nothing, because its width only decides how far it has scrolled, and atext-areadoing 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
kindsets a glyph and a hue. The glyphs are four newBox.Markkinds rather than four Lucide icons, fortab-close’s reason with one more on top: anIconis 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-dangeras “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,-fillfor words on top of it, and-linefor a stroke on the page.ContrastTestgained 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
collapseandcarouselcame with it — they decide at build time whether they are animating and nothing rebuilds a banner, so this asks thePhaseand 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.
Phasecarries 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 adismiss=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.NotificationsScreenTestpresses the buttons, because a golden cannot. - Four banners in a column touched, because a
columnhas 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 fortoast, 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,Enterand §7’s platform button order all need the dialog to know which button is which, so aDialogActioncarries one — and two affirmatives is refused when the dialog is built, becauseEntercannot 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: atext-areakeepsEnterand an openselectkeepsEscby 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
Toastis 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.aftergives 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 onerenderis 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-reversefor 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
isAnimatinganswered!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.closingmeans 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
renderby 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 onisAnimatingdirectly, 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
toasteris a corner overlay, so the column is anchored along the edge it is against, andcontrols.cssputs 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
Measuredand is banked every frame, because by the time it is wanted the toast is gone; the gap comes fromtoaster { 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.Phasealready 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
ToastGoldenTestis 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, whichHudGoldenTestfound first (ADR-0178)
What a popup measured, said out loud
- The measure step was never observable, and
Hosthad 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, andPlacementclamps 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.
Menusguessed — 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-heightto do it.selectdid not try at all, so a list with more options than the display is tall lost its bottom: the same defectmenuhad before ADR-0118, still shipping in the control §3 most expects to be long. Host.Fitis 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
:corehas 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.
Fittedis what both callers answer with, beside theScrollit builds. The 8px margin came out ofMenusand 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.unmounttells the element tree and nothing else. The router is not a listener, so a dialog closing leftPointerRouter.focusedpointing 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 fromupdateRegions, besidenotifyMeasuredandnotifyLocated, 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
:focusand:focus-visibleapart 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-heightandmax-height, anddialoghas the two numbers §2 has asked it for since it was specified. It was never only about dialogs:toast’s 360 is a width andcontrols.csssays outright that it is one “because the subset has nomax-width”, atooltiphas no maximum and so runs a long one onto a single line, andpopovertakesminimumWidthas 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, besideInsets. TheInsetsargument applies — the four are only meaningful together, andBoxandComputedStylewould 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.
tooltiphas 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’sminimumWidthis a runtime measurement no declaration can express, andtext-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.
RecordWitherTestasks every wither onBoxandComputedStyleto 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 awidth/heightswap the compiler cannot see. The structural answer — group the components until no argument list is long enough to get wrong, which is whatInsetsandLimitsalready do — would turnbox.width()intobox.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
messagea × and a toast an action button and nothing else, and that was followed exactly — which left aDuration.ZEROtoast with no action removable only byclear(). 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, andchangeis 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
setStatein the widget that opened it reached nothing in the popup’s window, and showing it something new meant closing and reopening.ElementTree.updatereconciles 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 andEntercommits, where follow-the-focus is aselect’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
autocompleteis built, and the editor is a realtext-input. The sentence says “makes the closed control an editabletext-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 insideselectwould 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 —Spacetypes 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
TextInputas itsvalue, andfollowoverwrites the field only when the offered value changes — so typing is never fought,Escrestores 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
onFocusWithinrather thanonFocusChanged, 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 theselect’s own (ADR-0183)
tree, and the last of §3’s select line
treeis built in a first cut, and it had to be: §3’sselect tree=takes “atree’s model”, and §3 also says a tree shareslist’s item-factory — butlistis not built either, so the model was defined here andlistwill have to agree with it.- The id is the whole model. §3 asks for expansion retained “by node id, not
by index”, so
TreeNoderequires 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.
Righton 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.Lefton 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
cascadeandindeterminate,*, type-to-select, multi-selection,Home/End. And §2’s chevronrotate, which is two marks instead because §8’s subset has notransformon 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, wherewidget()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 readingwidget()there is reading the past. The refresh moved intobuild. - An
autocompletetook 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
treewould 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.
MenusTestdrives 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.
WidgetWitherTestwalks 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 inSelect.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 focusesPOPUP_MENUwindows on some drivers and not others (ADR-0185, ADR-0186, ADR-0189)SelectLoopTestdrives §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 staleopenflag. 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: revertingNOT_FOCUSABLEfails nothing, because the headless backend has none.Still open: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 onflex-wrapis not in §8’s subset.Box, one onComputedStyle, 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-iconis built, and it is the first thing M3 owed that begins ingoldberry.symbolsrather than in a widget. Eleven symbols — nine tray calls, plusSDL_CreateSurfaceFromandSDL_DestroySurface, which are how a painted BGRA buffer becomes an icon — took the list from 192 to 203, and the fiveSDL_TRAYENTRY_*values went into the constant probe with everything else. The one that pays for the probe isDISABLED:0x80000000is a negativeint, and a mask assembled in one is wrong in a way nothing else would have noticed.SDL_UpdateTraysis 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
NSMenuor 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 aTrayIconis a value like aToastrather 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 anitemor aseparator— 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 supportedto tell a driver’s limit from a caller’s mistake; the tray has no such line, because the Linux path fails withCould 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 saystray unavailable on this desktopand carries on. HeadlessTrayis 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, sochoose("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
Quitdid 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 insideSDL_PumpEventsby way ofSDL_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.Quitworked 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.traygives 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 deprecatedwarning on Linux is the distribution’s, not the toolkit’s. SDL’s loader trieslibayatana-appindicator3.so.1andlibappindicator3.so.1; the-glibsuccessor 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
canvasis not built, and charts sit on it.content-widgets.md§3 builds the five chart widgets on thecanvasprimitive so they inherit the theme, the text stack, hit testing and the golden corpus — and §1’scanvaswas 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 iscanvas, the chart substrate, then the widgets.- The paint surface gained a state stack
(ADR-0193), which is the first
thing
canvasneeded 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’sonPaintis not one of those — it runs inside whatever clip the tree established, andresetClipgoes back to the whole frame rather than to the region before it, so a canvas inside ascrollwould paint over the viewport’s edge.bl_context_save/bl_context_restoreare 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/nord8at Δ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 aregoldberry-plot’s, and which are dashboard machinery a toolkit must not grow (query editors, field overrides, auto-refresh, dual y-axes). canvasis built — §1’s last unbuilt primitive, and the substrate the five chart widgets sit on. APainteris a content slot onBoxbesidetext,iconandmark, andpaintOnehands it the frame translated to the box’s content corner and clipped to it, inside thesave/restorepair 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
canvasnode 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 indirectioniconandactionuse, and the shape of that registry depends on whether a painter is a value or a method. Filed rather than guessed at. sparklineis built, andstatistichas 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 takescolor, 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 astatisticcan inherit the delta’s hue from a rule. The palette arrives withline-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.
Lttbis 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 — andsparkline-dark, filled and marked, in--gb-accent. - The axis substrate is built:
TicksandScale.Ticks.extendedis §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…97at five labels is either0, 20, 40, 60, 80, leaving a quarter of the axis unlabelled, or0, 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. Scalehas 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 islinear(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 aNaN, so the next four charts inherit the rulesparklinehad to state for itself.sparklinewas 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.cssandnord-dark.cssas--gb-chart-1…8and are read through a newPaints.Context#color, because a chart is the one widget that cannot express its colours as CSS properties: a node has onecolor, a stylesheet cannot say “the fourth series”, and ADR-0065’s parts do not help because acanvashas 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.Contextwhose answer is per node, and the context is deliberately one object per renderer — which is whynowMillisis a field rather than a clock call. So the renderer setscurrentElementbeforerenderand clears it in afinally, and the clearing is not tidiness: acanvaspainter 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-chartis built — the first chart with axes, and the first widget in the toolkit with more than one of anything. It is two halves: achart-plotthat is a canvas, because a chart of a thousand points must not be a tree of a thousand nodes; and achart-legendthat 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’sflex-wrapwas 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
renderand what happens in the painter is the design. The cascade and the text stack are only available inrender, 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 reading1,000,000reserves more room than one reading5and 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 labels1.0and not1— 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, soseriesandpointare registered nodes that draw nothing — exactly whatselect’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-chartandbar-chartare modes of the samechart-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.ChartPartsholds 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-chartis 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-chartfor separate quantities, whose total is meaningless;area-chartfor 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-chartrefuses two slices and refuses nine, at construction, which is wheredialogrefuses two affirmative buttons and for the same reason. Two is a ratio and reads better asprogress; nine has arcs too narrow to compare and more parts than there are distinguishable hues, andbar-chartanswers 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 —largeArcset 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, astatisticis three lines and aline-chartis 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. masonryreads 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 throughMeasured, 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 — whereMeasured’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 whycolumnsis 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
gaptakes 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 anothervar()and CSS resolves those at use time — reading the raw tokens saw “not a colour” and fell back silently.StyleResolverexposes 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-areawrapped 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, thejava.timeaxis, 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:
paintCanvasmoved the painter’s origin to the box’s content corner withFrame.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). Ascrollmoves its content with atranslate, so the canvas replaced the scroll’s matrix with its own.paintOnenow 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
ScrollContentputs no transform on the context at all, because an unscrolled viewport should not.CanvasPaintTestnow paints a canvas under atranslateand 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;
Showcaseheld its own copy of the list for theCtrl+1… accelerators, andchartswent into that copy instead ofchoosersrather than after it. SoCtrl+8selected the ninth tab, and the loop asked a nine-element digit list for its tenth entry — anIndexOutOfBoundsExceptioninstart, which is a window that never opens.Screen.GALLERYis the one order now: the strip is built throughScreen.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+0is 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.GalleryOrderTestruns 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-chartandarea-chart; a band highlight onbar-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 ofcharts.md§3.1’s interaction list (ADR-0198). - The plot’s geometry is one arithmetic used in two directions.
PlotGeometryturns 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
PaintedGeometryand 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-wrapwas 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 gutterchildren()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, inrender, 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’sPaints.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
ChartPlotis stateful above the canvas, andline-chart,area-chartandbar-chartare stateful above both halves, because the click lands on the legend and changes what the plot draws. What they build is aChartViewcarrying the chart’s own CSS type,idand classes, so every rule incontrols.cssstill 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.
RoundRectis 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.timeaxes, null handling, interpolation, soft bounds, gradient fills, empty and error states, a sharedCrosshairGroupacross charts, hover ondonut-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 than0%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.
DonutGeometryfollows from the box alone, so unlikePlotGeometry— 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/Rightwalk,Home/Endare the ends,Escapelets 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. UpandDownare deliberately left alone. A chart is very often inside ascroll, 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.Tabdoes not move between series, which is a refusal of one sentence ofcharts.md§3.5:Tabis 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 inARCHITECTURE.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
Endand 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.
LOADINGandFAILEDare 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 isREADY, 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 : chartin a line; what that costs is the height.chart-messagetakes the plot’sflex-grow, so a chart in a 156px card is 156px while it loads, and amasonryof 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 dataandLoading…, afterField.REQUIRED_MESSAGEand 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).GAPis the default because it is the only one of the three that invents nothing;CONNECTinterpolates the interior holes, which is the straight segment for a line and the same shape filled for a band;ZEROsays 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.minpropagatesNaN, so one missing reading madeSeries.min()answerNaN, the axis found its domain was not finite, fell back to0…0and collapsed the whole chart onto one line. A single absent sample destroyed the picture, silently, because no test had a hole in it. - A
nullis read as a hole rather than refused.List.copyOfrejects nulls, so before this a series read out of a nullable column had to be converted by its caller — and the obvious conversion isorElse(0), which is exactly the answer the policy exists to prevent. - One place applies the policy.
Gaps.resolveproduces 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
GAPand underZERO, with a line of prose under each. One card and not two, because amasonryplaces 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.extendedscores a candidate labelling on four things and coverage is only one of them, so the nicest labels for12…36are10, 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.
NaNis refused at construction with a message namingNullPolicy: it is the one place in this area where aNaNmeans something specific, and a limit that is missing is not a limit. - The showcase’s
p99 latencycard 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.
localTosubtracted 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 ascrollmoves its content with a transform and Yoga never saw it (ADR-0054, ADR-0068).Region.containsmaps 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 aty = -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:
PlotGeometryexists because a crosshair and a painter must not each work out where a point goes, andScaleexists 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.
paintCanvasassigned its matrix over its ancestors’ (ADR-0197) and this dropped the inverse; both were invisible until a widget that reads geometry was put inside ascroll. 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-areaplaces its caret fromlocal().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 leavesxalone, which is why aslider— which readsfractionX— looked fine and hid the bug. - Guarded at both levels.
LocalUnderTransformTestasserts the arithmetic in:corewith a hand-built region, which is where the defect is;ChartInputTestscrolls 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’sjava.timeaxis 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.Ticksis Wilkinson’s algorithm for numbers and a nice number is a round multiple; time has no round multiples. A step of2 592 000 000 msis a month only in a year with no February in it and has drifted five days by December; a step of86 400 000 msis a day except on the two days a year a zone changes offset.TimeTickspicks 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 withZonedDateTime.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 passesUTC. - 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
NaNandNullPolicybreaks 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. ChartOptionsearned 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 latencycard 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,smoothandstep, withLINEARthe default because it makes the weakest claim and a chart should not make a stronger one unasked (ADR-0204). SMOOTHis monotone cubic, and the point is what it refuses to draw. A Catmull-Rom or a natural spline through0, 0, 100, 100dips 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
α² + β² > 9circle 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 of1, 9, 2gives+0.5and 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
ifaway from being false is not one to argue from the algorithm. STEPholds 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 servedstack is smooth, which is the case that would show a mismatched pair. Curvesis public and pure — no renderer, no natives, no path. The painter asks for tangents and control points and does the drawing, sogoldberry-plotgets 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
GAPrun 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.logmapslog10(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, 1000is the only labelling anybody wants and it is, in value space, wildly uneven.LogTickslabels 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…1000at five labels — four whole decades — into1, 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.positiveOnlyturns a non-positive reading into a hole andNullPolicydraws 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-chartdraws 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.logrefuses 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.99auto-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
AUTOby 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.
CrosshairGroupis a mutable holder an application owns — aToastController’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.
paintHoverreturned early when the readout was null, so a linked chart drew nothing at all; and the hover marker’s ring colour was read off theReadout, 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 dayandBytes servedare 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, sodisposehad 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 anrgba32argument, 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_rgba32suffix — 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 isglobalAlpha’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:
0x00000000is transparent black, and a green fading to it goes through grey on the way out.BlendGradient.fadeis a constructor rather than two lines at each call site for exactly that reason. Fill.NONEis the default, so nothing changed. A line chart draws no fill, which is what a line chart already was; an area chart readsNONEasSOLID, 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-htmlandgoldberry-vectorstart 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 latencycard 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.MENUandShift+F10, both rather than either: the first is SDL’sSDLK_APPLICATION, and the second is the companion binding everywhere and the only one on a keyboard that has no menu key. BareF10is left alone, because it is themenubar’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 shippedtreewithout. 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 shapeLeft’s move-to-parent already had. Home/Endmean the flattened list, not the viewport:Endin 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 forselect’s reason (ADR-0141).- Typing moves the focus and chooses nothing.
Enteris what chooses, which isselect’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 andselection=is none/single/multi with the Ctrl/Shift semantics.- Multi-selection was recorded as blocked on
listand that reading was too strict.treedefined the node model itself for exactly the same reason (ADR-0184) and wrote down thatlistwill 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 onlist’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
checkabletwice — onselect tree=it is which rows are an answer, and on a standalonetreeit “adds a checkbox per node”. Both ship, under two names, and the word doing two jobs is now recorded inARCHITECTURE.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
mayHaveChildrenexists 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 borrowscheck-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
Shiftrange 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, soselect tree=and every existing caller see theStringthey 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_SendMouseButtonClicksrewrites them only when the event’sNSWindowis not the key window; a mouse-up goes to the key window, and aNOT_FOCUSABLEpopup 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_GetGlobalMouseStateasked. - A popup’s desktop origin is its owner’s position plus the offset it was asked
for, because
SDL_GetWindowPositionon 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.
SdlEventBuffergainedwriteMouseMotionandwriteMouseButtonforwriteWheel’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 realNSWindow. - 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
listis built (ADR-0212), which leavestableas 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.
treetook ADR-0184’s rule twice: the widget that needs a model first defines it and writes down that the other will have to agree. SoSelectionlived inpanel.treewhile §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.Checkablestayed, 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:
identitysays what it is,factorysays what it looks like, andtextsays 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. textis 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
ListRowcarries anAttributes, which no other part in the catalog does. - A row’s focus name is scoped by its list’s
id.host.focustakes a name global to the window, so two lists over items with equal identities would each answer to the other’sHome. This istree’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
selectthey 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 andEnterchooses, so those are genuinely two rows and need two marks — ADR-0063’s split, drawn. - A
nonelist is still walkable and still does not eat its clicks. §10’snonesays 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
ListViewand the CSS type islist, because a widget record namedListwould shadowjava.util.Listin every file that built one — including its own, whose model is ajava.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. Everytreegolden 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
affixopened:clip.top() - self.top()is how far into the model the viewport has reached, becauseselfis 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-heightand no widget can read a resolved custom property. It is also where the precondition becomes obvious —index × heightis only a position if every row is that height, so a list with rows of varying height must not virtualize. Home,Endand 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, andEnddid 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
setStateasked for has been drawn yet depends on the pacer.Host.focusreturns 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
tableis 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, soTablebuilds aListViewwhose 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.TableTestasserts the seam and leaves the rest toListTest, 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-growover a zero basis, because overautoa 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
TableTestpassed while that was true; the picture did not. Box.Mark.Kind.CHEVRON_UPis 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-headshipped withborder-bottomand drew nothing (ADR-0215). §8’s subset has oneborderand 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.mdhas recorded it since ADR-0109:border-bottom,currentColorandmarginwere each reached for and not found, “all silently ignored… and nothing warns when a declaration is dropped”.menubardocuments 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 isseparator’s answer to the same problem and the only one the subset allows. - And the toolkit’s own stylesheets are linted.
SupportedPropertyTestresolves 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 wasbox-shadowuntil ADR-0310 built it;backdrop-filteris 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 thanComputedStyle’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-bottomand 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-bottomin the showcase’s own sheet.
The corner that was written and never drawn
group-box-titleasked forborder-radius: 7px 7px 0 0and 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.
Cornersover CSS’s 1-4 shorthand, in CSS’s order. This is the change ADR-0215 declined to make forborder-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-boxin them — and every other golden is byte-identical, which is the assertion that a hundred controls did not move. background: noneis transparent andbackground-color: noneis not, which is CSS’s own division:noneturns off the layers in the shorthand, and the longhand takes a colour. It is whatborder: nonehas always done one property up, and it is why the field inside aselecthad 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 toComputedStylereported 164 failures on a healthy tree; the check builds a probe element per selector, chained by parent soselect text-inputis atext-inputinside aselect, and resolves it throughStyleResolver. The leftmost probe has no parent, which is what makes it:rootand 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:
SegmentedTestsaid 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
menubargoing away could unbind an application’s ownCtrl+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.
menubaris 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
itemcould tell its menu one thing — “the pointer arrived on me” — and fourTODO.mdentries were all the sentences it could not say (ADR-0219): a keyboardRightwaited out the pointer’s 150 ms hover-intent,Leftclosed nothing,Left/Rightdid not move between a bar’s menus, and nothing marked the row whose submenu was showing. MenuSignalsis the sentence:hovered,open,back,forward. Each says what happened to the row, not what to do about it — which is howLeftmeans “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
Siblingsand a submenu gets none, which is the whole of whyLeftgoes 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. Menusgrew 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 anOpenMenurather 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.ofrefused right-to-left text and atext-inputdoes 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 onTODO.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
segmenteddraws §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.inRowis in:corebecausebutton.square’s joined buttons andtabsare 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) / 4is not a percentage anything can name, and the travel depends on every cell being exactly1/n. The two beside the selection fade rather than blink, because the pill takesbaseto 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 arenparts now. Both find anoptionby type instead, which is what they meant.
The gallery that was twelve lists and is seven questions
- 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,ValuesandTextwere three tabs you had to visit in turn to see one screen’s worth of chrome;OverlaysandNotificationswere 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 amasonryof 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.captionor.prose. - A document supplies the cards it can and Java appends the rest to the same
masonry.
Panes.wallOfrefuses a document whose root is not amasonry, and that check is the load-bearing one: acolumnwrapped 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 falsepredicate onApplicationand aSDL_WINDOW_MAXIMIZEDflag beside the size rather than an enormous size instead of one.WindowSpecrefuses 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 missingGB_CONSTANTingoldberry_shim.con 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.themeis a name because three controls pick from a list;app.lightis a boolean becauseToggle.resolvedreadssource.get() instanceof Booleanand falls back to its own flag otherwise — a switch bound to"light"never moves. Both are assigned inpickThemeand nowhere else, and a test walks every route to the theme asserting the two agree after each. Set.ofis 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; aLinkedHashSetfixed it. Three consecutive--rerun-tasksruns are what confirmed it.Scrollingkeeps §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 endis not a selector. The slug helper that had appeared to cope with the others went with them. SectionHeaderis public and is the screen title.text.screen-titledid 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”.ShowcaseTypographyTestasserts 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
Kindredcolumn that repeats and aLeaguescolumn that does not shows a sortable header doing somethingRow 1…Row 6cannot, 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-narrowat 720: amasonry’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. WindowActionssurvives, and the reason is the interesting one. It was deleted when the menu bar started holding its handlers directly — and put back, becauseoverlays.kdlpressesapp.open-menuby 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:
WindowSpecTestin:corefor the flag and its refusal, andShowcaseShellTestin:examplefor 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
Alttap opens the menu bar (ADR-0223), which is §8’s “Alt-style keyboard activation” itself rather than theF10that 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. Keynames no modifier, deliberately, soAltreaches the router asKey.UNKNOWNand is indistinguishable there from every letter that arrives as text.Windowis the last component holding a platform keycode, which is why the recogniser lives there and is fed before theInputWatcherand the router both — a key a popup swallows still has to spoil a tap.- A new package,
input.tap, besideinput.keyrather than inside it:ModifierKeyis the four modifiers seen as keys that can be tapped, andModifierTapsis the detector and its owner-keyed registry. The first thing ininputthat 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+Fmust not read as a tap ofAltfollowed by anF, and the window switcher’sAltmust not open a menu on the way back. HostgrewmodifierTap/removeModifierTapwith 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.menubarnow holds two kinds of registration and returns both;F10andAltboth toggle, which is a behaviour change toF10and the right one.- Three test classes.
ModifierTapsTeststates the rule against the detector,ModifierTapWindowTestdrives the real launcher and asserts each interruption separately — a detector that is correct and unwired looks exactly like one that is absent — andMenusTesttapsAltthrough the real window and the real popup, twice, and then provesAlt+Fleaves 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
Selectsit 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.
Selectsis one method ininput.handler, and the first thing in that package that is a request rather than a report.ListRowandTreeRowimplement it in four lines each;tableinherits it, because a table is aListViewwhose item-factory returns a row of cells.- Twelve tests. Six in
:coreagainst 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:widgetsfor 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
RoleandaccessibleNamecannot 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(), answeringOFF,POLITEorASSERTIVEand defaulting to off, so no existing widget changed.RolegainedSTATUS— a region that reports what just happened, which is neither aGROUP(a boundary with content in it) nor aDIALOG(somewhere the user is until they leave).ASSERTIVEhas 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.
SemanticsSweepTestasserts thatToastBoxis the only class in the catalog overridinglive(), 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, afterSemanticsSweepTest, that enforces something no golden can show. A golden drivesrenderby hand and never asks whether the frame loop would have, so a widget that answersisAnimatingwithfalsewhile it fades produces perfect pictures of an animation that never runs.- Two rules. A widget holding a
PhasedeclaresisAnimating— structural, and scoped to things that implementPaints, because aStateand a value record may both hold a phase and neither is asked for a frame. And every declaration ofisAnimatinghas a test in its own package that names the method, which catches the animations aPhasedoes 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.
ScrollViewportandScrollFadehad noisAnimatingassertion anywhere.ScrollFadeTestnow 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. Everybuildhas 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 acolumnwith agapputs the gap round the thing that vanished) or be described away by its parent (which moves the decision to the application, which is whatbind=exists to spare).- No new branch anywhere. The mechanism was already there: a node that is
neither
StylednorPaintsand 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 astatic finalonWidgetholding 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;textstays 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-messageis 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
collapseandcarouselask theirPhasenow (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 opencollapseon 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
isAnimatinganswered “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; aDoubleUnaryOperatorclosing over one cannot say whether it has.messagewas already built the right way. - Two things the entry had not predicted. A section shut half way through its
arrival keeps an
ENTERINGphase that nothing will ever read again, soCollapseSectionguards onopen. AndCarouselTest’s ownanimating()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
isAnimatingbefore drawing. A phase learns it has finished by being read, and reading happens inrender— 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 acollapserotates a chevron under one. AnimationSweepTestfired on this change, naming both widgets the moment they gained aPhasecomponent — 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,
-fillfor words on it,-linefor a stroke on a surface, and-textfor 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-lineis derived against 3:1. Pointing them at-linewould have moved them from clearly wrong to quietly wrong:--gb-danger-lineis 3.53:1 on the dark theme’s surface. - The worst measurement was 2.04:1 —
statistic-delta.upin 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-textand--gb-hud-bgthat already were: its plate lies over the application’s colours, so a theme-varying hue is wrong on it — the light theme’s-textred 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.
noBareHueDrawsInkreadscontrols.cssforcolor:/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.onPointingChangedtakes a list of listeners (ADR-0230) and hands back aSubscription. 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
onPointingChangedreads 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.movehad 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
--framesfinishes 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-lookinganchor()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 apopoverdoes 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.isModalsaid 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
elementAtnow 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
Escapecloses 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 openingFile → Recentand pressingEscapeclosed 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
Popupwatches its own window and closes only itself, which is correct and never runs: since ADR-0189 no popup holds the platform keyboard, soEscapearrives 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 — otherwiseEscapewould do nothing with a menu open underneath.dismissedByInputreports 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 → removedwas 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.
Phaseis 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.
dialogandmessageeach 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. Departureis that, and it is still not anAnimationController. ADR-0081 refused one forspinner, 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.toastandtabare deliberately not converted. A toast’s departure ends when its stack’s queue says so and a tab’s ends insiderender; 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.
DepartureTestis 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,ProgressFilland aTODO.mdentry all said it in almost the same words;overflow: hiddenhas shipped since ADR-0114, is read by Yoga and the painter, reaches hit testing, and is used bytext-input,text-area,scrolland four CSS rules. - So clip a menu row and be done — except it does not work, and why is the
finding.
Box.textis 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 wasflex-shrink: 0rather 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: centerto that box fixed a different bug found on the way — aBox.of()wrapper defaults to Yoga’scolumn, wherealign-itemsis the horizontal axis — and did nothing about the wrapping. - The missing property is
white-space: nowrap, nottext-overflow. With it a clip works and an ellipsis becomes reachable; without it no arrangement ofoverflowandflex-shrinkcan 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: hiddenwhere 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. TwoTODO.mdentries 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.mdentries had been holding this open since ADR-0089 from either end — “a knob inside a scroll view is still untested” and “Kind.WHEELhad 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.
ScrollViewporthad 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” — whileKnob.wheelconsumed 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
askwould 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 nullonChange, means the value cannot change, so the event is not consumed. KnobChainingTestis 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.dispatchreturns before the chain is built when the target sits in a disabled subtree — so thescrollabove 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 aboutknob. It is inTODO.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.mdentry had carried its own fix since ADR-0057 — “re-runcursorAtafter 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-allowedships 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/Yspan a press-to-release and areNaNoutside 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. NaNmeans “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.
:hoverand:activehad exactly the same staleness, whilemarksaid “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:updateHoverreturns 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()ismark(…, true)and nothing else, becausemarkalready 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. NoENTEREDorEXITEDis 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
setCursorandsetPseudoClassload-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:
dispatchreturned before the chain was built when the target sat in a disabled subtree, so thescrollabove 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
isDisabledwalks up, so it is true from the target to the outermost disabled ancestor and false at every step above;dropWhileis exact in one pass. A wholly disabled tree trims to nothing, which is the old behaviour reached by the new route. isInputis untouched. TakingWHEELout 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.
DisabledPropagationTesthas covered “the wheel is refused too” since ADR-0077 — against a disabledformholding abuttonand 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).
ContrastTesthas 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
colorof the element it is drawn in, and that element supplies its ownbackground. For every mark in the catalog the same rule sets both — a checked tick is--gb-checkbox-mark-checkedon--gb-checkbox-bg-checked, both fromcheck-indicator:checked. So a pair is oneComputedStyle’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-borderfailing 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_FAILURESis 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 onKNOWN_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-bgis--gb-surface-2in the dark theme, so an unchecked box on agroup-boxdiffers 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-focusfollows 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-surfaceand 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
--nord8to--nord10for contrast, and the ring kept the pale one. Setting it to--nord10gives 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-lightis new and is the catalog’s first. ContrastTest’s exact-set lists worked on their first use. Emptying the token without emptyingRINGS_BELOW_FLOORfailed 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_FLOORandBOUNDARIES_BELOW_FLOOR. They are control fills and one accent-on-border pair, they move goldens in bulk rather than one at a time, andTODO.mdcarries 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).
ContrastTesthas 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.contrastis a new package in:core, and exported.:corebecause 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>-bgwith a matching--gb-<name>-text— and an application following the same convention for--gb-mycard-bgis checked for free. The surface pairs are stated beside it, because--gb-texton--gb-bgis 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-bgis#1c212ae6and 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.
ContrastTestlost its private arithmetic and its two literal floors. They areContrast’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_FLOORis 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 stayTODO.md’s.
The unit that meant one number everywhere
emis the element’s own computed font size (ADR-0242), which closes an entry open since ADR-0066.CssLength.Contextwas always the right shape —(fontSize, rootFontSize), one read by each unit — and nothing ever built one per element:WidgetRendererholds a single instance for the whole tree and hands it to everyComputedStyle.ofcall, soemwas one constant at every depth.- Two passes, because CSS has one exception.
1.2emonfont-sizemeans “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-sizeis 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 whatemshould resolve against. - Measuring it turned up a number the entry did not mention.
CssLength.Context.DEFAULTis(16, 16)andTypography.INITIAL’s size is 13. So1emwas 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. Transformwas the same bug in a second place, and had said so in a comment naming the gap: it reached forContext.DEFAULTdirectly. It takes aContextnow, threaded fromComputedStyle.with, which had one all along — two public call sites, both inComputedStyle.- No shipped rendering changed, and that was checked rather than assumed. Not
one
emorremappears innord-dark.css,nord-light.css,controls.cssor 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 declaresfont-size: 20pxnow 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 afont-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 publicforgetReportedDrops()so tests in two modules can clear it. AStyleResolveris 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
buttonand ontextis 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. substituteandexpandVarstopped 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-apiis 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. descendwalks depth, not siblings, which two of those tests got wrong first and which cost aNoSuchElementExceptionto find out. They build a freshwindow > typetree 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-selfresolves and reaches Yoga (ADR-0244), which closes an entry open since ADR-0111 and takes one of the two thingsstackis blocked on.- The entry was wrong twice, in the toolkit’s favour. §8’s layout list reads
align-items/self/contentand the sentence naming what is unimplemented said onlyflex-basis— so the document claimed this worked, and what was missing was the implementation rather than the sanction. AndAlign.AUTOwas already waiting: the enum’s own comment says “AUTOonly means anything foralign-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 onBox— andalignItemsandalignSelfare the same type, so a swap between them compiles, runs, and is wrong.RecordWitherTesthas 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 givealignItemsFLEX_ENDandalignSelfCENTER. - 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
RenderObjectand the whole risk is whether that line runs, so a test readingbox.alignSelf()back would pass on a box nothing laid out.autois asserted indistinguishable from saying nothing, and beside it a check that a non-autovalue 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-selfyet. 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-2stays (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 defaultbadge’s fill, ascrollbaron hover, agroup-box-titleband, askeleton-barand a collapsedsplit-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.
ThemeTestholds--gb-surface-raisedto never being darker than--gb-surfaceand--gb-surface-sunkento never being lighter, on both themes — and asserts that--gb-surface-2takes 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 —
Handleshad anonKeyCaptureand noonTextCapture— 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 anoptionfocused, so the letters stopped at a row that does not know what typing means. - It removes an asymmetry nobody had written down.
dispatchKeyhas captured root-first and then bubbled since the beginning;textInputonly bubbled. One event kind had a phase the other did not, for no recorded reason.SelectListnow reads letters on the way down and calls the sametypeaheadthe closed control calls, son,n,ncycles 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. startandendare 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: startis CSS — Box Alignment Level 3 — and Yoga has onlyflex-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
STARTcould never be shadowed by a mapping written for a different enum.leftandrightstay refused for a reason rather than an omission: they arejustify-contentonly and are notstart/endunder 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
scrollmoving 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
ComputedStyledoes not” — butinheritingFromis exactly that list and is two lines,colorandtypography, with a comment enumerating what is deliberately not there. What was missing was reading it twice, which is whatinheritsSameAsdoes, 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
buttonis never looked at for atext, 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 plainTextandButtonwidgets carrying a class, sotext.tour-titlematches exactly what.tour-titlematched 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
:rootis the theme’s token layer.RuleBucketTestholds 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-titleand 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
stackis built (ADR-0250), which closes §1’s last core-group gap butimageand an entry whose own final sentence had become “whatstackstill wants isstack” 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-itemsandjustify-contentand by its ownalign-self, and one with an inset goes where it says. This is the caseComputedStyle.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”.stackis 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:
stackis in §1’scorelist, 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 andcolumn’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.colorhas 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.lengthis it, deliberately still narrow oncolor’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 — soScrollViewportreads--gb-scroll-lineinrenderand banks it intoScrollStatethrough the shapeonMeasuredalready 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 asetStateevery 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.findAncestorStateis the whole implementation: it exists forscrollIntoViewand answers this with nothing added, which is why it is asked inScrollState.buildrather 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:
buildruns per element per invalidation, and a document that nests in four places has one mistake rather than four. listis 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 inchildren()— so a value banked fromrenderwould be a frame late in the one place a frame late means building the wrong rows. The doorscrollneeded is not the onelistneeds.
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 becameSDL_WINDOW_MAXIMIZEDand 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.reportMaximizedis 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(...)andApplication.minimumSize()declare the smallest a window may be dragged to, and the window manager enforces it throughSDL_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_MAXIMIZEDis0x20AandRESTOREDis0x20B— derived by counting an unnumbered C enum from the last explicit value, which is precisely the arithmetic that is silently wrong.LayoutVerificationTestrefuses 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-widthships (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 samerenderthat positions it, so the cascade’s answer is replaced rather than consulted.widthis 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.Caretsholds 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.laidOutreserves “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.laidOuttakes the width now, which is free because it is already called fromrender. - 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.
-Werrorcaught 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.tokenisPaints.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.
Elementimplements bothBuildContextandStyleElement, andElementTreehas held aStyleResolversince ADR-0149 so a node whose state changed could ask what the sheets say. What was missing was the method.WidgetRenderer.prepareis the genuinely new part, and it is about ordering:renderhands the tree its resolver on the way in, which is a frame too late for a reader inbuild. - The stakes were higher than a repeated number.
density-compact.csssets--gb-list-row-height: 26px, so a list writtenvirtualized(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.
rowHeightis already a tagged number —0means “do not virtualize” — so-1for “ask the token” looked free.ListVirtualTestasserts thatvirtualized(-1)throws, and its name says why: “a negative row height is refused where it is written”.-1is what a typo looks like. Abooleancomponent instead, across seven constructor sites wheredouble, boolean, Attributesin 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
Statefulwidget builds once inside theElementTreeconstructor, 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.
ListViewis a composition node whose state builds thelistelement, so the build that decides the row count runs one level above the node alist { … }rule would match. That is where it ships —:root, in bothcontrols.cssanddensity-compact.css— and it is written down becauselist { … }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|nowrapandtext-overflow: clip|ellipsis. white-spaceis the whole mechanism, and it lives in the measure function.Paragraph.measureFunction(TextFlow)ignores the width Yoga offers undernowrapand 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 stateoverflow: hiddenand 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-overflowis 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.
ComputedStylegained two components rather than oneTextFlow, and the split is CSS’s own:white-spaceinherits andtext-overflowdoes 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, andtext-overflowon a container that draws no text would otherwise mark every label under it. SowhiteSpacejoinscolorandtypographyininheritingFromand ininheritsSameAs, 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
optionin asegmentedbar whose cells are exactly 1/n of the track, aselect-valuein 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: 0came off the menu label, which is ADR-0148’s fix being released rather than reverted: it stopped the wrap by stopping the shrink, andnowrapis a way of stopping the wrap that does not. The accelerator keeps itsflex-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.stylecarries the flow onto aBox.Textexactly as it carriescolor, sotextandselect-valueneeded nothing; a menuitemand anoptionbuild their label as a child box that no style is applied to, so theirrenderpassesstyle.textFlow()to a newBox.text(paragraph, argb, flow). That is the same inheritance one level below the cascade. RenderObjectrebinds 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 freshTextFlowon 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-spacevalues are deliberately absent.pre,pre-wrapandpre-lineare all statements about collapsing runs of spaces and newlines, and aParagraphnever collapses anything — sopre-wrapis whatnormalalready does here andpreis whatnowrapalready does. Naming them would be four spellings of two behaviours. Fontgained 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 | endresolves now, and nothing was added toBox— 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-shadowneeds a drawingBoxhas no field for,backdrop-filterneeds a second pass over what is underneath, andletter-spacingneeds the shaper to be told something before it shapes. (What changed since: two, not three.box-shadowneeded noBoxfield either — it is a component ofDecorationand a stack of rounded rectangles, and the drawing was the whole of the problem, ADR-0310.)text-alignneeds neither engine:Paragraph.paintis already handed the box’s width, because it has to be or the text could not wrap to it, and everyTextLinehas 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-spaceandtext-overflowanswer 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, likewhite-spaceand unliketext-overflow— and it has to, becausetext-alignis 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-alignmeans — 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: anowrapline wider than its box would otherwise be pulled left bytext-align: end, hiding the beginning to show an end the reader can already guess; andmaxWidthisUNCONSTRAINEDwherever a caller is measuring rather than placing, which without it is an infinite offset and a blank frame. slider-valuewas the consumer, and it had been waiting since the control shipped. It iswidth: 40pxby 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%and100%start in the same column and end four pixels apart. Four goldens moved and all four are that readout:slider-value.pngand the showcase’s Basic screen in its three variants, where the whole diff is 274 pixels and every one of them is the40%on the gain slider.leftandrightare refused, for ADR-0247’s reason read the other way round:startandendare what Box Alignment defines, andleftandrightname sides of the screen. They coincide under LTR and part company under RTL, so acceptingrightas a synonym would be writing down an answer that is right today and silently wrong later.justifyis 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-alignplaces 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,rightandjustifyare dropped, and thatslider-valueresolves toend.
Four things the toolkit knew and did not say
- A diagnostic is asked for, not logged
(ADR-0257), which is the
answer two
TODO.mdentries had already written down and two more were waiting for. css.lintisSupportedPropertyTestwith the test taken off it. The machinery worked and had one caller: itself.StyleLintresolves every rule through the real cascade, hands every declaration to the realComputedStyleand returnsFindingvalues 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 itsvar()s stood for, and a sheet linted without its theme reports every colour in it as a value the engine refuses.ComputedStyle.appliesis four lines, and the reason is a fact about the control flow.withreturnsthisin exactly two places and both of them are failures — thedefaultarm anddropped— 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-growinside ascrollsays so now, and the entry had expected the hard version: “a diagnostic would have to know that agrowresolved against an unbounded main axis, which Yoga knows and does not report”. Nothing had to be asked of Yoga.ScrollContent.renderis handed its children as boxes withflex-growalready 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
Measuredassertion on the first built row; what it got is the cascade, becauselist-rowdeclaresheight: 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
forgetbeside 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:StyleElementdocumentstype(),id()andparent()as “or null” and annotates none of them, inside acsspackage 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-bgis--gb-surface-2on the dark theme, so an unchecked box on agroup-boxdiffered 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, andcontrols.cssargued 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
--nord3and--nord4the palette stops, and against--gb-surface-2those two are 1.17:1 and 6.39:1 — invisible, or a white ring round a dark control.--gb-checkbox-borderis 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-accenton--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-darkandcontrols-on-surface-lightare 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_FAILURESwas 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,toastandtooltiphad each written a width where they meant a maximum — andmin-widthhad none until now. - The minimum alone would have done nothing. A caption digit is about 7px, so
8 + 7 + 8is 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 addedmin-widthwould 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 lists2, 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
fullradius 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-chipstopped 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()andaccessibleName(), 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 answersnullnow when nobody named it, which is honest and is distinguishable from being named with an empty string. - It sits on
Attributesbesidetooltipandcontext-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 setAttributescovers — so two widgets read it today and the rest carry one already. core-widgets.mdwas ahead of the code: §3 already documentsname=on bothbuttonandsegmented, so this is the spec being implemented rather than amended. §3’sbadgerow 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
nullis 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-focusresolves 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. FocusGoldenPairTestdiscovers 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 — withtabs-focus-light.pngmoved 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
-focusnaming 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
ContrastTestis 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 anElement, which is aBuildContext. - 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’stooltiprow 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.
pointingChangedscheduled 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.durationistoken’s sibling, and a third accessor rather than a general one forPaints.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 callsComputedStyle.durationMillis— thems/sreadertransitionhas 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.
movingis 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.
TooltipTestgrew 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 lists2, 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 shipped8/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 givescaptionto 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.
SupportedPropertyTestasks whether a declaration does something,ContrastTestasks 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.TooltipMetricsTestasserts 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-sizewould draw one on adisplay-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.metricsdoes, 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 theOverlay.of(context)-shaped callTODO.mdasked for by name and the last open half of the overlay-layer entry.findAncestorStatecannot 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 underwindow-rootrather than an ancestor of anything inside it — so walking up from a deep widget reacheswindow-rootand stops. The mechanism that answers this shape forscrollandformis the wrong shape here.host()gives the window andToasts.atknows 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 onHost::corelearning what a toast is would undo the reasonToasts,MenusandDialogsare three classes in:widgetsrather than three methods on the window. A generalhost.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 madeBuildContext.host()wait forselect.- 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.overlayrecords rather than builds — a controller registered byatalone is still detached and swallows what it is shown, which would have made the test pass by asserting nothing. MenusandDialogsare 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,errataat its spec-compliant default: withleft: 0; top: 0it 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.
RenderObjectapplies 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 removetext-input’s andtext-area’s compensation or it double-counts, with a golden tail acrosssegmented,tourandscroll. 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
onPointerguard entry closed on its own last sentence. It called the default “right fordragX’sNaNand quietly wrong for a nullbutton”, and that is exactly the distinction:NaNis 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, sobutton() != PRIMARYis 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.NONEwas 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 aswitcharm andToggle’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
ComputedStylebecomes aFontand nowhere in the cascade, so a paragraph is shaped larger and a measured leaf grows around it while aheight: 32pxstays 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.2emresolves against a parent that would already have been scaled, so anemchain takes the factor once per level; and apadding: 0.5emwould grow with it, hiding the clipping the feature exists to reveal. Thebutton-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: ellipsisshipped, 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.masonryalready banks every card’s height throughMeasured; reading its own width is the same door one step over, andMeasured’s third rule holds because a column count changes the masonry’s height and not its width. What actually blocks it is thatmasonryhas no row incore-widgets.mdat 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”;
TourStopalready banks the window’s own rectangle from the frame before throughLocated, 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 forMeasured’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:
findAncestorStatewalks 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
TabPhaseentry was describing work done two records earlier (ADR-0269). It asked for the lifecycle to be promoted “when the second consumer arrives”; it iswidgets.core.Phase, moved there by ADR-0166 — whose javadoc says “there was never anything tab-shaped in it” — with theclosing → removedhalf extracted intoDepartureby ADR-0234. Six families use one or both, includingtoastanddialog, which are the two consumers the entry named as wanting it. - So the tour entry’s blocker was a door. “That is
TabPhaseagain: 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. beginTravelruns before the index moves, becauseanchorOfhas 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,translateYandscale“from anchor origin”;transform-originresolves against a box the painter measures, so a card scaling from its own centre reads as a pop rather than an arrival.popoverhas the same gap for the same reason. AnimationSweepTestearned its keep. It failed twice within a minute of the phase being added:TourVeilheld aPhaseand never overrodeisAnimating, 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.
TourGoldenTestwarmed 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 isGalleryGoldenTest’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_MOVEDtranslated and deduplicated per position, andHeadlessWindow.moveToposting the event a window manager would — so the whole re-clamping path runs in CI. The layout probe caught the constant being unregistered ingoldberry_shim.cbefore anything else did, which is exactly the failureSdlEventType’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, becauseanchor(id)answers from a capture taken every frame. What was missing was the question, soreplacePopupsruns 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 andpainted()is where the box was drawn; both javadocs said a popup anchors to the first. A button inside ascrollsits 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 readpainted()now. The two rectangles are identical for every box nothing transformed, which is why it took a scrolling anchor to show it. lateis ahudreading (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.pendingSinceis 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_GetModStatecosts 8.71 ns a call — 34.8 µs per second of dragging at 4000 events a second, 0.0035% of one core — measured byModifierPollBenchmarkon the same headless pathSdlTestuses. 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.RenderObjectnow 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
leftandrightboth 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 andtext-area’s compensations came out in the same commit, as ADR-0265 insisted they must — threelefts 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,tourandscrollwere the three named and not one of them moved, because their parents genuinely have no padding. What moved wastabs, where an underline pinned across a header withpadding: 0 12pxcame out 24 points short of its own label, andtoast, 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 throughacrossBorderBox, 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:
sameAppearancecompares the parent’s own padding, so the parent is damaged, andcollectDamagereports 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;AbsolutePlacementTestis about the toolkit built on it and asserts (12, 12). The day Yoga fixes its inset path, the:nativespair fails first and names the correction that has to come out.
Six boxes over one string, and §4’s shortest specification
code-inputis built (ADR-0273), and §4’s paragraph on it turned out to be two rules seen from four directions.CodeEditis a string and a box count — no caret, no anchor, no undo stack and no per-box array — and the active box ismin(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
Backspacehas 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
12and56with 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.
TextFilterrejects and never corrects, for a stated reason: a filter that rewrote what was typed would move the caret out from under somebody mid-word.CodeTypedrops per character, because neither half of that survives here — there is no caret to disturb, and a whole-value filter rejects a paste ofYour code is 123 456outright, which is the paste §4 calls “the thing users actually do”. The alphabets areTextFilter’s own, which had named this widget in a javadoc since it was written. completeis 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. ABackspaceclears 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
lengthis even”, and §8’s subset has no:nth-child. The boxes go intocode-groupparts: 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-widthand--gb-code-box-heightare 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-inputhad 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
calendaranddate-pickerare 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.
DeterminismTestfailed it:ZoneId.systemDefault()has exactly one sanctioned caller in the catalog and it isTimeAxis(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,todaymay be null and null marks no day. A golden of September 2026 is the same image tomorrow, and in Auckland. DateSelectionis one value for three models, wherelistuses 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 “radiusfullon the selected day, range ends only” one CSS rule: the ends are:checkedand 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
FocusScoperoves 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
Rightthat 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 rowcaption” 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
LocalDateand 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-2026is a date in some locales. - A document’s
changecarries text and Java’s carries a value, because §9’s valued actions cross as aString. Found by the binding weaver refusing the showcase’s first handler, which is that check doing its job. - The
Validatorentry closed by composition.Validator.parsingis a rule over the parsed value expressed as a rule over the text it came from, andfieldneeded 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.
DeterminismTeston the clock;TokenClosureTeston a--gb-accent-onthat does not exist; andSemanticsSweepTeston two parts that overrodeisFocusableto 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: fullis not a value the engine has, §2 writesfullandcontrols.cssspells 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-pickeris built (ADR-0275), and it isdate-picker’s control with different things in the popover: the field, the affordance,Alt+Down,Esc, the delegated focus and the:checkedaffordance 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-pickeropened its calendar with the field’s width as the popup’s minimum, which isselect’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 fromselect’s to the same question. - A wheel and not a scrolling list. Sixty minutes in a viewport costs a
scroll, aScrollControllerand aLocatedcell 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, and23 → 00in one press rather than sixty rows back up. - The wrap is what makes the quiet neighbours honest. A column showing
58 59 00 01 02says what comes next; a list clamped at59stops. - 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/Downturn one andLeft/Rightchoose 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. selectedis 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.WidgetWitherTestcaught a coupling.precision()was rebuilding the format so a seconds column got a field that could show one, which madeprecision(its own value)produce an unequal record — aDateTimeFormatterhas no value equality. The format is null until somebody sets it now, andresolvedFormat()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-pickeris built (ADR-0276), and §4 is complete:text-input,text-area,field,form, the validation model, autocomplete,code-inputand 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 ordinary0xAARRGGBBthat 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
HsvColoris 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.withArgbkeeps the hue a colour does not carry, which is CSS Color 4’s powerless-hue rule and whatOklchalready 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,Spaceopens 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=#falserefuses in both directions. A picker with no way to change alpha must not report one, or abind=carrying#88c0d080leaves the control showing a colour it cannot express and a form holding one nobody chose.#88c0is a colour, found by a test asserting it was rubbish: it is CSS’s four-digit#rgbaform. 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-togglewas 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
chiprow and the catalog gained the widget (ADR-0305).badgewas described as a “count/status chip”,select multiplehad aselect-chippart, 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. Growingbadgeapresswould 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-2is 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, whereBackspaceis 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=drivesselectedthe way it drives acheckbox’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 asegmented.
breadcrumbs, and the nav package finally has something in it
- §6’s first widget is built
(ADR-0306).
navhas been in §11’s package table since v0.2 with nothing in it;stepsandwizardnow have somewhere to land that is notpanel. - The trail decides which crumb is current — the last one, written down on
every build, which is
tabstelling atabit 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;
Rolehas neitherLINKnor a landmark, so the crumbs answerBUTTONand the row answersGROUP. Inventing the constants now would make a gap look closed —docs/gaps.mdcarries 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+0mean exactly what they always meant andiconsis reached by the strip, the arrows and Edit ▸ Go to. The machinery already allowed it —screenShortcutshas always bound what it can and stopped, andGalleryOrderTesthas 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-rowis--gb-list-row-height— 32 — which is half a tile, so every row of the sheet overlapped the one below it untilshowcase.cssre-statedIconsScreen.ROW_HEIGHTfor#icon-sheet list-row. BundledAssets.iconNames()has no order, which this found: it is the key set of aMap.copyOf, so the sheet first opened onbook-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:
IconTileisWidget.LeafplusStyledplusPaints, 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
masonrywhose column count is as many tiles as fit — measured throughMeasured, 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-sheethadflex-grow: 1, which is what a box that should fill its viewport looks like — and it is a verticalscroll’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.
FrameBudgetTestnow 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-shadowdraws (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. “
Boxhas 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 is1 - (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 8pxis eight bands and five after;0 8px 32pxis thirty-one and twenty-two. - It rides on
Decoration, not onBox. A drop shadow is drawn around a box and not in it, and its geometry is derived from the corner radii — the sentenceDecorationopens with. The alternative wasBox’s twenty-eighth component and a wither in every one of the other twenty-seven. --gb-elevation-1/-2/-3, in both themes, as wholebox-shadowvalues. A rule writesbox-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#eceff4and 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-2cost three widgets.- The alpha is the theme’s and the geometry is not. §1.5’s
0 2px 8pxand0 8px 32pxare identical in the two files; only the alpha differs, by roughly two and a half times.ThemeTestasserts both halves, and two goldens — one per theme — are what the difference looks like. transition: box-shadowanimates, because a transition naming a property the engine resolves and cannot move is the silent nothingTransitionsrefuses by policy. Every component interpolates; arriving fromnonefades the shape in at full size rather than inflating it.- The damage rectangle is asymmetric now.
0 8px 32pxreaches 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, amenu, apopoveror adialogand belongs in its own. Both are in TODO.md.
margin, and two defects older than it
marginresolves and lays out (ADR-0311). The shorthand and its four longhands, over the sameInsetsthatpaddingandinsetuse, applied per edge inRenderObject.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 hadYGNodeStyleSetMarginsince ADR-0029.TODO.mdhad closed the case on the wrong evidence.tab-newwanted a margin to sit somewhere other than the top of its row,align-selfanswered that (ADR-0244), and the entry recorded “no live consumer”. Butalign-selfis 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-contentis the container’s decision about every child at once, and aflex-grow: 1spacer is a box in the tree that draws nothing.- So
autois the half that mattered.Length.AUTOon an edge reaches Yoga’s ownYGNodeStyleSetMarginAutoand absorbs the free space on that side —margin: 0 autocentres,margin-left: autopushes 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.ZEROis skipped wholesale, which is ADR-0181’s arrangement forlimitsand 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: autoclosed the window. Yoga’s setters come in pairs and four of them have noautohalf — there is noYGNodeStyleSetPaddingAuto.Yogabinds those without it and refuses anautoby name, which is right for a binding and madepadding: autoin a stylesheet an exception thrown in the middle of a layout pass.CssLengthreadsautofor any length, so it was reachable from ten properties,min-width: autoamong them — valid CSS, and what that property computes to on a flex item in a browser.ComputedStylehas afixed()beside itslength()now and those ten go through it;width,heightandmargindo not, because Yoga binds all three with their auto call.- The cascade returned its winners in hash order. Nothing between
StyleResolver.resolveandComputedStyle.applyre-orders, so the order properties come out in is the order they are applied in — and apaddingapplied after apadding-leftoverwrites the edge the longhand set.cascade()used aHashMap, so which way round a pair came out was whichever way their names’ buckets fell:padding/padding-leftfell the right way andinset/leftfell the wrong one, soinset: 8px; left: 20pxresolved to 8px on all four edges. It is aLinkedHashMapfilled from the already-sorted match list now, with aremovebefore eachput, becauseLinkedHashMapkeeps 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.
marginis the first property added to this engine with four longhands over a value a shorthand also sets, andmargin: 8px; margin-left: 20pxis the test that failed.
The catalog puts both properties on
- Five surfaces wear §1.5’s elevation now
(ADR-0312):
cardat level 1,dialog,tour-cardandtoastat level 2, andaffix:affixed > affix-contentat 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.interactiveis 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, andtransition: box-shadowinterpolates 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.affixis §1.7’s line, finally. “detach/attach:opacityon the elevation shadow, fast” has been in the motion table since before there was a shadow to put an opacity on.affix-pinned.pngis the whole argument for the widget in one picture: the pinned header casts onto the rows sliding under it.toastat 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,menuandtooltipare 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-actionswrites the margin §2 asked for, where it had beenpadding-topwith 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 itsSpacer.TourStopbuilt[Skip][Spacer][Back?][Next]and builds[Skip][Back?][Next]withmargin-right: autoon 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 absorbsW − Σwidths − n·gand 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.spaceris 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.
RuleBucketTestcaught a selector on the way.tour-card > column > row .tour-skiphas a rightmost compound naming no type, so the cascade would check it against every element of every kind (ADR-0152). It isbutton.tour-skipnow, 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,affixandtour-cardeach 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()answersOptional<SystemTheme>andHost.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_GetSystemThemejoined the export list as an optional symbol andSDL_EVENT_SYSTEM_THEME_CHANGEDbecame one event per open window, the wayQUITalready becomes oneCloseRequestedper window — because aHostis 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. TheOptionalis the design — SDL saysUNKNOWNon 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).underlineandline-throughresolve in the cascade, inherit (CSS propagates them, which reads as inheritance here fortext-align‘s reason), and are drawn byParagraph.paintas a rectangle per line at the position and thickness the font file gives. Four more ofBLFontMetrics’ sixteen floats crossed the boundary to do it — no new native symbol and no relink, becausebl_font_get_metricswas already filling all sixteen and this side was reading six. The rule is as long as the line, indented with it undertext-align, absent from a blank line, and drawn across an ellipsis because the mark is part of the line. A face that carries nopostentry 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_ITALICandUI_STRONG_ITALIC, out of the release the manifest already pins, andfont-style: normal | italicin the cascade. Two files rather than one, so the matrix closes: a single italic would leavefont-weight: 600; font-style: italicresolving 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 Monohaving one face.obliqueis 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).
Locatednow 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 correctionPopup.anchoralready 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, whereEnterpressed a swatch instead of breaking a line.takesFocus(false)settled the opening and nothing else; this settles the lifetime.EscapestayslightDismiss’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 ofParagraph.paint’s private half and ontoTextAlign.indentOf, and all four ofTextGeometry’s questions gained a form that takes the width the text was drawn in and its alignment —caretAt,offsetAt,moveLineandselectionRects, 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.Editorcarries the alignment, so a canvas editor’s paint, caret, hit test,Up/Downand 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.
Valuepassesstyle.textFlow(), and each control places its own geometry from the same alignment — by the box intext-input, whose value hugs its text so the paragraph has no slack to indent, and per line intext-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 likeslider-valuehas since ADR-0256, and atext-areacan 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).
refocusestablished that the focused element had left the tree and then handed it tofocus, which told it so, andState.setStatethrew — every party correct and the window dead on the next frame. The fix islost.isMounted()in two places, per element rather than per notification, so an ancestor that survived its child is still told it lost:focus-within.markhad 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:
stepsandwizard(ADR-0344), the other two of §6’snavpackage. The list writes index, count and state onto every step on every build, the way the trail writes which crumb is current;errorandreachableare the step’s own words, and a press needs bothclickableon the list andreachableon the step. The wizard makes oneStepperpageand hands them to the standaloneSteps, 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 isdialog-actionsunder 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 whenpending— 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 theflex-basis: 0the subset does not have. The Collections screen has one. A marker can be a widget — abadgein amarkerchild (ADR-0356) — and a step’s connector grows byscaleXabout 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, behindBackend.openUrlandHost.openExternal. The state makes and closes theexternal-linkicon — the one icon the toolkit owns — andEnteractivates whileSpacedoes not. Three of them are on the Basic screen, one external.button’soutlined,square,circleandfloat(ADR-0347). Three classes, one line of logic — an icon-only button addscircleunless toldsquare— andFloated, 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
canvasasks for its next frame (ADR-0348).Canvas.animating(Predicate<CanvasStyle>), asked by the renderer straight afterrender, through a newPaints.isAnimating(ComputedStyle, Context)whose default is the old question. - Faces an application ships
(ADR-0349).
Application.fonts()returnsFontSources, 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_SetWindowIconandSDL_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.floatenters with §3.1’s scale 0.9→1, which ADR-0347 could not build.@keyframesandanimation(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.Pathis an immutable outline over two parallel arrays, with a sealedPath.Segmentof six records for reading one back;Stroke,Cap,Join,Dashand a sealedGradientsit beside it.Frametakes those and nothing else in public: theBlendPathoverloads are package-private, and the seam is a single package-privatePath.replayInto(BlendPath).Iconholds aPathnow rather than a native allocation,SvgPathparses into aPath.Builder— computing SVG’sSandTreflections itself, since aPathhas no stateful verb — andBoxPainter.paintOnelost theBlendPathparameter it only carried to pool one. The frame pools it instead, in the one place that sees every drawing call::corehad done that by hand and:widgetshad 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.cppis 988 lines with no occurrence of the word. Six symbols were added to the export list and five were taken back out; what shipped ispaint.geom.Flattenerandpaint.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 isbl_context_set_stroke_miter_limit, which closes a gapBlendStrokeJoinhad admitted to in its own javadoc. - The layout family is closed, and sealed
(ADR-0279,
ADR-0280). A
goldberry.layoutpackage holdsLength,Insets,Limits,FlexDirection,Justify,Align,Wrap,Position,Overflowand the measure protocol;Box,ComputedStyleandCssLength.parseare written in it;ComputedLayoutwas deleted rather than mirrored, becauserender.model.LogicalRectwas already the toolkit’s rectangle. The translation is one package-private file besideRenderObject, the only class that ever touches aYogaNode— andYogaTestchecks every constant by name fromvalues(), because the compiler guarantees theswitchis exhaustive and cannot guarantee an arm names the right counterpart. Around a thousand references moved across 84 files, and no golden image did.:nativesnow exports its Yoga packages to:coreand to nobody else, which was verified by compiling a module that tries to importStyleLengthand watching javac refuse it. - The shaping family is closed, and sealed
(ADR-0282).
text.ShapedRunandtext.TextDirectionreplaced the shaper’s own types — a shaped run is sixint[]with no foreign memory and nothing to close — and HarfBuzz’s two packages now export to:coreand nobody else, checked by compiling a module that tries to nameGlyphRun. - What was left was one method, and it is closed
(ADR-0290,
paint.GlyphPen). It wasFrame.drawGlyphs(double, double, BlendFont, BlendGlyphBuffer, int), whose only caller isFont.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,paintowns the first andtext.fontthe other two, and within one module Java has nothing between package-private and public. The answer is to move the native font intopaint; it changes where fonts are created, so it gets its own decision rather than being improvised. The enumeration is free: deletetransitivefrom:core’srequiresand-Xlint:exportsunder-Werrornames every site — which is why the plannedPublicSurfaceTestwas never written. - And a
canvashears input (ADR-0281), which wasdocs/gaps.mdG3 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 —Canvassimply 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 andPointerEvent.content()reports the pointer inside it;Length.resolveis the single implementation both the painter and the snapshot call, so they cannot drift. Making the widget focusable immediately failedSemanticsSweepTest— every focusable widget must say what it is — so a canvas is aRole.FIGUREand its name is the application’s. - And a canvas can draw an image (ADR-0283),
which was
docs/gaps.mdG4 and is what G5 and G7 were waiting on.image.Imagedecodes PNG, JPEG and QOI — the codecs were compiled intolibgoldberryfrom M0 and the export list had simply never named them — and is a value: the decoder’s allocation is copied into aPixelBufferand destroyed beforedecodereturns, so there is noclose(), no lifetime, and the showcase holds one in a static field. Three symbols were added and no more, because the PNG writer isjava.base’sDeflaterand four chunks rather than seven more bindings — ADR-0278’s reasoning a second time.Frame.drawImagehas 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 onetryblock. - And a scene can be photographed with no window
(ADR-0284), which was
docs/gaps.mdG5 — a server-rendered preview, an OpenGraph card, an export.offscreen.Offscreenruns 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 insideLauncherand 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 calledElementTree.flush(), so amasonryrearranging 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.mdG6 apart from IME preedit.TextEditandEditHistorywere already built and in the wrong module — the rules of text editing lived insidetext-input— so they moved totext.editbeside the shaping they are arithmetic over. What was genuinely missing is the two-dimensional half:TextGeometryanswers where a caret is on a wrapped paragraph, whatUpmeans when lines differ in length (a column is an x, not a character count), and what shape a selection is across a line break.Editoris the whole editor without a widget —text-input’s key map, its undo coalescing and its clipboard, over a canvas at any transform — andInputgainedonFocusChanged, 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_EDITINGis 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.mdG7:has/read/writeover a MIME type, withImage.fromClipboardandtoClipboardbeside 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_SetClipboardDatakeeps 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
:coreand:gpualone (ADR-0475). 56SDL_GPUfunctions are on the export list, their structs are checked by the layout probe, and the wrappers innatives.sdl.gpuare 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 drivesSDL_GPUdirectly rather than SDL’s GPU renderer (ADR-0477). :gpuhas a public API (ADR-0478): devices, textures, buffers, shaders and pipelines asAutoCloseableresources 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 noMemorySegmentorSdlGpu…type is in an exported signature.- A window is composited through a seam
:coredeclares and:gpuprovides (ADR-0479).render.compositeis exported to:gpualone, and:gpu’sSdlCompositorprovides itsCompositorthroughServiceLoader: 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 — nogoldberry-gpuon the module path, no device, a refused claim, a popup,goldberry.gpu=off— and it says which, and why, in the log, inWindow.presentation()and in the showcase’s bar (ADR-0492). - GPU layers are placed in paint order
(ADR-0481).
Frame.gpuLayerclears 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. canvas3dis a GPU layer an application renders into (ADR-0482): aCanvas3dRendereron the window’s device, drawn continuously or at each newrevision, and--gb-canvas3d-unavailablewith the reason where there is no GPU. The showcase’s GPU screen has two cubes.video-viewshows its pictures through a GPU layer when:gpuis 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.resizeis the SPI method, a request the window manager answers with aResized;Sdl3Windowhands it toSDL_SetWindowSize, andHeadlessWindowplays the manager the way its popup already did — clamped to the floor, applied when the event is delivered.Window.resizeis the public face, and--resize=WxHwalks the size a pixel a frame there and back through aResizeWalkthat steps from the window’s own size, between frames: asking from inside the painter changed the size under the frame on every driver whereSDL_SetWindowSizeis synchronous, and every frame was refused and counted late. - A run that says what it cost.
FrameRingkeeps the run’s totals beside its window,FrameStats.summary()hands them out as aFrameSummary, 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=Nturns it into a verdict: overN,FrameBudgetExceptionafter shutdown and a non-zero exit. - A ceiling under it, on three runners.
showcase.ymlruns 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-budgeton any platform and reports what it cost. What still fails the step is a run that does not logpainted 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):
| Leg | Frames | Late | Paint mean | Worst | Display |
|---|---|---|---|---|---|
linux-x64 | 302 | 75 | 10.14 ms | 1799.59 ms | 0.0 Hz |
macos-aarch64 | 300 | 200 | 6.42 ms | 200.26 ms | 60.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.1is the line being worked towards and never carries-SNAPSHOT; the build adds it, and drops it only for-Pgoldberry.release=truewith av2026.1tag that matches. A mismatched tag fails configuration.CalendarVersionandBuildVersionin build-logic, tested. - Maven Central.
goldberry.publishon the six shipped libraries — eight since:emojiand:mediajoined — POMs, sources, javadoc, signing for releases,:core’s test fixtures kept out, andgoldberry-nativescarrying 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-bompins every artifact;goldberrydepends on-common,-natives,-coreand-widgetsand lists-htmland-gpuas<optional>, both generated fromPublishedModulesso a content module is one line. A consumer build against a local repository resolved the four withoutgoldberry-html, andgoldberry-htmlat the BOM’s version once asked for. The optional list is-html,-emoji,-gpuand-medianow, andgoldberry-mediacarries FFmpeg asffmpeg-<target>classifier jars, the shape ofgoldberry-natives’ (ADR-0495). - One uploader.
publish.ymlcalls the three per-OS workflows and publishes once;snapshot.yml(every push to master) andrelease.yml(everyv*tag, dispatch rehearses) call it. The per-OS workflows lost theirpushtrigger, 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.ymlruns on av*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 onlinux.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:htmlcatalog 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-fallbackdeprecated 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, whoseHEADwas checked againstlibs.versions.tomlbefore copying — andcheckLicenses -Pgoldberry.releaseCheck=truepasses: eleven components, all vendored. - The javadoc is linted (ADR-0343):
-Xdoclint:all,-missingon every published module, and clean. The 120 errors were 425@paramlines on…Callsholder classes, moved to theircallmethods 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
libgoldberryand reaches Java through fivegoldberry_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 aDocumentowns 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:
:nativesexports md4c’s wrapper to:htmland to nobody else, whichExportedSurfaceTestchecks, and neither:corenor:widgetsknows 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-viewis the first widget outside:widgets, which exercises ADR-0131 end to end: the showcase’smarkdown.kdlnames the node and nothing in that application mentions the module that provides it.- A preview follows a property (ADR-0296).
markdown-viewtakes §9’sbind=, so an editor and its preview are two nodes over one value — the gallery’s ninth screen is asplit-paneholding atext-areathat writesmd.sourceand 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
:widgetsfell out of building that screen (ADR-0297), both older than Markdown: asplit-panetook its measured length and never asked for the rebuild that would use it, sopositionwas a first-frame guess that only a drag corrected; and atext-areachased its caret from the first layout, so an area opened on a document showed its last line.text-areaalso gainedfill=#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.linkthat hands its destination to the application; anImageSourcethe application supplies is what turns asrcinto a drawn picture, and asrcnothing answers for is still its alt text; a Markdown task box reports its ordinal, andMarkdown.toggleTaskflips 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:Locatedsays where each word was painted, a painter reading mutable state means a drag repaints rather than rebuilds, andParagraph.offsetAtputs the caret between the right two glyphs. A word is awordpart now rather than atextwidget — 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-viewis built, and litehtml is not what it is built on (ADR-0298).Html.parseis 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 contributeshtml-<tag>, sohtml.cssreads 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 itshrefto the application and nothing else: a Tab stop, a hover,SpaceandEnter, 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.viewandhtml.viewis the package:coreowns — and two named modules holding one package is aLayerInstantiationExceptionon the module path that no class-path test can see.CatalogWeavernow 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
textwidget 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 — andFrameBudgetTestasserts 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
BLImagecosts 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-viewandcamera-viewall are — the same shapetextalready uses, where Yoga asks and the widget answers. Thehtml-viewthat 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_savearrived withcanvas(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, sogoldberry-htmlstill starts by wideninglibgoldberry’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-iconreached 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-webhad 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/webviewis 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.shellmember, besidetray-icon, because it could not be a widget:webview/webviewhas 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. Aweb-viewin 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, aWS_CHILDwindow on Windows. On Wayland it opens nothing and paints why. The window form stays beside it in…widgets.shell.web,WebPageandWebViews.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, ascrolldoes 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 inlibgoldberry’sNEEDEDwould make them load-time dependencies of every application on Linux;objdump -p libgoldberry.soshows 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. Andwebview/webview0.12.0 has a bug on its GTK backend:set_size_implapplies the size and then falls off the end intoreturn 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:GdkDisplayManageris registered by bothgdk-3andgdk-4into GObject’s process-global type registry, so the second one back gets 0 andgtk_init_checkdereferences 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 linkswebkit2gtk-4.1, which is WebKitGTK on GTK 3, against webview’s own preference; and the shim checks withRTLD_NOLOADwhether the rival major is already mapped and declines rather than crashing.web-viewandtray-iconare 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
WKWebViewadded 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, intray-icon’s sense: webview.h makes its ownWS_CHILDwindow 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 realHostwith 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,swresampleandswscale, 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 JavaMediaIO. :mediabinds its own libraries (ADR-0461), the one module besides:nativesthat holds aMemorySegment, 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-platformuntil 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-controlsandmedia-player, with the showcase’s Audio and Video screens over them. - It is published, optional
(ADR-0495), as
goldberry-media, with FFmpeg as itsffmpeg-<target>classifier jars. A snapshot carries the targets the Media workflow builds,macos-aarch64andlinux-x64; a release refuses to publish without all four.media.ymlis written and has not run on a runner yet.
Module layout
| Module | Artifact | Contents |
|---|---|---|
:common | goldberry-common | What 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) |
:natives | goldberry-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 |
:core | goldberry-core | The 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) |
:widgets | goldberry-widgets | The 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) |
:weaver | not published | The 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) |
:html | goldberry-html | The 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 |
:emoji | goldberry-emoji | Optional. 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) |
:media | goldberry-media, with ffmpeg-{platform}-{arch} classifiers | Optional. 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) |
:gpu | goldberry-gpu | Optional. 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 —
Yoga658 → 409,Blend2D821 → 561,SdlVideo837 → 653,HarfBuzz393 → 249,Sdl296 → 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.
Blend2DCallswas forty-six functions; it is nowImageCalls,ContextCalls,PathCalls,FontCallsandRuntimeCalls, and the 821-lineBlend2Dbinding split the same way intoBlend2dImage,Blend2dContext,Blend2dPath,Blend2dFontandBlend2dRuntime— 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
callnames its parameters and says what they are.call(a1, a2)is nowcall(context, rect, argb), with a summary, the C prototype, and a@paramfor 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 sharedinvokehelpers 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 finaland 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-timenames. Measured, because the alternative fails silently: a handlestatic finalon 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-nativesjar — the shippednative-image.properties, nothing added — calls through a holder at 9.84 ns/call, against 10 ns on the JVM. HolderShapeTestreplacesDowncallsTest. 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.
| Module | Packages before | After | Largest package |
|---|---|---|---|
:core | 15 | 35 | 10 |
:natives | 7 | 15 | 12 |
:widgets | 38 | 39 | 11 |
- The CSS engine is a compiler, so it reads like one —
css.parse,css.select,css.cascade,css.value, withcssitself holding the sheet an application loads and theComputedStyleit 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,SdlEventBufferandSdlEventWatchwere each moved out and moved back the moment they turned out to traffic inMemorySegment. - 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.
WidgetRendererreads and writesElement’s package-private style cache, so it is the element tree’s own paint pass rather than a neighbouring role. And the root…goldberrypackage keeps its ten types becauseLauncherandGoldberryRuntimemake twenty-one calls intoWindow’s package-private event intake — a toolkit whoseWindowoffers an application ahandlePointerMovedhas 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 aMemorySegment. It discovers its subject rather than listing it, so a package added next month is checked next month. This isdocs/ARCHITECTURE.md§3.1 becoming a check instead of a claim.WrittenNamesTest(:weaver) resolves every class name the weavers write into bytecode as text. SplittingbindturnedModelWeaver’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 aNoClassDefFoundErrornaming 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).
| Target | Built on | Output |
|---|---|---|
linux-x64 | ubuntu-24.04 + manylinux_2_28_x86_64 | libgoldberry.so |
linux-aarch64 | ubuntu-24.04-arm + manylinux_2_28_aarch64 | libgoldberry.so |
windows-x64 | windows-2022, MSVC -A x64 | goldberry.dll |
macos-aarch64 | macos-14 | libgoldberry.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-config | Debian/Ubuntu | RHEL/Fedora | What a library loses |
|---|---|---|---|
dbus-1 | libdbus-1-dev | dbus-devel | SYSTEM_THEME, FILE_DIALOG, SCREENSAVER_INHIBIT |
ibus-1.0 | libibus-1.0-dev | ibus-devel | INPUT_METHOD (X11 only; Wayland needs none) |
libudev | libudev-dev | systemd-devel | DEVICE_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_MENUwindow 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, nofont-sizeand 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 fortooltip, which wants the styling of the thing it describes, and the answer there is to pass the anchor’s resolved style in” — andtooltippins its typography for a stated reason, §1.4’scaptionrank being the one departure in its row that somebody had thought about (ADR-0263). Inheriting the anchor’sfont-sizewould draw a tooltip on adisplay-ranked heading at 28px. So the subject stands and the example does not: what is left is apopoveror amenuwhose 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=250overrides 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
rendercannot have, and a third would be an argument for handing the width torenderrather than toMeasured. — ADR-0167 -
Nothing re-places the caret when the font changes under it. A restyle that changes
font-sizereshapes the paragraph and the caret follows, because both are computed in the samerender. 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 -
isModalhas one consumer, which is one fewer than a mechanism should have.sheetis the plausible second, and it is not built. Awizardis 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:coreagainst bare widgets rather than throughdialog, so the second one finds a mechanism rather than a dialog-shaped hole. — ADR-0176, ADR-0356Read again on 2026-09-30, and it stands.
DialogPanelis still the only widget that answersisModal. What is new is a reader:web-viewasksHost.isModaland 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-subtlewas added, resolved tonord3, and produced a counter nobody could read onnord1— §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
-
Measuredhas 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 atransform, 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
tablefocuses 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_SetTrayEntryCheckedis 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.
BackendTraysets 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
Itemin amenubarstill 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’sappindicator_nameslist holdslibayatana-appindicator3.so.1andlibappindicator3.so.1and 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
SdlTrayTestagainst 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.invokeis 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
canvasnode inflates to a styled, sized surface that draws nothing; the drawing is Java.iconsolved the same problem with a registry the application owns (ADR-0043) andactionwith 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@Actionanswered 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
rowwith 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.decodeis still synchronous. A large JPEG is tens of milliseconds, and acanvaspainter that decodes pays it on the UI thread. Theimagewidget does not: it decodes on a virtual thread throughImageLoader(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.isBidiApproximatealready 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
Editordoes 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 istext-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-htmlare built, and neither has an engine under it.:htmlshipsMarkdown.parse,MarkdownHtml,markdown-view,Html.parseandhtml-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-areahas, 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, whichtext-areashares. 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:assetsis 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 aspacerwithflex-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:
borderis uniform, so there is noborder-left, and horizontalscrollis 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 areflex-basis: 0now. The quotation bar became theborder-leftit 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 answersIconsand the stylesheets each needed a resolver for. An application reads the file and passes the text. -
<style>andstyle=are kept in the HTML model and applied by nothing, and neither is a<script>run. The cascade anhtml-viewis 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
listvirtualization 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:htmlgolden moved. The CSS split is the decision:.md-proseand.html-proseare declaration-less paragraph hooks now, the row geometry moved to.md-line/.html-line, and the column carries the same0.25emgap so a typed break and a width break sit at the same leading. A break inside a link deliberately does not split — onebutton.linkis 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-markdownmoved, 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 atext-areain 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), soCtrl+B, list continuation andTab-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-htmlputs litehtml’s C++ container inside its own native library because FFM cannot implement a virtual class, and that container draws throughlibgoldberry’s exported C symbols. There are twentybl_context_*entries and they are the ones the toolkit’s own painter needs: no gradient, no rounded geometry, and nobl_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 andborder-radius, and CSS state nests. So the first commit of an engine-backedgoldberry-htmlis a widening of the toolkit’s own native surface, reviewable on its own, and it is shared work:goldberry-vectorandgoldberry-terminalwant the same surface. Nothing on screen is waiting on this any more (ADR-0298):html-viewrenders 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 aBLContexthanded across them is undefined behaviour. — ADR-0190, ADR-0007Narrower 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_*andbl_context_set_fill_style, ADR-0207), and so arebl_context_saveandrestore, whose comment in the symbol file now says a second clip depth is needed (ADR-0193). There are 25bl_context_*entries rather than twenty. Rounded geometry is cubic paths plusbl_path_elliptic_arc_to, with no round-rectangle primitive. Whether what is exported now is enough fordocument_containerhas 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-micand the coreSoundAPI thatcontent-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-iconbegan 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: nineSDL_*audio-stream symbols, which:media’sAudioSinkwrites through (ADR-0462), with SDL’s ALSA and PulseAudio drivers required on Linux (ADR-0488). Of the 149SDL_*entries on the list, 56 areSDL_GPUand 9 are audio. Still missing: every camera and recording symbol, and the coreSoundAPI, sogoldberry-cameraandgoldberry-micbegin at the same file as before. -
The backend SPI has no PTY.
goldberry-terminalneedsOptional<Pty> openPty(cmd, env, size)—forkpty/openptyon Linux and macOS, ConPTY on Windows — which is the same optional-capability shape asgpuSurface()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-pdfis the only module that vendors a prebuilt. PDFium’s own build wants gn/depot_tools, so:natives-pdfconsumes 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-codehas a consumer before it has a widget, and the consumer now exists. md4c’s code fences want a highlighter;markdown-viewrenders 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. Sogoldberry-htmleither depends ongoldberry-codeoptionally 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::coreuses anEmojiFontthat:emojiprovides (ADR-0384) and aCompositorthat:gpuprovides (ADR-0479), andvideo-viewdraws through a GPU layer when:gpuis present (ADR-0484). Sogoldberry-html→goldberry-codeandgoldberry-vector→image/svg+xmlare 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 checkLicensesknows about artifacts.
Style, colour and motion
-
Nothing in the catalog wears an elevation yet.Five surfaces do (ADR-0312):cardat §1.5’s level 1 and lifting to level 2 oncard.interactive:hover,dialog,tour-cardandtoastat level 2, andaffix:affixedat level 1 with thetransition: 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,menuandtooltipare 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-3is 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-areacosts 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, andTextAreaFrameBenchmarkis 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=trueprints what every check measured, which is how a runner pressing against the limit would say so in numbers. — ADR-0162Partly answered by CI, as it said it would be. The golden suites run on
windows-x64under MSVC and onmacos-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
BLMatrix2Dbeing six consecutive doubles in the ordermatrix(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 asvoid*and a reordered union would produce a skewed frame andBL_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 returnsBL_SUCCESS. The layout table cannot catch this: it is an agreement between two libraries, not a fact about either. What holds it isFontowning both objects and never scaling the shaper, plus a test that compares the inked span against the measured width. Anything that builds aShapedFontand aBlendFontby 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.updateinvalidates 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 whenmatchesDiffersays 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-webviewis opened at start-up, and WebKitGTK with it. The backend asks it whetherCapability.WEB_VIEWholds, 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.resizeand--resize=WxHwalk a window’s size from outside,FrameSummaryprints what a run cost at exit, andshowcase.ymlpaints 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-x64302 frames and 75 late,macos-aarch64300 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 forwayland,x11instead, 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-0027The 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=waylandor where there is no XWayland. -
The macOS window opens, and the CI leg still would not have caught it.
gradlew runfailed 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 putmainthere. The showcase passes-XstartOnFirstThreadon macOS andSdl3Backendappends 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.ymlstill links the library and runs the tests without ever opening a window, butshowcase.ymlruns the packaged image onmacos-14and asserts it painted three frames — so a repeat of this failure would now turn a tick red. What that leg cannot catch is anything aboutgradlew 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 calledshutdown()ever since, and GNOME Shell crashed twice more on 2026-08-17./var/crashhad 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 ← SIGSEGVMutter, tearing down a departing client, disconnects a signal handler on a GObject thatg_type_check_instancerejects — an instance already finalized. That is unambiguously a compositor bug:wl_client_destroyruns 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 calledSDL_Quitfirst. The nearest exported symbol below the faulting frame ismeta_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 callsSDL_Quit; nothing called it.Goldberry.run()returning does not shut the runtime down — its contract says so — andGoldberry.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, inwl_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 callsGoldberry.shutdown(). Open: whetherrun()should shut down on return, which would change a documented contract. Seen once, on GNOME 46.0 under VirtualBox/vmwgfx, after SDL3 moved fromrelease-3.2.0torelease-3.4.14in the same session. — ADR-0022Narrowed since:
Goldberry.launch(), the documented front door (ADR-0093), owns the runtime and callsGoldberry.shutdown()itself, so the showcase no longer has to. The open question is now only aboutrun(), whose contract still saysshutdown()is rarely needed, and so only about applications that assemble the loop by hand. -
No CI leg exercises Wayland.
showcase.ymlruns underxvfb-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=headlessorsway --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 stockjavalauncher runsmainon a thread it creates and so fails it; a launcher whose ownmaincallsJNI_CreateJavaVMand then the Javamainruns Java on the primordial thread, and the plugin loads there — demonstrated with a throwaway C launcher against the real showcase.jpackagedoes not help; it goes through the sameContinueInNewThread. Shipping one is a distribution change (a native binary per platform, VM argument handling, and a story for./gradlew runandjava -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 theSdlWindowFlag.BORDERLESSdesign Goldberry has reserved but not built. — ADR-0084 -
A window on GNOME/Wayland needs two packages from two different phases.
libdecor-0-devat build time, or SDL compiles no libdecor support at all (ADR-0083), andlibdecor-0-plugin-1-cairoat 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.BORDERLESSalready 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
fd36169aandd478ecfe. 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-0338And 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:masterhas not been pushed since. -
Two workflows are written and have not passed.
media.ymlbuilds FFmpeg and runs:media:checkwith FFmpeg and the platform decoders required onmacos-aarch64andlinux-x64, with GStreamer’s plugins on the Linux runner (docs/media-plan.md, phases 1 and 5). The GPU lane inlinux.ymlruns lavapipe under theoffscreendriver 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 ingpu-plan.md’s measurements table. Both are answered by a push. — ADR-0495, ADR-0480The 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.jsonnow. Run here on lavapipe, the lane then found a real bug — a test destroying a GPU device afterSDL_Quit, theVULKAN_DestroyDevicesegfault 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.ymlhas still never run. -
Media on Windows and on
linux-aarch64is 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 makeslibavutillinklibva. — 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 —YGSizeis 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:YGAlignCenteris 2 andYGJustifyCenteris 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 VideoPlaybackTesthas two races, and one of them fails alone.statistics(“expected 4 shown, got 5”) was believed to fail only when:media:testand:media:testWithoutGpuran side by side; on 2026-10-01 it failed once in three runs on its own.playsToTheEndfailed in a fullcheckwith[BUFFERING, PLAYING, ENDED]where it expectsOPENINGfirst — 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 redcheckmean 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 isrelease.yml→publish.yml→ a Central Portal deployment, which waits on the first tag.docs/releasing.mdis 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-14runner’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
ForeignSurfaceTestholds the owner list to the sources that callupcallStub(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
goldberryumbrella cannot pickgoldberry-natives:<v>:linux-x64for 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-0336Narrowed — 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
runtimeElementsties 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.:mediahas the same problem since ADR-0495: an application picksgoldberry-media’sffmpeg-<target>classifier by hand too. What did change is the documented snippet — all four classifiers, becauseNativeLibrarypicks 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 firstv*tag is its first run, and a manual dispatch exercises everything but that step. — ADR-0340 -
A release refuses to publish
goldberry-mediawith two of its four FFmpeg builds missing.media.ymlbuildsmacos-aarch64andlinux-x64, so a snapshot carries those twoffmpeg-<target>classifiers, and the release path requireswindows-x64andlinux-aarch64as 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 throughlibnss_mdns4_minimal(/etc/nsswitch.conf,/etc/hosts, then the wait). The JVM does no such thing — its onensswitch.confread is for the user’s name — and logback resolvesHOSTNAMElazily and this configuration never asks, so it is not logback’sContextBase. The image is stripped, so the stack did not say whose it is; a build with symbols, or anInetAddressResolverProviderthat 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
Rolehas no link and no list.linkanswersBUTTON, andsteps,timelineandbreadcrumbsanswerGROUPoverROWs, 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-inputis “a single textbox with the whole code as its value” —Role.TEXT_FIELD, one Tab stop, boxes with no role at all.calendaris “grid with each cell’s full date as its name” —Role.GRID, cells that are parts.date-pickeris “combobox owning a grid, with the formatted date as its value text” —Role.COMBO_BOX.color-pickeris “combobox with the hex as its value text”, and it gets half of that one: its closed swatch is aRole.BUTTONwhose 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:Semanticsis 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”, andRolehas neither a landmark norLINK: the row answersRole.GROUP— “a boundary with content in it and no better word” — and the crumbs answerRole.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:checkedand 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 addingLINKand 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 answersRole.SLIDER, which is true as far as it goes — a control whose value you move continuously — and says nothing about the second axis.GROUPis “a boundary with content in it” and a plane has none;GRIDpromises 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,NOTICEandffmpeg-NOTICE.txtwith 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-0490Closed — 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-mediapublishes it as itsffmpeg-sourcesclassifier, one jar per version, beside everyffmpeg-<target>— snapshots included, because a snapshot on Central is a distribution too. It holds FFmpegn8.1.3and dav1d1.5.4fromgit 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.publishrefuses anyffmpeg-<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 mappinglibgoldberryis under 2ms — but they were measured undergradle run, which adds a launcher and its own JVM. The headline claim needs the example launched directly. — ADR-0028Closed — ADR-0506. Launched directly, timed from
execby 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 wasProcessHandle’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.ProcessAgereads 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
PrimarySelectiononBackendandHost: the sdl3 backend offers one only when SDL’s driver isx11orwayland, and the headless backend an in-memory one a test can turn off.text-input,text-area,Editorand 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. Apasswordnever 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. -
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:Styled.restyleis an escape hatch with nine overrides now, and the honest risk is what goes into it.ColorSwatch,SegmentedDivider,SegmentedIndicator,TabIndicator,Tab,ScrollContent,ScrollThumb,ScrollViewportandAffixContent(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’sopacity: 0is thebeside-selectionclass and a rule incontrols.cssnow, andScrollContent’sflex-shrink: 0was a pin against the stylesheet and is set inrender. No picture changed.RestyleSweepTestholds 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)andBackendTray.icon— and §9’s “theme-aware light/dark variants” is an application’s twoPixelBuffers and aWindow.onSystemThemeChangedhandler 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, andTrays.showswaps the icon in place throughBackendTray.iconon every theme change, leaving the menu alone;forLightShellwhere the desktop says nothing, as CSS reads no preference. Reality differed in two places. The swap could not be built without a leak:Host.onSystemThemeChangedreturned nothing, and a tray is closed and shown again whenever its menu changes, so it returns aSubscriptionnow, 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. -
, 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-0070customPropertiesForstill walks to the rootClosed — ADR-0502, and the entry had the cause wrong. Measured with
DeepTreeStyleBenchmarkat 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, whichCustomPropertiesCacheTestchecks against an uncached walk. The term that still grows with depth is descendant-combinator matching, recorded in the ADR and not scheduled. -
A§5 asks it to scroll a target into view, andtourcannot find the viewport its target is in — read against the code, and it stands.Stoptakes aScrollControllerthe application supplies. Discovering it means walking from an element to its nearest scrolling ancestor.BuildContext.findAncestorStatelooks 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-0121Closed — ADR-0439. Both premises are true and the conclusion is false, which is why re-reading it twice did not catch it.
ElementimplementsBuildContext, sofindAncestorStatewalks up from whatever element it is called on rather than from the one being built; andHost.anchor(id)already returns a region whoseowner()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 thatfindAncestorState“stays, because it is how an application-levelscrollIntoViewfrom 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. -
It is now (ADR-0311), and this entry closed the case on the wrong evidence. It was right thatmarginis not in §8’s subset, whichtab-newfound afterborder-bottomandcurrentColor.tab-newstopped wanting one — what that widget reached for was a way to sit somewhere other than the top of its row, whichalign-selfanswers (ADR-0244) — and wrong to conclude from it that the property had no consumer, becausealign-selfis 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-contentis the container’s decision about every child at once, and aflex-grow: 1spacer is a box in the tree that draws nothing.margin: 0 autoandmargin-left: autoare what those are, and Yoga’s binding has had theautocall 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-bottomwas written a fourth time, intable-head, and drew nothing (ADR-0215).SupportedPropertyTestresolves 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 0andbackground: nonewere two more rules doing nothing, with the property spelled right and the value refused. An application’s stylesheet is still on its own, deliberately: namingbackdrop-filterbefore it exists must not stop a window opening. (That sentence saidbox-shadowuntil ADR-0310 built it;backdrop-filterandletter-spacingare 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.mdgained twenty-one widgets and four options in one pass —link,affix,segmented,date-picker,time-picker,color-picker,code-input, autocomplete on bothtext-inputandselect, tree-select,collapse,carousel,statistic,skeleton,breadcrumbs,steps,wizard,message,tour,tree,calendar,timeline, andbutton’soutlined/square/circle/floatoptions — each with adesign-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),stepsandwizard(ADR-0344),timeline(ADR-0345), andbutton’soutlined/square/circle/float(ADR-0347). What each left behind is its own entry under The catalog below.Everything else on it went in:
segmentedfirst, thenaffix, the three pickers,code-input, autocomplete on both controls, tree-select,collapse,carousel,statistic,skeleton,message,tour,tree,calendar, andbreadcrumbslast.The way
segmentedwent 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 —messageagainsttoast,segmentedagainstradio-group,code-inputagainst a styledtext-inputare all decisions that would otherwise have been made by whoever happened to need one, and none of them was. -
, which changes what M5 owes. ARCHITECTURE §17 defers “tables/trees”;treemoved from deferred to specified, andtablehas since followed ittablestill is, because it waits on virtualization, buttreereuseslist’s model and item-factory and does not — andselect tree=#trueneeds it, so the two arrived together.Answered — both are built.
tablestopped waiting on virtualization when virtualization arrived: it is a list with columns (ADR-0214), anddocs/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” — andtoggleis not among them, while §3.1’stogglerow asks for the opposite, “thumbtranslatebase”. 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 fortogglewhere 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 thetogglerow 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-listis 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.onFileDropdelivers oneFileDropper gesture, with the paths and the point they landed on (ADR-0330). What is still unbound there isSDL_EVENT_DROP_TEXT— the same shape, and nothing has asked for it.SDL_EVENT_DROP_TEXTclosed — ADR-0408. “The same shape” turns out to be literal rather than loose: SDL tokenises dropped text on\r\nand raises one event per line, then one sharedDROP_COMPLETEfor both kinds — soTextDropcarries 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:
UriListreadstext/uri-listinto names, and the entry was never told. Found by the 2026-09-30 sweep. -
LGPL relinkability means libVLC stays a separate shared object with its plugin tree beside it, and every packaging rule ingoldberry-mediabreaks the one-library assumption.:natives— one static library, hidden visibility, one export list — assumes the opposite. It also needs a codec/patent note written before it gets code, whichcontent-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.
:mediadrives 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) inffmpeg-<target>classifier jars, and-Dgoldberry.media.libdiris the relinking path (ADR-0495). The codec note was written before the code: royalty-free codecs only, with theDecoderSPI for the patented ones (docs/goldberry-media.md). What is still open about shipping it is under Build, artifacts and release. -
Text selection in, and it is the same character-quad work as text-editing depth (html-viewis deferredARCHITECTURE.md§17) and aspdf-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-htmlentry above has said so since 2026-09-13; this one was never struck.pdf-viewis still unbuilt. -
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 agoldberry-webis parked, not deferred.cdylibshim and its breakage — and nobody asked whether a page needed an engine of this project’s at all.webview/webviewis 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/webviewcannot 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 aweb-viewin 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 secondwidget.shellmember 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-viewis 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 WindowsSetParentis written and unverified. On Wayland it opens nothing and paints why, rather than a loose window.WebViews.openstays as the separate-window form. -
Nothing in the catalog uses a margin yet.It does (ADR-0312):dialog-actionswrites the top margin §2 asked for instead of thepadding-topthat stood in for it, andtour-card’s footer lost theSpacerthat pushed Skip away from Back and Next —margin-right: auto, pixel for pixel the same picture. The showcase’s notice bar likewise.spaceris 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, andThe Panels screen filled the console while it scrolled:align-items: startis why.startis CSS’s alias forflex-startand 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:startandendare taken now, because they are not aliases but CSS — Box Alignment Level 3 defines them and Yoga has only theflex-pair, so the toolkit had been dropping a declaration the specification allows (ADR-0247).leftandrightare still refused, and for a reason rather than an omission: they are not the same asstart/endunder 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 insegmented-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.AVarHandlelookup that cannot find its field throwsExceptionInInitializerErrorwhere a direct field reference would have thrownNoSuchFieldErrorat 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/mainholds noVarHandle. 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.videoDriverexisted and was not in:example’s forwarded-property list, so-Dgoldberry.backend.videoDriver=dummyreached the Gradle daemon and stopped there — the exact failure the comment beside that list already described forgoldberry.log.level. The obvious fallback,SDL_VIDEODRIVER=dummyin the environment, does not work either: aJavaExecfork inherits the daemon’s environment rather than the onegradlewwas 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=dummyis 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 andlinux.ymlinstalls neither.eglis one of the five specs in SDL’s singleCheckWaylandpkg_check_modules— lose any one and the entire Wayland driver is dropped silently, and the container has nomesa-libEGL-devel.libdecor-0decides whether a Wayland window that does get built has a titlebar and a resize edge. The manylinux leg runs CMake directly with no JDK, socheckToolchainnever 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 readingSDL_VIDEO_DRIVER_WAYLANDandHAVE_LIBDECOR_Hout of a container build’sSDL_build_config.h— not another look at the table. — ADR-0082, ADR-0083Half 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-develandmesa-libEGL-develall install and all provide their.pcfiles;libdecor-develandxkeyboard-configare 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 generatedSDL_build_config.hand cross-checks it against its own probe — forHAVE_DBUS_DBUS_H,HAVE_IBUS_IBUS_HandHAVE_LIBUDEV_H, which is the same machinery this question asks for pointed at three other defines — and the drift guard now also holdslinux.ymlto every package a capability depends on. Extending both toSDL_VIDEO_DRIVER_WAYLANDandHAVE_LIBDECOR_His the remaining work, and the honest form of it is probably aCapability.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_DECORATIONSandCapability.WAYLAND, warned about rather than required, becauselibdecor-develis unavailable in the release container and aREQUIREDprobe 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 ofSDL_build_config.hand 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_DECORATIONSis 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,ALLforced static-archive symbols local, soSDL_Initlinked 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 definesBL_STATIC, which makesBL_APIexpand to nothing, so the superbuild’s globalhiddenvisibility applied to every Blend2D function. All 13 linked in and arrived local —nm -Dshowed none of them whilenmshowed them all ast. HarfBuzz then did it a third time and more bluntly:HB_EXTERNis defined as bareextern, 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’slocal: *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.defand Mach-O-exported_symbols_listbranches 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-0031Answered 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 aFollowing is a property of having been opened by id, which ispopoverfollows a scrolling anchor; amenuand aselecthold the rectangle they opened against.Popover’s documented shape and the one the entry that asked for this named.MenusandSelectStateresolve the anchor to a rectangle themselves, because they want a minimum width and aFitas well and noHost.popupoverload 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-0145Closed — ADR-0432. The overload exists —
Host.popup(content, anchorId, placement, minimumWidth, fit)— andMenusopens by name through it. The entry is wrong aboutSelectState: aselecthas no id to be anchored by,SelectFieldisLocatedand 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 onlyMenuswas a customer andselectstill 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 apopovertravels 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-0114Closed — 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
Downmoves a selection nobody sees andEnterruns 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’sfollowsAScrollingAnchorasserted a menu travelling with an anchor that had left the window entirely, which is the picture this record calls wrong. -
ATwo 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’s column count is a number and not a breakpoint, and what stops it is the spec gate rather than the mechanism.masonryalready banks every card’s height throughMeasured, 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 thatmasonryhas no row indocs/core-widgets.mdat 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-0196Closed — ADR-0436.
min-column-widthis built, exclusive withcolumns, defaulting to 320, with the wall’s own width read throughMeasuredfromMasonryBox— and the resolvedgapread with it, becausencolumns neednminimums andn−1gaps and counting without them over-counts at every boundary. It settles in 3 passes worst case, which matters exactly:Offscreenmeasures 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-columnisflex-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 fixedcolumns, 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.GalleryGoldenTestbuilds its renderer with the single-font constructor — which ignoresfont-family,font-sizeandfont-weightby 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.ShowcaseTypographyTestasserts 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.textScaleexists 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: sincetext-overflow: ellipsisshipped, 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-0118Closed — 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.textScaleexists but does not reach the gallery, becauseWidgetRenderer’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 realfont-family,font-sizeandfont-weight.OverflowWatchanswers half: its noise is a fact, its silence is not, because the walk is gated on the root node’shadOverflow. 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 bymasonry’s responsive columns rather than by anything aimed at them. -
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-0117Measuredis a door every widget can now open and almost none should.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 caughttoastplacing no box in a windowless harness and passing vacuously.toast,tour,imageandIconSheetstay uncovered, each with its reason written down. -
A row’s focus name still collides between two unnamed lists.host.focustakes a name global to the window, andlistscopes its rows by the list’s ownid— which settles it wherever an application named one, and leaves the case of two lists, both unnamed, holding an item with the same identity.treehas 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-0212Closed — ADR-0437.
PointerRouter.focusByIdresolves inside the enclosingfocus-scopechain 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-scopeis 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 thatEndcan reach it. The entry also understatestree, 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. -
It now has two callers, which is what movedSelectListis in the wrong package.Optioninto a package of its own; it stayed put because the CSS type it carries isselect-list, so moving it renames a type in every stylesheet and every golden rather than editing one file. Autocomplete itself reaches markup throughsuggestions=andoptions=(ADR-0367). — ADR-0182Closed — 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 — everyselect-listincontrols.css, theselect-list-darkgolden name andSelectTest’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” isPaints.Context.lengthand has been since ADR-0251 — but it is arender-time read, and the pointer arrives atonPointerwhere there is no context to ask.scrollsolved exactly that by banking the number into itsState; aSlideris arecordwith nowhere to bank one, so closing this means makingsliderstateful. 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-0079Closed — ADR-0430.
slideris stateful onscroll‘s arrangement —Slider(record) buildsSliderControl(the CSS type), andSliderStatebanks--gb-slider-thumb-sizeread atrenderforonPointerto use. The mapping is over the travel. The entry was right about everything including the tick marks, whichSliderGeometryTesthad been asserting all along and which pass untouched. One cost it could not have known: there is nocalc(), so the thumb’sborder-radiusandslider-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: hiddenhas 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-0235Closed — ADR-0418. The sweep crosses and leaves — −100% to 333% of the bar’s own width — with
overflow: hiddenwritten incontrols.cssrather than forced in Java, so the clip stays the stylesheet’s. Two goldens moved and were re-blessed;progress-determinate,progress-light,progress-reducedand both spinners did not, which is the evidence the clip costs the other drawings nothing. One new cost, named rather than hidden:Clipis a rectangle and not CSS’s rounded clip, so the track’s 2px cap squares off momentarily — about 0.86 px² per corner. -
NoScaling 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 thatImage.scaled(...).bl_image_scalehas and nothing has asked for.Closed — ADR-0428.
bl_image_scaleis bound asBlendScaledImage, mirroringBlendDecodedImage. 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, soImage.scaled(w, h)is Lanczos andImage.scaled(w, h, Resampling)is the other four.BL_IMAGE_SCALE_FILTER_NONEis deliberately unbound — it is the absence of a filter rather than one of them. -
The frame sequence exists twice.Launcher.paintandOffscreenrun 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 throughOffscreen, so a divergence moves a picture.Closed — ADR-0423.
FrameSequencein a new non-exportedframepackage 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 anddrawis 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 aFilmstrip: a closeable object that mounts the tree once and answersadvance(millis)andframe()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.
Studiokeeps a renderer over a font book and hands out wiredOffscreenbuilders. 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 — aWidgethas anequals, 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 oneOffscreen’s own javadoc recommended, “hand over oneFontsand keep it”. That advice is gone and the assert is there;rendersConcurrentlydrives eight threads to a pixel-identical result. So the javadoc says it rather than saying “probably”. -
No word-wrap-awareThe 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.PageUp/PageDown.Closed — ADR-0410.
Editor.viewportHeight(double)makes a pagemax(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-implementingdesiredXcolumn-keeping and intercepting the key beforeonKey, 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:nativesagainst 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
readcalls the supplier again rather than caching, which is the platform’s actual contract: a double that produced bytes atwritetime 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 thefalsebranch reachable from a test, with the default behaviour unchanged — a test seam rather than a new policy. Worth noting thatClipboardDataTest’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. -
Atext-inputholding a long value shows its end, not its beginning.TextEdit.ofputs the caret at the end and the field keeps the caret in view from its first layout, which is whattext-areadid 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, soEndon an untouched field did nothing at all. -
An icon larger than its slot overflows it.AnIconis 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-0143Closed — ADR-0419.
Icons.SLOTandIcons.bind(String)name the size at the door, andItemLeadreports 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-leadis the only slot in the catalog an icon can overflow, because every other widget usesBox.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, notBox.icon. -
An outer shadow is painted under the box, not cut out of it.CSS knocks the border box out of abox-shadowso 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-opacitytransition, which fades its shadow by the same factor and so darkens itself slightly. What it would take:BLContextSetFillRuleor a path-clip call on the export list, and then one reversed sub-path per band.ShadowPaintTest.throughATranslucentBoxpins the current behaviour, so the day that lands there is a test that says the deviation is gone. — ADR-0310Closed — 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 bothbl_context_clip_to_rect_i/_dwere 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’soccludedband flag has one answer once the hole is cut, so culling moved toShadowGeometry.coveredAt.ShadowPaintTest.throughATranslucentBoxasserted 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--nord3and--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-borderis the midpoint, at 3.17:1 and 3.22:1. The three marks were--gb-accenton--gb-border, one pair wearing three names, missing by 0.02; the light accent slid to#5c7ea8and 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 sentencedocs/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-0088Closed — 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 —
#525252or 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-borderanswers the groove, and the dark theme sets ittransparentbecause 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 wasvar(--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, andMARKS_BELOW_FLOORis empty. “Twenty-two goldens moved” was the cost of sliding a ramp; an edge touches only the thumb, and two moved. -
Its fill isbutton.ghosthas no contrast ratio, and is therefore not checked.transparentand its hover is a#ffffff14wash, so what a user reads depends on the surface underneath — there is no single pair to measure. It is left out ofContrastTestrather 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-0087Closed — ADR-0431.
BackdropContrastTestrenders real trees with the real rasterizer and reads the pixels back — five surfaces × four probes × two themes, forty pairs. It deliberately does not reimplementsrc-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:activeon 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. -
What ADR-0242 left:remis the configured root size, not the root element’s.emresolves against the element’s own computed size now, andremstill readsCssLength.Context.rootFontSize(). CSS says the root element’s computedfont-size, so the two agree unless a root declares one — and recovering that insideComputedStyle.ofis 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’sfont-size, so this is exact today. — ADR-0242Closed — ADR-0416. Both halves, split: the root’s own
remfalls out of the two-pass structure ADR-0242 already built forem, and descendants get it throughWidgetRenderer. The entry called the renderer-field shape “correct only after the root has resolved” as a drawback, and it is the specification — CSS saysremon the root’s ownfont-sizerefers to the initial value. And the claim that this was “not possible insideComputedStyle.of” was half wrong: that half was already in reach. -
A bare, which is ADR-0066’s deliberatetextwith no ancestor settingcolorrenders blackINITIALand a trap all the same: the showcase’s new gain label was unreadable on the dark theme. A control gets away with saying nothing becausecontrols.csssetscoloroncheckbox,radio,toggleandsliderthemselves; a primitive does not. The showcase now setscolor: var(--gb-text)on its root, which is what an application should do — but nothing warns one that has not. — ADR-0066Closed — 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#rootrather than:root, so a synthetic:rootprobe would have reported the reference application as the defect. It takes the root element instead. -
StyleElementdocuments three nullable members inside a@NullMarkedpackage and annotates none of them.type(),id()andparent()each say “or null” in their own javadoc and each is declared as a plainStringorStyleElement, in acsspackage 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-0257Closed — ADR-0413. All three are
@Nullablenow,Selector.Compoundwith them, andcss.lintis 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@NullMarkedpackage 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.Ahudreading nameddisplaypicked up §1.4’s.displaytype 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-0153Closed — 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
ClassNamespaceTestholds 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.TreeRowmintedheading, so every branch label in every tree drew at 15px/600 in a fixed-height row — three goldens moved when it was fixed.Skeleton.Shapemintedtitle, harmless in pixels today and waiting for the firstem. -
TheEditorstill shapes its whole text.canvasediting seam from ADR-0285 holds oneParagraphover everything it is given, which is whattext-areadid until ADR-0388.TextDocumentis exported and is the obvious second caller. Nothing has measured anEditorover a document, so nothing has earned the change. — ADR-0388, ADR-0285Closed — ADR-0411.
Editorholds aTextDocumentnow, 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-canvasis 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, andClipTest,TransformPaintTestandIconPaintTestdo 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:BoxPainterTestandTextPaintTesteach already carry their own scale cases and would gain little;DamageTestis 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; andThreadedPaintTestis 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-0157Closed — 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 offsetsk·m mod 1visits — 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 ofcheckper multiplier repo-wide. Two things the entry could not know:gallery-canvasmisses 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’sdummyvideo 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-0045Closed — 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, withdraw’s 5.9 ms of queueing in front of it, so a frame underdummyis 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: underdummythe 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 bumpsForgetting leaves master publishinggoldberryVersionafter a release.2026.1-SNAPSHOTafter2026.1is out, which Maven orders below the release. A step inrelease.ymlthat opens the bump as a pull request would close it. — ADR-0333Closed — ADR-0421. That is exactly what landed, and the entry named the right shape: a
bumpjob thatneeds: publish, checks out the default branch and opensbump/<next>. Two things it did not say. The arithmetic is the part with a decision in it, and it is a tested value inbuild-logicrather than ased—VersionBumpmoves a patch line to its next patch, because arelease/2026.1branch bumped to2026.2would claim a feature release from a maintenance branch, and asedthat incremented the last number would get that right and get2026.3in 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.nextReleasehad 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, soencodeWebp()is the lossless path andencodeWebp(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,SystemParametersInfoWon Windows andNSWorkspaceon 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 atd478ecfe, which is the runbook/src/status.mdrecords as twelve green jobs. — ADR-0016 -
AsmJit’s W^X handling on Apple Silicon is now reachable.Reached, and green. The showcase painted frames onmacos-14on 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 -
It has, 2026-09-17, and it is the same class the stylesheet already had a rule for:texthas nostyle="body"attribute.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.Animationsays which one is showing and holds no clock. An animated WebP followed a few hours later, oncewebpdemuxwas linked — libwebp composites its own canvases, so the disposal model is upstream’s there. — ADR-0382, ADR-0385 -
AIt 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-0377tabsindicator still cannot travel, thoughsegmented’s does. -
Two key maps.One, 2026-09-17, and there were three by the time it was read again.text.edit.keysis the table; each editor keeps its own text and answers a sealedEditCommand, 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. Ascrollviewport and an absolute child are not overruns. — ADR-0375 -
It is resolved, 2026-09-17, defaulting toalign-contentis still absent.stretch, which is what every box in the catalog already did. It also givesSPACE_BETWEENand its two neighbours a property that means them: they were constants the enum advertised and no declaration could reach. — ADR-0374 -
It resolves, 2026-09-17, andflex-basisis one of two layout properties §8 names and nothing resolves.masonryis the consumer that wanted it:flex-basis: 0withflex-grow: 1is 1/n of a row after its gaps, where the inlinewidth: 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 -
Built on 2026-08-23, and the entry outlived it:statistic’s sparkline waits oncanvas.canvasis in the catalog and the sparkline is the last child of the column, exactly as this said it would be. -
No image cache forAnswered rather than built, 2026-09-17.Image.decodeitself.ImageLoader.shared()is public, bounded and off-thread, and acanvaspainter that decodes by hand can use it — which is what the entry itself named as the seam. A second cache insideImage.decodewould be one the caller cannot see, cannot bound and cannot clear. — ADR-0358 -
Nothing reorders tabs.A strip withonReorderdoes, 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 -
AnOn one per axis, 2026-09-17:affixpins on one axis.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 withreveal(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 -
AIt 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 withselect tree=#truehas no typeahead.host.focus(id), and a popup’s host asked only the window. Focus by name now tries the open popups, topmost first. — ADR-0368 -
AA document places one, 2026-09-17, andlistis Java, likecanvasand like autocomplete.tableandtreethe same way:bind=names the widget the model built, since its factory is code. Autocomplete names the bound list its answer lands in, withsuggestions=oroptions=. — ADR-0367 -
A tab’s content is rebuilt when it is selected again.Only by default, 2026-09-17.keep-alivekeeps every shown tab mounted and hidden while another is selected, through a newStyled.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: aScrollControllercan 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.ALWAYSis a token stylesheet an application passes toControls.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 -
AIt hastext-areahas no visible scrollbar.scroll’s, 2026-09-17: neither ascrollaround the text nor a second bar.ScrollBaris three numbers and two callbacks, and a text area knows all three. — ADR-0362 -
AIt 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 atablehas no column resizing.split-panebetween the headers, which divides one box between two panes. — ADR-0361 -
A pinnedIt is, 2026-09-17, without knowing its sibling: an affix stays inside the box it is in, which is CSS’s rule foraffixis not pushed out by the next one.sticky, so a section’s header leaves with its section. The same change gavetablea sticky header, which this entry was blocking. — ADR-0360 -
AAs 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 inselect’s field is as wide as its current value.render; a stylesheet’s width still wins. — ADR-0359 -
There is noThere is, 2026-09-17:imgwidget.image, with theobject-fitmodes, 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 byIt grows, 2026-09-17. The reason given was that §8’s subset has noscaleX.transform-origin, and it has had one since ADR-0068. The sentence was copied from an older note and never checked againstTransformTest. The connector is now a track with a fill scaled about its start edge. — ADR-0356 -
AIt can, 2026-09-17, from abadgecannot be a timeline’s marker.markerchild thatentrylifts 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: aFloatSlotbound to a switch putsleavingon the button, the stylesheet plays the exit onfast, 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 isEach edge comes off once, 2026-09-17, inwidth - 2 × left padding.text-inputas intext-area. — ADR-0355 -
§8’s subset has noIt 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@keyframesand is not going to grow one.ToolkitLoopsTestchecks it. — ADR-0353 -
The published javadoc is built with doclint off.On, lessmissing, and clean across every published module. The 120 errors were one idiom —@paramlines on a…Callsholder class rather than on itscallmethod, 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. Themissinggroup 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 undernatives/.deps/<target>/<name>-srcare the pinned revisions — theirHEADs were compared againstlibs.versions.tomlbefore copying — so the verbatim files came from there rather than from a download that might have been a different tag.checkLicenses -Pgoldberry.releaseCheck=truepasses 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 bydocs/gaps.mdG15 and G16 and the entries had not been struck.SDL_EVENT_TEXT_EDITINGandSDL_SetTextInputAreaare bound,text.edit.Editordraws the composition where it will land, andtext-inputandtext-areatake one inline; apassworddeliberately 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 andDeclaredResourcesTestnow 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 -
AThe second seam turned out to be a composition, andValidatoris over aString, anddate-pickerwill want otherwise.fieldneeded 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, soFieldstill holds aValidator<String>,FieldStatestill 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.parsemay throw or answer null and both mean the same thing, becausejava.timethrows 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, formatching’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.ContainingBlockshifts 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 withleftandrightboth 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 andtext-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: notsegmented,tourorscroll, whose parents genuinely have no padding, buttabs— an underline pinned across a header withpadding: 0 12pxcame out 24 points short of its own label — andtoast, 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, throughacrossBorderBox, 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 ofWINDOW_MOVEDdeduplicated per position, and a fabricated-event test under thedummydriver. 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 asLocatedon 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.replacePopupsnow 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 toRegion.bounds(), the layout rectangle, which for a button inside ascrollis hundreds of pixels from where the button is drawn. It readspainted()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.lateis 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 ismax(pendingSince, lastFrame + interval)— andpendingSinceis 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 onFrameStats: 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 anMeasured: 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 noSDL_GetModState.modfield 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 whatModifierPollBenchmark(./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 ofIt warns now, once per kind per node type. The entry’s own last sentence was the design: a default that is “right foronPointeris a guard on every pointer kind, and nothing warns.dragX’sNaNand quietly wrong for a nullbutton”, because the two are not the same kind of default.NaNis 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, sobutton() != PRIMARYis 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 — andButton.NONEwas the other shape and fixes nothing, since the guard still fires backwards against a value that now looks deliberate. All ninebutton()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 atoastraised from a handler deep in the tree, with every layer above it carrying a callback.findAncestorStatecannot 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 underwindow-rootand not an ancestor of anything inside it, so walking up reacheswindow-rootand stops.host()gives the window andToasts.atis 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:corelearning what a toast is would undo the reasonToasts,MenusandDialogsare three classes there rather than three methods on the window. A generalhost.service(Class)is the shape to reach for ifMenusorDialogsever 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 forTabPhaseto be promoted “when the second consumer arrives”; it iswidgets.core.Phase, moved there by ADR-0166 — whose own javadoc says “there was never anything tab-shaped in it” — with theclosing → removedhalf extracted intoDepartureby ADR-0234. Six families use one or both, includingtoastanddialog, which is to say both of the consumers the entry named as wanting it. So the second entry’s “that isTabPhaseagain” was pointing at a wall that had been a door for milestones, and what was missing was atourwalking through it. It has an arrival now (opacityand a 4px rise, §3.1’s popover row bar the scale, whichpopoveritself also lacks and for the sametransform-originreason) and a travelling cut-out (one rectangle interpolated, so the ring, the hole and the card cannot disagree mid-flight).AnimationSweepTestcaught two real gaps within a minute — aPhaseonTourVeilwith noisAnimating, and a package with no test naming it — neither of which an image would have shown. The goldens did not move:TourGoldenTesthas 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” — andTourStopalready banks the window’s own rectangle from the frame before, throughLocated. The card is one node further in andMeasuredis the same door.ESTIMATED_HEIGHTsurvives 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 ismasonry’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” — isBuildContext.token(ADR-0254), and the launcher holds anElement, which is aBuildContext. The second asked whether the design system should carry a duration that is not motion, anddesign-system.md§3’stooltiprow 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.durationistoken’s sibling with the cascade’s ownms/sparser 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-focusdiffers per theme.FocusGoldenPairTestreads 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 onAttributesnow, 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 ofSemantics.role()andaccessibleName(), 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, soButton.accessibleName()answered"": a control a reader cannot announce, passing a sweep that only checked for null. It sits besidetooltipandcontext-menufor their reason, which the entry had already written (“a gap the whole catalog shares”), and the label wins where there is one. — ADR-0260 -
It has two, and has had since--gb-list-row-heighthas no consumer.listshipped.ListStatereads the token throughBuildContext.token(ADR-0254) andlist-rowwritesheight: 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, “listis M3”, is the other half that expired:listis built. — ADR-0257, ADR-0254, ADR-0074 -
ABoth paths refuse one by name, and both refusals are tested. The entry was right that it cannot work — the woven path would generatestatic@Actionis still unsupported, and nothing refuses one explicitly.target::methodfor a method with no target, and the reflective one writesfindVirtual— and wrong that nothing says so.ModelWeaverthrows aWeaveExceptionandRuntimeBindinganIllegalStateException, both reading “is static; an action changes a model, and a static one has no model to change”, andModelWeaverTest.staticActionandRuntimeBindingTest.staticActionare 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.StyleLintisSupportedPropertyTest’s machinery with the test taken off it: every rule through the real cascade, every declaration to the realComputedStyle, andFindingvalues 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 andgroup-box-titledrew square corners for months anyway. The engine side is four lines:withreturnsthisin 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 unresolvablevar()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 avar()the other does not. — ADR-0257, ADR-0249, ADR-0216, ADR-0215 -
It says so now, once per axis. The entry expected this to be hard — “a diagnostic would have to know that aflex-growmeans nothing inside ascroll, and nothing says so.growresolved against an unbounded main axis, which Yoga knows and does not report” — and it is a field comparison:ScrollContent.renderis handed its children as boxes, withflex-growalready 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’swarnIfNestedOnTheSameAxisis 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 “aMeasuredassertion on the first built row”; what it got is the cascade, becauselist-rowdeclaresheight: 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-heightis 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 noIt istext-align.text-align: endnow, and nothing had to be added toBox. The entry’s reason was quoting §8’s own note — “Boxcannot express them” — and that note was right aboutbackdrop-filterandletter-spacing, wrong about this one, and has since been overtaken onbox-shadowtoo (ADR-0310: aDecorationcomponent and a stack of rounded rectangles, noBoxfield required).Paragraph.paintis already handed the box’s width, because it has to be or the text could not wrap to it, and everyTextLinehas 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-valueiswidth: 40pxby 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.pngplus the showcase’s Basic screen in its three variants, with9%,50%and100%finally lining up on their trailing edge.leftandrightare refused, for ADR-0247’s reason: they are not the same asstart/endunder RTL.justifyis 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 iswhite-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 areoptionandselect-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 haswhite-space: normal|nowrapandtext-overflow: clip|ellipsisnow, andwhite-spaceis the whole mechanism — undernowrapthe 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-overflowis 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 inheritswhite-spaceand does not inherittext-overflowand a bundle cannot be half-inherited — sowhiteSpacehad to joininheritsSameAsas well asinheritingFrom, which is ADR-0248’s standing warning. And ADR-0148’sflex-shrink: 0came off the label rather than being reverted: it stopped the wrap by stopping the shrink, andnowrapstops the wrap without it. The accelerator keeps its own, because half ofCtrl+Shift+Kis not a shortcut. What ADR-0235 left that is not closed isprogress’s indeterminate sweep, which is a design decision about a shipped animation. — ADR-0255, ADR-0235, ADR-0148 -
--gb-list-row-heighthas no consumer, and no widget can read a resolved custom property at build time.BuildContext.tokenis the other door, and it was three lines.Elementalready implemented bothBuildContextandStyleElement, andElementTreehas held aStyleResolversince ADR-0149 — what was missing was the method.WidgetRenderer.prepareis the new part and it is about ordering:renderhands the tree its resolver on the way in, which is a frame too late for a reader inbuild. The stakes were higher than a repeated number:density-compact.csssets--gb-list-row-height: 26px, so a list writtenvirtualized(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 — aStatefulwidget builds inside theElementTreeconstructor, 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, becauseListViewis a composition node whose state builds thelistelement — which is where it ships, on:root. — ADR-0254, ADR-0251, ADR-0213 -
It is a token now, and it was an accessibility gap rather than a styling question. The entry’s diagnosis was right — a--gb-caret-widthis not a token and the caret is one logical pixel.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-inputandtext-areaeach had their ownCARET_WIDTH = 1, the second’s comment saying it was the first’s — one constant inwidgets.form.Caretsnow, with a test that says they agree. And there is a third consumer:TextInputState.laidOutreserves “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()andisMaximized()ship, andSDL_EVENT_WINDOW_MAXIMIZED/RESTOREDarrive as oneBackendEvent.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, soIt can, and the entry was half stale when it was written.scroll’s line height is a constant.Paints.Context.colorhas 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, andlengthis it. The interesting half is that reading it is not enough: the wheel arrives where there is no context to ask, soScrollViewportreads the token inrenderand banks it intoScrollStatethrough the shapeonMeasuredalready had. That makes it a frame late, which is ADR-0117’s bargain unchanged — a paint always precedes an input. What is left islist, 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.findAncestorStateis the whole implementation — it exists forscrollIntoViewand answers this question with nothing added, which is why it is asked inScrollState.buildrather 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, becausebuildruns per element per invalidation and a document that nests in four places has one mistake. — ADR-0251, ADR-0243, ADR-0116 -
It is built, and it positions nothing. The entry’s own last sentence had become “whatstackis still owed.stackstill wants isstack” 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 isposition: absoluteso 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 caseComputedStyle.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.ComputedStyledoes have a list of what inherits —inheritingFromis two lines,colorandtypography, 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 weretour’s parts — built from plainTextandButtonwidgets carrying a class, so every one was matching a known type and simply not saying so;text.tour-titlematches exactly what.tour-titlematched 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:rootis the theme’s token layer.RuleBucketTestholds those eight as an exact set — a threshold is a number somebody raises. — ADR-0249, ADR-0152 -
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--gb-surface-2has been mistaken for an elevation three times, and it is unresolved whether it should keep existing.badge’s fill, ascrollbaron hover, agroup-box-titleband, askeleton-barand a collapsedsplit-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 isThemeTest:--gb-surface-raisedis never darker than--gb-surfaceand--gb-surface-sunkennever lighter, on both themes — and--gb-surface-2takes 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 -
AIt works open, and the condition the entry set for adding a capture phase was met. The entry named the fix —select’s typeahead works closed and not open.Handleshad anonKeyCaptureand noonTextCapture— 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.textInputcaptures root-first then bubbles, which isdispatchKey’s shape exactly and removes an asymmetry nobody had written down: one event kind had a phase the other did not.SelectListreads the letters on the way down and calls the sametypeaheadthe closed control calls, son,n,ncycles 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 atreegets 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: startis CSS — Box Alignment Level 3 — and Yoga has onlyflex-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 inkeywordafter the enum’s own lookup so a constant namedSTARTcould never be shadowed by it.leftandrightstay refused: they arejustify-contentonly and are notstart/endunder 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 -
It is built, and the entry was wrong twice in the toolkit’s favour. §8 had listedalign-selfis not in §8’s subset.align-items/self/contentall along and named onlyflex-basisas unimplemented — so the document claimed this worked, and what was missing was the implementation rather than the sanction. AndAlign.AUTOwas already waiting for it: the enum’s own comment says “AUTOonly means anything foralign-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, withalignItemsandalignSelfthe same type, so a swap between them compiles and runs — and it was already insured.RecordWitherTesthas 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.stackis one blocker lighter; what it still wants isstackitself. — ADR-0244, ADR-0181, ADR-0111 -
Nothing warns that aIt 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” — andvar()resolved to nothing — it logs, per node, per frame.ComputedStylehad 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 publicforgetReportedDrops()for tests; aStyleResolveris 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 onbuttonand ontextis 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 -
emandremdo not resolve against the node’s ownfont-size.emdoes now, in two passes, and the fix needed no plumbing at all.CssLength.Contextwas always the right shape; what was missing is that nothing built one per element —WidgetRendererholds one for the whole tree and handed the same instance to every node, soemwas one constant at every depth. The two passes are CSS’s own rule rather than a refinement: onfont-sizeanemis 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.DEFAULTis 16 andTypography.INITIALis 13, so1emwas not the parent’s size, not the element’s own, and not any size the toolkit renders text at.Transformwas the same bug in a second place and said so in a comment; it takes aContextnow. What is left isrem, and it is above. — ADR-0242, ADR-0066 -
Nothing validates an application’s own theme.ThemeAuditdoes, and the pairs are found by convention rather than listed. The arithmetic was nine private lines inContrastTest; it iscss.contrast.Contrastnow, in:coreand 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>-bgwith a matching--gb-<name>-text— which the design system already follows, and which audits--gb-mycard-bgfor 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-bgis#1c212ae6and is the shipped example.ContrastTestnow 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--nord10for 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 wasNORD_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 thecolorof the element it is drawn in and that element supplies its ownbackground, and for every mark in the catalog the same rule sets both — a checked tick is--gb-checkbox-mark-checkedon--gb-checkbox-bg-checked, both fromcheck-indicator:checked. So the pair is oneComputedStyle’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-borderfailing 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:dispatchstill 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.isInputis untouched — takingWHEELout 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 frompointerMovedalone, andcursorAtre-run fromupdateRegionsafter each paint.NaNis 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:hoverand:activehad the same staleness, and thatmark’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 saidnot-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, andBoth 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 fabricatedKind.WHEELhad exactly one consumer for a long time.SDL_MouseWheelEventthrough the real translate and the real sink — but untilscrollshipped 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.wheelconsumed unconditionally; it now consumes what it moved, which isScrollViewport’s existing rule applied to a second widget rather than a new one invented for it.KnobChainingTestis 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 imperativeThe survey is done, and the answer is two objects rather than one controller. The arrival needs nothing shared —AnimationControllerhas now lost all three of its own.Phaseis already the whole of it, and six widgets use it without wanting more. The departure was the same code twice:dialogandmessageeach 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 isDeparture. It is still not anAnimationController: it drives no value, interpolates nothing and owns no clock. What the survey also settles is thattoastandtabmust not be converted — a toast’s departure ends when its stack’s queue says so and a tab’s ends insiderender— 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]). Thestackhalf 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 thatEscapeand 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;Escapeis the user stepping back out of what they opened, one menu at a time. The launcher randismissPopups()for both, so openingFile → Recentand pressingEscapetook the parent with the submenu. Finding which handler was at fault was most of the work — aPopupwatches 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 -
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;Placementstill clamps, and now two callers have stopped asking it to.menuandselectcap their own content first, from a measurement rather than a guess ([ADR-0179]). Any other caller that opens an oversized popup and offers noHost.Fitgets 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 onelementAtwith a test that fails if it stops being. The modal half was worse than unwritten:Handles.isModalsaid 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 -
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 readsPointerRouterhas one listener slot, not a list.hovered()orfocused()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 namedonPointingChangedreads 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 aSubscriptionnow, which the slot could not express at all. — ADR-0230 -
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--gb-*-linehas one consumer, and the widgets that should be next have not been looked at.field:invalidedge the entry named. The other four were words, and §1.2’s floor for words is 4.5:1 where-lineis derived against 3:1, so pointing them at-linewould have moved them from clearly wrong to quietly wrong:--gb-danger-lineis 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. Thebadgehalf 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 —noBareHueDrawsInkreads the stylesheet, which is the one question a contrast measurement cannot answer. — ADR-0229, ADR-0175 -
They ask theircollapseandcarouselnever stop asking for frames.Phasenow, 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 — acarousel’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; aDoubleUnaryOperatorclosing 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, soCollapseSectionguards onopen; andCarouselTest’s ownanimating()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 isrender. Asked afterwards, the answer is current. One line moved, and it is worth one frame of every animation in the toolkit.TabMotionTestdocumented the waste in a comment and now asserts its absence. — ADR-0228 -
AIt 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 neithermessagetakes nobind=, so a banner whose text comes from a model has to be described away rather than emptied.StylednorPaintsand has no children contributes no box, which is how every composition node already works. SoWidget.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 isfield-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 onisAnimatingis 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.AnimationSweepTestis that test, in two rules: a widget holding aPhasedeclaresisAnimating(structural, scoped to things that actually paint, or it names six false positives and gets deleted), and every declaration ofisAnimatinghas a test beside it that names the method (which catches the animations aPhasedoes not describe — a tab’s number, a scrollbar’s idle clock). It found a real gap on its first run:ScrollViewportandScrollFadehad 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 bareIt 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.Alttap does not activate the menu bar, andF10does.Keynames no modifier on purpose, soAltreaches the router asKey.UNKNOWNand is indistinguishable there from every letter that arrives as text; the platform keycode is the only place the distinction survives, andWindowis the last component that holds one. What is bound is not aShortcutat 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.F10stays beside it as the binding that survives a compositor which eatsAlt, and both toggle now. — ADR-0223 -
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; amenubaris not built, and it wants a menu that outlives one opening.Menuis a value, so amenubarholding one holds it for as long as the bar is mounted.Acceleratorswalks that description and binds every command with a key on it, with no menu on screen and none needed. A bar’s children areitems and a nesteditemis a heading, so no markup was added. — ADR-0163, ADR-0106 -
They do, and fixing either did fix both. The missing item-to-popup callback isLeftandRightdo not move between menus while one is showing.MenuSignals, and a bar hands its root menu aMenus.Siblingssaying 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 keepsLeftin one going back a level rather than leaping along the bar. — ADR-0219, ADR-0163 -
An accelerator is unbound by key, so aThe map remembers owners now, andmenubargoing away can take somebody else’s binding with it.menubaris 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, andremoveShortcut(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 doesShift+F10. The entry named both pieces correctly:Key.MENUis SDL’sSDLK_APPLICATION, and the element-wise anchor turned out to already exist asanchorOf, which the tooltip path had been using since ADR-0111.Shift+F10is bound beside it because a Mac keyboard has no menu key; bareF10is deliberately left to themenubar(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 keyboardIt opens in the same frame.Rightinto a submenu waits 150ms.Itemcan tell a hover from a keypress now:hovered()is what the pointer did andopen()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 -
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, andLeftdoes not close a submenu.Escapeis the key that means “put this away”. — ADR-0219, ADR-0112 -
Nothing marks the row whose submenu is showing.item.opendoes. 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 shapemenu-title.openalready used. — ADR-0219, ADR-0113 -
AIt 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.messagecannot go away with a fade. -
The sibling reflow is still not built, andIt is built, and which toasts move turned out to be a fact about the overlay layer rather than about the widget. Atoastis now the thing that could build it.toasteris pinned to a corner andcontrols.cssputs 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 wasHost.anchor(id)in the end: the height comes fromMeasured, banked every frame because the toast is gone by the time it is wanted, and the gap comes fromtoaster { gap }through the channel ADR-0177 opened for the frame clock. A column ofmessagees 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 itThe 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 ownIt 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 isToaster.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” ofmax-widthturned out not to be:toastkeeps 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’sminimumWidthis a runtime measurement (field.size().width()) that no declaration can express (ADR-0145); andtext-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 doesselect. The popup facility takes aHost.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-heightare 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 toLTR, 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.Bidirun 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 whatwidthBetweenmeans 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 besideactionsandbindings”, and it was; what it did not guess was that the binding registry would settle the question. A@Bindfield holding a controller is refused with “a value that cannot change is not something to subscribe to”, which is exactly what a controller is.Namedis the registry for objects that are neither methods, resources, nor values that change.scrollstill has the gap — aScrollControllercould be named the same way and nothing has done it. — ADR-0170 -
Nothing can ask for focus, soA container can hand focus down now.fieldhas no click-to-focus.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-boxandcardare candidates and neither has asked. — ADR-0170 -
It does, andHost.focusstill does not exist.dialogis what needed it:host.focus(id, fromKeyboard), by id forHost.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.unmounttells 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 -
All four are, andmin-widthandmax-widthare not in the CSS subsetdialoghas 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.tooltiphas amax-widthof 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 adialog’s minimum.text-area’s max rows shipped with the widget (ADR-0171).toaststays 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. Andpopover’sminimumWidthis not amin-widthconsumer 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 -
Afield’s error summary is a list and not a widget.messageis built andMessage.summary(errors)is the summary — onedangerbanner 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 childformadds, 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 fromValidatedtoFormAccessthat nothing else needs. What is still open is aform summary=#truethat does exactly that, and it is waiting on that notification rather than on the banner. — ADR-0175, ADR-0169 -
A golden of aBoth halves are fixed now. The first was the gallery rendering twice and asserting on the second, 200ms in, which §7’stext-areais a golden of its first frame.messageforced. The second — feeding the hit-test regions back between those frames — stayed open because nothing needed it badly enough, andmasonrydid: a layout that reads last frame cannot be photographed at all without it (ADR-0196).Measuredis 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 itstext-areawrapped at the width it actually has. — ADR-0175, ADR-0171 -
All five of the leftovers are built, and one of them was never actually blocked. The keyboard three —treeis built in a first cut, and §3 asks for more.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 arecheckable=and the selection models (ADR-0210). Multi-selection was recorded here as blocked onlistand that reading was too strict:treedefined the node model itself for the same reason, and wrote down thatlistwill have to agree — the selection models are the shape every desktop list has, which makes it a small promise to make onlist’s behalf.listis built now and the promise was kept:Selectionmoved to it andtreeimports 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 wordcheckabletwice, on which rows are an answer and on whether rows carry a box. Both ship, under two names, and the disagreement is now inARCHITECTURE.md§17.1. §2’s chevronrotateis two marks instead, because §8’s subset has notransformon a mark — the wallselect’s chevron hit — so a closed row draws>and an open onev, and the cost is the animation. — ADR-0210, ADR-0209, ADR-0184 -
Both are built, and the promise held. The item-factory did survive contact:listrenders every row, andtableis still waiting on the recycler neither has.virtualized(rowHeight)calls the same function with the same items and nothing about the API moved (ADR-0213).tableturned out not to be waiting for the recycler at all but forlist— 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 × heightis 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. -
AIt scrolls. The popup facility reports what it measured, so neither caller has to guess, and both give the same answer from the same helper —select’s list is clamped rather than scrolled when it is taller than the screen.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 -
It is. The selection is a set,select multiple=… is not builtchangeis 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 aSelectListunder the field, the rows commit onEnterrather than following the focus, and the field’s text is never rewritten without the user choosing.select autocomplete=#trueis built too (ADR-0183): the closed control holds a realtext-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;Escrestores and a free-typed value is refused unlessfree, both off one nullable string of offered text thatTextInputState.followalready knew how to honour.tree=#trueis built too (ADR-0184), and so is a first cut oftreeitself, whichlisthad to agree with since §3 says the two share an item-factory — and does, now thatlistis built and the selection models have moved to it (ADR-0212). — ADR-0182, ADR-0141 -
AThe 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-0104selectopened from the keyboard does not give focus back to the field. -
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,_destroyand_add_stop_rgba32build one,bl_context_set_fill_styleand its_rgba32companion put it on the context and take it off, andbl_context_fill_path_d— the plain fill, with no_rgba32suffix — 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, because0x00000000is transparent black and a green fading to it goes through grey.goldberry-htmlandgoldberry-vectorboth start one commit further along. — ADR-0207 -
split-paneis not built.Both ship, and §5 is complete. The divider turned out to wantcarouselis not built.knob’s gesture anchor rather thanslider’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 -
AThe 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 “carouseldoes not pause when focus lands inside a slide.:focus-withinin 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 afield, which validates when the keyboard leaves it. So what shipped isHandles.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 -
It ships, as a widget rather than as a flag oncollapse’saccordion=is not built.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 oncolumnwould give every column in every document aStateit never uses.column accordion=#trueinflates to anAccordionthat reportscolumnas 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, andThey exist,segmentedis the second control that wanted one.segmenteduses them, and the fourth asking is what built them.button.squareasked first,segmentedsecond — both went round the outside, the bar keeping the radius and the segment inset.group-box-titlecould 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 writingborder-radius: 7px 7px 0 0since it shipped, and the engine had been dropping the declaration with a warning nobody read.Cornersis 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 andtabsare the two callers ofCorners.inRowthat have not arrived yet. — ADR-0217, ADR-0216, ADR-0097 -
It asserts the platform’s own half of the contract now. One test read the realWaylandDecorationsTestasserted/procexists./proc/thread-selfand assertedOptional.of(false)unconditionally — true on Linux and false everywhere else, in a suite all three OS legs run (macos.ymlandwindows.ymlboth run:core:testunfiltered). The fix is not a skip: where/proccan 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 isonInitialThread’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 windowNo popup of any kind holds the platform keyboard now, soanyWindowFocusedmeans 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 focusesPOPUP_MENUwindows 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. AMENU-kind popup is focusable and is the likely culprit; the suggestion panels areTOOLTIP-kind andNOT_FOCUSABLEsince ADR-0186, so they can no longer be it. The next step is a real window and a log ofFocusChangedper 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 watchesFocusChangedand callsdismissPopupsafter 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, soanyWindowFocusednever goes false; or the platform not sendingFocusChangedat all when the owner is hidden rather than deactivated. Diagnosing it needs a real window and a real compositor. — ADR-0185 -
It is, and it took the shape this entry predicted — one component onflex-wrapis not in §8’s subset.Box, one onComputedStyle, 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.SelectLoopTestdoes, 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 staleopenflag. One signal opens an editable control now, and the signal is focus. What the harness still cannot reach is the platform’s window flags — revertingNOT_FOCUSABLEfails 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.
MenusTestdoes 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 ahudthroughFrameStats.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 sincescrollshipped. 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 —restyleruns afterwards and allocates. Every node under ascroll, atabor asegmentedre-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.FocusChangeddoes. 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 -
It lives inoptionlives in…controls.segmentedandselectwill want it.…controls.option, andselectwants exactly whatsegmentedwanted. 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 aselecttoggled 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.measurelays 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.Placementis, 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 throughBackendWindow.workArea()and translated byposition()— 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 -
Answered: it runs, through the real SDL, on every CI run. A test cannot turn a wheel — butSdl3Backend.translate’sMOUSE_WHEELbranch has never run.SDL_PushEventcan, which is what the call is for. A fabricatedSDL_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 realtranslate, 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’sdummyvideo driver, so it needs no display and runs on all three platforms. The cursor half was already answered: the showcase setsCursor.CROSSHAIRat start-up, soSDL_CreateSystemCursorandSDL_SetCursorreally run. — ADR-0061, ADR-0056, ADR-0057 -
Group opacity is a multiply, not a layer.Answered: it is a layer. A node withopacity < 1and children is composited through an offscreen raster drawn at full strength and faded once, which is what CSS specifies.group-opacity.pngis 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:disabledcontrol at 45% moved, and the diff is confined to that control — the correction, reviewed rather than accepted. — ADR-0071, ADR-0064 -
Answered: a weight is a face.body-strongis not drawn, and no control uses a weight.Inter-SemiBold.ttfis extracted beside the variable file,font-weightresolves to one of two shipped faces in the cascade, and a button’s label is Inter 600 at 13/18. Instancing thewghtaxis 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.mdG27 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;obliqueis 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 shippedFixed, 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 untilbuttoncolour pairs are below §1.2’s 4.5:1 floor.badgeforced the question, and the first run ofContrastTestfound--gb-button-danger-texton--gb-button-danger-bgat 3.55:1 —--nord6on--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:activeis 5.11:1 on light), so nothing needed a new colour system — the ramps needed sliding, and the value that was:activeis 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:hoverat 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. Sobutton.dangeron dark now darkens on hover, against that theme’s usual direction and alone in the toolkit in doing so.--gb-danger-filland--gb-accent-fillreplace the aliases to--nord11and--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-hoverand 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/-activeare 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_FAILURESis 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 -
Answered, and the trap it named is what the change is about.transformis in §1.7’s whitelist and is not implemented.transformandtransform-originparse, cascade, apply down the box subtree the wayopacitydoes, 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_opwas already exported for the display scale, andBL_TRANSFORM_OP_ASSIGNreplaces the context’s matrix rather than composing onto it — so the stack is accumulated in Java, which is also what makes it invertible. Blend2D’ssave/restoreare not exported and turned out not to be needed. A computedtransformis the function list, not a matrix, becausetranslate(50%)and the50% 50%origin default are proportions of a box that has no size until Yoga has run — and because halfway betweenrotate(0)androtate(180deg), interpolated entry by entry, is a collapsed box rather than a right angle. — ADR-0068 -
The check mark still does not scale.Answered, andtransformwas 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 withtransform. The reason is that aBox.Markis 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 withopacity, because a node that appears with the value has no previous style to move from and would snap.radio-group-scaling.pngis 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 -
Fixed.:activewas set on one element, so no control had a pressed state.:hoverwalked the ancestor chain from the beginning;:activewas set on the single deepest element the press landed on — so pressing a checkbox’s 16px glyph lit upcheck-indicator, pressing its label lit uptext, andcheckboxitself matched only in the sliver of padding between them.checkbox:activehad been incontrols.csssince 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.keyPressedbuilt aShortcutfrom every key that reached it, to use as a map key.Shortcutrefuses to holdKey.UNKNOWN— an accelerator on it could never fire — so theIllegalArgumentExceptionwent up the UI thread with nothing above it. Not an edge case:Keynames the keys a shortcut might use, so every letter, digit and punctuation mark that arrives as text isUNKNOWN, 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-bgwasnord1, 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-surfacewas invisible to the entire suite;controls-on-surface-{dark,light}.pngadd the missing axis rather than one more scene. — ADR-0073, ADR-0050 -
Answered, and deliberately at four controls rather than at thirteen. §1.3’s--gb-densityis not implemented.regular | compactships: every control sizes itself from--gb-control-height, anddensity-compact.cssis a three-token:rootblock in the theme layer — the same slot asnord-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 andlayeris the only term left to separate them, which is why the test asserts the layer rather than the resolved height.Density.REGULARships no stylesheet at all — regular is not something an application applies, it is what the toolkit already is, and adensity-regular.cssrestating 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-densityitself 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 onCtrl+Dand 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-bgwasnord1on 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-bordernow, because a 4px groove is an edge. What is different this time is thatcontrols-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.everySurfacelessControlIsCoverednow asserts every entry inControls.controlTypes()is in that scene, withbuttonexempt 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 atransformso that adding a scale does not move the groove. — ADR-0080, ADR-0079 -
It ships, as a value rather than a function.fader’s dB scale is not implemented.Scaleis a sealed interface with two inverse methods and two records — the obviousDoubleUnaryOperatorspelling 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 alogor anexpfor 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, andtabs,menu,select’s popup list and a toolbar all get it by returningtruefrom 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:corerather than againstradio, because the next three users will look nothing like a radio. — ADR-0073 -
A focus scope has no axis.Answered.Handles.focusScope()returns aFocusScope—NONE,HORIZONTAL,VERTICALorBOTH— andradio-groupis the one composite in the catalog that legitimately answersBOTH, because its direction is its stylesheet’s and.inlineflips 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 handlesDownitself works either way. The failure it prevents is a menu item with no submenu decliningRightand aBOTHscope quietly sliding focus to the next item: the user asked to open something and the selection moved instead, with no error anywhere.HomeandEndbelong 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 droppingopacityfrom 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_dandbl_context_restore_clippingare the third and fourth new exports, andRenderTree.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.Windowchecks 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 isWindow’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.mdsays “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::disabledstays 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 indispatchplusisFocusable, so a control written without its owndisabledcheck 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 coversonKey,onKeyCaptureandonTexttogether. 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-boxand adialogin itsclosingphase 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,setStatemutates 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 -
Answered, and now driven by Yoga itself. A Java upcall returningYGSizestruct-by-value upcall returns.YGSizeby 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_listpair onmacos-aarch64, and the MSVC/INCLUDE:and.defbranch onwindows-x64. The Windows leg buildsgoldberry.dll, runs:natives:testagainst it withgoldberry.native.required=trueso a skipped test cannot pass for a passing one, and matches the golden images — which is also what answers Win64’s 4-bytelong, 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 anSDL_AddEventWatchcallback 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 at6dbc2ceand AsmJit at0bd5787, 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 intogoldberry-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_*andbl_context_fill_glyph_run_d_rgba32were 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.SvgPathreads 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 aBox: 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 -
ATwo copies per face now, not per size.Fontcosts two copies of the font file, and there is one per size.FontFaceholds 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. AParagraphshapes once and wraps with arithmetic, and its measure function reports a height to Yoga through theYGSizeupcall. 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.ParagraphCacheholds shaped paragraphs keyed by(font, text); the width memo stays inside eachParagraph. 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 aFontuntil 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.RenderObjectowns aYGNodethat 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-indicatorrestyles the indicator while the checkbox’s own style need not change at all, and that rule is incontrols.csstoday. One hook —setPseudoClass— covers:hover,:active,:focus,:disabled,:checkedand: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 ispresent. 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’sdummydriver 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.txtreadsgradle/libs.versions.tomlitself, 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.checkPinnedRefsis inverted — it asserts no copy has come back, across every workflow rather than three, which is what would have caughtexample.ymlpinning Blend2D to a floatingmaster. 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:WaylandDecorationswarns, once, with the command that fixes it. Not by asking SDL, which cannot answer —libdecor_newsucceeds even when every plugin failed, so SDL marks the surfaceWAYLAND_SHELL_SURFACE_TYPE_LIBDECORand 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 forx11,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=waylandasks 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. Withoutlibdecor-0-devSDL 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.ofasks for decorated and resizable andSdl3Backend.createWindowpasses exactly that. It only became visible when ADR-0082 addedegland the Wayland driver started being built at all. — ADR-0083 -
Answered: the table it checked had drifted from what SDL demands. It probedcheckToolchainpassed and the build died two minutes later.pkg-config --exists xss, a module no distribution ships — SDL’s own spec isxscrnsaver— so the row returned “absent” whether the package was installed or not, and it was marked optional besides, while SDL’sCheckX11treats XScrnSaver as aFATAL_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 nowLinuxDependenciesin build-logic with a three-valuedNecessity, andLinuxDependenciesTestasserts 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 is | What it may know | |
|---|---|---|
| Values | a @Model class of plain fields | nothing. No widget, no window, no toolkit type beyond @Bind |
| Actions | an @Actions record nested in the values | the values. Not widgets, not the window |
| Views | Widget records — or a .kdl document | the values it reads and the actions it calls |
| Application | one implements Application, and no annotation | all 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.gainis told. - a frame — by default;
repaint = falsefor a value nothing on screen shows (ADR-0135). - a restyle —
restyle = truewhen 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:
| Shape | Fields | When |
|---|---|---|
values with a nested @Actions record | private | the default |
| one class, values and methods together | private | a model with three fields |
values and a sibling @Actions class | package-private | you 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 in | Dies when | |
|---|---|---|
| a scroll offset, a caret, which tab is open, a hover | State on the widget | the widget is unmounted |
| the gain, the theme, the document being edited | the values class | the 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=andpress=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 shows | a @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 does | an @Action on the actions class |
| something only Java calls | a plain method on the actions class |
| a derived answer about the values | a method on the values class |
| a scroll offset, a caret | State on the widget |
| an icon, a font, a native handle | opened in start, closed in stop |
| a menu, a popup, an accelerator | the Application, which has the Host |
| a new node name for markup | @Markup on the widget |
an action that needs the Host | a 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):
| Launch | First frame |
|---|---|
| GraalVM native image | about 520 ms, the window open at about 120 ms |
| JVM with a JDK 25 AOT cache | about 1.3 s |
| JVM, cold | about 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
| On | Means | |
|---|---|---|
@Model | a class | it holds values: its @Bind fields are rewired |
@Actions | a class | it holds only methods, acting on somebody else’s values |
@Bind("a.b") | a field | markup names this value; restyle = true means a rule depends on it |
@Action("a.b") | a method | markup names this handler; no argument, or one the toolkit can parse from a string |
@Markup("button") | a widget class | this 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:
implements BoundModel, and a lazily createdFieldListeners;- a synthesised
goldberry$set$gain(int)— compare, store, notify; - every
putfield gainrewritten 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@Actionsclass beside the model change its values (ADR-0134); bindings()andactions(), built from the annotations, the second as oneinvokedynamicper action bootstrapped byLambdaMetafactory.
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):
| Woven | Bound at run time | |
|---|---|---|
| Who | a GraalVM native image | everything else — gradle run, mvn exec:java, an IDE, java -jar |
| Build step | the weaver, over the compiled classes | none |
| A change notifies | inside the assignment that made it | at the next sweep |
| Needs | nothing | the model’s package open to the toolkit, in a named module |
Models.isWoven | true | false |
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
@Actionsrecord 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.
- 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:
| Flag | Does | Needed by |
|---|---|---|
--models | rewires @Bind fields, writes the @Action call sites | a native image only |
--catalog | writes the module’s WidgetCatalog from its @Markup widgets, patches provides into module-info.class, writes META-INF/services | every 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.
| Refused | Because |
|---|---|
static @Bind field | A 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 array | Only 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.c | The grammar Bindings enforces at runtime, checked first (ADR-0062) |
| two members claiming one name | Two features quietly sharing one name presents as a value changing by itself |
an @Action taking two arguments | A 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 box | A valued action crosses as the string the document wrote down |
a static @Action | An action changes a model, and a static one has no model to change |
an abstract or empty @Model | Nothing to weave into, or nothing to publish |
@Actions with a @Bind field | A class that holds values is a @Model |
@Actions with no @Action method | It publishes nothing |
both @Model and @Actions on one class | A class holds values or it does not |
a @Model extending a @Model | Each 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 Property | No 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 name | A 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
WidgetCatalogservice andlibgoldberryitself 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
| Flag | Why |
|---|---|
--module-path / --module | The showcase runs modular, as it does everywhere else (ADR-0007) |
--enable-native-access=…natives | JEP 472, naming the one module that touches native code |
--no-fallback | Removed. 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:+ReportExceptionStackTraces | Names 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:
| Class | When | Why |
|---|---|---|
NativeLibrary | run time | It dlopens in its initializer, which must not happen in the builder |
Downcalls | build time | It holds the shared Linker, and every holder’s initializer calls Downcalls.link |
the …calls packages | build time | A 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 frames | per frame | |
|---|---|---|
without the --initialize-at-build-time lines | 2.55 s | 42.5 ms |
| with it | 0.061 s | 1.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-NNNNline 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
| Status | Meaning |
|---|---|
| Proposed | Written down, not yet agreed. Open question. |
| Accepted | Agreed and in force. |
| Superseded | Replaced; 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-gpuon 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
headlessbackend 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 OSheadless— renders to aBLImagefor 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. headlesskeeps 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_GetWindowSurfaceplusSDL_UpdateWindowSurfaceRectsmaps directly ontopresent(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:
- Widgets — immutable Java records with a pure
build(). Cheap to construct, cheap to throw away, diffed by type and key. - Elements — the mutable instantiation of a widget. Holds state, owns lifecycle, and is what persists across rebuilds.
- Render objects — one per visual node. Owns a
YGNode, aComputedStyle, 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.mdsays elements hold state and mentions aProperty<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
MemorySegmentnever leaves thenativesmodule (see ADR-0007, which makes this enforceable rather than aspirational). - Arena discipline. One shared
Arenaper 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 exposeclose(); aCleaneris 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
jextractat 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 underMETA-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
:nativescannot name a generated binding type, whatever its author intended. --enable-native-access=io.github.digitalsmile.goldberry.nativesis 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
jlinkor native-image. - JPMS imposes real constraints: no split packages, and reflective access needs
explicit
opens. Test source sets need--patch-modulehandling, which Gradle does automatically but which shows up in stack traces when it goes wrong. - Adding a module now costs a
module-info.javaper 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
nativestasks are kept out of the defaultbuildgraph 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
MemorySegmentnever 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
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:
| Target | Zig triple | Output |
|---|---|---|
linux-x64 | x86_64-linux-gnu.2.28 | libgoldberry.so |
linux-aarch64 | aarch64-linux-gnu.2.28 | libgoldberry.so |
windows-x64 | x86_64-windows-gnu | goldberry.dll |
windows-aarch64 | aarch64-windows-gnu | goldberry.dll |
macos-x64 | x86_64-macos.11 | libgoldberry.dylib |
macos-aarch64 | aarch64-macos.11 | libgoldberry.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
ziginstall 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.macosSdkpoints at an SDK, and fails configuration with an explicit message otherwise. Building the macOS artifacts on a macOS runner remains the unambiguous option. windows-aarch64via 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.tomlfor 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.04links 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:
| Runner | Container | Produces |
|---|---|---|
ubuntu-24.04 | manylinux_2_28_x86_64 | linux-x64 |
ubuntu-24.04-arm | manylinux_2_28_aarch64 | linux-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 ARM64and Xcode atCMAKE_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-x64and 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, andlinux-x64is 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
.sobuilt 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-aarch64and 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-logicforsubprojects { }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 thanbuild-logicbut not configuration-cache friendly, and it has no plugin identity, soplugins { 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
libsaccessors, so the catalog is read throughextensions.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 generatedlibsaccessor. 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
:chartsseparate (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
:chartsinto:core. Rejected::coreis the primitives, style, layout, text, paint, and backend SPI. Charts are ordinary widgets built oncanvaslike any other, and putting them in:corewould blur what:coreis. - Keep
:galleryas 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-widgetsget 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:corealready 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-chartsbecomesgoldberry-widgets, and:galleryis 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 intolibgoldberry, 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-PARTYfile 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=trueis 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
LICENSEplusNOTICEfiles 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
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
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
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-accessargument 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
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:
| stage | cost |
|---|---|
| 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
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_GetVersionthrough 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:
| Call | Short? | Hot? | Verdict |
|---|---|---|---|
SDL_WaitEventTimeout | no — blocks up to a second | once per pump | never; it would stall the VM |
SDL_UpdateWindowSurfaceRects | no — 3–6 ms, talks to the compositor | once per frame | never |
SDL_GetWindowSurface | no — 72 ms when it allocates a new surface | once per frame | never |
SDL_GetWindowSizeInPixels, SDL_GetWindowDisplayScale | yes | ~3 per frame | allowed, 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:
-Dgoldberry.backend.videoDriver=<name>— an explicit choice wins.SDL_VIDEO_DRIVERorSDL_VIDEODRIVERalready 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.- 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:
LogsandStartupmoved out of:nativesinto 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
- Status: Accepted
- Date: 2026-08-15
- Relates to:
docs/ARCHITECTURE.md§3.1, §5, §8, ADR-0010, ADR-0017, ADR-0019, ADR-0020
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
- Status: Accepted
- Date: 2026-08-15
- Relates to:
docs/ARCHITECTURE.md§3.2, ADR-0002, ADR-0012, ADR-0015
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 | ||
|---|---|---|
| Blend2D | 6dbc2cefbc996379e07104e34519a440b49b15d7 | master @ 2025-11-29 |
| AsmJit | 0bd5787b54b575ed94bf32ac452153b34385c514 | master @ 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
- Status: Accepted
- Date: 2026-08-15
- Relates to:
docs/ARCHITECTURE.md§3.1, §5, ADR-0002, ADR-0010, ADR-0018, ADR-0019, ADR-0030
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
- Status: Accepted
- Date: 2026-08-15
- Relates to:
docs/ARCHITECTURE.md§6, ADR-0010, ADR-0017, ADR-0029, ADR-0031
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
- Status: Accepted
- Date: 2026-08-15
- Relates to:
docs/ARCHITECTURE.md§6.1, §6.2, §6.3, §15, ADR-0015, ADR-0029, ADR-0030, ADR-0031, ADR-0032
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
- Status: Accepted
- Date: 2026-08-15
- Relates to:
docs/ARCHITECTURE.md§6, ADR-0010, ADR-0031, ADR-0032, ADR-0033
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
checkPinnedRefscaught the fifth only after a failed build. example.ymlwas pinning Blend2D to a floatingmaster. 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
masterthrough 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
- Status: Accepted
- Date: 2026-08-16
- Relates to:
docs/ARCHITECTURE.md§6, ADR-0017, ADR-0029, ADR-0032, ADR-0034
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
- Status: Accepted
- Date: 2026-08-16
- Relates to:
docs/ARCHITECTURE.md§6, ADR-0004, ADR-0017, ADR-0028, ADR-0031, ADR-0036
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 hit | 0.02 µs | free |
ParagraphCache hit | 0.05 µs | a map lookup |
the YGSize upcall crossing | ~0.3 µs | Java called from C, struct by value |
| wrapping, memo miss | 4.8 µs | breaking five lines |
| creating one upcall stub | 11.0 µs | MeasureCallback.of + close |
| shaping | 56 µs | Font.shape, what the cache avoids |
Paragraph.of | 61 µs | shaping, plus building the prefix sums |
loading a Font | 650 µs | two face parses (ADR-0034) |
And a whole layout pass over the same tree:
| median | |
|---|---|
| layout pass, no text | 12.5 µs |
| layout pass, one paragraph | 40.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:
| median | p95 | max | |
|---|---|---|---|
| acquiring the buffer | 0.18 ms | 0.40 ms | 7.76 ms |
| painting | 5.10 ms | 10.65 ms | 19.18 ms |
| presenting | 1.92 ms | 4.10 ms | 6.96 ms |
| total | 7.86 ms | 14.18 ms | 23.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
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_QUIETisTRUEby default, which swallows the populate step’s output.git clonewrites no progress when stdout is not a terminal, and under Gradle’sExecit 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
-Darguments at all — the superbuild readsgradle/libs.versions.tomlitself — so there is nothing left to mirror into aninputs.property. The catalog is declared as an input tocmakeConfigureinstead, 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:
- A plain C program linked against the same
libSDL3.areported three compiled-in drivers —cocoa,offscreen,dummy— and initializedcocoa. So SDL 3.2.0 builds correctly on macOS 26, and the window server was reachable. - The same C program
dlopeninglibgoldberry.dyliband callingSDL_Initthrough it also succeeded. So the packaging — static archives, hidden visibility,-dead_strip, a 30-symbol export list — is sound, and_COCOA_bootstrapreally is in the binary. - The JVM with
-XstartOnFirstThreadsucceeded, 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
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 set | environment map set | result |
|---|---|---|
| no | no | finds cmake 4.3.4 |
| yes | no | finds cmake 4.3.4 |
| no | yes | Exec failed, error: 2 |
| yes | yes | Exec 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 --stopfixes it, which makes it look intermittent. The next build from a terminal starts a daemon with a goodPATH; the next build from the IDE starts one with launchd’s.checkToolchainwas 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.
checkToolchainbuilt it with a+at the start of a continuation line, which Groovy reads as unary plus on aString— so the branch that was meant to say “meson 1.3.2 is too old, here is how to upgrade” threwNo 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
ExternalProjectbuild directory configured by an older meson has to be removed, because meson refuses abuild.datwritten 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:
| Target | Runner | Output |
|---|---|---|
linux-x64 | ubuntu-24.04 + manylinux_2_28_x86_64 | libgoldberry.so |
linux-aarch64 | ubuntu-24.04-arm + manylinux_2_28_aarch64 | libgoldberry.so |
windows-x64 | windows-2022 | goldberry.dll |
macos-aarch64 | macos-14 | libgoldberry.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-x64has a right to expect it loads. - Keep
macos-x64only, 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
NativePlatformkeep accepting all six pairs and fail at load. Rejected:classifier()would keep producingmacos-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.
NativePlatformcan now throw where it previously could not. Constructing the record — not justof()— 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
nativeTargetsentry and oneswitcharm 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:
- 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.
- Cap at four. Four was best or tied-best at every size measured, and eight was worse at every size but the smallest.
- 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:
| Surface | 0 | 1 | 2 | 3 | 4 | 6 | 8 |
|---|---|---|---|---|---|---|---|
| 240×120 | 0.240 | 0.223 | 0.238 | 0.196 | 0.194 | 0.191 | 0.212 |
| 400×300 | 0.412 | 0.438 | 0.314 | 0.301 | 0.270 | 0.269 | 0.279 |
| 640×480 | 0.478 | 0.499 | 0.404 | 0.314 | 0.302 | 0.316 | 0.323 |
| 960×640 | 0.473 | 0.481 | 0.355 | 0.337 | 0.337 | 0.328 | 0.357 |
| 1920×1080 | 0.594 | 0.586 | 0.480 | 0.395 | 0.380 | 0.391 | 0.415 |
| 3840×2160 | 6.034 | 4.245 | 3.151 | 3.482 | 2.337 | 2.500 | 2.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:
| Workers | in-app paint (median) |
|---|---|
| 0 | 2.856 ms |
| 1 | 3.005 ms |
| 2 | 2.363 ms |
| 4 | 2.146 ms |
| 8 | 2.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::nativesis 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()beforepresentwas documented as a caution against a context with work in flight; it is now the synchronization point. Anything that reads pixels beforeend()returns is a bug that did not exist yesterday —ThreadedPaintTestasserts 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
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:
| SVG | Blend2D |
|---|---|
M L H V | bl_path_move_to, bl_path_line_to |
C Q | bl_path_cubic_to, bl_path_quad_to |
S T | bl_path_smooth_cubic_to, bl_path_smooth_quad_to |
A | bl_path_elliptic_arc_to |
Z | bl_path_close |
The two rows worth arguing about are the last three:
Amaps 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.SandTmap 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 aZor a bareMintervenes, 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
:assetsemits an absolute, arc-free,M/L/C/Zstream and:coreneeds 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:assetsmeans writing exactly the code theAbinding 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
fillis 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
Iconfor 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
IconCompileralready rejected it: transforms, groups, gradients and fills are not what an icon set needs, and supporting them badly is worse than refusing them.SvgShapeshandles 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.
BLPathCoreis checked to beBLObjectDetail-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_ROUNDis 4 whileBL_STROKE_CAP_ROUNDis 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, returningBL_SUCCESS. BlendPath.close()is not SVG’sZ. A path has two closes — finish this figure, give the memory back — and they arecloseSubPath()andclose()respectively. Naming them alike would maketry-with-resourcesdraw a segment.- A drawing command after
Zissues an implicit move. SVG says a new sub-path starts at the closed one’s start point; Blend2D says it more firmly, by refusing aline_towith 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.
SvgPathTestwalks 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
Boxyet. 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
Iconparses 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 reasonParagraphCachehas no consumer (ADR-0037).
ADR-0044: One face, many sizes
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:
| Object | Where it lives now | Why |
|---|---|---|
hb_blob_t, hb_face_t, hb_font_t | FontFace | All 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, BLFontFace | FontFace, via the new BlendFontFace | Size-independent, and the expensive two |
BLFont | Font | This is the size — it carries the font matrix |
ShapingBuffer, BlendGlyphBuffer | Font | Scratch 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 before | 680.9 µs |
FontFace.bundled — the parse, once | 429.9 µs |
Font.on — another size over a face that exists | 4.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 —
ShapedFontandBlendFontboth check their owner — so a process-wide cache would have to be per-thread, and aThreadLocalholding 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.
FontFaceTestasserts both halves: closing one size leaves the others shaping, and a font that parsed its own face still closes it. BlendFontno longer owns its bytes.BlendFontFacedoes, andBlendFont.on(face, size)borrows.BlendFont.fromBytesstill works and now makes a private face it closes — so nothing outside:corehad 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 aFontadds a second size without being given the face too.- The §6 sentence is now true. “One font buffer feeds both
hb_face_tandBLFontFace” 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
- Status: Accepted, with one row corrected by ADR-0046
- Date: 2026-08-16
- Relates to:
docs/ARCHITECTURE.md§5; ADR-0031, ADR-0037, ADR-0042, ADR-0046
Correction. The
dummy/offscreenrow below, and the consequence drawn from it, do not reproduce: underdummy,presentis 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:
| Suspect | Test | Result |
|---|---|---|
| The borrowed compositor buffer | Force the fallback path, paint into a heap buffer | Refuted. 2.28 ms heap vs 2.22 ms borrowed |
| The three new icons | Benchmark the scene with and without them | Refuted. +0.010 ms |
| The display server | Same build under Wayland and under X11 | Refuted. 2.22 ms vs 2.07 ms |
| Compositor contention | SDL’s dummy and offscreen drivers — nothing composites | |
| Per-frame logging | Move the showcase’s LOG.info out of the timed region | Real but small: ~0.4 ms |
| A cold cache | Rotate 24 buffers (59 MB, past the 32 MB L3) | Real but small: 1.3–1.4× |
| The environment as a whole | Run the benchmark’s exact loop inside the live application, on the UI thread, between two real frames | Refuted. 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:
PaintBenchmarkmeasures 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.- 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
Windowreports as paint, at about 0.4 ms a frame. The instrument was changing the reading. goldberry.paint.noBorrowandgoldberry.paint.noPresentare 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:
| Step | Median |
|---|---|
physicalSize() | 0.022 ms |
| Damage validation and marshalling | 0.021 ms |
SDL_UpdateWindowSurfaceRects | 6.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 wall | 6.43 ms |
present CPU | 1.61 ms |
| Blocked | 4.82 ms (75%) |
The CPU quarter is a copy. Sweeping the window size:
| Size | Mpixels | present CPU | Blocked |
|---|---|---|---|
| 480×320 | 0.154 | 1.00 ms | 4.30 ms |
| 960×640 | 0.614 | 1.70 ms | 4.36 ms |
| 1440×960 | 1.382 | 3.43 ms | 4.68 ms |
| 1920×1280 | 2.458 | 4.74 ms | 2.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:
- creates a full hardware
SDL_Rendererbehind the window, 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:
| Damage | present CPU | Blocked |
|---|---|---|
| whole frame | 1.76 ms | 5.75 ms |
| half | 1.36 ms | 5.73 ms |
| quarter | 0.98 ms | 5.38 ms |
| a tenth | 0.87 ms | 5.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.
presenton 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.- ADR-0045’s
dummyrow is struck, and its conclusion that the cost “is not waiting for a compositor” with it. It is. - 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.
PaintBenchmark’s number is not an artefact. Underdummy, 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
requestFrameis paced to the display, thoughBackendWindow.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, andSDL_LockTexturewould 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 insideSdl3Window.presentare 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
- Status: Accepted
- Date: 2026-08-16
- Relates to:
docs/ARCHITECTURE.md§5; ADR-0024, ADR-0031, ADR-0045, ADR-0046
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_rateas0.0fwhen unspecified and some drivers never fill it in. It means “do not pace”, not “fail to open a window” — sorefreshRate()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_CHANGEDusually 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:
| fps | paint | present | frame path per second | |
|---|---|---|---|---|
-Dgoldberry.frame.rate=0 | 111.1 | 2.25 ms | 5.51 ms | 862 ms |
| Paced from the display | 58.8 | 1.61 ms | 1.20 ms | 165 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
EventLooprather than in the backend.EventLoopdoes not mintFrameDueand does not own the pump timeout, so it would have to hold an event it had already been handed. The backend has both, andheadlessis 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_DisplayModeis in the layout probe, so the offsetrefreshRate()reads is checked against the compiled library rather than trusted. That check is not decorative here:refresh_rateandpixel_densityare adjacent floats, and swapping them deliberately producesSDL_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 ingoldberry_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. Alibgoldberrybuilt 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 usualdowncalldid, and it is too high a price for an optimization whose “unavailable” path is already defined. goldberry.frame.rateis 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.
PaintBenchmarkis 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
libgoldberryin 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.ymlstill runs the showcase from the source tree under Xvfb — that job is about the module path being right in development.showcase.ymlis about the thing a user would actually download, and asserts the same three painted frames. - The images are uploaded as
.tar.gzand.zip, not as directories.upload-artifactzips whatever it is handed, and the zip it writes does not carry the executable bit — which would hand somebody an image whosebin/javawill not run. Verified by round-tripping the tarball and launching from the extracted copy, not by reasoning about it. -XstartOnFirstThreadis in the macOS launcher. The same flagrunneeds (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
- Status: Accepted
- Date: 2026-08-16
- Relates to:
docs/ARCHITECTURE.md§8, §10; ADR-0004, ADR-0010, ADR-0031
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-betweentoSPACE_BETWEENis a name transform, and a table would be a second place for them to drift. - Resolving
em/reminside 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
papayawhipis not using the theming mechanism, and 148 names is 148 chances forgrey/grayto look like a toolkit bug. The 16 Level 1 names are there, with both spellings of grey.
Consequences
ComputedStyleis deliberately shorter than §8’s property list. It carries whatBoxcan 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.opacityresolves but is dropped byBox.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 becausetext()is not a round trip. A hash holdsff0000without its#and a dimension holds16without its unit. Anything reassembling a value — a serialized style, a hot-reload diff, an error naming the value that failed — needs the spelling back.:rootis 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
- Status: Accepted
- Date: 2026-08-16
- Relates to:
docs/ARCHITECTURE.md§14; ADR-0003, ADR-0016, ADR-0030, ADR-0033, ADR-0049
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=truerewrites 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 incore/build.gradle, because a system property otherwise reaches the Gradle daemon and stops there — the same trapexample/build.gradlealready 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
- Status: Accepted
- Date: 2026-08-16
- Relates to:
docs/ARCHITECTURE.md§1, §8, §9; ADR-0004, ADR-0010, ADR-0020, ADR-0049
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. Baretrueis not a boolean and is not a legal argument at all. - Raw strings are
#"…"#, fenced by the number of#, notr"…". - 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.
ReloadableSourceis parameterised on the parser, so aStylesheetand aList<KdlNode>reload through the same type; both are tested. - The §9 example document is a test. The settings window in
ARCHITECTURE.mdis 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
WatchServicehas 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. bindandactionare not implemented. §9 wantsKdl.inflate(doc).bind(controller)with explicit wiring and no reflective handler lookup. The lookup half — finding a node byid, 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
- Status: Accepted
- Date: 2026-08-16
- Relates to:
docs/ARCHITECTURE.md§5, §8, §11; ADR-0004, ADR-0020, ADR-0047, ADR-0049
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.
setStaterebuilds 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 nosetState. §9 does want aProperty<T>for KDL’sbind, and it will be built on this rather than instead of it: a property that marks its element dirty is exactlysetStatewith 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 consultneedsBuild(), 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
YGNodeand aComputedStyle— is not here.Elementproduces noBox. That is the next piece, and it is the one that makes the parity invariant testable, because it needs widgets that actually paint. - The
ElementAPI is wider than an application should need.rebuild(),update()andunmount()are package-private;markNeedsBuild()andsetPseudoClass()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()isObject, compared withequals. AString, anIntegeror 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
- Status: Accepted
- Date: 2026-08-16
- Relates to:
docs/ARCHITECTURE.md§5, §9, §11; ADR-0004, ADR-0045, ADR-0046, ADR-0049, ADR-0052
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.Statelessdescribes 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.
Rowsetsflex-direction: rowafter applying the style, so a stylesheet cannot turn arowinto a column. Everything else — colour, padding, gap, size — is the stylesheet’s. A name that a stylesheet can falsify is worse than no name. Spacerdefaults toflex-grow: 1unless the cascade set a grow. Taking the free space is what a spacer is for, and requiringspacer { 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 duplicatesBoxPainterwhile 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-directiononrow. 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”.
WidgetParityTestiterates the built-ins and checks all three, including that a Java-built and a KDL-built widget areequals— which records make a checkable claim rather than a slogan. - A golden image now covers the whole stack.
widget-tree.pnggoes 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.Contextexists 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,checkboxand the rest need input, which does not exist yet (§7).
ADR-0054: Hit testing runs against the painted frame
- Status: Accepted
- Date: 2026-08-16
- Relates to:
docs/ARCHITECTURE.md§7, §8; ADR-0031, ADR-0049, ADR-0052, ADR-0053
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 andforEachBox’s traversal have to agree forever, with nothing checking that they do. A field cannot drift. - Type
ownerasElement. Rejected:layoutwould then depend onwidget, and the box tree is deliberately usable without one — every golden image builds boxes directly. - Dispatch to every node and let widgets filter. Rejected:
Handlesis opt-in, so dispatch costs the number of interested nodes rather than the depth of the tree.
Consequences
buttonis 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.
BackendEventhas no pointer cases and the sdl3 backend translates none, so nothing callsPointerRouterfrom 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 breakGoldberryRuntime’s exhaustive switch until it handles them — by design. - Keyboard, text input, wheel and cursor are not here. §7.1’s
KeyEvent/TextEventsplit, libxkbcommon’sxkb_composefor 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
- Status: Accepted
- Date: 2026-08-16
- Relates to:
docs/ARCHITECTURE.md§3, §7.1, §15; ADR-0008, ADR-0010, ADR-0015, ADR-0035, ADR-0040 - Amends: ADR-0008 (drops one of the five pinned upstreams)
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:
| Check | Result |
|---|---|
xkb symbols in goldberry.symbols | 0 — the section header had nothing under it |
| Java code binding xkb | none |
| Exported xkb symbols | 0 |
| Undefined xkb symbols (dynamic linkage) | 0 |
DT_NEEDED for libxkbcommon | absent |
String libxkbcommon.so.0 present | yes |
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.tomlloses itsxkbcommonentry, andGOLDBERRY_XKBCOMMON_REFandGOLDBERRY_MESONare 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,NOTICEandlicenses/(ADR-0015).checkLicensesverifies 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 resultinglibgoldberry.socontains 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
- Status: Accepted
- Date: 2026-08-16
- Relates to:
docs/ARCHITECTURE.md§7.1; ADR-0010, ADR-0019, ADR-0054
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:
- There is no pixel-precise delta. SDL reports
xandyas 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. - The sign is a platform preference. When the user has “natural scrolling”
turned on, SDL sets
directiontoSDL_MOUSEWHEEL_FLIPPEDand leavesxandyinverted, 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. - 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 whendirectionisFLIPPED, 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.translatenegatesyonce 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
WheelEventtype and anonWheelmethod. Rejected: it would need its ownconsume(), 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_yfor 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
scrollwas 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.
deltaXis populated from a shift-wheel or a horizontal touchpad gesture; there is no widget to receive it untilscrollexists. - §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
- Status: Accepted
- Date: 2026-08-16
- Relates to:
docs/ARCHITECTURE.md§7.3, §8; ADR-0049, ADR-0053, ADR-0054
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
Cursorininputand havelayoutimport it. Rejected:inputalready depends onlayout, 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
BackendWindowonly 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: pointerworks 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_CreateSystemCursorandSDL_SetCursoractually 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. BoxandComputedStyleeach gained a component, which touched every wither and every branch ofComputedStyle.with. That is the cost of records with positional construction, and it is paid once per property.grabandgrabbingare 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
cursorAtafter 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
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
:hoverduring 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
Windowrather 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+Zwould then fireCtrl+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 — butof(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-paneandscrollthumb were both waiting on. :activecannot get stuck. The release reaches the captor even when the pointer is elsewhere, and clearing:activeis what it does with it.- Accelerators are per window, which is the scope a user means:
Ctrl+Wcloses 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
- Status: Accepted
- Date: 2026-08-16
- Relates to:
docs/ARCHITECTURE.md§7, §8, §9, §11;docs/core-widgets.md§3;docs/design-system.md§3; ADR-0004, ADR-0049, ADR-0051, ADR-0054, ADR-0058
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
Variantenum 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
RELEASEDand 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
Clickableinterface with anonClickmethod, instead of aCLICKEDkind. Rejected: it would need its own capture and bubble path and its ownconsume(), 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
buttonwith 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,radioand the rest are the same four pieces: a record, a registry line, a base rule, a parity test. - What
Boxcannot express is now visible in a shipped stylesheet. The 8px radius, the 1px border onghost, thebody-strongweight, and the 2px--gb-focusring at 2px offset are all indocs/design-system.mdand none of them can be drawn.controls.csssays so in a comment rather than approximating them, and:focus-visiblestands in with a background change so keyboard focus is at least visible. Each arrives with the thing that paints it. - An icon is a
Boxnow, 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.Buttontakes a label, an icon, or both. The icon is borrowed: a widget is a value rebuilt every frame and must not own something with aclose(), so markup names an icon against a registry rather than building one — a document reloaded on every keystroke would otherwise leak one per reload. :disabledis the one pseudo-class a widget owns.:hover,:activeand:focusare facts about the pointer and the keyboard and the router derives them;disabledis a fact about the description.WidgetRenderermirrorsStyled.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,TestFramesandRendererRequirementmoved 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 makessetState, reconciliation, theme switching, focus traversal and:hoverrepaints run outside a test at all.Windownow 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. bindis still missing — the read half of §9.actionis 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,
requestFramewakes 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
Observablerather than aProperty, 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).Boxfilled axis-aligned rectangles. - The focus ring — 2px
--gb-focus, 2px offset, following the control’s radius (§2.2).controls.cssfaked 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.
:disabledat 45% opacity, never colour-remapped (§2.1).ComputedStylehad parsedopacitysince the CSS engine landed andBox.styledropped 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
buttoncomplies 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-textand--gb-button-bg-focusare gone from both themes: the first two were the colour remap §2.1 forbids, and the third was the focus stand-in. A disableddangerbutton now still reads as dangerous, which a remap to one grey surface had made impossible. - One
BlendPathis allocated perpaintcall andresetbetween 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.withis 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 setwidthfrom one that setheightwithout counting commas, which is precisely the mistake the shape invites.- Still not expressible, and absent rather than approximated: the
body-strongweight on a button’s label, which needs a second Inter face (or the variable font’swghtaxis) 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 inbook/src/status.mdas 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:
- 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. - 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.
- 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
checkboxships: 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,selectandtabsall have one waiting.Box.Mark.DOTis already there forradio, which is the next control and the one that brings §7.2’s roving arrow-key focus with it. :indeterminateis the first pseudo-class added since the CSS engine was written, anddocs/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
:disabledby being handed the flag, not by the cascade.docs/core-widgets.mdsays “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 forformorgroup-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.
buttonis fully §3-compliant.body-strongwas the last of the four thingscontrols.csssaid 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:
headingwas 16 where the table says 15,bodywas 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, andcontrols.cssexposes them as classes —.body,.body-strong,.caption,.mono— sotext 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’sbody— 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.
WidgetRendererkeeps a single-Fontconstructor for benchmarks and for tests that are about something other than typography. It ignoresfont-family,font-sizeandfont-weight, and says so.- Open:
texthas nostyle="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 whenfieldandformneed labels. - Open:
emandremstill resolve againstCssLength.Context’s fixed numbers rather than against the node’s own resolvedfont-size. Nothing in the toolkit’s own stylesheets usesem, so this has no effect today — butfont-size: 1.2emcurrently 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:
| Midpoint | Result | Channel spread |
|---|---|---|
| sRGB | #b18f7b | 54 |
| OKLCH | #bf9152 | 109 |
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.
transformis not implemented, and it is in §1.7’s whitelist. It is whatcheckbox’s specified check animation (“scale 0.6→1 + opacity”) needs for its scale; the opacity half ships and the scale does not.Boxcarries 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/transformto 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
AnimationControlleris 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.pngis 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 thetransformgap 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:
Boxcarries 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
transformis. Every other property inComputedStyleis finished when the cascade produces it. This one cannot be:translate(50%)and thetransform-origindefault of50% 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)androtate(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)isscale(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
transformandtransform-originparse, cascade, inherit their effect down the box subtree exactly asopacitydoes, animate through the overlay, and route input correctly.Transitions.Animatablehas 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.Runningnow holds anObjectrather than adouble. Four of the five animatable properties are numbers — a colour is a number, because adoubleholds every 32-bit integer exactly — andtransformis the first that is not. A second map keyed by the same enum was the alternative, and would have givenobserve,apply,settleandcurrentOra second half to keep in step with the first.- The 2D subset only:
translate,scale,rotate,skew,matrixand the axis variants. The 3D functions need a projection the painter has no concept of, andperspectiveon a CPU rasterizer is a different feature wearing this one’s name. emandremin a transform resolve againstCssLength.Context’s fixed numbers rather than the node’s ownfont-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_dat minimum, andbl_context_set_global_alphato 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.pngis 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 rasterization | 354 µ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.rendercalledParagraph.ofper frame — 56 µs against 0.05 µs for a cache hit.ParagraphCachehad been built for exactly this andstatus.mdrecorded it as having no consumer. - The measure callbacks. A
MeasureCallbackis a confinedArenaand aMethodHandlebound 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
Boxis 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 + walk | median |
|---|---|
| throwaway tree | 190 µs |
| retained, nothing changed | 9.1 µs |
| retained, a fresh box tree every frame | 7.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
painton 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.paintstill works and is still what the goldens use. It builds a throwawayRenderTree, 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
RenderTreemust 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.ParagraphCacheis 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 rasterization median before any of this 354 µs with the paragraph cache 260 µs with the retained render tree 148 µs with the invalidation-driven cascade 3.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 + walkwith a fresh box tree each frame fell from 7.2 µs to about 4.2 µs, because a cachedComputedStylehandsBox.stylethe sameDecoration,TransformandInsetsinstances 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.
StyleCacheTestis 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
invalidateStylemust 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
customPropertiesForstill 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
:hoverthat changes onlybackground-colorre-resolves the whole style, including the typography and the layout half that no:hoverrule 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
opacityas 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 predictedstackwould make the difference visible. - Layer promotion.
docs/design-system.md§1.7 promotes a node animatingopacityortransformto 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
opacityis CSS’s.group-opacity.pngis two overlapping squares under a parent at 50%: the overlap is the upper square and the lower one does not show through it.LayerTestasserts 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, andstackno longer has to wait for it.- A promoted subtree that did not change is a blit.
RenderTree.rootChangedis 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.
opacitylives 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 excludeopacityfrom 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.LayerTestasserts 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.defand 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.
opacitylives 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:
| Question | Flag | Does the node’s own opacity/transform count? |
|---|---|---|
| Does the screen look different? (damage) | selfChanged | Yes |
| Does an ancestor’s raster need redrawing? | changed | Yes — an ancestor bakes in this node’s finished blit |
| Does this node’s raster need redrawing? | contentChanged | No — 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 fade | median |
|---|---|
| raster rebuilt each frame | 554 µs |
| raster reused | 199 µ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:
- the backend promises it;
- it is the same buffer as last frame — a backend may promise retention and still rotate between two, and identity is what catches that;
- 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×640 | median |
|---|---|
| repaint the whole frame | 367 µs |
| repaint only the damage | 117 µ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_dandbl_context_restore_clipping.restore_clippingrather than a save/restore pair, because there is only one clip depth in this frame path andbl_context_saveis 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 pickspaint(frame, damage)orpaint(frame). Deciding insideWindowwould meanWindowknowing what aRenderTreeis, and the two are deliberately independent —BoxPainter.paintstill works with neither. - The traversal is still full. Above.
canRepaintPartiallyis 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.defand 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.
CheckMarkhas to pick a shape for a state where none is visible, and it picksCHECKbecause unchecked → checked is the common transition. Going toMIXEDswaps 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: 4pxon 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 besidebutton’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
:activesection 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-groupgap 8,.inlinegap 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
radioandradio-groupship: records, nodes, CSS types, the invariant,bind+ valuedchange, 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 returningtruefrom one method.FocusScopeTestis written against bare widgets in:corerather than againstradio, because the next three users will look nothing like a radio. - Options are content-sized, not stretched:
align-items: flex-starton 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 returnsMap<String, Object>rather thanMap<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 andMap.copyOfhad 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
Downshould open a menu rather than move along the bar. That is a decision formenu, 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 forradioclosed it forcheckboxtoo, because the mechanism is one mechanism. Four parts exist where there was one. checkboxmoved, and deliberately: it gained the radius, the hover surface step, a working pressed state and the scaling tick.checkbox-states-darkand-lightare pixel-identical — the mark refactor changes nothing at rest, which is the check that it was a refactor — and onlycheckbox-interactionmoved, 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
disableddown to every option, so withoutradio-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” — andformandgroup-boxare 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-densityregular(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.
theGlyphHoldsStillasserts 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+Dand 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
buttonand notcheckboxwould 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 failsDensityTeston the day it is added, which is the point of scheduling this at four controls. theTwoDifferexists 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-heighthas no consumer.listis M3. It ships now because the density alistwill 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 forParagraphCache, 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 isnord0, 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
toggleships: a record, a node, a CSS type,bind+ a valuedchange, 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-paneand a scrollbar get a gesture origin by reading one accessor, andDragOriginTestis 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
togglehas 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.sliderwill have to build one. - Open:
--gb-toggle-heightand the other three component tokens have no test that they are honoured individually.DensityTestasserts 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
togglerow 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:
| what | specified | at 40px |
|---|---|---|
toggle-track | 36 | 16 |
check-indicator | 16 | 10 |
radio-indicator | 16 | 10 (an ellipse — border-radius follows the box) |
| control height, in a short column | 32 | 13 |
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.
ControlShrinkTestruns over the whole catalog, not over the control that was reported. The reported symptom wastoggle’s, and three of the four failures were incheckboxandradio— 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-shrinkis now available to applications, which §8 had promised and the engine had not delivered.flex-basisremains 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 forspacer. Aspacerwith a fixed size is presumably meant to keep it. - Open: no minimum size anywhere.
flex-shrink: 0stops 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 —
:disabledstays 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
formandgroup-boxcan be built without inventing anything: they declareisDisabled()and everything inside them becomes unavailable. So can adialogrunning §1.7’sclosingphase, 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
:coreagainst bare widgets, because its users —form,group-box,dialog— do not exist yet and will look nothing like a radio group. Same reason asFocusScopeTestandDragOriginTest. - 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
disabledcheck 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,ColumnandPanelcannot be disabled. None of them has adisabledflag, so today the only container that exercises this isradio-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
.inlineflips it — and will be wrong for a menu bar, whereDownshould 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-groupis the one composite in the catalog that legitimately answersBOTH, 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.inlineflips 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-groupisBOTHand no other scope exists, soHORIZONTALandVERTICALare covered byFocusScopeTest’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.tabsis 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:
- Where does the thumb go? No rule can name a position that came out of a model.
- 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 makeminunreachable, 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
Rightshould offer 50 rather than40 + 25rounded 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
Endand lands on 9 has been told the end of the track is not the end.maxis 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
sliderships: a record, a node, a CSS type,bind+ a valuedchange, drag, arrows, PageUp/PageDown, Home/End,:disabled, the shared focus ring, and four golden images. Six of thirteen controls, andfaderwith it.PointerEvent.local()is the primitiveknob,split-paneand 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. slideris deliberately absent from the sharedtransitionrule, 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 thingknobwill 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 whatScalewas built general for, and there is noknob.
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
AnimationAPI.AnimationController(forward/reverse/repeat/stagger) on the same frame clock — used internally by indeterminate progress, spinner, toast reflow, and available to apps forcanvaswork.
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 toSystem.nanoTimea 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 hasClockand its ownonPaint, which is what the two controls here use. - The reduced-motion pulse is absent, as above.
progresshas no:disabledand no label. §3 gives it neither, and a progress bar is not interactive, so:disabledwould 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
- Status: Accepted
- Date: 2026-08-18
- Relates to:
docs/ARCHITECTURE.md§15; ADR-0008, ADR-0012, ADR-0040, ADR-0055
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:
| Necessity | What SDL does | What the check does |
|---|---|---|
HARD_STOP | Stops the configure with SDL_missing_dependency | Fails, and says the configure will stop |
NEEDED | Drops a backend silently and configures successfully | Fails |
OPTIONAL | Builds without a feature Goldberry does not use | Warns |
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
checkToolchainthrough Gradle (example.yml,showcase.yml), or CI would fail its own preflight; - every
HARD_STOPpackage is installed bylinux.yml, which runs CMake directly inside the manylinux container with no JDK — socheckToolchainnever runs there and this list is the only thing between it and SDL’sFATAL_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_optionandSDL_missing_dependencyout 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.
eglwas never checked, so a machine withoutlibegl1-mesa-devpreviously 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/workflowsfrom a unit test, which couples build-logic’s tests to the repository layout.build-logic/build.gradlepasses-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.ymlis only guarded for hard stops, not forNEEDED. It installs nomesa-libEGL-develand noxkeyboard-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 inbook/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_DRIVERSfromwayland,x11. Rejected: it trades a missing titlebar for XWayland’s blurry fractional scaling, which is precisely whatSDL_WINDOW_HIGH_PIXEL_DENSITYand 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
BORDERLESSas 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_LIBDECORthrough 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 runscheckToolchainat all, and it is listed as an open question rather than half-built here.
Consequences
- A machine without
libdecor-0-devnow failscheckToolchainin 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_HinCMakeCache.txt, and Gradle sees no changed input, so a plain rebuild afterapt installsilently 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.ymlbuilds in a manylinux AlmaLinux 8 container, which runs CMake directly with no JDK and so never runscheckToolchain; whetherlibdecor-develeven exists in its repositories is unverified. The drift guard inLinuxDependenciesTestonly holds that workflow toHARD_STOProws, 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 inbook/src/status.mdtogether with ADR-0082’s unanswered question aboutmesa-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-0is 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:
| launcher | main runs on | libdecor |
|---|---|---|
stock java | a created thread | failed to init, then No plugins found |
embedded JNI_CreateJavaVM | the primordial thread | silent — 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:
| Caller | pid/tid | Result |
|---|---|---|
| the process’s initial thread | 51011 / 51011 | libdecor_new -> 0x5c16005d62d0, decorated |
a pthread | 51018 / 51019 | Failed 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
BackendExceptionwith 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=0so 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_PRELOADshim interposing glibc’sgettidto returngetpidfor 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
mainon 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_CreateJavaVMargument handling, and an answer to what./gradlew runand a plainjava -jarshould 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-cairorequires no rebuild; ADR-0083’slibdecor-0-devis 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_libdecorreturns false when the compositor offerszxdg_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,lib64andlibpaths). A distribution that puts it somewhere else getsUNKNOWNand 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.
WaylandDecorationsTestcovers 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 inbook/src/status.md.
ADR-0085: A window that closes beats a sharper one that cannot
- Status: Superseded by ADR-0086
- Date: 2026-08-18
- Relates to:
docs/ARCHITECTURE.md§3; ADR-0019, ADR-0027, ADR-0083, ADR-0084 - Amends: ADR-0084 (which decided to report and not act)
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
masterstill carries the unconditional thread check, and the loader reads onlyLIBDECOR_FORCE_CSD,LIBDECOR_PLUGIN_DIRandXDG_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-gtkis pulled in as a dependency of libdecor;libdecor-0-plugin-1-cairois 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.videoDriveralready 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
INFOwith 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-caironow 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. TheINFOline 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
verdictForWaylandis asked before SDL exists whileverdictis 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
- Status: Accepted
- Date: 2026-08-18
- Relates to:
docs/ARCHITECTURE.md§3; ADR-0027, ADR-0083, ADR-0084 - Supersedes: ADR-0085
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
javalauncher, and that is upstream’s deliberate position, unconditional in libdecormaster(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.BORDERLESSalready 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
x11alone. 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_DISPLAYthere 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.videoDriveris 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. TheINFOline at start-up names that flag, so the default is discoverable from a log rather than from this record. verdictForWaylandnow has only one caller. It stays split fromverdictrather 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:
| fill | with --nord6 | with --nord0 |
|---|---|---|
--gb-warning (--nord13) | 1.35 | 8.00 |
--gb-success (--nord14) | 1.77 | 6.13 |
--gb-info (--nord9) | 2.34 | 4.64 |
--gb-danger (--nord11) | 3.55 | 3.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:
| pair | measured |
|---|---|
nord-dark button.danger | 3.55 |
nord-dark button.danger:hover | 2.95 |
nord-dark button.danger:active | 4.38 |
nord-light button.primary | 3.50 |
nord-light button.primary:hover | 4.18 |
nord-light button.danger | 3.55 |
nord-light button.danger:hover | 4.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:
| variant | text | hover | correct? |
|---|---|---|---|
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.ARCexisted forspinnerand 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-coreGradle 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:
:corewas 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.mdhad specified their packages since v0.1 —row,columnandspacerundercore,textundertext,panelunderpanel— 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"))— aList.ofbetween every parent and its children, and a static helper turning a string into anAttributesbecause 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
@Bindhalf) and ADR-0126 (the@Actionhalf). 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
privatefield or method the generated code cannot see, with the fix in the message; @Bindon something that is not aProperty;- two members claiming one path — which
Bindingsrefuses at run time and this refuses before there is a run time; - an
@Actiontaking more than one argument, or one the toolkit cannot parse from theStringa 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.Lookuppassed in by the application —Bindings.of(lookup(), model). Authorised reflection, noopens, 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
translatewould 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 — andbuild/renderrun 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.insetssets 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
stackwidget the application wraps its own root in.docs/core-widgets.md§1 specifiesstackand 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.stackis 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 throughBuildContext, Flutter’s shape: any descendant finds the layer and pushes an entry into it. That is whattoastandtooltipwill 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.overlayis the half that is certainly needed either way — aBuildContext-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
positionin §8’s subset, deliberately: it is the same reasonaffixis a widget rather thanposition: sticky. A corner and a margin are Java’s, and--gb-window-marginis the name the number will take when a floating button needs it in a rule.
Consequences
Hostgrows two methods —overlay(...)andframes()— 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:rootmatches 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-scopeand 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, andfocus-scopeexists 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
collapsehas 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
recordtouches. - 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:coreand the catalog is:widgets, so:corewould have to ship the one widget it deliberately stopped shipping (ADR-0092). An application writeshost.overlay(new Hud(), Corner.BOTTOM_END), orhudin a document, and both are one line.
Consequences
Window.painttakes twonanoTimereadings 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-bgis 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
sdl3backend’s. paintMillisis the painter’s wall time, which on a multi-threaded Blend2D context includes waiting for its workers atendand excludes the platform’s own upload inpresent. 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
createPopupis 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 anifwould have. The SPI already draws this line:acquireFramereturns empty for a backend with no buffer to lend. - A separate
PopupWindowtype not extendingBackendWindow. It would keep a popup out ofwindows()— which sounds tidy until shutdown misses one — and duplicate the entire present and pacing path for no difference in behaviour. - Binding
SDL_CreateWindowWithPropertiesinstead and setting the popup properties by hand. It is whatSDL_CreatePopupWindowdoes 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
selectis 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,tooltipandpopoverare 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 withpopover. - Three new SDL symbols (
SDL_CreatePopupWindow,SDL_SetWindowPosition,SDL_SetWindowSize) and four new window flags, all exported fromlibgoldberryand 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_SUPPORTis 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
RenderTreeandHitTestfor a benefit onlytooltiphas 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
scrimunder 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 seeEscapeat all. Host.popupreturning aPopupand throwing when the platform has none. The SPI’sOptionalis 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,tooltipandpopoverare 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.updatecurrently 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
Tabinside 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 afocus-scopeand restores focus on close” is the widgets’ to keep, andfocus-scopeexists (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 (theselectpopup generalized): placement with flip/shift when near edges, light-dismiss on outside click/Esc; the primitive under menus, dropdowns,date-picker,color-pickerand autocomplete.
- A size.
host.popup(content, at, size)made the caller supply one, and the showcase’s menu was180×132because 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
Downby 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 out960×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.
- Preferred side,
gapaway, aligned byalign. - 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.
- 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
maxWidthon the box, measured once. The clean version of the two-pass measure, and it needsmax-widthin §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 makePopupneed 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,menuandtooltipare 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.
scrollisdocs/core-widgets.md§1’s and unbuilt, and it is the one thing between here and aselectover a realistic option list. BackendWindowgrew two calls, bothOptional, andlibgoldberryexports 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.moveexists, and nothing calls it. Apopoverthat 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:coremay 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
Tooltippedinterface 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
tooltipwidget wrapping its target, as some toolkits do. It puts an element between a node and its parent, sopanel > buttonstops matching a button with a tooltip — the same argument that keepsbindon 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
Attributeshas 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
menuneeds next for hover intent andtoastwill need for its timeout. PointerRouterhas 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-delaytoken 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
Menuwidget that opens itself, holding aHostor 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
RenderTreehas no notion of and should not. - A
submenunode in KDL. Nestingiteminsideitemis 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
menubaris not built. §8’s in-window bar withAltactivation 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 onAttributesexactly as a tooltip’s text does. - A keyboard
Rightwaits 150 ms, because it goes through the same hover-intent path. Wrong, and one line to fix onceItemcan tell a hover from a keypress. Hostgrewafter(delay, action), which an application can use for anything and which is what the tooltip already used privately.GoldberryTestAccessmoved to test fixtures, so:widgetstests 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:
closeasks for a tab to go. The strip removes nothing.newasks 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. closableon 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
tabsispanel’s first widget, andcard,group-box,split-pane,collapse,carousel,statisticandskeletonare still unbuilt.- A tab strip does not scroll. Enough tabs and the row overflows its window,
because §1’s
scrolldoes 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-panelwhere 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:widgetswidget and opening one isMenus.open, which wraps every item.:corewould have to depend on the catalog it deliberately does not ship (ADR-0092). - A
context-menuwidget wrapping its target, which some toolkits do. It puts an element between a node and its parent, sopanel > buttonstops matching — the argument that keepsbindandtooltipon the widget rather than on a wrapper. - The widget holding the
Menuitself 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.MENUin the key map and an anchor from the focused element’s rectangle — whichHost.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.
Attributeshas five components. Every wither has to preserve all of them, which the tooltip’s arrival is the reason anyone now checks (ADR-0105).menubarremains 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.
- A tab added or closed did not appear until the window was resized.
- The
+was 28 wide and 20 tall, so the mark drawn to fill it had a long arm and a short one. - 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
closeasking 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
Clockon the state instead of reading the frame clock inrender. 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
selectand noclose: picking it would report a value that does not exist. - This is the toolkit’s first enter/exit animation, and
toast,dialogandpopoverwant the same thing. What is here is deliberately a tab’s own — a sharedTabPhasepromoted 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.
| Screen | What it is about | Where it lives |
|---|---|---|
| Controls | §3’s controls whose value is a state | controls.kdl |
| Values | §3’s controls whose value is a number | values.kdl |
| Text | §2’s wrapped paragraph, and buttons that act on the model | Content.java |
| Overlays | §7’s two places something can float | overlays.kdl |
| Tabs | §5’s strip, gaining and losing tabs | TabsDemo.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=#trueis a constant, and a document that could evaluateclicks == 0would 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.
The gallery is a golden-image corpus, and the clock has to be frozen
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
spinneron 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 takesClock.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.kdlis 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:
selectbelongs on Controls,text-inputon a Forms screen that does not exist yet,dialogandtoaston 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:
- Warning spam:
dropping "transition": color 100ms ease-out, andignoring unsupported property "align-self". - A menu popup with black corners where its radius cut them.
- A tooltip whose text was not vertically aligned, and which “looks poor”.
- The cursor changing from a hand to an arrow the moment a tooltip appeared.
- 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-selfto §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
TooltipPanela 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
paddingworks 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.
tooltiphas 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?
- 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.
- 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-visiblerather thanitem:focus— the highlight is the keyboard’s affordance, which is what:focus-visiblehas 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
Downhas 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:focusand having the launcher clear focus on a pointer move. It makes focus follow the pointer, soEnterwould run whatever the mouse last passed over.
Consequences
- A keyboard
Rightinto 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. Leftdoes 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 thingMenuswould 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-tooltiprather than going back tocaption.
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.
- A submenu opened on top of the right-hand border of the menu it came from.
- 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
checkableflag besidechecked. 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()returnsBoolean. 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, atourstop 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
Leftto close it, and a chevron does not yet rotate or highlight when its submenu is open — the row is:focus-visiblewhen 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 (overflowin the layout subset), §11 (“hit-testing … respects clips and transforms”),docs/core-widgets.md§1’sscroll; 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:
clipTointersects with the clip in force. It can narrow and cannot widen.restoreClippinggoes 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
selectstepping 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:
- 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 pointedlocal()at a part had no way back to itself. - It works for the keyboard, which is why it is on
KeyEventtoo.PageDowncarries 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. - 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
- It is last frame’s. A measurement, not a prediction. A widget acting on it is one frame behind.
- 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.
- 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: widthoutright. 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:hoveris 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
The gallery wraps every screen, not the tall ones
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:
:corehas no widgets to wrap anything in (ADR-0092), andScrolllives 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:
setStatedefers. 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:
implements BoundModel, and a lazily createdFieldListeners;- a synthesised
goldberry$set$gain(int)— compare, store, notify; - every
putfield gainin that class rewritten into a call to it; bindings()andactions(), 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):
Property | woven field | ||
|---|---|---|---|
| write, no listeners | 9.5 ns | 2.5 ns | 3.9× faster |
| write, one listener | 19.0 ns | 12.9 ns | 1.5× faster |
| write, value unchanged | 0.28 ns | 0.22 ns | 1.3× faster |
read through binding, int | 1.2 ns | 2.3 ns | 1.9× slower |
| read through binding, reference | 1.1 ns | 1.6 ns | 1.4× slower |
| construct the model | 23.8 ns | 2.8 ns | 8.6× faster |
| build both registries | 1.15 µs | 1.15 µs | the 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
Stringthe document wrote down, andgoldberry$action$setGain(String v)callssetGain(Double.parseDouble(v))— one place, visible injavap, 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 byinvokespecial. An action that returns something is called for its effect and the value dropped, which is what aRunnablewrapping 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(...)plusLookup::defineHiddenClassgenerates 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, orLambdaMetafactory.metafactory; - every
invokedynamicbootstrap isLambdaMetafactory.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;
@Bindand@Actionare gone from the class at runtime, and@Modelis 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:
- a
GoldberryCatalogimplementingWidgetCatalog, whoseregistercallsinto.add("button", Button::inflate)for every annotated class — each one aninvokedynamicbootstrapped byLambdaMetafactory, the same call site an@Actiongets (ADR-0126); - a
provides io.…widgets.WidgetCatalog with …GoldberryCatalogpatched into the module’s ownmodule-info.class; - a
META-INF/servicesentry, 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
@Bindfield compiles to aputfieldin 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
IllegalAccessErrorat 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:
| Declared | Fires | |
|---|---|---|
| the binding | @Bind("a.b") | always, on a real change |
| a frame | by default; off with repaint = false | after the binding’s listeners |
| a restyle | restyle = true | before 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
@Modelclass of plain fields. Knows nothing: no widget, no window, no toolkit type beyond@Bind. - Actions — a
@Modelrecord wrapping the values. One method per thing a control can ask for. Knows the values, and nothing else. - Views —
Widgetrecords, or a.kdldocument. Knows the values it reads and the actions it calls, and cannot write, because what it is handed is anObservablewith noseton 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
privateto 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:
@Actionswith a@Bindfield is refused: a class that holds values is a model, and the message says to annotate it@Modelor move the field.@Actionswith no@Actionmethod is refused, the same way an empty@Modelalready 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:
| screen | before | after |
|---|---|---|
| Controls | 10 069 µs | 294 µs |
| Values | 8 125 µs | 126 µs |
| Text | 2 607 µs | 50 µs |
| Overlays | 2 923 µs | 17 µs |
| Tabs | 5 036 µs | 22 µ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 msreads 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.
paintis the toolkit’s share of an interval;frameis 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 — andSelectorMatcherwas asked about all of them for atextnode as readily as for abutton. - Custom properties are collected by walking to the root, and each level ran a
full cascade. One node at depth ten was eleven cascades.
resolvethen 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 ofexec-maven-pluginbecause there is no Mojo, and anything else getsjava -jar goldberry-weaver.jar target/classesand 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,
VarHandlereads the field,MethodHandlecalls 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
Modelsbuilds the same two registries reflectively. What an ordinary jar uses, and the default:./gradlew run,mvn exec:java, a green Run button and ajava -jarall 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:
- 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
@Actionsrecord 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. - At the top of every frame, over the models an
Applicationnamed. 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. - Wherever the application says so, with
Models.refresh(model)— a no-op returningfalsefor 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 today | 15.7 | |
unboxed reflective — same VarHandle, asked for an int | 5.2 | no codegen |
generated nestmate — checkcast, getfield, if_icmpne | 0.60 | |
| a plain Java call | 0.61 | the 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):
| woven | bound at run time | first cut | |
|---|---|---|---|
| a press one widget is watching | 15 ns | 45 ns | 107 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 binding | 1.3 ns | 10 ns | 32 ns |
| rebuilding both registries (a document reload) | 570 ns | 630 ns | 617 ns |
| binding a class the first time | 0.58 ms | 2.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:
nativeImageMetadataruns the showcase under-agentlib:native-image-agent, headless, for 120 frames, and writes what it observed intosrc/main/resources/META-INF/native-image/io.github.digitalsmile/goldberry-example.nativeImagerunsnative-imageover 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.ofallocates 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:
| Missing | Why the trace never saw it |
|---|---|
nord-light.css | the run never toggled the theme |
density-compact.css | the run never switched density |
JetBrainsMono.ttf | the run drew no monospace text |
OpenMoji-black.ttf | the 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 held | JVM | native image |
|---|---|---|
| bound to its address, built at run time | 10 ns | 4560 ns |
| unbound, built at run time | 10 ns | 4500 ns |
| unbound, built at image build time | 10 ns | 10 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:
| build | 60 frames | per frame |
|---|---|---|
| before | 2.533 s | 42.2 ms |
| this change, with the flag withheld | 2.55 s | 42.5 ms |
| this change | 0.061 s | 1.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:
| Setting | Value | Why |
|---|---|---|
| Neighbourhood radius | 1 pixel | The differences forgiven are sub-pixel. A radius of 2 would start forgiving a control that moved. |
| Channel tolerance | 72 levels | High 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 it | 1.2% | The worst honest case measured is 0.332%. |
| Multipliers | 2, 1.5 | A 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:
| Key | In a menu (Item) | In a bar (MenuTitle) |
|---|---|---|
Down | move to the next row | open this menu |
Right | open this row’s submenu | move to the next heading |
Left | nothing | move 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.
carousel: three brakes, and one of them is not built
§5 in one sentence:
Nothing advances on its own unless
intervalis 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:
- The top bar with counter is small now, only at Panels tab
- In black theme I do not see any visual differences between panel and card
- What is the purpose of group box? I thought I should group elements with title and border.
- Make carousel animations
- 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:
| Token | Dark | Light |
|---|---|---|
--gb-surface-raised | nord2, a step up | #ffffff, because white is the top |
--gb-border-strong | white 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
collapseunmounts 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:
SDL_StartTextInputwas not on the export list. SDL3 delivers noSDL_EVENT_TEXT_INPUTto a window that has not asked for one.SdlEventBuffercould read the event,Window.handleTextInputrouted it andKeyboardTestexercised it — and on a real SDL window it had never once arrived.- 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.
- A
Paragraphcould 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
Stateholds 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
Backspacedoes to a selection, whereCtrl+Leftlands, 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.
The caret blinks on a timer, not on the frame clock
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-inputand autocomplete all reuseTextEditandEditHistory, 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 doorBuildContext.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_SetTextInputAreato 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-areawants onetext-selectionper line rather than a different part, andParagraph’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:
- Move fields to cards
- 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.
- The placeholder needs different text/style from the regular text
- Caret is too large in height
- In light theme the background of fields is too pale
- 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-sunkenand--gb-text-placeholderare design-system surface, and both are translucent — so an application overriding either must think about what it composites over, exactly as--gb-border-strongrequires.selectchanged 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-sunkenexists. - 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-areainherits 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
carouselhas its third brake, and the entry ADR-0165 left open closes.findAncestorStatehas a consumer, and its shape is confirmed by the one case that fits it rather than by the case that did not.TestHost.afterno longer throws. It could not survivetext-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
formis the second widget to want to —scrollwas 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
Validatoris over aString. What a user typed is text until something parses it, and a validator is exactly the thing that decides whether it can be. Adate-pickerwill wantValidator<LocalDate>over its parsed value, which is a second seam and not a change to this one. text-area, the pickers andcode-inputinherit all of it — afieldvalidates 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
formis the second to want one. […] Nothing can ask for focus, sofieldhas 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:
@Bindfield … 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:
| Registry | What …= names | What it is |
|---|---|---|
ActionRegistry | press=, change=, submit= | a method |
BindingRegistry | bind= | a value that changes |
Icons | icon= | a resource with a lifetime |
Named | controller=, 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 elsesplitserves. The bug was general and the token that exposed it was the first to have spaces in a function. Wiringhas a fourth component. Its old three-argument constructor stays and defaults the new one toNamed.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.
delegatesFocushas one consumer and is the kind of thing that should have two.group-boxandcardare 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
wrapis 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-inputand the typed fields ofdate-picker,time-pickerandcolor-pickerare allTextEditplus 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 atext-areameans either ascrollaround 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-areais 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. Enteris 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:
| package | types |
|---|---|
…goldberry.css | 23 |
…goldberry.backend | 21 |
…natives.yoga | 22 |
…natives.blend2d | 20 |
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:
| module | packages before | after | largest package |
|---|---|---|---|
:core | 15 | 35 | 10 |
:natives | 7 | 15 | 12 |
:widgets | 38 | 39 | 11 |
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 held | JVM | native image |
|---|---|---|
| bound to its address, built at run time | 10 ns | 4560 ns |
| unbound, built at run time | 10 ns | 4500 ns |
| unbound, built at image build time | 10 ns | 10 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:
| flag | where the handle is | ns/call |
|---|---|---|
--initialize-at-build-time=Outer | static final on Outer | 10.55 |
--initialize-at-build-time=Outer | static final on Outer$Nested | 4537.82 |
--initialize-at-build-time=Outer,Outer$Nested | the same nested class | 11.25 |
--initialize-at-build-time=<package> | nested, anywhere in it | 8.07 |
| any | instance field of a record | 4539.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:
| binding | before | after |
|---|---|---|
Yoga | 658 | 409 |
Blend2D | 821 | 561 |
SdlVideo | 837 | 653 |
HarfBuzz | 393 | 249 |
Sdl | 296 | 198 |
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.corewould 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:
| info | success | warning | danger | |
|---|---|---|---|---|
dark, on --gb-surface | 3.74 | 4.94 | 6.44 | 2.46 |
light, on --gb-surface | 2.21 | 1.67 | 1.28 | 3.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, forcarousel’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.
A gallery image is the second frame now
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,hudand nowmessage. What is left in the group isdialogandtoast, and both inherit this record’s arrival: a dialog’s scrim and a toast’s slide are the same clock-driven mount. toastinherits 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 viatranslate, liketoast” — 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-*-lineis right for any glyph or border in a semantic hue, and the widgets that already draw one — afield’s:invalidborder, abadge’s edge — have not been looked at.ContrastTestwill 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 nodisplay, so no widget can take itself out of a layout. The thing that can is whatever describes it — which is why the summary is anOptional, 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
columnhas 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 becausetoastwill 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
-linederivation 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 nocolor-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.
toastis what is left, and it inherits both halves of this: the fade-then-tell order, and aPhaseper entry. What it adds is a queue, which is also what will let it solve the reflow neither this normessagecan. min-widthandmax-widthare not in the CSS subset, and §2 asks a dialog for both. Yoga has the setters andBoxhas 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.isModalhas one consumer, which is one fewer than a mechanism should have. Awizardstep and asheetare the plausible seconds. It is tested in:coreagainst bare widgets rather than throughdialog, so the second consumer finds a mechanism rather than a dialog-shaped hole.Host.focuscloses 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,dialogandtoast— 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 aPhaseper 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.ZEROand no action can only be removed byclear(). 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
ToastBoximplementsMeasuredand 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 atransform, 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 fromrenderbeside the frame clock, through the channel ADR-0177 opened for the clock and for the same reason: it is a reading onlyrendercan 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. isAnimatinghad 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 onisAnimatingdirectly.- 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
ToastGoldenTestis 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, whichHudGoldenTestfound first. Measuredhas a fifth consumer, and the first whose reason is not its own geometry but a sibling’s.book/src/TODO.mdcalls 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:
Menusguessed. It decided whether a menu would be taller than the screen from its row count times an assumed 34px, and wrapped it in ascrollif 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-heightis 32, so the number was also kept in two places, one of which is a stylesheet a widget cannot read.selectdid not try, and a list with more options than the display is tall lost its bottom. The same defectmenuhad 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).
:corecould not act on the answer anyway. A viewport is a widget and:corehas 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_ESTIMATEis 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
selectlist longer than the screen scrolls, which is the user-visible defect this was written for. Placementstill clamps, and that has not changed: a caller that opens an oversized popup and offers noFitgets 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.Hostgained a fifthpopupoverload, which is one more than a surface this wide wants. The alternative was a standaloneHost.measure(Widget), and it was refused on cost: it would build a throwaway element tree on every popup — runninginitStatetwice 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.TestHostconsults theFit, driven by ameasuring(width, height)knob, because aFit’s only observable is the widget its caller decided to open and a test without a window has nothing to measure with. Unset, noFitis 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.
refocusdrops 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_MENUwindow 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. -
refocusis the router’s fourth per-frame job, and the only one that can change focus. That is worth knowing when readingupdateRegions: a frame can now move the keyboard, where before only input could. -
hovered,pressedandcapturedare not swept. They can go stale the same way and none of them has a demonstrated bug:hoveredis 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.csssays outright that 360 “is a width rather than a maximum because the subset has nomax-width(seedialog)”.tooltip— “has no maximum width of its own”, which is what makes a long one a single unreadable line.popover—minimumWidthis a Java argument onHost.popupdoing 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.applyskips 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 failurestaysIdenticalAcrossFramesexists 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:
tooltipgets 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 ofToaster.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.toastkeeps its width. The note incontrols.cssgave 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’sminimumWidthcannot become a declaration. It isfield.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 betweenrowsandmax-rowsand scrolls past that. Also never waiting on this.
-
BoxandComputedStyleare 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.RecordWitherTestcloses 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 awidth/heightswap 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
InsetsandLimitsalready do for their four apiece — and doing it to the rest would turnbox.width()intobox.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.ZEROand no action button was removable only throughToastController.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=#trueis not built. §3’s combobox form makes the closed control an editabletext-input— typing filters,Escrestores the last committed value, and a free-typed value is refused unlessfree=#true. The suggestion machinery it needs now exists and is proven by the free-text form; what is left is hosting an editable field insideSelectFieldand the commit/restore rules over it, which is its own decision about where the editing state lives.select tree=#truestill waits ontree.- 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 aboutWiring, not about this widget. SelectListis public and in the wrong package, which is a wart taken knowingly.Optionwas moved into a package of its own the day it had two callers, and this now has two; the CSS type it carries isselect-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
badgeand is not thebadgewidget.controls.cssnames both types in one rule so the metrics are stated once. It could not simply be aBadge, 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 forTabClose’s reason: aselectis 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 pressEnteron 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:
Spacetypes a space. §3 listsSpaceas 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
selectnow has eleven components, and this is the second control to reach the size where ADR-0181’s positional-constructor argument applies.RecordWitherTestcoversBoxandComputedStyleand not the widgets; extending it to every record with withers is the obvious next move and was not made here.tree=#trueis the last of §3’sselectline still unbuilt, and it waits ontree, which does not exist.- The editor is not told to select-all on focus. A
text-inputreached 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 realtext-inputand 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 theselect’s own — which was checked rather than assumed — socontrols.cssstrips 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 aselect. - 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=#truefails 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
cascadepropagating down andindeterminateupward — 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, andHome/Endto the first and last visible rows. Each is additive and none changes what is here; they are filed rather than half-built. - §2’s
rotateon the chevron is two marks instead. “Expand/collapse: chevronrotatebase” is not available, because §8’s subset has notransformon a mark — the wallselect’s chevron hit and the reasonCHEVRON_DOWNexists besideCHEVRON_ENDat all (ADR-0141). A closed row draws>and an open one drawsv. 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 aSupplierfor its children, which is not a thing KDL can say; §3 calls it “atree’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. Selectis twelve components wide. ADR-0181’s positional-constructor argument now applies to it more than toBox, and this change churned four call sites to prove it.RecordWitherTeststill covers onlyBoxandComputedStyle; extending it across the widgets is overdue.listwill have to agree withTreeNode. §3 says the two share an item-factory, and defining the model here meanslistinherits 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:
- A
multiple’s chip appeared and the row it came from stayed grey. - An
autocompletetook one character and then went dead. - 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
FocusChangedand 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.
MenusTestdoes 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
anyWindowFocusedtrue. 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 ofFocusChangedper 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
MenusTestdrives 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
MenusTesthas 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
ATTACHEDpopup isNOT_FOCUSABLE, so it cannot be what keepsanyWindowFocusedtrue; aMENUone 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 theNOT_FOCUSABLEflag 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.
SelectLoopTestnow 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.placeableAreaconverts the work area into the window’s space correctly,Placementclamps 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 in6c0618eprints 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.
A content module’s native library links against libgoldberry
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:widgetsbefore 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-plotmay 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 owntray-iconmet first and has since fixed (ADR-0191): eleven symbols added, the list at 203.SDL_OpenAudioDeviceandSDL_OpenCameraare 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-mediais 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
libgoldberryand one symbol file; the paint surfacegoldberry-htmlneeds 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
menubarstill registers one; - any widget that is neither
itemnorseparator, becauseMenu.childrentakes any widget and a shell has nowhere to put atext.
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_CreateSurfaceFromandSDL_DestroySurface, which are how a painted BGRA buffer becomes an icon.SDL_UpdateTraysis deliberately absent — SDL calls it from its own event loop, and this toolkit pumps events. The fiveSDL_TRAYENTRY_*values went into the constant probe with everything else, which is what catchesDISABLEDbeing0x80000000and therefore a negativeint. HeadlessTrayis 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 deprecatedline 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 trieslibayatana-appindicator3.so.1andlibappindicator3.so.1and nothing else — the-glibsuccessor 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.mdrather 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
menubarand the tray now share a description and not a behaviour. The sameItemregisters 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
Boxand 22 inComputedStyle, 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, becauseWrapis a type no other component has — the same protectionRecordWitherTestprovides for components that are same-typed, and the reason its fixture now holdsWRAP_REVERSErather than a default. select multiple=shows whole chips on as many lines as it needs. The control grows, whichheight: auto; min-height: 32pxhad 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-wrapshas 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-wrapis available to every widget and stylesheet, and nothing else uses it yet.wrap-reverseparses 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-contentis 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_clippingrather than a save/restore pair, because there is only ever one clip depth here andbl_context_saveis 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.symbolsthat 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:
| Check | Result |
|---|---|
| Lightness band | FAIL — nord13 at OKLCH L 0.855, nord8 at 0.775 |
| Chroma floor | FAIL — six of eight below C 0.10, so they read as gray |
| CVD separation | WARN — nord14↔nord13 ΔE 6.5 under protanopia |
| Normal-vision floor | FAIL — nord9↔nord8 ΔE 8.5 |
| Contrast vs surface | WARN — 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.
| Slot | Hue | Light | Dark |
|---|---|---|---|
| 1 | nord14 green | #679732 | #73a340 |
| 2 | nord15 purple | #b663aa | #c46fb7 |
| 3 | nord13 yellow | #aa7e05 | #b88a07 |
| 4 | nord10 blue | #4488d8 | #5094e5 |
| 5 | nord11 red | #cc5e6a | #da6a76 |
| 6 | nord8 cyan | #0796b2 | #02a3c1 |
| 7 | nord12 orange | #cb6443 | #d9704f |
| 8 | nord7 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-dangermeans “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 isdesign-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.Contexthas 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 thecurrentElement/finallypair is the part to copy.- A test fixture that implements
Contextby hand answers the fallback.TestFont.context()has no element and no cascade behind it — a test callingrenderdirectly is not styling a tree — so a test that wants the theme’s values drives aWidgetRenderer, 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.
sparklineis one series and takescolor; this is built forline-chart,bar-chart,area-chartanddonut-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.
Measuredhas 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-areawrapped 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.transformcompose. 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 composingtransformwould 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)overBL_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 trackscurrentin Java and would no longer know what the context holds — and the scale pre-multiply inBlendContext.transformexists 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 asplit-panebeing dragged, inside anything animating atransform, and inside a promoted layer — which composites at identity, so it was already right and stays right. paintOnehas 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 outsidecorechanges: the eight widgets that callpaintOnedirectly paint into their own untransformed frames.- The rule generalizes to the next caller.
goldberry-html’s nativedocument_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.
CanvasPaintTestputs a canvas under atranslateand 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.
ChartPlotbecomes stateful and buildsChartSurface. The state holds the hovered index; the surface is thechart-plotpart — the canvas, the painter, and the node that implementsHandles. The shape isSplitPane’s: a stateful widget above, a leaf below that hears the pointer and reports upward ([ADR-0063]).- The three axis charts become stateful too, through one
ChartSpec,ChartStateandChartView. The state holds the isolated series and reaches both halves; the view is the chart’s own box, with the chart’s CSS type,idand 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.DonutChartis not one of them: isolating one slice of a part-to-whole chart leaves a chart that no longer shows a whole. - The geometry is lifted into
PlotGeometry, and the painter leaves it inPaintedGeometryfor 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. - 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, needingMeasuredand 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
tooltipwidget. 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-bodynode 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 namesline-chart > …rewritten — for the same integer.
Consequences
- A crosshair, markers and a readout on
line-chartandarea-chart; a band highlight onbar-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.
ChartHoverTestandLegendIsolationTestrender, 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. RoundRectis 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.timeaxes, null handling, interpolation, soft bounds, gradient fills, empty and error states, a sharedCrosshairGroupacross charts, hover ondonut-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/Endjump to the ends,Tabmoves 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/Rightwalk. 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.Escapelets go, and is consumed only if it cleared something — so it still closes the dialog the chart is sitting in.UpandDownare left alone. A chart is very often inside ascroll, and a focused widget that consumed the vertical arrows would swallow the keys that move the page. Two arrows reach every point.Tabis not one of them, which is a refusal of §3.5’s own sentence:Tabis the focus traversal and a composite is one Tab stop with roving arrow keys inside it (ADR-0073). Recorded inARCHITECTURE.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/Downas 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
ChartSpecsays 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-visibleanddonut-plot:focus-visibletake the same ring every other control takes. - The keyboard and the pointer are held to one answer.
ChartInputTestasserts thatEndand 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.timeaxes, null handling, interpolation, soft bounds, gradient fills, empty and error states, and a sharedCrosshairGroupacross charts. §3.5’s other two items — copying the hovered value withCtrl+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
EMPTYstate the application declares. Two sources of truth for one fact, and the interesting failure is the quiet one: a chart told it isREADYwith an empty list and a chart told it isEMPTYwith a full one. - Draw the message in the canvas, like the readout. It would need the text
shaped in
renderand 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-chartdoes 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-dangerand nothing else does.
Consequences
- Four widgets, one answer.
ChartParts.messageForis shared, soline,area,baranddonutcannot 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.
ChartStatusTestmeasures 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. Seriesgained 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:
GAPsubstitutes nothing and produces a run per stretch, so a line is a polyline per run and a hole is a hole.CONNECTinterpolates 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.ZEROsubstitutes 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 everymapToDoubledownstream of it, including the ones in this file’s own arithmetic. Accepting a null and storing aNaNgets 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.
ZEROchanges 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
Gapsand draw one picture for all three policies. smoothinterpolation 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 — andCONNECTis 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_arrayis 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: aChartOptionsrecord 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.
ThresholdTestasserts 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 latencycard 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
TimeTicksTestbecause it is the one nobody writes a test for. Duration-based steps for months and years.Durationis a fixed number of seconds by construction, so it cannot express “a month”; that is whatPeriodandChronoUnit.MONTHSare 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
UTCin one call.
Consequences
ChartOptionsearned 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:07is 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
renderand 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 wherePaintedGeometryalready 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
α² + β² > 9circle, which scales a pair of tangents back; - and a local extremum has a flat tangent. Averaging the secants at the top
of
1, 9, 2gives+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
Seriesis 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.
CurvesTestevaluates the Hermite form densely and checks the bounds, which is the only kind of proof worth having about something one missingifaway from being false — and it is what found the missingif: the local-extremum case failed the interval test before the flat-tangent rule was added. Curvesis public and pure. No renderer, no natives, noBlendPath: the painter asks for tangents and control points and does the drawing. A futuregoldberry-plotgets 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
GAPrun 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
LogScalesubtype, or makingScalean interface. Every call site would gain a type parameter to save one branch, andPlotGeometry— 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, andScalenow has the flag it needs.
Consequences
- The gridlines are unevenly spaced, which is the point.
paintGridtakes an explicit list of tick values now rather than deriving them from a linear labelling’smin + i·step, because on a log axis there is no step. - It composes with everything before it.
SMOOTHstays 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.atcan now returnNaN. 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
CrosshairScopeancestor the charts find withfindAncestorState, asscrollIntoViewdoes (ADR-0120). It would bind the linking to the layout: two charts in different panels of asplit-panecould 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 —
paintHoverreturned 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 theReadoutfor 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
setStateper 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 withgoldberry-html(ADR-0190) and recorded inTODO.mdrather 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
Gradientvalue type in:core’spaintpackage, converted to a Blend2D object per fill. It would keepFrame’s surface free of a native resource — butFramealready takesBlendPath,BlendFontandBlendGlyphBuffer, 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 (0and1) thatNONEandSOLIDsay better. SOLIDas 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_das 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 isBLObjectDetailagain, andBLLinearGradientValues, which earns its row the wayBLMatrix2Ddoes: it crosses as aconst 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-htmlandgoldberry-vectorstart one commit further along. Both entries inTODO.mdnamed 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
Shortcutregistered throughHost.addShortcut. It would put the binding in the same map an application’s accelerators live in, whereremoveShortcutis keyed by the shortcut rather than by who bound it — so an application bindingShift+F10for its own reasons would silently take the context menu with it, which is a live entry inTODO.mdrather 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
Placementflip 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
:corecan notice, only the catalog can build one), so a widget-level key would arrive in the layer that cannot act on it.
Consequences
Keyhas 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/Endon 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/Endmeaning 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.*asKey-basedShift+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
Enterredundant 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
TreeStatekeeps the flattened list in a field, written bybuildand 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. ATreeNodeis 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. TestHostgainedforgetFocusRequests().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 withcascadeandindeterminate, and multi-selection. The second is genuinely blocked — §3 says a tree shareslist’s selection models andlistis 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 atree: §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
mayHaveChildrenexists 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
Shiftrange runs over rows whose order and visibility are the tree’s. - Keeping the selection inside the tree so
Ctrlhad 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
TreeOptionsrecord, asChartOptionsdid 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
Treehas nine components, and the three-argument constructor is what almost every caller uses.selectedis aSetinside and aStringat 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 aString, 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. SelectionandCheckableare defined inpanel.tree, andlistwill 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
checkabledisagreement is now inARCHITECTURE.md§17.1, which is where a word doing two jobs in the design documents belongs. treeowes 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_GetGlobalMouseStateand 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 besidemodifierState(), 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.
SdlEventBuffergainedwriteMouseMotionandwriteMouseButtonforwriteWheel’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 realNSWindow; 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
Listand asking callers to qualifyjava.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
Iteminterface —id(),label(),widget()— astreehasTreeNode. 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
TreeRowbetween 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
Shiftrange 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
treegains 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’slistrow is built, andtableis 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. ListRowcarrying 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; alistis 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 couplelisttoscroll, and a list is not always in one —Locatedreports 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-spaceris 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
Measuredassertion on the first built row could catch it, and is not built. tableis 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
Comparatorper 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.nextis 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 whataffixis for — butaffixpins to the nearestscroll, and a pinned affix is not pushed out by the next one (a liveTODO.mdentry), so two tables on one screen would overlap their headers. Left until something asks.
Consequences
- §10 is complete, and
tableleaves 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 asplit-panedivider 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-bottomand 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.sortabletakes a boolean rather than reading assortable(), because a record’s accessor already has that name. The same reasonTable’sselectionandtree’scheckabletake 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-bottomto 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-bottomintable-headandpadding-bottomin the showcase’s#peakswere the only two live instances in the tree. :examplehas 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.mdentry is narrowed rather than closed: what warns is a test over the toolkit’s sheets, and an author writingmarginin 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 autocompleteselectholds a realtext-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 —strokehas takennonesince borders existed.backgrounddid not, so the field kept the well colourtext-inputgives 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()onDecorationas 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 aCorners. - 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
warntoerror, 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.radiusis nowDecoration.corners, aCorners.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:widgetsand:corecompare aCornersinstead of adouble.- Two golden images changed,
group-box-darkandgallery-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, becauseselectandtext-inputare both--gb-surface-sunkenwells: 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.
SegmentedTestsays 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
ComputedStyleper 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:
- A per-corner radius did not exist. §8’s subset resolved one radius per box.
- Nothing clips. There is no
overflow: hiddenin the subset and no clip inBox, 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-childso 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-unsetgolden 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.pngis the evidence that a middle segment’s ring is still legible where it crosses the edge. Corners.inRowis in:core, not in:widgets, because the next two callers are already named: §3 givesbutton.squareradius 0 “where buttons butt against each other”, which is this drawing seen from the other side, andtabswill want it.- A test that counted past the track’s parts had to stop. Both
SegmentedTestandSegmentedGoldenTestreached a segment aschildren().get(index + 1)— one past the indicator — and there are nownparts before the segments. They find anoptionby 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/catcharound 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 ofParagraphwould still crash: alabelbound 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.mdruled 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:
widthBetweenmeasures from the line’s left edge, and in a right-to-left line the caret before offsetois atlineWidth - 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.mdis gone.TextInputTestpastes Arabic and asserts the field keeps it and the next frame is described; it fails against the oldParagraph, 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(). ParagraphCachehas 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.shapehas 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
Rightinto a submenu waits 150 ms, because it went through the same hover-intent path as a pointer. “Wrong, and one line to fix onceItemcan tell a hover from a keypress.” Leftdoes 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.”LeftandRightdo 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-visiblewhen the keyboard is on it and:hoverwhen 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:
| signal | what happened | what a menu does with it |
|---|---|---|
hovered() | the pointer arrived | open this row’s submenu after §8’s hover-intent delay, or put away what is showing |
open() | Right or Enter on a row with children | open it now |
back() | Left | close this submenu, or move to the menu on the bar’s left |
forward() | Right on a row with no children | move 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 fromKeyboardon 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
Itemopen its own submenu. It needs aHostand aPopup, and a widget is a value — ADR-0106’s whole argument, unchanged. - An
:openpseudo-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.selectandmenu-titlealready 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
Leftat 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 —Escapeis the key that means “put this away”.
Consequences
- Four
TODO.mdentries close together, which is what the entries predicted: “fixing either would probably fix both” was right about all four. Item’sonHoveredcomponent is nowsignals, typedMenuSignalsand never null. The accessor was only ever read byMenus, andhovering(Runnable)becomessignalling(MenuSignals).Menus.openhas a fifth-argument overload takingSiblings. 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
Fileagainst a one-rowEdit— 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
Alttap does not activate the bar (F10does), 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
Hostgains two methods:shortcut(Shortcut, Runnable, Object)andremoveShortcut(Shortcut, Object). The four-year-old two-argument forms mean “owned by nobody”, which is what an application’s own binding is.Accelerators.bind/unbindtake an owner, andMenuBarStatepassesthis. The unowned overloads stay, because an application walking aMenuof its own is a legitimate caller with no owner to name.TestHostmirrors the ownership, becauseMenuBarTestasserts 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
Alttap does not activate the bar (F10does), 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
WindowStateenum —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()onWindow. Genuinely useful and a different feature: it needsSDL_EVENT_WINDOW_MAXIMIZEDplumbed through so the application can find out the user did it, and aisMaximized()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 onBackendWindow. 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
WindowSpecgains a fifth component. It is a record and every construction site in the repository isWindowSpec.of(...)plus withers, so the change is the record and its two callers.SdlWindowFlaggainsMAXIMIZED, which meansgoldberry_shim.cgains aGB_CONSTANTfor 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.
ShowcaseShellTestasserts 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,ValuesandTextwere 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.OverlaysandNotificationswere 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,ScrollingandChooserswere 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
Widgetper screen that returns aMasonry, 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
menubaris drawn by this toolkit, styled by the same stylesheet and routed through the same router, soF10, 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 ordinaryMenuvalue. - Dropping
WindowActionsnow that the menu holds handlers directly. Tried, and put back:overlays.kdlpressesapp.open-menuby 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-narrowasserts they still fit, because a card with a minimum width would overflow rather than wrap and §10’swrapis not built. Set.ofis banned from anything a golden image prints. Its iteration order is randomized once per JVM, so a caption built withString.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 aLinkedHashSet.SectionHeaderbecomes public and becomes the screen title. Atext.screen-titledid 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.Scrollingbecomes 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 endis 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
statisticcards 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
Altis a modifier released with nothing in between, and aShortcuthere is a key plus modifiers —Keyhas noALTto name, because a shortcut on a modifier alone can never fire.
That diagnosis is right and it is the whole difficulty. Three facts collide:
Shortcutis a value.(Key, Modifiers), hashable, a map key. It is looked up on a press — one event, no history.Keydeliberately names no modifier, and should not start: an accelerator onKey.ALTwould 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.
Altdown thenAltup is a tap;Altdown,Fdown,Fup,Altup isAlt+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 itsModand its two SDL keycodes. Left and right fold to one, the same foldModifiers.fromSdldoes. 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 up | Result |
|---|---|
| nothing | fires |
| another key goes down | disarmed — it was Alt+F |
| the same key auto-repeats | disarmed — it is being held |
| a second modifier goes down | disarmed — Alt+Shift is a layout switch |
| a pointer button or a wheel | disarmed — it was a modified click or scroll |
| the window loses focus | disarmed — the window switcher took it |
| pointer motion | still 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.ALTand lettingShortcuthold a modifier alone. The smallest diff and the worst outcome: it makesMod.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, fromKey.UNKNOWNplus 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
F10alone and closing the entry as “won’t do”. Defensible for one more release and not past it:Altis 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 ininputthat holds a recogniser rather than a value or a dispatcher, which is why it is not ininput.key. Windowgrows five call sites — two feeds and three interruptions — and each is one line that nothing else would notice going missing. That is whatModifierTapWindowTestis 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
Hostimplementation gains two methods. There are three:Launcher,TestHostandTourTestHost.TestHostmirrors the ownership rule and gains atap(ModifierKey)beside itspress(String), so a widget test can fire one without a window. - A
menubarnow holds two kinds of registration and has to give back both.MenuBarStatekeeps a separatetappedflag rather than pretending a tap is aShortcut, andMenuBarTestasserts that unmounting returns it — the leak is the same one ADR-0220 was about, in a second map. F10andAltnow close an open bar. A behaviour change toF10, 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
dummydriver on all three platforms, which proves the arithmetic and not the platform’sAlt.
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>onHost.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
CLICKEDfor the secondary button so rows handle it inonPointerlike 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
Selectablecontract — “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.Handlesis told what happened; this asks for something to happen. - Two widgets implement it and a third inherits it.
ListRow,TreeRow, andtablethroughListView. 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
instanceofper 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
onContextMenuhandler 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. @Nullableis 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 makepolitelook 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.GROUPrather than addingSTATUS. 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.
Semanticsgrew a defaulted method, so no existing implementation changed — which is the shape that made this affordable to add before its consumer exists.Rolegrew 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.ASSERTIVEhas no consumer. Deliberate, and the sweep will notice if that changes.ToastGoldenTestis unaffected, because none of this draws anything. That is worth saying: this is the second facility in the toolkit (afterRoleitself) 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
messagethat 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
renderby hand and never asks whether the frame loop would have, so a widget that answeredisAnimatingwithfalsewhile it was fading produced perfect pictures of an animation that never ran. […] the corpus cannot catch this class of bug by construction — an assertion onisAnimatingis 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, whoseisAnimatingis asserted inMessageGoldenTest— 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
SemanticsSweepTestas the second sweep that enforces something no picture can show. - A gap closed on the way in.
ScrollFadeTestis 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
isAnimatingfrom something other than aPhase, and whose package already has an unrelatedisAnimatingmention, passes both rules while being wrong. Closing that needs the behavioural sweep above and its fixture. - The
TODO.mdentry 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 whichcollapse,group-boxandfield-messagehave 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
columnwithgap: 12pxputs twelve pixels round it. The thing that vanished leaves a hole.MessageBoxdid 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
messagebound to an empty string can only be described away by whoever placed it, which is the application, which is the thing §9’sbind=exists to spare.Message.summaryreturns anOptionalfor 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 — anddescribewould 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
displayproperty 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
messagewithoutbind=. Defensible whileMessage.summary’sOptionalwas the only caller; not once a document wants to writemessage 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.
Messagegrew a component, so its canonical constructor has six arguments. The five-argument form is kept, because every caller written beforebind=existed passes exactly those.MessageBoxlost one, and with it a branch inchildren(), a branch inrenderand a term inisAnimating.- 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-messagedraws a styled empty box when a field is fine, and switching it toWidget.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
BooleanSupplierbeside theDoubleUnaryOperator, which is whatTabdoes 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
nullcheck —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
isAnimatingboth 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.
TabMotionTestlost 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’sanimating()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
IdleLoopTestin: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
collapseputs.openon it, whose chevron rotates under a transition of its own. - The
AnimationSweepTestrule fired on this change, namingCarouselViewandCollapseSectionthe moment they gained aPhasecomponent. 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:invalidedge and abadge’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:
| rule | draws | bare hue, worst measured |
|---|---|---|
field:invalid text-input | a border | 2.46:1 on dark --gb-surface |
field-message | words | 2.46:1 on dark --gb-surface |
statistic-delta.up | words | 2.04:1 on light --gb-surface |
statistic-delta.down | words | 2.46:1 on dark --gb-surface |
chart-message.failed | words | 2.46:1 on dark --gb-surface |
hud-reading.over | words, on the HUD’s own plate | 3.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:
| token | for | floor |
|---|---|---|
--gb-<hue> | a fill | — |
--gb-<hue>-fill | words on that fill | 4.5:1 |
--gb-<hue>-line | a stroke or a glyph on a surface | 3:1 |
--gb-<hue>-text | words on a surface | 4.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:-lineis derived against a 3:1 target, so--gb-danger-lineat 3.53:1 would have leftfield-messagebelow 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-deltaand no option at all forfield-message, whose whole job §4 describes in terms of the danger hue. - Giving the HUD the theme’s
-textrank. 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
fieldscreens, 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.
everyTextRankIsLegiblemeasures the new rank on every surface in both themes;everyHudReadingIsLegiblemeasures the HUD’s plate on its own;noBareHueDrawsInkis 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
ContrastTestrefuses 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 aremove(Runnable). It works and it compares lambdas by identity, so a caller has to keep the exact reference it passed — which is what aSubscriptionis, 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
onPointingChangedreturns aSubscriptionand adds instead of replacing. There is one caller in the toolkit and it isLauncher; no application code changes, because the method is on a class an application does not hold.inputnow namesbind.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 ininput.- 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.moveexists 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.Movednow and re-clamping on window moves too. It is the honest completion and it is a different change: an SPI event, an SDL translation forSDL_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 — “apopoverthat follows a scrolling anchor” — and it needs the anchor widget to implementLocatedand 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.
replacedOnResizeshrinks 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
--framesfinishes 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-lookinganchor()from a dead launcher and gets an emptyOptionalfrom 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
dialogneeds 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 leavehoveredpointing at an unreachable node, so:hoverwould 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
Handleswas right that the layer is the wrong owner: the next modal is awizardstep or asheet, 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
ModalPointerTestin:core, built from bare widgets rather than fromdialog—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
dialogand totourby 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
Escapewatcher 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
menuhandleEscapeas a widget.Menushas 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
Escapedismiss 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
Escapein a chain now behaves like every desktop menu: out of the submenu, then out of the menu.dismissedByInputreturns a boolean, which is also what makes “the topmost one that will go” expressible at all.dismissPopupsis 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
MenusTestthrough 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 ownEscapewatcher 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 → removedapplies 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:
| widget | arrives | departs | ends the departure |
|---|---|---|---|
dialog | Phase 240ms | Phase 160ms | a timer, then two flags |
message | Phase 160ms | Phase 100ms | a timer, then two flags |
toast | Phase + slide | Phase + reflow | the stack’s own queue |
tab | Phase | Phase | Phase.hasDeparted, read in render |
collapse, carousel | Phase | nothing — closing is instant | — |
menu, tooltip | nothing — they are platform windows | nothing | — |
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:
- 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.
- Two flags, not one.
hasBegunmeans input is off, from the instant the answer is given;isOvermeans 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). - 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.
- 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
OverlayStatethe two extend. It would carry the arrival as well, which the table says is not shared, and it would putdialog’s focus handling andmessage’s binding in a class that has to know about both. - Putting the departure on
Phase.Phaseis a value read fromrender, where a widget has a clock and nothing else; a departure needs aHostand a timer, which is aState’s world. Merging them would drag the window into the one typecollapseandcarouseluse without ever seeing one. - A full
opening → open → closing → removedstate machine, withopenas a named state.openis “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
DialogStateandMessageState, 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.
DepartureTestis 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.toastandtabare not converted. A toast’s departure ends when the stack’s queue says so and a tab’s ends insiderenderthroughPhase.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.stackis 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-overflowand 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:
overflow: hiddenon 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.- 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.
- The same,
flex-direction: rowandalign-items: center. Fixes a second, separate bug found on the way — a box’s default direction is Yoga’scolumn, in whichalign-itemsis 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: hiddenonselect-valueandoptionanyway. 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: nowrapnow. 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 —scrollandselectalready 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.mdentries keep their subject and lose their reason. The menu-row entry said “§8’s subset has notext-overflowand 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’scolumndirection, soalign-itemson it centres horizontally. Any future clip box has to sayflex-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.wheelconsumes 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
askwould 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 nullonChange, 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
scrolllook 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()onHandles, 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
askreturn whether it asked, and consuming on that. It is tidier inwheeland 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
KnobChainingTestis 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.dispatchreturns before the chain is built when the target is in a disabled subtree, so an ancestorscrollnever 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 aboutknob. It is recorded inTODO.mdrather than answered here. Knobgained 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
cursorAtafter 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 frompointerMovedalone, so a window whose first event is a click is not left with nowhere to ask about. NaNis the whole of “we do not know”, and it means it twice: before the pointer has ever arrived, and afterpointerExited, which is another window’s pointer or none at all.updateRegionsskips the recompute in both cases rather than askingcursorAtabout 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:hoverand:activeover the hovered and pressed chains, and its whole implementation ismark(…, true)— becausemarkalready 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
ENTEREDorEXITEDis 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 linemarkitself 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
:hoverrefusal inmarkrather than in each widget. - Call
updateHoverfromupdateRegionsinstead ofrestate. It is fewer lines and it would fireENTERED/EXITEDon 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:
cursorAtis a walk of the rectangles under one point, andsetCursorandsetPseudoClassare both already edge-triggered, so an unchanged frame is silent without help. - Track the position in
Windowand 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
:hoverhalf 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
setCursorandsetPseudoClassload-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::hoverlost on disabling,:hoverreturned on re-enabling, and:activelost mid-press. Three of them were checked against the old code and fail on it. Switchableis 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
NaNcheck 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.dispatchreturns before the chain is built when the target is in a disabled subtree, so an ancestorscrollnever 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
:hoverrefusal inmarkrather than in each widget. A control’s owndisabledcheck 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
scrollabove it scrolls. - The disabled elements are a prefix, and that is a fact rather than an
assumption.
chainis deepest-first andisDisabledwalks up, so it is true from the target to the outermost disabled ancestor and false at every step above.dropWhileis 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.
isInputis untouched. The kinds it partitions are “the user doing something”, and a wheel still is one; takingWHEELout 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
WHEELfromisInput. One character of diff and wrong:isInputalso 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()onHandles. 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
disabledcheck 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
dropWhileover the chain. Identical result;isDisabledper element is O(depth²) on a path that is a handful deep and only walked for a wheel over a disabled subtree, and thedropWhilesays 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 livescrollabove a disabledformgets the wheel, and a press through the same tree still stops dead — the second is what keeps the change from being “let everything through”. InKnobChainingTest, 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, andbounds/partare re-measured per handler anyway, which is what ascrollreads.- The catalog gains a second line of defence it already had.
Knob.movesrefuses whendisabled(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.
ContrastTestmeasures 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 acardor inside agroup-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
cardon 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 shapepairs()already uses. - Composite translucent fills over a stated surface, so
button.ghostand--gb-selectioncould join the sweep. That is the backdrop-aware check thebutton.ghostentry 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-accenton--gb-borderin 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-2in the dark theme — the fill is the token — and agroup-boxpaints 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-bgis--gb-surface-2in the dark theme, so on agroup-boxan 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:
| surface | ratio |
|---|---|
--gb-bg | 1.74:1 |
--gb-surface | 2.00:1 |
--gb-surface-2 | 1.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—#4c6d94reaches 4.40:1 and--nord36.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-2is 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
outlinethe 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
ContrastTestare what stop the remainder being forgotten. - Change
--gb-focusin 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_FLOORis empty, andContrastTest’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-lightis 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_FLOORandBOUNDARIES_BELOW_FLOOR. They are the control fills and the accent-on-border pair, they move goldens in bulk, andTODO.mdcarries 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
ContrastTestruns over the two themes the toolkit ships. A third-party theme that pairs--gb-badge-warning-bgwith an unreadable--gb-badge-warning-textis 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_FLOOR4.5 andNON_TEXT_FLOOR3.0.ContrastTestnow 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 onefilter, and the other direction is impossible.ThemeAudit—audit(sheets)andfailures(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 aBadgedid 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-titleprecedent 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
Themerather than a list of stylesheets.Themeis 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
cssbesideTheme. 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.
ThemeAuditTestproves it on a theme the toolkit has never seen, including the entry’s own example: white on--nord13at 1.56:1 is caught by name.--gb-hud-bgis 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.ContrastTestlost its private arithmetic and its two literal floors. They areContrast’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_FLOORis exported andThemeAuditdoes 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 isTODO.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:
emandremdo not resolve against the node’s ownfont-size. They useCssLength.Context’s fixed numbers, sofont-size: 1.2emmeans 1.2 × 16 and not 1.2 × the parent’s size. Nothing in the toolkit’s own stylesheets usesem, 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.2emonfont-sizemeans “a fifth larger than my parent”, because the value being computed cannot be its own input.1.2emon 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:
emandremagainst 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: 1emon a 20px heading would be 13, the parent’s size, which is the opposite of whatemis for. - Give
ComputedStyleafontSizeparameter instead of deriving it. Every caller would have to know the rule, and the two that matter —WidgetRendererand 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’sfont-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
emorremappears innord-dark.css,nord-light.css,controls.cssor 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” passedContext(20, 16)with no parent and asserted1.5emwas 30 — asserting the old semantics, on an element whose computed font size was 13. It now declaresfont-size: 20pxand asserts the same 30 for a reason that is true. - Six new tests, four of which fail against the old code:
emagainst a declared size, against an undeclared one (19.5, not the old 24), against an inherited one, onfont-sizeitself against the parent’s, and two fortransform: translate. computeChildis a new test helper that resolves the parent through the real cascade and hands it down, which is whatWidgetRendererdoes and what the existingcompute— every element a root — could not express.Transform.parseandparseOrigintake aContext. Both are public; both have exactly two callers, both inComputedStyle.Context.fontSizenow means “what the root’semresolves against” rather than “what everyemresolves against”. It is consulted once per tree instead of once per node, andContext.DEFAULT’s 16 no longer contradictsTypography.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.TokenClosureTestandShowcaseTokensTestnow 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
forgethook — 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-titledrew 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
TokenClosureTestandShowcaseTokensTestnow 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
substituteandexpandVarare 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 badvar()is one message however many elements hit it, and onlyslf4j-apiis 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.
descendwalks depth, not siblings — which the first draft of two of those tests got wrong and which cost aNoSuchElementExceptionto find out. They build a freshwindow > typetree 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-selfis 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 ofalign-items, which is in the subset, and Yoga’ssetAlignSelfis already bound — what it costs is a component inComputedStyleand one inBox, 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 readsalign-items/self/content, and the sentence naming what is unimplemented says onlyflex-basis. So the document claimed this worked. What was missing was the implementation, not the sanction. Align.AUTOwas already waiting for it. The enum’s own comment says “AUTOonly means anything foralign-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.
ComputedStylegains the component, the wither and analign-selfcase inwith.Boxgains the component, a builder method, and carries it throughstyle(ComputedStyle)so a resolved declaration reaches the box.RenderObjectcallsnode.setAlignSelfbeside the existingsetAlignItems, 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
InsetsandLimitsgroup their four. It is the structural answer to long argument lists andRecordWitherTest’s own comment says so — and it would renamebox.alignItems()at every call site in the toolkit for a benefit the test already delivers completely. - Put it only on
Boxand not onComputedStyle. A widget could then set it inrenderand 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-contentat 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
stackis one blocker lighter. Its entry named two: the layering half, whichWindowRootalready does withposition: absoluteand Yoga insets, and the alignment half, which was this. What remains isstackitself.- 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
RenderObjectand the whole risk is whether that line runs, so a test readingbox.alignSelf()back would pass on a box nothing laid out. autois asserted to be indistinguishable from saying nothing, and beside it a test that a non-autovalue 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-selfyet. 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 includedalign-items/self/contentwhile 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-2has now been mistaken for an elevation three times — bycard(ADR-0166), bytext-inputand byselect(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-2should 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:
| reader | what it is |
|---|---|
--gb-badge-bg | a default chip’s fill |
scroll:hover scrollbar | the track’s plate while the pointer is over it |
group-box-title | the header band above a body |
skeleton-bar | a placeholder bar |
split-divider.collapsed | a 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-raisedis 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-sunkenis never lighter, composited over the surface — it isrgba(0, 0, 0, …)in both files by design, so comparing its raw value would be comparing a black nobody paints.--gb-surface-2takes opposite directions in the two themes — a step up from--gb-surfaceon 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-raisedunder a second name, and the light theme’s ramp has nowhere to put it — the reason--gb-surface-raisedis 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-2direction 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
-2reads are five and shrinking is not a goal.card,text-inputandselectleft 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 aTextEventgoes to the focused node, which in an open list is anoption— so the list has nothing to intercept it in.Handleshas anonKeyCaptureand noonTextCapture, 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
treegets none.select tree=#trueputs aTreein 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
optionforward text it does not understand to its list. Every row would need to know what encloses it, which is the couplingOption#withinalready exists to avoid, and a row outside aselectwould 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-visibleon the highlighted row, and the reason the popup has a focus scope at all. - A second typeahead in
SelectListover 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
SelectListgains 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
KeyboardTestfor 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 inSelectTestfor the list: it forwards and consumes, and the callback-less form leaves the text alone. KeyboardTest’s node gained aconsumeTextfield, beside theconsumeKeyit already had, because a capture phase only means anything if something can stop the event there.- Every other widget is unaffected.
onTextCapturedefaults to doing nothing, and the bubble is unchanged — atext-inputstill 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,endandspace-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/endare 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.leftandrightare deliberately absent. They arejustify-contentonly, they are not the same asstart/endunder 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 reachfont-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.
alwaysDroppedusedalign-items: startas its example of a value the toolkit has not got and now uses one it really has not got,sideways. And “startis notflex-start” is now “startandflex-startare 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.
keywordgrew 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
leftstill 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
ComputedStyledoes 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.
selfstays exactly what the cascade andrestyleproduced. The node paints it, transitions observe it, animations apply to it.handDown = element.stableStyle(self)is the children’s cache key, and isselfor 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
equalsminus the transform. It fixes scrolling and nothing else, and the next property nobody inherits —opacityunder a fade,decorationunder 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
stableStylereturn both. A two-field return for a method whose callers want different things, where two locals say it plainly. - Give
ComputedStyleaninherited()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, becausecolorinherits 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.
inheritsSameAsis public, because it is a claim about the record rather than about the renderer, and the comment besideinheritingFromis 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-nextand its two states. The tour builds them from plainTextandButtonwidgets carrying a class, so every one of them was matching a known type and simply not saying so.text.tour-titlematches exactly what.tour-titlematched 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
:rootis 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-titlerather thantext.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.
StyleResolvergains three read-only accessors:untypedRuleCount,ruleCountanduntypedSelectors. 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.untypedSelectorsreturns the selectors and not a count, which is the difference between a failure that names.tour-titleand 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
stacka 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-itemsandjustify-content, and by its ownalign-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=orz=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.elevatedremains 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 writestack align="top-end". It is a second vocabulary foralign-itemsandjustify-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
coregroup is complete but forimage. - Ten tests, six of which fail against a
stackthat 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 —
stackis in §1’scorelist, so what is under test is the registration. - No stylesheet rule ships for it. A
stacksets no colour, no padding and no gap, and where its overlays land is the application’s to declare — which isrow’s andcolumn’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.LINEis 20 logical pixels and a--gb-scroll-linewas 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, includingrestyle. 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 accessorcolor’s comment already argued against: a widget would parse them, and there would be two parsers. - Read the line height in
onPointerfrom 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, whereScrollis not even visible.
Consequences
Paints.Contexthas a second implementor to update, and the test one in:widgetsanswers 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.
ScrollStategained a static report set and two accessors for the test, because onlyslf4j-apiis on the classpath and there is no appender to read the log back from —StyleResolverTest’s arrangement exactly.listis 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 inchildren(), and a value banked fromrenderwould be a frame late in the one place a frame late means building the wrong rows. The door this opens is the onescrollneeded;listneeds a different one.- A comment in
controls.cssis written without a selector, becauseButtonTestasserts 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 becomesSDL_WINDOW_MAXIMIZEDand after that nobody involved knows whether the window still is one. There is noWindow.maximize(), norestore(), noisMaximized(), and noSDL_EVENT_WINDOW_MAXIMIZEDplumbed 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 doesisMaximized()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 — atruethat does not imply the thing the caller wanted is worse than no return at all. - A
Minimizedstate beside it. Nothing has asked, and SDL’sRESTOREDcollapses the two undos into one event — so tracking both needs a rule about whatRESTOREDmeans when both were set, for a state no entry mentions. - Expose
SDL_GetWindowFlagsand 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_MaximizeWindowandSDL_RestoreWindowgo ingoldberry.symbols; the two event constants go ingoldberry_shim.c, becauseLayoutVerificationTestrefuses a constant declared in Java that nothing verifies against the compiled library. - That refusal earned its keep immediately.
SDL_EVENT_WINDOW_MAXIMIZEDis0x20AandRESTOREDis0x20B, 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-widthis 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
widththe cascade resolves. Its box is computed from the shaped paragraph in the samerenderthat 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-inputand havetext-areaimport 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
scrolldoes with--gb-scroll-line(ADR-0251). Unnecessary here: every consumer of this number is reached fromrender, 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-areahonours 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.laidOutgained a parameter, which is an interface with one implementor and one caller.- Two dangling doc comments were left behind when the constants moved, and
-Werrorwithdangling-doc-commentsrefused the build until they went. Worth recording as the check doing its job on a refactor rather than on new code. --gb-caret-widthis the third component-token default to ship since a widget could read one, after--gb-scroll-line.--gb-list-row-heightis still waiting, and still on the other door: its number is an API argument consumed inchildren(), not inrender.
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:
listis 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 inchildren(), and a value banked fromrenderwould be a frame late in the one place a frame late means building the wrong rows. The door this opens is the onescrollneeded;listneeds 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, asscrolldoes. 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 thelistelement and would honourlist { … }. It is built by the state that needs the answer, so the question would be asked after it was needed. - Make
virtualized()meanvirtualized(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 argumentPaints.Context.colormade 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 callspreparebefore its flush, matchingLauncher. 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-heightfinally has a consumer, which is what itsTODO.mdentry 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 ofoverflowandflex-shrinkcan 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-spaceproperty 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-wrapandpre-line. All three are statements about collapsing runs of spaces and newlines, and Goldberry never collapses anything: aParagraphdraws the string it was handed. Sopre-wrapis whatnormalalready does here andpreis whatnowrapalready does. Naming them would be four spellings of two behaviours.- CSS’s newline collapsing under
nowrap. A hard\nstill 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
nowrapparagraph 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 inComputedStylewhere an enum is.
Alternatives considered
- A clip box, again. ADR-0235 records three arrangements of
overflowandflex-shrinkand 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 —scrollandselectalready 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
TextFlowcomponent onComputedStyle. Simpler by one field and wrong about inheritance, which is the only thing that actually differs between the two properties. - Bundling
overflow: hiddenintonowrap. They are separate questions:nowrapsays how the text is measured andoverflowsays 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 whatTextOverflow.CLIPstill 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, soFont.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
segmentedcell, aselectwhose value is longer than its field, and an autocomplete suggestion. Four stylesheet comments and tworendercomments that explained why they could not are replaced with what they now do. RenderObjectrebinds its measure callback when the flow changes, not only when the paragraph does. A restyle that turnsnowrapon 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 onapplyMeasurealready records for a changed paragraph. The flow is compared by equality where the paragraph is compared by identity, because the cascade hands out a freshTextFlowon every resolution.inheritsSameAswidened by one field, so a subtree under a node whosewhite-spacechanged re-resolves. That is correct and is a real cost: it is one more way for a style cache to miss.Fontgained a memo, its first. It is notvolatilefor 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-inputandtext-areaare untouched. A field’s text is drawn by its own machinery rather than throughBox.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-spacingandtext-alignare absent, becauseBoxcannot 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 propertiesBoxcannot 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 needsBoxto 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
leftandright. ADR-0247 settled the same question foralign-itemsin the other direction:startandendare what CSS Box Alignment defines and what Yoga lacked, whileleftandrightname sides of the screen. They coincide under LTR and part company under RTL, so acceptingrightas a synonym forendwrites 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, andSupportedPropertyTestmakes 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-itemson the box already does it, because a paragraph is the whole of a measured leaf’s content.
Alternatives considered
- A field on
Boxand an offset inBoxPainter. 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-alignplaces 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 inItem.rendernow says which of the two each is for. - Accepting
rightand mapping it toendwith 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-valueistext-align: end, andslider-value.pngmoved:9%,50%and100%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-valueplus the showcase’s Basic screen in its three variants, where the diff is 274 pixels and every one of them is the40%on the gain slider. Nothing else in the corpus draws aslider-value, which is why the count is four rather than the number of images with text in them.ComputedStylegrew a fourth wither and a third text component, andinheritsSameAsgrew 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.TextFlowgained a two-argument constructor so that every caller written before this keeps drawing exactly what it drew.TextFlow.NORMALandTextFlow.ELLIPSISboth saySTART.- §8’s “properties
Boxcannot express” is down to three, and the three that are left are there for reasons that are actually aboutBox. - Nothing centres anything yet.
CENTERis 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-titledrew 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-growmeans nothing inside ascroll, 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
WARNandgroup-box-titledrew 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.KindcarriesisDefect()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 forRuleBucketTestand needed only somewhere to be asked from. - A
Measuredcallback 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 theTODO.mdentry literally suggested — “next to thehud”. 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
SupportedPropertyTestlost 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 areStyleLintTest’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.lintis not@NullMarked, and the reason is not this package.StyleElementdocuments three members as “or null” —type(),id()andparent()— and annotates none, inside acsspackage 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 inTODO.mdrather than fixed in passing: annotating it moves every implementation and every caller.ListRowgrew adouble, 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 nowScrollContent’s andListRow’s — each with aforgetfor 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.
--nord9clears 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.--nord4clears at 6.39 and is a near-white ring around a dark box. The gap is why this is derived. - Darkening
--gb-checkbox-bginstead 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-borderitself. 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_FLOORis 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-darkandcontrols-on-surface-lightare 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-borderand 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_FAILURESwas 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-widthat all, so a one-digit chip is a stadium rather than the circle a badge usually is.badge-digits.pngis 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-widthwith 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-widthdid not exist. It does.
Consequences
badge-digits.pngmoved 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 —3round,12,128and1024growing sideways.- Every badge is 8px narrower.
badge-variants,badge-on-surfaceand 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-chipis 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.Optionrefuses 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
namefield 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 fortab-closeandscroll, which are deliberately named by their surroundings — the interface’s own javadoc says null is an answer.
Consequences
Attributesgained 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 sixthnullon 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 andnullis a legal name so nothing else would complain.- An icon-only button answers
nullrather 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.ofreadsname=off the node, so every widget that inflates through it can be named without touching its inflater. SemanticsSweepTestis 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-lightis new and is the catalog’s first;menu-focusandmenubar-focusare stillNORD_DARKonly, 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-focushas anX-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
-lighttwin 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.
ContrastTestalready 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-focusexists 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-lightalready 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.
-focusin 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
ContrastTestis 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-delaycustom 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
ApplicationorHost. 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 existingtoken, and it spells a duration as a length. The cascade refuses a bare number fortransitionand 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.durationMillisis public, and is the second thing that class has been asked from outside for the same reasonapplieswas: something above the cascade has a question only the cascade’s own parser can answer honestly.BuildContextgained 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-movehas no design-system row of its own, because it is half of one that already existed. §3’stooltiprow 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 §3 | controls.css | |
|---|---|---|
| padding | 6/8 | 8px 12px |
| radius | 4 | 8px |
| type rank | caption | body |
| delays | 500ms / 100ms | both, 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.cssto match §3 on all three. It would put an off-ramp6into the stylesheet, whichSupportedPropertyTestwould 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, asBadgeTest.metricsand 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 —
tooltipis an attribute, and the only thing carrying its type isTooltipPanelin:core. The test lives in:widgetsbecause that is wherecontrols.cssis. - 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.overlayis still the door atoastraised from a handler deep in the tree would want, and nothing wraps it in theOverlay.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().:corelearns 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 argumentBuildContext.host()itself was held to (ADR-0140), which waited forselectto be the second. IfMenusandDialogsgrow the same need, that is the moment, and this map is what would be replaced. - Making the stack an ancestor so
findAncestorStateworks. 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.
Toastsholds static state, which it did not before. It is one weak map, documented, with a package-privateforgetAttachmentsfor 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.overlayrecords rather than builds — a controller registered byatalone is still detached and swallows what it is shown, which would have made the test pass by asserting nothing. MenusandDialogsare 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-inputallows for it by adding its own padding to every child’sleft, 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:
| child | Yoga | CSS |
|---|---|---|
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-inputandtext-areaadd their own padding to every child’sleft, which is the workaround the entry names.SegmentedIndicator,TourVeil,TourStop,WindowRootandscrollbarplace 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
errataflag. 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: hiddenis 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, minustext-input’s andtext-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
onPointeris a guard on every pointer kind, and the kinds do not carry the same fields.text-inputtestedbutton() == PRIMARYthere and silently lost every drag, becausePointerRouter.pointerMovedbuilds its event with a null button — a motion is not a button event (ADR-0168).Sliderasks per kind and reads as a style choice until this happens. Nothing warns; aPointerEventaccessor that is meaningless for the kind in hand answers with a default rather than refusing, which is right fordragX’sNaNand quietly wrong for a nullbutton.
That last sentence is the whole of it, and it is exactly right. The two defaults are not the same kind of default:
dragX’sNaNis arithmetic. The meaninglessness propagates: every comparison againstNaNis false, in both directions. A caller cannot act on it by accident, which is whyToggle’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() != PRIMARYis 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 aswitcharm 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.
PointerEventwould 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: “
Sliderasks 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-inputwritten next month that loses its drags says so on the first move instead of on the first bug report. PointerEventgained a static report set — the sixth in the toolkit, with itsforgetfor tests.ADR-0257already 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 reachesemandrem. Every font size in the design system ispx, so it would scale nothing that matters. - Scaling in
ComputedStyle.ofafter resolution. Compounds throughemchains, 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 aTypographynothing 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 acolumnis as wide as the window and in arowis 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
renderfrom 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 apopover. - Making
TourStopstateful. It is a record whose valuesTourStatecomputes, 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.
TourCardgained a second component and becameMeasured. 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
tourentry stands and is now verified. A tour still cannot find the viewport its target is in:findAncestorStatewalks 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
translateand resize between stops. That isTabPhaseagain: 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.
TabPhaseis what §1.7’s “overlay enter/exit lifecycle” asks for, built for one widget:toast,dialogandpopoverall 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:
TourVeilheld aPhaseand did not overrideisAnimating— “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
transitionon 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 isPhase’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.
TourVeilgained two components and anisAnimating. 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
TabPhaseentry 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
AnimationSweepTestrequires 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 apopoverfollowing a scrolling anchor needs the anchor to report that it moved, which isLocated’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.handleMovedtherefore does not callrepaint(), which is the one line that distinguishes it fromhandleResize. - 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.
Locatedon 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.moveexists 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
popovertravels 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. BackendEventgained 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.moveTonow 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 thedummydriver. - A
popoverfollows; amenuand aselectdo not. Following is a property of having been opened by id, andPopoveris 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.MenusandSelectStateresolve their anchor to a rectangle themselves because they need a minimum width and aFitas well, and there is noHostoverload that takes all three. Giving them one is a small piece of work nobody has asked for; it is inTODO.mdrather 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.mdrather 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
hudrecords 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 thesdl3backend.
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.paintalready 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 atdebug.
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 photographslate 7in 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 voicerefreshRatedoes. 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: 0inside apadding: 0 12pxheader now means 24 points narrower than the tab. An underline that stops short of its own label is not an underline.Tab.renderwidens the indicator using its own resolved padding, sodensity-compact.cssmoves it without mentioning it — atab-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
sameAppearancecompares, so the parent isselfChangedand its rectangle is damaged. AndcollectDamagereports 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.
RenderObjectcomparedprevious.inset()againstbox.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 keepsappliedInsetinstead.ContainingBlockreturns 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,tourandscroll. 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 wastabsandtoast, 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.AbsolutePlacementTestis the toolkit’s half and asserts (12, 12). If Yoga ever fixes its inset path, the:nativestests 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
taband the overlay layer to the stylesheet. A negativeleftincontrols.cssis the same number written twice and a density file obliged to change both.acrossBorderBoxreads 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
overflowtoo.
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=6separate single-character boxes over one value,type="digits|alnum". It exists as its own widget rather than a styledtext-inputbecause its editing model is different, and that is the whole of the specification: typing advances,Backspaceon 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.completefires when the last box fills, which is what lets a form submit without a button.mask=#truefor 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.
- “
Backspaceon 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
lengthis even.” §8’s selector subset has no:nth-child, so nothing in a stylesheet can say “wider after the third one”. The boxes go intocode-groupparts 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-widthand--gb-code-box-heightare both tokens anddensity-compact.cssmoves 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-inputconsumes its arrows because it has a caret they move, and consuming them is what stopsLeftfrom walking the focus scope out from under somebody editing. This has one insertion point that is a function of what is filled, soLeftwould 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 forpassword’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-inputhad to build a 530 ms timer to keep that true. - No
Validatorseam. A code is text until something checks it, which is exactly whatValidator<String>already is. The typed-value seam the TODO list names isdate-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
titlecharacter centred in each box, and what a mask draws.code-input-focushas its-lighttwin, whichFocusGoldenPairTestrequires 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, andcomplete=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-boxgained afilledclass §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 vocabularycardalready uses for “this edge is the meaningful one”, andborder-coloris 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:TextEditis 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-childselector 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
Roleat 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 methodZoneId.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.
todaymay 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
DateSelectionis one value for three models, wherelistuses aSelectionenum 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 andcontains,isStart,isEndandcoversare four different questions.- A range’s ends are
:checkedand its middle is not, which is what makes §2’s “radiusfullon 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
CalendarMonthLayereach, so the pinning isinset: 0 0 auto 0on one node rather than a row height multiplied by an index thatrendercannot 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
FocusScopeis 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. SoCalendarBoxtakes every key and the roving day is a class. - The arrows clamp to
minandmaxand not to the disabled predicate. A bound is a window and a predicate is a rule inside it; aRightthat 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:disabledand 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, socalendar-headeris an addition — written down here, anddocs/design-system.md§2 gained a row for it in the same change rather than it being an undocumented part. Neither arrow is focusable, forTabClose’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
LocalDateand 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,maxand adisabledpredicate gate both the field and the grid, so an unreachable date cannot be typed either.”allowsis 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+Downis taken on the capture pass, becausetext-inputreads a plainDownas “go to the end of the line” and does not ask about the modifier. Teachingtext-inputabout 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-2026is a date in some locales. - A document’s
changecarries text and Java’s carries a value. §9’s valued actions cross as aStringand nothing else, so a document is handed the formatted date and an application in Java is handed aDateSelection. 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
Validatoris over aString, anddate-pickerwill 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:
DeterminismTeston the clock,TokenClosureTeston a--gb-accent-onthat does not exist, andSemanticsSweepTeston two parts that overrodeisFocusableto 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: fullis not a thing, and the first calendar asked for it in two rules. §2 writesfullandcontrols.cssspells it as half the height in points, because §8’s subset has no keyword and nocalc(). It cost a token —--gb-calendar-day-radius, beside--gb-calendar-dayso a density moves both — and it was found by looking at the image, since a dropped declaration warns and fails nothing.Role.GRIDis new, and distinct fromGROUPby 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
Semanticscarries a role, a name and a liveness with no channel for either. That is the entrycode-inputopened; 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, andcolor-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
FocusScopeover 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>onField. A breaking change to a shipped API, to express something composition already expresses. - A
time-pickerin 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-heightis--gb-list-row-heightand 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, ascalendar-headerdid, rather than the metrics being undocumented.min/maxdo 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
TimeFormatwrites a pattern:FormatStyle.SHORTon a time is never seconds, so a picker with a seconds column would have a field that cannot show one, and noFormatStyleproduces 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 ofPickerField.
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()asordinal() + 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 todate-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
popoverwith a saturation/value plane, a hue slider, an optional alpha slider, a hextext-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-inputand all three pickers. SemanticsSweepTestasked what a plane is, and the honest answer isRole.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.GROUPsays “a boundary with content in it” and this has none;GRIDpromises 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-inputcannot say what it holds. #88c0is a colour, which a test found by asserting it was rubbish: it is CSS’s four-digit#rgbaform. The picker takes whateverCssColor.parsetakes, because one that second-guessed the engine would refuse text a stylesheet accepts.- The affordance’s placement is scoped now.
picker-togglewas 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.GRIDfor 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:
| before | after | |
|---|---|---|
| frame, 0 threads (median) | 0.957 ms | 0.966 ms |
| frame, 4 threads (median) | 0.760 ms | 0.751 ms |
| three stroked icons add | 0.206 ms | 0.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
Strokeand eightints. - Radial gradients.
Gradientis sealed with one implementation becauseBlendGradientbinds one. Sealing is what makes addingRadiallater a record and an exhaustiveswitchthat stops compiling, rather than a default branch that quietly draws the wrong thing. - An origin, which
docs/gaps.mdG1 did not propose.fillPath(x, y, Path, …)andstrokePath(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 insave/restore.Icon.drawand a chart’s readout are both that case.
Consequences
Joinhas aMITERthe rasterizer’s enum does not. Blend2D has three miter variants differing only in what happens past the limit; SVG and CSS have onemiterand a separate number. The toolkit takes SVG’s shape, andStroke.miterLimitis where the number goes — which meansJoincannot be translated by ordinal and needs a realswitch. That is the general cost of owning a vocabulary rather than re-exporting one, and it is the point.CapandJoincarry 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.Pathcollides withjava.nio.file.Pathby simple name. A single-type import shadows a same-package type, so a class inpaintthat needs the file one can still import it; everywhere else it is an import like any other. G4’s proposedImage.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.
ArcandRoundRecthave one implementation between them andPath. 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
RoundRectandArcemitted before, asserted inPathTestrather than assumed. A diff out ofblessGoldensduring this phase is a bug in the value type, not a picture to re-record.
Alternatives considered
- Keep the
BlendPathoverloads 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
BlendPathinside eachPath. It removes the replay, and it gives an immutable value a native resource with a thread affinity and noclose()— 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, andFrame’s coordinates are alreadydouble. 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
:exampleand 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 4is 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.
:nativesgrows a publicstrokeMiterLimitandJoin.MITERnow 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
Strokewhosedashfield 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 importStyleLengthis refused by javac with “does not export it”.yoga.measurehad to go withyoga.style. ItsMeasureModeimplementsYogaEnum, which lives inyoga.style, so qualifying one broke the other’s public surface — found by-Xlint:exports, not by reading. That is whyParagraph.measureFunction()returns a toolkitMeasurenow.-Xlint:exportsunder-Werroris the check this needed. The plan called for aPublicSurfaceTestthat read:core’s module descriptor and failed on any exported signature naming a:nativestype. The compiler already does exactly that, better, and it named the three remaining sites in the text stack the momentrequires transitivewas removed. The test was not written, because it would have been a worse copy of something already running.Position, notPositionType. CSS calls the propertyposition; the binding’s name is the sort a binding carries. The constants are unchanged, so the name-based test still lines them up.Insets.NONEis new, and is notInsets.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,:exampleand 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
:widgetsgoldens, 13:exampleand 8:coresay so. - One cost, paid per node per layout: a
switchand, for insets, four of them. Against a foreign call each, which is what follows it.
Alternatives considered
- Re-export the Yoga types from
:coreunder alayoutalias. Java has no type alias, and a subclass of an enum is not a thing. - Make
layoutdepend on:nativesand have the values carry their own wire numbers. That is the binding’s job, it would put anativeValue()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
ComputedLayouttoo, 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 ofBoxbroke 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:
| names | called from | |
|---|---|---|
Font.shape / Font.draw / Font.widthOf | GlyphRun, TextDirection | text.Paragraph |
Paragraph.glyphs() | GlyphRun | nothing |
Frame.drawGlyphs | BlendFont, BlendGlyphBuffer | text.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
-Werrorrejects it.:corecannot be on:natives’ compile module path — the dependency runs the other way — so javac reports module not found for everyexports … to. The fix is@SuppressWarnings("module")on the module declaration itself, which is narrower than the-Xlint:-modulethe build file would otherwise have needed: it covers this descriptor’s twelve directives and nothing else in the project. :gpuneeded no qualification. It has zeronativesreferences, 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
maincompilation, and by the scratch-module probe. - The seal is reversible by one word, and that is the risk: adding
transitiveback, or an unqualifiedexports, would reopen it silently. The compiler catches the first of those the moment a leak appears; nothing catches the second, andBoundaryTestin:widgetsis 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 transitiveand 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:-moduleingoldberry.java-conventions.gradle. Switches the lint off for every module in the project to quiet twelve directives in one.- An
io.github.digitalsmile.goldberry.internalpackage 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
PointerEventof kindWHEEL, withdeltaY()for a touchpad’s fraction andticksY()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”.SemanticsSweepTestcaught 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, throughInput.accessibleName(), because the toolkit knows only that something was drawn. Canvasgained a record component, so its canonical constructor changed. The two existing shapes —new Canvas(painter)andnew Canvas(painter, attributes)— are kept.PointerEventgainedcontent(), which every widget now carries and almost none reads. It is four floats set per handler besidelocal(), 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.onTextdelivers 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 aswitchonkind()is what a tool actually writes. - A
CanvasPointerEventin canvas coordinates. A second vocabulary over the same facts, and it would have had to grow a field every timePointerEventdid. - 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-canvaswidget. 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:
- Move the native font into
paint.paintgains a pen that owns theBlendFontand the buffer;text.fontbecomes shaping and metrics, andFrame.drawGlyphsgoes package-private. The cleanest end state, and it moves font creation — which today isFontFace’s, and which the fallback chain and the paragraph cache are both built on. - 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.
- Leave it. One documented method,
blend2dstays exported, and:corekeepsrequires 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.blend2dandsdlare not:blend2dfor the method above, andsdlbecause nothing has looked at it yet. :corestill declaresrequires 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.ShapedRunis 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.ofcopies.Paragraph.glyphs()andFont.widthOfstill 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
BlendFontis 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.drawGlyphsand inline it intoFont.Fontwould need theBlendContext, which is the same crossing in the other direction and a worse one: the context is the frame’s most dangerous object. - Keep
GlyphRunand 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
Layoutshas aBL_RECT_Irow — the firstBLRectIto cross in either direction, for the crop’simg_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.ImageDatacarries the size now, because an image the decoder filled in was never told one.Image.pixels()hands out a read-onlyPixelBuffer, 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.ofArgbis unpremultiplied and so isargb(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/testFixturesrather 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, andPngEncoderTestis the round trip between them. - No widget draws an image yet. An
imgwidget — withobject-fit, a loading state and a cache — is a catalogue entry and is not this. What exists is the primitive, and acanvasis how an application uses it today. - Animated formats are absent, not forgotten.
bl_image_read_from_datadecodes one frame; an APNG or a GIF is a sequence and a clock, and would be its own decision.
Alternatives considered
Imageas a handle over aBLImage. 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
BLArrayand aBLImageCodecobject family in:natives, to write bytesjava.util.zipalready 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:
- 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
messageis a banner at zero opacity holding its space and drawing nothing. - 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
spinneris enough to make that happen. - 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 atext-arealearns how wide it really is and how amasonrylearns how tall its columns came out. - 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 isPixelBuffer’s existing doctrine — andasReadOnly()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.Offscreentherefore ends the frame, then closes the render tree, then unmounts. Getting it wrong is not an exception: the first widget whoseStateowned aFontand closed it indispose(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
Fontsand 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
Offscreenreuse across calls. Each render builds a fresh element tree and unmounts it, so two renders cannot share state through one and aState’sdisposeruns. 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:
Framepaints into memory, and a backend is about presenting. G5 named it because it is what a reader expects to need — seeGoldenImage’s own note, which has said “no window, no compositor and noxvfb” 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; andrenderis 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 throughOffscreen, andOffscreendoes whatLauncherdoes. - 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
PixelBufferrather than anImage. It is what the render produces, and it is the type with a borrowed-buffer doctrine attached.Imageis 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), besideonCursorChange(sink)— the router asks the focused widget on every focus change and tells whoever is listening, knowing nothing about the platform.Windowwires 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
TextEditandEditHistorychanged 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 makingtext-inputandtext-areahold anEditor, which is a rewrite of two controls’ state machines and is filed inTODO.mdrather than done during a feature. - No scrolling. An
Editordraws where it is told and does not know it has been clipped;text-areascrolls 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.isBidiApproximatesays 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-inputremains the answer for a form. TextInputStateno 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 throughrefocus, 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
Stateowns aFontand closes it indispose, andOffscreenwas 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 inOffscreennow (ADR-0284). Fontstays the caller’s. An editor holds one and closes nothing, which is why the showcase’s sticky opens its font in aStateand closes it indispose.
Alternatives considered
- Leave the model in
:widgetsand 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
Editora widget. Then it istext-inputagain, 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, andParagraphdeliberately 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
Editorown 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— andSDL_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 askshas(mime)when its menu opens gets the same answer for none of the machinery. - The showcase’s image card pastes now — click it,
Ctrl+Vfor a screenshot,Ctrl+Cto 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)onClipboard, 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
ClipboardContentunion — 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_ShowFileDialogWithPropertieswas 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.FileDialogSpechas 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_DRIVERat 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 andshown()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. Backendgrew a sixth facility and, like the clipboard, it is neverOptional: absence isFileDialogs.none(), which answers every request with aFailedrather 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()onBackend, the way popups and the tray report absence. A caller that got empty would writeFileDialogs.none()itself, which is the argumentClipboardalready made and won.- A
Dialogsstatic facade, as G9 proposed —Dialogs.openFile(...)/saveFile(...)/openFolder(...). Static access to a per-session facility is the thingHostexists to avoid (ADR-0140), and the names survive asFileDialogSpec’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, anddocs/core-widgets.mdsays 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 theStyledPainteroverload more specific, and the existing call sites that passnullcompile untouched.- Nothing in
paintchanged.Box.paintingstill holds aPainter,BoxPainterstill 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.
Canvasgained four constructors — the three it had, once more each for aStyledPainter, 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
StyledPainterpaints withCanvasStyle.none()rather than failing. Handed straight to aBoxor toOffscreen.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.bundledparses the face out of the jar, which is not work to do in a class initializer or twice.CanvasStyleis 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
Boxas a 26th component. It would have pushed the resolution intoBoxPainterand made everynew 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/lengthis the clause the input method is currently working on; it is filled withInk.selectionand the whole composition is underlined inInk.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 theSDL_EVENT_TEXT_EDITINGconstant. 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-inputdoes not compose yet, and that is now G16. Its editing model isTextInputStateoverTextEdit, 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 apasswordfield does with a composition. G15 asked forEditor, andEditoris 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
Propertybound 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 fromTextGeometry.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 rawMemorySegmentmust never escape:natives; ADR-0280 added that no type of:nativesmay 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. GlyphFaceandGlyphPenare 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 inSdlWindowHandle, never in an address. :core’s test fixtures still reachBlendImage.RendererRequirementprobes 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
SharedSecretspattern.paintpublishes a registration point,text.fontfills 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
drawGlyphspublic 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
Fontintopaint. More than the leak needed. Shaping is not painting, andtextis 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:
- Registration — telling the OS that this application handles
brd://. AHKCU\Software\Classes\brdkey on Windows, aCFBundleURLTypesarray inInfo.pliston macOS, a.desktopfile withMimeType=x-scheme-handler/brdand aupdate-desktop-databaserun on Linux. - Delivery — the URL reaching a running process. On macOS SDL already does
this:
handleURLEvent:becomes anSDL_EVENT_DROP_FILEwith a NULL window. On Windows and Linux the URL isargv[1]of a new process. - 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.
Applicationis handed the process’s ownargs; an application readsargv[1]and decides what abrd://in it means. - On macOS it arrives as a drop. SDL translates
handleURLEvent:intoSDL_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
FileLockon a file under the application’s own data directory, and ajava.net.UnixDomainSocketAddresschannel 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 aUiExecutor, 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.mdstops 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.desktopgenerator 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-inputhas its own editing model (TextInputStateoverTextEdit), 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 apassworddoes 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-inputreports 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-areareports 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-caretandtext-selectionalready do when they have nothing to cover. text-area’s parts doubled, frommaxRows + 2to2 * maxRows + 2, andTextAreaBox.renderstopped indexing from the end (children.size() - 2) and started indexing frommaxRows. 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_EDITINGthat normally ends one goes to whatever has focus, which by then is something else. onChangenever 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
Maskchanged. 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
passwordcompose unmasked. Honest and unusable: it would be the one control in the toolkit whose contents are on screen. - One underline in a
text-areainstead ofmaxRows. 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
Maskto carry the composition. Tempting — one splice instead of two — and wrong: a mask is per character and a composition is a span, and apassworddoes 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.link is a class, like the other four
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 #88c0d0 | 6.24 | 5.03 | 4.31 |
light, --gb-accent #5c7ea8 | 3.64 | 4.20 | 3.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 forbutton.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 fromComputedStylethroughBox.Textinto the paragraph painter, or a child box inButton’s Java — andButtonbuilds 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-darkandbutton-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-textis a new component token, so an application restyles every link button by overriding one name.- §2’s
linkis still unbuilt, anddocs/design-system.mdnow 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
:disabledis a global rule on opacity rather than per variant.
Alternatives considered
--gb-accentas 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
tabsdraws its indicator andtext-inputdraws a composition’s rule (ADR-0292). It works and it putsattributes.classes().contains("link")inButton.render— a widget reading its own class to decide what to draw, which is precisely the linecontrols.cssexists on the other side of. text-decoration: underlinein §8’s subset, now, for this. It is the right feature and the wrong reason: its consumer is §2’slink, and building a CSS property for a variant that does not need it would fix its shape around the wrong case.- A
linkwidget 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&q=2) is exactly why
that matters: flatten it first and an HTML renderer emits &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
libgoldberrynow 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:htmlcan name a type that reaches it, andExportedSurfaceTestfails if that changes.- A parse is one crossing and one allocation, freed before
Md4c.parsereturns. Nothing in:htmlowns native memory, which is why aDocumentis 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-NOTICESentries 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-codewill 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-htmlproducinglibgoldberry-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’sbody.htmlwith 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
borderis uniform — there is noborder-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)isGET /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
textwidgets 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-viewis the first widget outside:widgets, which exercises ADR-0131’s promise:panels.kdlnames the node, this module declares aWidgetCatalog, and the showcase never mentions either.- An application must add
MarkdownStyles.stylesheet()besideControls.stylesheets(theme).:widgetsdoes not know Markdown exists, so it cannot add them, and a document with no rules renders as unstyled words. html-viewis still a gap, and G8 stays open indocs/gaps.mdwith the Markdown half struck through. What it waits on is unchanged: rounded geometry and gradients on the export list, and a nativedocument_container.- No hard break inside a paragraph. A wrapping row has no widget meaning
“start a new line here”, and a
spacerwithflex-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
Stringof 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
textper 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.htmlis the same property throughMarkdownHtml. The two cannot drift, because there is one source and one parse per frame. - An unbound
markdown-viewis 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.mdrather than discovered later. markdown-viewcannot write.binding()is anObservable, 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+0still 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 withoutbind=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 callingsetState, which is the widget’s own rebuild written out by hand. - Caching the parse by text identity. A
WeakHashMapkeyed 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 ontext-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:widgetsmust 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
positionits 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.
Measuredalready 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-inputhas 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 inbook/src/TODO.mdrather 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
:widgetsand both are covered by:widgetstests, 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
SplitPaneTestwould have had to change with it. The measurement is needed for dragging anyway. - A CSS
heighton thetext-area. The stylesheet says how tall the editor is. It contradicts the note inTextAreaBox— 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_containeris a C++ virtual class, which FFM cannot implement, so it lives in a native library of its own and draws throughlibgoldberry’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.
HtmlNodeis sealed overHtmlDocument,Element,HtmlTextandComment; a fold over it is an exhaustiveswitchthat 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
textwidget 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.cssin theTOOLKIT_BASElayer, added by the application besideControls.stylesheets(theme). This iscontent-widgets.md§1.4’s “master stylesheet generated from the active theme”, written invar(--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>, andhtml.cssdecides 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.
An anchor is a button.link
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>Secondis 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>andstyle=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
hrefand asrcare 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-radiuson 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 .
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:
| screen | elements | style pass, settled | paragraphs shaped, settled |
|---|---|---|---|
| Basic (a wall of cards) | 219 | 1.0 ms | 0 |
| Markdown | 866 | 9.5 ms | 287 |
| HTML | 860 | 6.2 ms | 313 |
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_CAPACITYis 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:
| screen | style pass, settled | paragraphs shaped, settled |
|---|---|---|
| Markdown | 9.5 → 0.8 ms | 287 → 0 |
| HTML | 6.2 → 0.6 ms | 313 → 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:
- A Markdown link is a colour. ADR-0295 explained why — “a word is a
textwidget 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’sWords.Piecequietly 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. - An image is its alt text. ADR-0283 shipped
Image.decodeandFrame.drawImagein 2026; what was missing was a widget that draws one and an answer about who fetches.docs/gaps.mdG17 said so out loud: “not built, and not the engine’s”. - 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 anItem.taskMarkOffsetthat was never built — so the feature was waiting on something that did not exist. - 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
A link is a button.link, in both views
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"
ImageSourceis exported and is the only type inio.github.digitalsmile.goldberry.content: asrcis 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).Pictureis 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
srcshould 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 throughWiring.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:
- Hit-testing a point to a word and an offset.
buildandrenderrun 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. - Painting the highlight without a rebuild. A
selectedclass per word means a rebuild of six hundred widgets per pointer move — the frame shape ADR-0299 had just removed. - 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
textdraws, 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 aWordGeometry; - it hands its shaped
Paragraphto that geometry, which is what turns an x into a character offset —Paragraph.offsetAt, the same arithmetic a caret in atext-inputuses.
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
ENTEREDorEXITEDis 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:
- The pointer stops over a button.
updateHoverfires,hoveredis the button, the launcher starts its tooltip timer, the tooltip opens. - The user clicks. The button’s handler switches a tab, closes a dialog, deletes the row — anything that rebuilds.
- The tree flushes, the frame paints,
updateRegionsruns.refocus()puts the keyboard somewhere sensible.restate()re-asserts:hoveron a chain of unmounted elements and says nothing to anybody.hoveredstill points at the dead button. Launcher.pointingChanged— the only caller ofhideTooltip— is never reached, because nothing callednotifyPointing.
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:
- The application clamps. Watch
BackendEvent.Resized, and when the size is below the floor, ask for a bigger one. - 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— aLogicalSize, defaulting toNO_MINIMUM(0×0), beside the other three window properties.WindowSpec.ofgives no minimum.BackendWindow.setMinimumSize/minimumSize()— SPI, defaulting to a no-op andNO_MINIMUM, so a backend with no window manager to ask is honest rather than pretending.Sdl3Windowhands it toSDL_SetWindowMinimumSize;HeadlessWindowenforces it inresizeTo, 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
pressanddismiss. A badge takes neither. - A chip selects nothing itself:
pressraises and the application decides, which is ADR-0063’s loop and the same oneradio-groupandtabsrun.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,stepsandwizardall 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:
crumbchildren with a label, an optional icon and an action.- The last is the current page and is not a link.
- Overflow collapses the middle into a
…that opens amenuof the hidden crumbs, rather than eliding characters. - The separator is a
chevron-righticon 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:
- The pointer rests on the button;
hoveredis the button; the tooltip opens. - The user clicks. The router focuses the button —
fromKeyboard = false. - The pointer leaves.
hoveredgoes null, so the fallback runs and answers the focused node, which is still the button. pointingChangedcompares the target withtooltipOwner, 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
| elements | opens in | build | style | layout | |
|---|---|---|---|---|---|
| the whole sheet | 4709 | 464 ms | 0.0 ms | 3.7 ms | 0.6 ms |
after typing ar | 1085 | — | 0.0 ms | 0.9 ms | 0.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:
Boxhas no field for it (ADR-0164, ADR-0166).- Nothing in the toolkit paints outside a box’s own rectangle, so a shadow would need a damage rectangle nobody had (ADR-0166).
- 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 onDecorationrather than asBox’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 aFrame:ShadowRampsays how opaque each band is,ShadowGeometrysays what shape it is.paint.ShadowPainter— twelve lines, inpaintbecause 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-contentis 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: 1works 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 nobox-shadow”.dialog: “Elevation 2 is an edge and a scrim, not a shadow.”dialog-actions: “The margin ispadding-topon this node rather than a margin on the panel, because §8’s subset has nomargin.”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
| level | why | |
|---|---|---|
card | 1 | §1.5’s “raised: menus, cards, popovers” |
card.interactive:hover | 1 → 2 | §5’s “hover-elevation optional via class” |
dialog | 2 | §1.5 names dialogs for level 2 exactly |
tour-card | 2 | a tour card is a dialog by another name, and its own comment said so |
toast | 2 | below |
affix:affixed > affix-content | 1 | §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 thread | before |
|---|---|
| style | 3.5 ms |
| layout | 0.7 ms |
| raster | 18.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, likeClip, and its mirror image: a clip says what may be drawn and this says what would be.union,shiftedBy,mappedByand 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×900 | before | after |
|---|---|---|
| style | 3.5 ms | 3.6 ms |
layout (now including settle) | 0.7 ms | 1.0 ms |
| raster | 18.0 ms | 4.4 ms |
| a settled frame | 22.2 ms | 9.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-linestill 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)becomesscrollByLines(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 — amonoarea 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.TextAreaBoxcallseditor.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:
ScrollTestassertedLINEper notch. It now assertsLINE * LINES_PER_NOTCH, and the harness’s parameter is callednotchesrather thanlines— the distinction the handler used to collapse.KnobChainingTestpre-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.AffixGoldenTestis 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”.TextAreaTesthad 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 sheet | ms |
|---|---|
| flush (widget rebuilds) | 2.0 |
| style | 66.6 |
| layout | 2.7 |
| paint | 4.8 |
| hit-test snapshot | 2.6 |
| total | 78.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 sheet | before | after |
|---|---|---|
| flush | 2.0 ms | 0.1 ms |
| style | 66.6 ms | 4.2 ms |
| elements re-resolved | 1556 | 4 |
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()chunksmatchinginto slices ofcolumns— 221 records where the masonry was handed 1544 widgets.rowOfbuilds onerowof tiles, padded tocolumnswith 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_PITCHis 76 — a 68pt tile plusTILE_GAP— andshowcase.csspins#icon-wall list-rowto the same number.ListRowcomplains 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-0309 | now | |
|---|---|---|
| elements | 4709 | 711 |
| opens in | 414 ms | 106 ms |
| style | 3.5 ms | 0.72 ms |
| layout | 0.70 ms | 0.20 ms |
| raster | 18.0 ms | 4.2 ms |
And a wheel notch, which is the frame a reader actually feels — the whole arc, across all three records:
| wheel frame | before ADR-0313 | now |
|---|---|---|
| flush | 2.0 ms | 0.4 ms |
| style | 66.6 ms | 2.2 ms |
| layout | 2.7 ms | 8.4 ms |
| paint | 4.8 ms | 5.2 ms |
| hit-test | 2.6 ms | 0.5 ms |
| total | 78.6 ms | 16.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 pressingEnterpressed 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:
- Nothing is focused when it opens —
focusFirstreturns, as undertakesFocus(false). - A press inside it focuses nothing. The popup tells its own router
pressFocuses(false), andPointerRouter.focusFromPressreturns early. This is the halftakesFocuscould not reach, because the press arrives at the popup’s window and never passes through thePopupobject. - The owner does not forward keys to it.
Popup.handleKeydeclines 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:
Locatedis notified by thePointerRouterthat painted the node, and aPopuphas its own router — so a swatch 8 points from the bar’s left edge is told it is at x=8.Host.attachedPopupplaces 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.anchoranswers 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 aStylebesideWeight), or the variable-axis answer ADR-0066 deferred.TextFlow.decoration(Decoration.UNDERLINE | Decoration.LINE_THROUGH), drawn byParagraph.paintfrom 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
underlinePositionandunderlineThickness, which are in the face and not reachable fromParagraph. Drawing a rectangle under the text inTextPainterwould 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:
BlendFontMetricscarries ten fields instead of six —underlinePosition/ThicknessandstrikethroughPosition/Thickness, read out of the struct the call was already filling. No new native symbol and no relink:bl_font_get_metricswas writing all sixteen and this end was reading six.Font.decorations()answers aFont.Decorationsrecord — the four together, because they are only ever read together.TextDecoration(UNDERLINE,LINE_THROUGH) joinstext.flow, andTextFlowcarries aSetof them besidewhite-space,text-overflowandtext-align.Paragraph.paintdraws 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_GetSystemThemeis on the export list, bound as an optional symbol —SdlVideo.systemTheme()answersUNKNOWNrather 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 andSDL_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.
| upright | italic | |
|---|---|---|
| 400 | UI | UI_ITALIC |
| 600 | UI_STRONG | UI_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-areaandtext-inputcontrols are unchanged and still ignoretext-alignaltogether: both measure their own carets against their own origin, and both draw their value throughBox.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.
ReadOnlyCaretTestpins both halves: read-only opens at the head, editable still opens at the tail. TextEditgains a public factory. It isof’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.TextAreaStatealready had the same accessor for the same reason.text-areaneeds nothing: it hascaretMatters, 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. AnItembrings a tick column, an accelerator and a chevron that a search result has no use for, and its role ismenuitem, which a screen reader will announce. - Write a
HoverRegionwidget in the application — aWidget.LeafimplementingHandleswhose whole body is a three-arm switch overevent.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
Attributesgrows 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.MenuTitleandmenu.Itemkeep their ownonHovered. 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 byMenuson every open rather than written by an author. Replacing it with this would be a rewrite of the menu for no gain.- Two
Runnablefields on a value every widget carries. They are null for effectively every node, andPointerRouter.hookreturns after oneinstanceofwhen 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
Chipgrows 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 andChipDotColourTestasserts that they do.ChipDotgains a component and paints the colour over whatever the cascade resolved for itsbackground. 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
chipused 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.
ThemeAuditreasons 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.sobuilt before this refuses to load against this Java rather than failing later at the firstWebPDecodeRGBA. ADR-0330 lands in the same bump. - The published library grows by libwebp’s decoder.
webpdecoderis 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. :nativesgains awebppackage, sealed to:corelike Blend2D’s and Yoga’s wrappers: an application callsImage.decodeand names no type of that module. TheMemorySegmentnever 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.
WebPDecodeRGBAanswers null for one, which becomes anImageDecodeExceptionnaming that as the usual reason. Reading the first frame of one needswebpdemux, 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_DropEventlayout 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. BackendEventgains 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
HeadlessBackendproduces no drops. It has no desktop to drag from; the gesture is still fully testable throughWindow, 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
TextAreagrows 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-areawith no gutter is byte-for-byte the control it was:gutterWidthis 0, noTextAreaGutteris built, and every inset is what it was before. text-area-gutteris the only new CSS type. There is deliberately notext-area-line-numbernode, 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
TextAreagrows two components here and two in ADR-0331, reaching fourteen. The eleven-argument constructor every existing call site uses is kept.onEditfires more often thanchange=— 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 andsnapmoves an offset to the nearest legal grapheme boundary, so “absurd” is bounded rather than corrupting. text-inputdoes 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:
| Inputs | Version |
|---|---|
| nothing | 2026.1-SNAPSHOT |
-Pgoldberry.release=true -Pgoldberry.releaseTag=v2026.1 | 2026.1 |
-Pgoldberry.release=true -Pgoldberry.releaseTag=v2026.2 | build fails, naming both |
-Pgoldberry.release=true without a tag | build fails |
goldberryVersion=2026.1-SNAPSHOT | build 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
-SNAPSHOTingradle.propertiesand 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-SNAPSHOTto 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.propertiesafter 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.3tagged on 2 January 2027 is still2026.3.CalendarVersion.nextRelease(Year)starts a new year’s count only when asked. :assetsand:weaverdo not apply the conventions and still read the property directly, so their unpublished jars are named2026.1without a suffix. They are build-time tools and never leave the build.BuildVersionTestreadsgradle.properties, so a hand-written-SNAPSHOTthere 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 tohttps://central.sonatype.com/repository/maven-snapshots/. Queued, never cancelled, so an upload is not stopped halfway.release.yml— everyv*tag,release: true. Aworkflow_dispatchrun is a rehearsal: the same chain as a snapshot, intomavenLocal, 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— whichpublish.ymlpasses from the repository variableCENTRAL_AUTO_RELEASE. :nativesattaches the fournativeJar*classifier jars to its publication only when-Pgoldberry.artifactsDiris 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
pushon the per-OS workflows and addsnapshot.ymlbeside them. Every commit would run the twenty-minute superbuild on every platform twice. workflow_runafter 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.digitalsmilenamespace 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.mdlists them. -Xdoclint:noneon the published javadoc means a broken{@link}ships quietly. The 120 findings are inbook/src/TODO.md.- The
nativesclassifier 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.ymlruns 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:
| Classifier | File |
|---|---|
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
nightlyrelease rewritten on every push. Worth adding for tagged releases later; it does not replace a snapshot address. - An OCI artifact on
ghcr.iothrough ORAS. Anonymous pulls for public packages, but a user needsorasto 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 fromrelease.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, ajava-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 listsgoldberry-common,-natives,-core,-widgetsas ordinary compile dependencies, andgoldberry-html,-gpuas<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 realgoldberry-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-htmlwould 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.toolkitso the automatic module name derived fromgoldberry-2026.1.jarcannot 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
blessGoldensskips: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:
graalvm/setup-graalvmwithversion: '25.3'anddistribution: graalvm-community— GraalVM CE 25.3, today 25.3.4.1 on JDK 25.0.4.1 — which setsGRAALVM_HOME, where:example:nativeImagealready looks, and on Windows the MSVC environmentnative-imagelinks with.- macOS and Windows only:
:example:nativeImageMetadata, a fresh headless trace on that platform. Linux builds from the checked-in, reviewed trace. :example:nativeImage.- The binary is run for three frames, as the runtime image is — under Xvfb on Linux.
- Packaged as
goldberry-showcase-native-<target>.tar.gzon unix, for the executable bit, and asgoldberry-showcase-native-windows-x64.exeon 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
nativejob 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
MissingForeignRegistrationErrorat 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.mdwas 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-14has 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_LINEand 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:
TestFont.get()(:widgets) andTestFonts.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.- Tests that reach libgoldberry without asking first:
MaximizedStateTest(a headless window still paints into Blend2D),MarkdownHtmlTest, whose@BeforeAlltouched nothing native, and oneShowcaseDocumentsTestcase that parses Markdown.MarkdownPropertyTestis jqwik, and jqwik reports an abort from@BeforeContaineras an error rather than a skip. - The coverage floors (2026-08-30) were measured with the library loaded.
Under
-Pgoldberry.skipNativeevery test that shapes, paints or parses skips, and:core/:widgets/:htmlfall to 56/39/57% of lines. Thejavajob could never pass them, and nothing else ran them. GoldberryTeststill asked for a semverx.y.zafter ADR-0333 made the version2026.1-SNAPSHOT. This broke thejavajob and every verify job.nightly.ymlinstalled no system packages before:natives:cmakeBuild. Since ADR-0325,checkToolchainrefuses 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:
WorkflowCommandrenders::error title=…::messagewith the runner’s escaping rules (%, CR, LF; plus:and,in a property) and caps the body.TestFailureAnnotations, aTestListenerthatgoldberry.java-conventionsadds to everyTesttask, 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.
- Windows
:natives:test: seven structural tests, one path.ExportedSurfaceTestandHolderShapeTesteach found the module’s classes withPath.of(codeSource.getLocation().getPath()). On Windows that location isfile:/D:/a/goldberry/.../classes/java/test/,getPath()keeps the leading slash, andPath.ofrefuses/D:/...withIllegal char <:> at index 3. Both now go throughCompiledClasses, one helper in the test tree, which converts the URI — the form that knows about drive letters — and is tested on its own. - Windows
:build-logic:test: CRLF.GraalVmReleaseTestcutshowcase.ymlat its first blank line,indexOf("\n\n"); a Windows runner checks out withcore.autocrlf=true, no such sequence exists, and the test died of aStringIndexOutOfBoundsExceptionrather than a message.Repository.readnormalises what it hands out to LF, tested, and a.gitattributeskeeps every working tree at LF — the drift guards are about content, and endings are not content. - The Windows showcase built libgoldberry with MinGW.
:natives:cmakeBuildhands CMake the Ninja generator and no compiler, and the firstccon awindows-2022runner’s PATH isC:\mingw64\bin\cc.exe:clis only on the PATH inside a Developer Command Prompt. GNU 14.2.0 configured without a word, linkedlibgoldberry.dll— GNU naming, and alibstdc++-6.dlldependency — andshowcaseImagefailed looking for thegoldberry.dllthat MSVC writes andgoldberry-nativesships.windows.ymlnever met this because it drives CMake itself with the Visual Studio generator. NowWindowsToolchainin build-logic names the compiler:checkToolchainresolvesclthe way it resolvescmakeand refuses without it, naming the compiler CMake would have taken instead;cmakeConfigurepasses the path it found asCMAKE_C_COMPILERandCMAKE_CXX_COMPILER; andshowcase.ymlrunsilammy/msvc-dev-cmd@v1before the build, held there by a test. - The macOS trace aborted inside CoreGraphics. The jlink image ran to three
frames on the same runner, under Cocoa.
nativeImageMetadataran the same showcase undervideoDriver=dummyand died withAssertion failed: (CGAtomicGet(&is_initialized)), function CGSConnectionByIDand no Java frame. SDL’s macOS tray is Cocoa’s status bar, andSDL_CreateTraycalls[NSStatusBar systemStatusBar]before[NSApplication sharedApplication]; under the Cocoa driverSDL_Inithad already created the application, underdummynothing had, and the status bar’s first call into the window server is the assertion. Two changes, because one would not do:TrayAvailabilityin the sdl3 backend declines a tray on macOS underdummyso the process is never aborted for asking; and the macOS trace runs undercocoa(-Pgoldberry.trace.videoDriver,TraceVideoDriverin 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:
- Two timers overdue at one wake-up fired in creation order.
EventLoopcollected 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.fireDueTimerssorts 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. WaylandDecorationssplit a symlink target on/. The/procreader 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.FileChoiceTestcompared against/a.pngas text, which Windows prints as\a.png. The expectation goes throughPathtoo.HtmlStylesTestandMarkdownStylesTestcarried the samegetPath()conversion as 6, on the html module’s descriptor. Through the URI now.- The catalog sweeps found no catalog.
AnimationSweepTestandSemanticsSweepTestturned a relative source path into a binary name withreplace('/', '.'), which on Windows changes nothing, soClass.forNamefound no class and both sweeps reported an empty tree.SourceTree, one helper in thearchtest 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
javajob’s three steps pass. Then, against a local libgoldberry::natives:testand:core/:widgets/:html:testpass 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, whichwindows.ymlhad only ever done through the Visual Studio generator. 10–14 passed the same way atd478ecfe: 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 timepublish.ymlreached 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
Pathfirst. - A PR’s
javajob no longer enforces the coverage floors; the verify job does. A drop in coverage now turns the verify job red, not thejavajob. - 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’sFD_…initialiser already calls, records the descriptor it was handed.Downcalls.linked()is the downcall half of the surface.Upcalls.describeis the new choke point for the other half. The five classes that make a stub —SdlEventWatch,SdlFileDialogs,SdlTray,MeasureCallback,SdlClipboard— declare theirDESCRIPTORthrough it, so the shape is recorded when the class initialises rather than when the first tray or dialog exists.ForeignSurface, in a newnatives.metadatapackage, lists the module’s classes through itsModuleReader(or its code source, off the module path), initialises every class in a…callspackage and every upcall owner, and returns both lists. Initialising a holder needs nolibgoldberry, which is ADR-0173’s point: an unbound handle is linked from a descriptor and names no address.ForeignMetadatawrites them as theforeignsection of areachability-metadata.json, in the agent’s own spelling:jint,void*,struct(jfloat,jfloat),padding(n),sequence(n, …),union(…).:natives:foreignMetadatais aJavaExecon the module path that runs it, andjarcopies the result underMETA-INF/native-image/io.github.digitalsmile/goldberry-natives/.native-imagereads 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 aMethodTypewould have flattened toMemorySegment. - A holder added tomorrow is registered tomorrow, because it lives in a
…callspackage. A new upcall owner is not, until it is added toUPCALL_OWNERS— and the test that scans the sources fails the build until it is. Downcallsand 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.ymlruns onpush: tags: ['v*']andworkflow_dispatch. Theimagejob buildslibgoldberry, 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 newreleasejob, on a tag only, downloads the three and runsgh 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 whatdocs/releasing.md’s checklist already asked for.- The
publishjob, themaven-publishplugin on:example, its two publications and thegithubPackagesrepository are gone, and so isShowcasePackagein build-logic with its test. - The jlink tasks (
jlinkImage,showcaseImage) and their launcher scripts are gone fromexample/build.gradle. - The example’s tests move to
linux.yml’s linux-x64 verify leg, beside the:core/:widgets/:htmlsuites 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. PublishWorkflowsTestholds 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-imagereads 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:
| Kind | Count | What they are |
|---|---|---|
Security: comparison-with-wider-type (severity 8.1) | 4 | An int loop counter run against a long or double bound |
Correctness: index bound, null path, inherited-call, NumberFormatException | 22 | Twelve real, ten in code whose input is validated first or where a crash is the answer |
| Quality: never-read locals and unused parameters | 142 | 61 pattern bindings named ignored; 81 parameters, of which 71 are contract signatures |
Test smells: empty container, unused container, useless null check, new String | 4 | Two vacuous assertions, one GC anchor, one deliberate non-identical string |
| False positives: representation exposure, missing switch case | 12 | Compact 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.resamplehoists itsMath.ceilbounds toints before the loops;SdlClipboardcounts MIME types with along;TimeTicksnames the quantity it was comparing — how many labels a rung produces, as anint— instead of comparing adoubleratio with anintbudget. 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.mixtakes 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 thatlengthis the longer list’s.ParagraphCache’s eviction hook sayssuper.size(). The enclosing cache has asize()too, and an unqualified call inside the map read as either.- A
NumberFormatExceptionis turned into a refusal that names what was asked where the text came from somebody: the launcher’s--frames=and--size=flags, an SVGpointslist at build time (which icon, which token), a document’s action argument (which action, what it was given), and the showcase’smd.toggle-task. - Two test helpers lose a parameter nothing read,
SdlTray.surfaceOfloses an arena it never allocated from, and thetrythat opened it goes with it. - Two tests now assert what their names claim.
ImmutabilityTest’s “handlers defeat equality” assertedother != 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:
| Finding | Where | Why it stays |
|---|---|---|
local-variable-is-never-read on _ | 61 | The 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 _ -> lambdas | 7 | The same limitation: an unnamed lambda parameter is reported as <anonymous parameter> |
unused-parameter on inflate(node, children, wiring) | 62 widgets | The 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 targets | SdlTray, SdlEventWatch, MeasureCallback | The 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 parameter | Handles.onFocusWithin, Input.caretOffsetIn, Input.onFocusChanged, Measure.measure, CodeEditor.focusChanged, AreaEditor.focusChanged, TextEditor.located | An 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-exposure | 9 record compact constructors | Every 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-switch | MarkdownParser ×2, MarkdownWidgets | The “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-exception | CssTokenizer ×2, CssColor ×2 | The 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 tests | 6 | A test that parses what it wrote should fail loudly if the text is not a number; a catch would hide the failure |
unused-container | BindingSchemeBenchmark.alive | The list exists to be held, not read: it keeps models reachable so the population a benchmark line reports is exact |
inefficient-string-constructor | PropertyTest | new 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:
| Finding | Where | Why it stays |
|---|---|---|
unused-parameter on upcall targets | GlibLog ×2, SdlLog, IoCallbacks ×2, AudioToolboxDecoder ×2, VideoToolboxDecoder ×3 | The 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-exception | image.qr.Segment ×2, GraalVmRelease | The 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-exposure | 18 record and class constructors, among them MediaInfo, MediaError.UnsupportedCodec, Source, ShaderCode, TextureSpec, VertexBufferLayout, UriList, TextDrop | As above: each copies with List.copyOf, Map.copyOf or clone() before it stores, including the explicit canonical constructors ADR-0497 introduced |
missing-case-in-switch | Playback ×2 | Multi-label case A, B -> arms, as for MarkdownParser |
random-used-once | DrawTest, GpuApiTest, SdlGpuDeviceTest ×4 | A 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-algorithm | media test Fixtures.md5 | A frame’s fingerprint in a test, compared with the same function’s output. Nothing is secured by it |
constant-comparison | MemoryIO.awaitRelease | releases == entered is re-read after every wait(), which the query does not model as a write by another thread |
empty-zip-file-entry | CatalogCompilerTest | The entry is a directory, which is what the test’s archive needs to have |
Alternatives considered
- A
query-filters:block excludingjava/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-ignoreforsrc/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 theminflate’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 isvar _. A future formatter may allow the bare form; until then the convention isvar _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
Pathstride is aswitchon every replay, where it was an array read. It compiles to a jump table over six constants;FrameBudgetTestis 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_SetWindowSizewas bound in:nativesand used for popups, andBackendWindownever 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.
FrameRingkeeps 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.ymlopened 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 whatBackendPopup.resizealready was, made available to a window — the popup’s declaration now overrides it.Sdl3Windowhands it toSDL_SetWindowSize, rounded, and reports nothing itself: the compositor answers with aResizedandsize()reads what it decided.HeadlessWindowplays the window manager the wayHeadlessPopupalready 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;HeadlessBackenddelivers it for either.Window.resizeis the public face, ignored on a closed window.--resize=WxHwalks the window a pixel a frame on each axis from its opening size toWxHand back, for as long as the run lasts.ResizeWalk, in a newdrivepackage, 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.FrameRingkeeps three totals beside the window — late refreshes, paint time and the worst frame — andFrameStats.summary()hands them out as aFrameSummary. The launcher logsframes: 300 frame(s) painted, 2 late; paint mean 1.31 ms, worst 8.90 ms; display 60.0 Hzafter the window has closed, inLocale.ROOT, because a workflow greps it.--late-budget=Nmakes that a verdict: pastNlate refreshes the launcher throwsFrameBudgetExceptionafter shutdown, so the process exits non-zero with the summary in its message and nothing left open.showcase.ymlruns 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.
Windowgained package-privatelauncherOnResizeandlauncherOnMove, run before the application’s handler;onResizeandonMoveare 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
onResizeandonMovehandlers survivestart. Anything that relied on them not firing was relying on a bug. --frames,--size,--resizeand--late-budgetare 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:
| Kind | Count | Where |
|---|---|---|
invalid use of @param / @return | 100 (the cap; 425 tag lines in fact) | :natives’ …Calls holders |
reference not found | 20 | :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
callmethod, 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,
Backendfrom:nativesorListViewfrom:core— turned into a code span, which is what they always were. goldberry.publishpasses-Xdoclint:all,-missing.missingis left out on purpose: it wants a@param x the xfor 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 javadocfails 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
…Callsholder documents itscallmethod, 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.
stepsis aWidget.Statelesscomposition building aStepList(the CSS type, per ADR-0109). On every build it hands eachStepits position, the total, and aStepState—DONEbefore the index,CURRENTat it,UPCOMINGafter — unless the step sayserror, which overrides all three: a step that failed is neither done nor merely upcoming, wherever the index is. The current step is:checkedand every state is also a class, because a stylesheet wants to colour four and:checkednames one. AStepConnectorbetween 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
clickableand the step isreachable, and then reports its index throughchange, 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.currentis read throughbindor 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. wizardisWidget.Statefulwith one fact in its state: the page it last showed. It makes oneStepperWizardPageand hands them to the standaloneSteps— “a wizard’s indicator is the standalone one and cannot drift from it” — builds the current page’s children into aWizardContentand nothing for the others, and writes Back then Next-or-Finish into aWizardActionsbar. Back is disabled rather than absent on the first page, so the bar does not change shape; a wizard given noback=has no Back at all. The bar isdialog-actionsunder 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#focusresolves 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-contentafter 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
goTois 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,wizardandpageare markup;step-marker,step-body,step-label,step-description,step-connector,wizard-contentandwizard-actionsare parts, CSS-selectable and not constructible.- §3’s “connector fill
transform: scaleXbase” is a colour transition instead: a line that grows from one end needs a transform origin the subset does not express.book/src/TODO.mdhas it. Rolehas no list; the container answersGROUPand the itemsROW, asbreadcrumbsdoes, 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.
TimelineisWidget.Stateless, building aTimelineList(ADR-0109) that carrieshorizontalandalternateas classes. On every build it writes aPlacementonto eachEntry: 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 whenpending. 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
TimelineLinethat 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 arithmeticflex-basis: 0; flex-grow: 1would 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-cellismin-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
colouror the stylesheet’s, which ischip’s rule for a dot (ADR-0328); an icon sits in a larger disc. Abadgeas 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
ROWand 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
timelineandentryare markup;timeline-rail,timeline-marker,timeline-line,timeline-side,timeline-body,timeline-head,timeline-label,timeline-timeandtimeline-contentare parts.- A record with a
childrencomponent and aWidget.Leafoverride ofchildren()hands the parts out through the accessor the component was meant to have.Entrycalls its contentbody; the parts call theirscontent. 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_OpenURLjoins the export list as an optional symbol, like the theme call (ADR-0322): alibgoldberrybuilt before it must keep opening windows, and “the platform would not” is an answer a link already has to handle.Backend.openUrldefaults to false; the SDL backend hands the string over; the headless one records it and answers true.Host.openExternalis 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 toxdg-openand says yes — so there is no “refuses nonsense” test, because it would open nonsense.LinkisWidget.Statefulbuilding aLinkText(the CSS type, ADR-0109). The state holds the host and, for an external link, theexternal-linkicon 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.Enteractivates andSpacedoes not, which is §2’s word and every browser’s:Spacescrolls a page.visitedandexternalare classes; the underline on hover is the stylesheet’s, throughtext-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’sspan class="link", as §2 says. Role.BUTTON, forcrumb’s reason:Rolehas 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
linkis inPrimitives.builtInTypes(), besidetext, and the parity and immutability tests hand both a word, since neither exists without one.- An application can open a URL:
host.openExternal("https://…"). libgoldberryhas 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.outlinedis a transparent fill, a 1px--gb-borderand the text ink, and it composes:button.outlined.dangerchanges the border and the ink together,button.outlined.primarytakes the accent.button.squareis radius 0;button.circleis afullradius on a box as wide as it is tall.Button#classes()addscirclewhen the label is empty, there is an icon, and neither shape was written.Floated(button, corner)isWidget.Stateful. Its state attaches the button — withfloatadded to its classes — throughHost#overlayon the first build and removes it on unmount;button float=#true corner=…inflates to one, andWidget.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.mdhas it.
Consequences
Button.SQUARE,CIRCLE,OUTLINEDandFLOATname the classes.- The
button-icongolden 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
#—ButtonTestreads one as a colour literal — sofloat=trueis 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.
PaintsgainsisAnimating(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
renderruns in, beforecurrentElementis 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(), andstyle -> !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
Canvasis 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
animatinghas 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.Faceis a sealed interface overBundledFontandFontSource: a family, one of the two weights, and upright or italic.Face.matchis the matching ruleBundledFont.ofalready 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)asksBundledFont.ofand only then the shipped list, so a file an application callsInteris 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 wayStylesheet.resourcedoes. 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()beforestart, 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: Forumworks everywhere a bundled family does: in labels, fields, the caret,canvaspainters (throughCanvasStyle.font()),markdown-viewand offscreen renders.BundledFont.ofkeeps its signature and its answers.BundledFontTest,ItalicFaceTestandFontsTestpass 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.mdcovers 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.
ContainingBlockshifts 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 isoverflow: 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 givestext-area-gutter, which has no shipped rule for one. AreaPaddingcarries all four edges fromrenderto the state, replacing the two-edge record. The wrap subtractsleft + rightand the visible height subtractstop + bottom.
Consequences
text-area-gutter { background: … }fills the column from border to border, and the stopgapbackground: transparentin the reporting application can go.- A field with asymmetric padding wraps inside its room.
TextAreaGutterStripTestchecks the right padding for ink across five padding and gap combinations, and checks that scrolled text still stops at the top padding. gallery-formsandgallery-forms-lightchanged where the numbered area’s strip now fills the padding, and nowhere else.- The box tree under a
text-areais one level deeper. Tests that walk it look in the last child, asTextAreaGutterTestdoes now. text-inputstill computes its room aswidth - 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 inTODO.mdrather 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
HICONfrom 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.decodealready 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.IconImageis one size in straight-alpha, native-order ARGB in a direct buffer, read throughImage.argb, which unpremultiplies.Window.icon(List<Image>)converts, andBackendWindow.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_SetWindowIconandSDL_AddSurfaceAlternateImageare 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 beforeSdlVideo.setWindowIconreturns. SDL converts the base and its alternates into its own copy first, and the pinned source confirms thatSDL_ConvertSurfacecarries 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
libgoldberryhas 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
Hostmethod to change the icon at runtime, for example to show an unread badge.Window.iconexists, so an application can reach it throughhost.window(), and aHostmethod 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 withstarting = true, numbered in source order among the other rules. A nested at-rule or a prelude is refused. - Cascade.
StyleResolverputs starting rules in buckets of their own. They are never part ofresolve, 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 onlyopacity: 0leaves 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 beforeobserve(style). The widget’srestyleis 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.floatenters fromopacity: 0; transform: scale(0.9)over--gb-motion-base. Its press still snaps because of abutton.float:activerule, since the float rule would otherwise out-rankbutton:activeby source order.FloatEntranceTestpins 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
closingphase rather than a stylesheet’s. check-markandradio-dotcould 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
Keyframesblock is a name and frames sorted by offset.from, 50%becomes two frames. An offset outside 0–100%, a word that is notfromorto,noneas a name, and!importantinside a keyframe are all refused.Stylesheetgains akeyframeslist, 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)substitutesvar()for the element the animation runs on, so a keyframe ofvar(--gb-chart-1)follows the theme. ComputedStyle.animationsisKeyframeAnimations: seven lists, kept apart untilentries()combines them the way CSS does, with names deciding the count and shorter lists repeating. That is what lets a later rule change onlyanimation-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, withease-enteras the default, which istransition’s default. A bad value drops the declaration and names it.- Timing.
KeyframeTrack.progressimplements CSS’s model: a delay, which may be negative and is filled only underbackwardsorboth; iterations, each played backwards peranimation-direction; and an end that is held only underforwardsorboth. 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 outsideTransitions.Animatableis dropped with one warning per block and property.Animatablesholds 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.settleandisAnimatinginclude keyframe animations that are waiting or running, and exclude one that has ended and is only holding its last frame. So aforwardsanimation 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
ToolkitLoopsTestchecks that no toolkit sheet declares an infinite animation. progress,spinnerandskeletonkeep 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). @keyframesinside@media,animation-play-stateandanimation-compositionare 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.Settleis 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’sease-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.TileFloorholds 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)callsswap()andsetState. 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.transformstates 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
isMovingis 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.
TileFloorTestchecks 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.GALLERYgainsmotion, 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:
text-inputcomputed its room aswidth - 2 × left padding, the same arithmetic that wrappedtext-areawrong in G43. A field does not wrap, so underpadding: 0 16px 0 4pxthe error was a caret scrolled into view 12px late, with the end of the value under the right padding where the clip cut it.- 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. - 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.laidOuttakes the left and right padding separately, asAreaEditordoes since ADR-0350.caretAreauses both as well.FloatSlotis whatFloatednow puts in the overlay layer: the button, and aProperty<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 putsleavingon the button, andbutton.float.leavingincontrols.cssis the exit:opacity: 0andscale(0.9)on--gb-motion-fastwithease-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 byhost.after(--gb-motion-fast), read throughBuildContext.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’sanimatingpredicate calls it, and the renderer asks that predicate on every render, painted or not.paintstill calls it too, so a painter used without the predicate behaves as before.gallery-motionis 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.FloatSlotis 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:
- 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 notransform-origin, so ascaleXwould grow from the middle. - A
badgecould not be a timeline’s marker. §10 lists “dot, icon orbadge”. ADR-0345 built the first two and said a widget marker needs a slot markup can name. isModalhas one consumer,dialog. That entry named awizardstep and asheetas 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-connectorkeeps 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 ischeck-mark’s reason (ADR-0067).controls.cssgives the filltransform: scaleX(0)aboutleft center, andscaleX(1)understep-connector.done, withtransition: transformon--gb-motion-base. A vertical list usesscaleYaboutcenter top, so the line grows down.- The fill carries no class.
step-connector.done step-connector-fillreaches it, so the one word the list writes is written once. - Settled, the pixels are what the colour version drew, so both
stepsgoldens and bothwizardgoldens are unchanged. What differs is the frames between the two states.
The marker slot
EntryMarkeris@Markup("marker")and holds exactly one widget. It is a description, the waypageis towizardandtabtotabs:Entry.inflatelifts its content out of the children and everything else stays the body.- Named rather than inferred. “The first
badgein the body” would move a badge that a document wrote as content onto the axis. Twomarkerchildren, or amarkerwith zero or two widgets, are refused. Entrygains amarkercomponent andwithMarker(Widget). The seven-argument constructor is kept. A widget marker wins over an icon, and the pending ring never holds one.TimelineMarkerbecomes a bare holder with the classwidget. 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-railin 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, andcontrols.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.
markeris a markup name. It is only meaningful insideentry, and elsewhere it inflates to a description that draws nothing, which ispage’s behaviour outside awizard.- The showcase’s
Chroniclegives Rivendell anIXbadge marker, andgallery-collectionsis 1040 tall so the card is photographed whole. timeline-badges-dark.pngis a new golden: a successv2, a one-digit3that 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:
publish / {linux,macos,windows} / Java—:core:test, 2267 tests, 1 failed:WindowResizeTest > what the manager decided arrives through onResize, and a frame follows, withUnsatisfiedLinkError: 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 calledRendererRequirement.enforce(), which is the fifth instance of the defect ADR-0338 fixed in four tests.publish / linux / Verify layouts (linux-aarch64)—:core:prepareAssetsfailed withServer returned HTTP response code: 500forgithub.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, andAssetCachemade 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.arrivesThroughTheHandlercallsRendererRequirement.enforce(). The other four tests there only move a headless window and keep running without the library.io.github.digitalsmile.goldberry.assets.download.Downloadermakes the request throughjava.net.httpwith redirects followed. It tries four times, waiting 2, 4 and 8 seconds, on HTTP 408, 429, any 5xx or anIOExceptionfrom 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.fetchandfetchTextgo through it.fetchTextis 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
DownloaderTestcovers the retry policy with no network and no waiting. - Any new
:coretest that opens a window and runsGoldberry.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.decodeis synchronous, and a large JPEG is tens of milliseconds. A build runs every frame.Boxhas no measure hook for an image and noaspect-ratio.content.image.Picturein:htmlalready sizes a picture by arithmetic against its style.Frame.drawImagetakes 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
ImageSourceis sealed:File,Resource(an anchor class, or a class loader for markup’sclasspath:),Bytes(copied, keyed by SHA-256),Decoded(anImagein hand) andSupplied(an application’s own work under its own key).ImageLoader.shared()is one process-wideImageCache. 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.asyncwhen 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. ADecodedsource never leaves the caller. ImageStatereads 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 changedsrcand 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-widthormax-heightin 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.loadingandimage.errortake--gb-surface-2, the skeleton’s placeholder fill. An error shows Lucide’simage-offand the alt text, as a browser does.- Alt text is required unless
decorative=#true. A meaningful image builds anImageFigure(Role.FIGURE, named by its alt); a decorative one builds anImageBox, which does not implementSemanticsat all. The role set has no image of its own, andcanvasanswersFIGUREtoo.
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.decodestays synchronous and uncached for acanvasthat 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, andgallery-canvasis re-blessed.image-darkandimage-lightare 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.
SelectStatehandsSelectFieldthe labels, andSelectValueshapes each against its own style and setswidthto 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 formultipleorautocomplete, which draw chips or an editor instead of a value. - It is not the
Measuredtrap: 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-surfaceandselect-placeholderare 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.notifyLocatedfinds 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.AffixStatelimits its shift to the room between the hole’s far side and the container’s: fortop, the container’s bottom minus the hole’s bottom; forbottom, the hole’s top minus the container’s top; and likewise for the horizontal edges.:affixedstill 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.
Tablewraps its head and rule in anaffix. 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
affixandaffix-contentabovetable-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)andTable.resized((key, width) -> …). The application answers by making the columnfixed(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 throughMeasured. The header answersgestureAnchor()with it. table-gripis a 6px part over the header’s trailing edge, positionedright: -12pxbecause 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 foranchor + 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-headerlosesoverflow: 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-panedrag. - 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-collectionsis 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, anddragBarholds.draggingfor the thumb, exactly asScrollStatedoes. TextAreaBoxputs 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
laidOutcan 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:hoverwidens the thumb and shows the track, asscroll:hoverdoes.
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-htmlandgallery-markdownare re-blessed: their source editors overflow and now show a thumb.TextAreaGutterTestfinds 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
renderhas a clock.ScrollFadealready found this: a wake knows something happened and has no time, andrenderhas 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.
ScrollGlideholds where the drawing started, where it ends, and when it started. It is stamped inScrollViewport.render, which replaces the content box’s translation with the in-between one, andisAnimating()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
Locatedrectangle is painted mid-glide while the offset is already at the end, soScrollController.revealfirst 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 fromlocatedworks) scrolled a second time and overshot.
Consequences
- Hit testing,
Locatedandaffixall 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.
ScrollControllerTestand the showcase’sScrollingScreenTestrun 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.cssdeclares--gb-scrollbar-gutter: 0px,--gb-scrollbar-size: 10px,--gb-scrollbar-track: transparent,--gb-scroll-thumb-size: 6pxand--gb-scroll-thumb-hover-size: 10px, which are the numbers the rules held, so every existing golden is unchanged. Thescrollbarandscroll-thumbrules read them.scrollbars-always.csssets a 12px gutter, a 12px bar, the--gb-surface-2track, and an 8px thumb that does not widen.Scrollbars { OVERLAY, ALWAYS }in:widgets, withstylesheets()andsource()shaped likeDensity’s, andControls.stylesheets(theme, density, scrollbars).ScrollViewportreads the gutter token inrenderand banks it inScrollState, as it already banks--gb-scroll-line.ScrollContentadds 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.Positionholds the offsets, the overflows and the viewport’s size, withoverflowsX(),canScrollLeft()andcanScrollRight().position()answersNONEwith nothing attached.onChange(Runnable)sets a single listener, because a controller has one owner.ScrollStatenotifies it when the offset moves (by any route) and when its measured extents change.TabsStatelistens and keeps the last position.TabListbuilds[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
:disabledat the edge it would page past, is aBUTTONnamed “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_STARTisCHEVRON_ENDmirrored, a kind rather than a transform forCHEVRON_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-narrowandgallery-icons-narroware 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.renderreturns no box for a hidden node and does not render under it, so it has no layout, no paint, no hit-test region, and noMeasuredorLocatednotifications.PointerRouter.isFocusablerefuses anything under a hidden ancestor, walking up asisDisableddoes, andrefocuslets go of a focus that has become hidden.Tabs.keepAlive(boolean)andkeep-alive=#truein markup.TabsStatekeeps the values it has shown and still has, in first-shown order. The panel then holds oneTabPage(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.
isHiddenis not a stylesheet feature, so §8’s subset still has nodisplay: none.- The showcase’s chapter strip keeps its tabs alive, and each chapter has an
unbound note field to show it;
gallery-navigationis 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)inwidgets.markupis a stateless composition node whose binding is the source. It draws the value when it is atype, with the document’sidwinning 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 theListViewrebuilds the node, and the list under it reconciles by key.ListView,TableandTreecarry@Markupand aninflatereturning aBoundover their own class.Suggested(source, field)incontrols.optionrebuilds its field whenever the bound collection changes. It offersOptions as they are and anything else as an option of its string.TextInput.inflatewraps withfield::suggesting;Select.inflatewraps with the newSelect.withOptions, which replaces the written options and keeps any other children.tourandtoastget no markup. Starting a tour needs aHost, which a document node does not have (ADR-0121). A toast is raised through aToastController, not placed. Both are imperative by design.
Consequences
- The application model holds widgets for these three: a
@Bindfield of typeListView<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.focusByIdasks the popup’s own router.Launcher.focuswalks 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.chosenTreeRownamestree-<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
treehas. - 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 nearercurrent; on the travel it is the angle’s fraction, unless that is more than half the travel fromcurrent, 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
axesdoes not include is dropped beforescrollBy.ScrollAxisalready 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)andedge="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:affixedis on when either is non-zero.AffixSlotandAffixContentcarryshiftXandshiftYinstead of an edge and a shift, and the content translates by both.
Consequences
- One
offsetapplies 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.
TabDragis a composition node with no CSS type around each header of a reorderable strip. It hears the pointer after theTab, 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.TabsStatekeeps 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
Tabcarries adragOffsetthat itsrestyleturns into atranslateX, 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-navigationis 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:
masonryneeds1/nof a row wherenis a count no selector can make. It wrote an inlinewidth: 100/n %instead — which is1/nof the row before its gaps, so three columns and two 12px gaps overflowed by 24px.timeline.alternatewants half a row per side and writeswidth: 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.
autois 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 isalign-self: auto’s argument (ADR-0244).Box.basis(Length)is the builder, andRenderObjectsets it on the node the same waywidthis set: only when it differs from the previous frame.masonry-columnisflex-basis: 0; flex-grow: 1incontrols.css, andMasonryColumnno longer takes a count or writes arestyle. 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: 0in a row with no definite main size sizes that row to nothing. Thesegmentedbar keeps the grid ADR-0099 gave it. RecordWitherTestcovers the new component on both records for free.timeline.alternateis 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.
stretchis Yoga’s default underuseWebDefaultsand CSS’snormalfor 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, besidealignItemsandalignSelf.
Consequences
- The three constants that no property accepted are reachable, and reachable only from the property that means them.
align-items: space-betweenis 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
chiprow 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.updatecallsYGNodeLayoutGetHadOverflowon the root aftercalculateLayout. That is one foreign call on a frame where everything fits, which is nearly every frame.OverflowWatch(inpaint.tree, where the tree is) walks only then, and reports each child laid out past its container’s own edge.paint.overflowholds the rest:Overrunis the value — two names and two distances — andOverflowLogis 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
overflowis notvisible, because that is ascrollviewport working; and a child whosepositionisabsolute, 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 amin-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 anEditCommand—Move,MoveLine,Delete,Type, or one of sixSimpleaccelerators — or null for a key no editor may take.Tab,Escapeand a field’sEnterare null, which is the half of the contract that keeps focus moving and dialogs closing.EditSurfaceis everything the map needs to know about the editor asking, and it is two questions: areUpandDownlines here, and isEntera newline.FIELDanswers no and no,WRAPPEDyes and no,DOCUMENTyes and yes.EditCommandis sealed, so each editor’sswitchis 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 —rowsfor atext-area, ten for a canvas editor that has no viewport to measure.
Consequences
EditKeysTestasserts the table once, including the two redo spellings and thatCtrl+Alt+Vis 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 —
translateXandscaleXin onetransformabout the top-left corner — and is therefore drawn exactly over the header being left. The frame after, the strip hands over zero and thetransitionincontrols.cssslides it home on--gb-motion-base, which is the clocksegmented’s pill is on. idis 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.arrivedis called from the indicator’srender, 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.
transitiontakes the compositor-cheap set only: aleftor awidththat animated would run Yoga on every frame of the journey. TabTravelTestdrives 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()isMod.METAon macOS andMod.CTRLeverywhere else, read once fromos.nameand overridable with-Dgoldberry.input.primary=ctrl|meta.resolve(osName, override)is the testable half, which isNativePlatform’s rule for the same problem.Shortcut.of("Primary+S")andShortcut.primary(Key.S)build the accelerator this desktop would have written.ModandCmdOrCtrlparse as the same thing, because those are the names the same idea goes by elsewhere.Ctrlis stillCtrlandCmdis stillCmd. 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 atext-inputon macOS answersCmd+Crather thanCtrl+C. On Linux and Windows nothing changes, because the primary modifier isCtrlthere. Shortcut.toString()printsCmd+where the desktop calls it that, so a menu row reads as that platform’s menus read.
Consequences
- Word-wise movement stays on
Ctrl, andHome/Endstay where they are. The rest of macOS’s editing conventions —Alt+Leftfor a word,Cmd+Leftfor the line,Ctrl+Afor 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.
WidgetRenderercarries “an ancestor is disabled” down the render walk — the direction styles already resolve in — and mirrors:disabledonto 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.cssgains: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-disabledincluded: 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 :disabledis the ninth untyped selector in the toolkit’s own sheets, andRuleBucketTestrecords 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
captionto 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.
TooltipMetricsTestnow 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.mdsays where thebodyargument 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.
TextRankis §1.4’s scale as an enum, andcssClass()is its name as CSS spells it —BODY_STRONGisbody-strong.text style="title"addstitleto the element’s classes at inflate time, so a rule writtentext.title, an application’s own.titleoverride 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
headingis worth stays incontrols.css, which is what lets a large-text theme move every rank without a widget hearing about it. TextRankTestasserts 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.Sequenceis 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.Animationis the value:at(elapsedMillis)is the only question it answers, and the elapsed time belongs to whoever is drawing — acanvaspainter 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), andImage.decodeAnimationanswers 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.decodeis unchanged: it still reads the first frame, which is what a picture on a board wants and what most GIFs contain.- The
imagewidget still shows that first frame. Playing one is a widget decision — §1’s row forimageasks for fits, DPI variants and states, and says nothing about animation — and it now has something to play. Today an application animates one on acanvasin two lines. - An animated WebP is still one frame, and the reason is unchanged: the
frames live in a
webpdemuxthe 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.
| asked | of | |
|---|---|---|
| Linux | org.freedesktop.portal.Settings.Read | the XDG portal, over libdbus |
| Windows | SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION) | user32.dll |
| macOS | NSWorkspace.accessibilityDisplayShouldReduceMotion | libobjc |
- 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’sreduced-motion, auint32whose 0 means “no preference” — and falls back to GNOME’sorg.gnome.desktop.interface/enable-animations, which is the same question the other way round. dbus_message_append_argsis 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.
UNKNOWNis 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()andHost.reducedMotion()carry it, andLauncherapplies 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|fulloverrides 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
ExportedSurfaceTestenforces: what leaves:nativesis 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
uint32preference, auint32zero, 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 toUNKNOWN, 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.EmojiFontis a service interface in:core, whichusesit.goldberry-emojiprovidesit withOpenMojiFont, and declares the provider inMETA-INF/servicesas 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 throwsMissingEmojiFontException, 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.CREDITis the sentence to display, as a constant, so an application meets the obligation without transcribing it.:assetsprepares a selection now —--only=inter,jetbrains-mono,lucidefor:core,--only=openmojifor:emoji— and each asset task empties its directory first, because an incremental build otherwise keeps shipping a face the module has stopped fetching.PublishedModulesgains one line and the BOM, the umbrella and the POMs follow; the artifact is optional, likegoldberry-htmlandgoldberry-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.
:coreis 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
:coreasserts the absence — thathasEmojiFont()is false and that the message names the artifact. NOTICEandTHIRD-PARTY-NOTICES.mdsay which jar the font is in, and that an application adding it owes the credit.licenses/openmoji.txtstays 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.decodeAnimationreads an animated WebP the same way it reads a GIF, and the work is upstream’s:WebPAnimDecoderGetNexthands 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.
WebPAnimDecoderOptionsInitandWebPAnimDecoderNeware static inlines indemux.h, so what is exported is the…Internalpair 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.
ImageEncodeExceptionis 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
libgoldberrygrows 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 TrueTypecmap— formats 4 and 12 — and answers which characters a face has. Java rather thanhb_face_collect_unicodes, forGifDecoder’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 toCharacter.isEmoji: the second is true of0,#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 aU+231Bfrom 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.notdefboxes. Fontsnow falls back when the emoji face is absent rather than throwing: a stylesheet namingfont-family: OpenMojiin 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+0still 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.
:exampledepends on:emoji, which is the first application in this repository to opt into an artifact with an obligation attached — andEmojiScreenTestasserts that the credit is on the screen, not merely that the face loaded.FaceCoverageis 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.
PrepareAssetstakes--root=, so a build script says where inside the jar its assets land.:corekeepsio/github/digitalsmile/goldberry/assets;:emojiwritesio/github/digitalsmile/goldberry/emoji, and its face is…/emoji/fonts/OpenMoji-black.ttf.SplitPackageTestis:example’s, because:exampleis 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 theModulePackagesattribute 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:runpainting 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.
:htmlalready 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:
| note | build | style | layout | raster | frame |
|---|---|---|---|---|---|
| 2 kB | 0.12 | 0.73 | 0.40 | 3.12 | 4.63 |
| 50 kB | 0.52 | 6.38 | 0.72 | 12.53 | 20.47 |
| 500 kB | 5.09 | 92.26 | 6.30 | 104.98 | 207.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:
| note | characters shaped | characters drawn |
|---|---|---|
| 2 kB | 2 062 | 2 127 |
| 50 kB | 50 005 | 52 060 |
| 500 kB | 500 005 | 524 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.paintdraws 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.TextDocumentshapes a text one hard line at a time. Wrapping was already per hard line —Paragraph.layoutsplits on\nfirst 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 theParagraphinstance it had, and its wrap memo with it.DocumentLinesis that document broken at one width: a computedList<TextLine>in the whole text’s offsets, so everything written againstParagraph.layout().lines()keeps working and a ten-thousand-line note does not allocate ten thousand records to answer three questions.TextAreaBoxdraws 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.carrieris atext-valuethat shapes nothing and reports what the cascade resolved. The node still exists, because that is where the ink, thewhite-spaceand the.placeholderrule are decided; it cannot hold the glyphs, because which rows are in view is settled duringrenderand 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:
| note | build | style | layout | raster | frame |
|---|---|---|---|---|---|
| 2 kB | 0.09 | 0.83 | 0.47 | 3.47 | 4.92 |
| 50 kB | 0.06 | 0.53 | 0.33 | 3.08 | 4.10 |
| 500 kB | 0.05 | 0.90 | 0.27 | 3.04 | 4.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.mdrather 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-valueno longer carries atext-area’s glyphs. A test that read the value node’s paragraph reads the control’s own box instead.TextAreaGutterTestchecks 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-inputis 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.TextDocumentis exported.Editor— thecanvasediting 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.TextAreaKeystrokeCostTestguards 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 against5d70609bit 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:
| note | build | style | layout | raster | frame |
|---|---|---|---|---|---|
| 2 kB | 0.45 / 16.44 | 0.29 / 0.96 | 0.34 / 0.86 | 2.22 / 5.21 | 3.30 / 23.47 |
| 50 kB | 8.71 / 18.33 | 7.29 / 12.49 | 4.98 / 7.50 | 3.16 / 5.36 | 24.14 / 43.68 |
| 500 kB | 85.35 / 109.41 | 78.32 / 102.93 | 42.60 / 60.94 | 3.71 / 4.99 | 209.97 / 278.26 |
| 50 kB, typing a space | 6.84 / 12.10 | 18.30 / 25.85 | 72.48 / 82.33 | 2.42 / 3.11 | 100.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
equalsto 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 ownbuildhands 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 throughMarkdownWiring— 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.
ImageSourceanswers 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:
| note | build | style | layout | raster | frame |
|---|---|---|---|---|---|
| 2 kB | 0.19 / 0.42 | 0.48 / 12.00 | 0.49 / 0.93 | 2.88 / 4.62 | 4.04 / 17.96 |
| 50 kB | 2.05 / 7.43 | 6.68 / 21.57 | 5.10 / 9.45 | 3.01 / 5.87 | 16.83 / 44.32 |
| 500 kB | 16.09 / 24.16 | 109.67 / 146.74 | 62.19 / 77.09 | 5.42 / 6.82 | 193.37 / 254.82 |
| 50 kB, typing a space | 1.41 / 6.28 | 7.17 / 16.95 | 5.93 / 9.23 | 2.93 / 5.66 | 17.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, besideFlattenerandDasher, maps aPaththrough an affine.Path.transformed(Affine)is the entry point, withrotated(radians, cx, cy),translated(dx, dy)andscaled(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.Affineis that matrix, unmoved. It already hasrotate,translate,scale,thenandabout, itsaboutistransform-origin, and its arithmetic is the arithmetic hit testing inverts. A second matrix type inpaint.geomwould be two implementations that must agree exactly, which is the failure its own javadoc was written to prevent. It stays incss.valuebecause moving it would churn the cascade for a package name:Pathalready importscss.Cornersfor 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 asFrame.transform— a concatenatedtranslate(10, 0)moves ten logical pixels at any scale — and the same absence of a push, sincesave()andrestore()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_opis 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 byLayoutVerifier. OnlyRESET,ASSIGN,TRANSLATEandSCALEare ingoldberry_shim.c, so naming a fifth means a newGB_CONSTANTrow and a rebuilt native library — a native change for six multiplies, and six multiplies that must agree with what hit testing inverts. SoFramemirrors its own logical matrix in anAffinefield, composes there, and assigns the answer through theASSIGNop that was already bound. The mirror is exact because every change to the matrix goes throughtransform,concatorresetTransform, andsave/restorepush and pop it alongside the rasterizer’s own stack. example.motion.Rotatedis deleted.TileFloorcomposes the turn and the drop into oneAffineand callsPath.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.
concatis for a painter drawing many shapes under one matrix — text included, which a path transform cannot reach. Transformerkeeps 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.Frameholds 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.
concatis 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.qris the encoder —QrEncoder.encode, and aQrMatrixout the other end. It is in:corebesideimage.gifandimage.pngand 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
Stringhas 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 misspeltlevel=falls back toMrather 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.cssanswers it once at 160×160 and an application overrides it. - Two colour tokens,
--gb-qr-inkand--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’sname=. 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.
QrCachekeeps the last eight codes keyed by payload and level and hands the sameQrMatrixback 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
Pathand not asfillRects, and this is the one surprise in the change.Frame.fillRecttakes floats, and a float is not enough: a module boundary at device pixel 152 is logical 101.333… at 150%, and the nearestfloatto that multiplied back by 1.5 is 152.000004 — which Blend2D dutifully draws as one pixel of#1b1b1bbeside a run of#1a1a1a.Pathcarries 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 to1 / factorand working in whole device pixels was the first attempt, and it was not available:Frame.transformreplaces 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 landedFrame.concatthe 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:
- Worked examples, at the codeword level.
01234567at version 1 level M — the standard’s own — andHELLO WORLDat 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. - 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.
- 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. - 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.4was 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 bylibzbar.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
:coreexports 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-codeis documented incore-widgets.md§1 rather than incontent-widgets.md, and not because it was convenient: §1 is whereimageandcanvasare,content-widgets.mdis 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
imageone, 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 commonerscroll { 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=#trueis 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.ScrollTimelineTeststates 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.Anchoredis 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
scrollis unchanged:STARTis 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.
#isEmoji_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:
| overrun | reports |
|---|---|
| ≤ 0.5 px | 10 |
| ≤ 1 px | 252 |
| ≤ 2 px | 384 |
| ≤ 4 px | 3 |
| ≤ 16 px | 22 |
| > 16 px | 17 |
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-ticksits in a0 × 0box — 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-thumbis 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-6was put there, which is the “placed rather than flowed” exemptionOverflowWatchalready 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-heighttighter 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 failedstays aWARN. It carries a stack trace and only fires when something threw.walking the window's size a pixel a framestaysINFO. It is printed once at start-up and only when--resize=WxHasked 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.gradlesets-Dgoldberry.input.primary=ctrlon everyTesttask. A test that pressesCtrlis the same test on every desktop, which is what a golden or a clipboard assertion needs to be.PrimaryModifierTestasserts the property is set andcurrent()isCtrl. 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, throughPrimaryModifier.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-inputansweringCmd+Cthrough the whole event path — has to say so by setting the property tometafor that test’s JVM. None does today;EditKeysTest’s “the accelerators are the same six on all three” askscurrent()and is therefore aCtrltest 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.showcaseModelresolvesapp.clickonnew ShowcaseModel.Actions(model)and readsapp.clicksoff the model, which is the pairShowcasepublishes.ShowcaseActionsTest.theRoadsClickCountsresolves the same two names undercheckand asserts that two clicks count two. It is a count, so the reason timings stay out ofcheck(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
Actionsrecord in a local and fence it withReference.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
Benchmarksjob is green again on the next run; nothing about the numbers it prints changes. - Other benchmarks that resolve names by string (
BindingBenchmark’sapp.say/app.noop/app.labelon its own local models, and the:weaverscheme 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.
weaveCatalogandweaveModelsdeclare 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 throughoutput.classesDirs, the jar,testClassesDirsand every consumer — a much larger change to fix a declaration.- The root formats its own prose, with two ordinary tasks —
checkMarkdownandformatMarkdown— 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 abuild-logicplugin, because that puts that build’s whole classpath under every module and:core’salias(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.javais 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.blessGoldensdepends ontasks.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:
compileJavawrites raw classes, the weaver produces the directory everything else consumes, and Gradle can cache and skip it. Rejected for now becausesourceSets.main.output.classesDirsis what the jar, the test task’stestClassesDirsand every IDE read, and pointing those at a woven directory while leaving the raw one on the same classpath puts two copies ofmodule-info.classin front of the JVM. That is an ADR of its own, not a line in this one. - Dropping
transitiveorstaticfrom the jspecify requires. It would let PMD parse the file, at the cost of changing what consumers of the toolkit see:transitiveis there because@Nullableappears on exported signatures and-Xlint:exportsrequires 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:compileJavais 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-tasksare 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:
spotlessApplyfor Java,formatMarkdownfor everything else.checkMarkdownruns inlinux.yml’sjavajob besidecheckLicenses, 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
onTaskcould 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
toggleTaskparses 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.NESTEDholds all three cases, andAgreesWithMd4cruns 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.
clauseEndisclauseLengthinTextEditor,AreaEditorand 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
Composingcarries — 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
lengthto 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 asclamp(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. maxLengthcan now refuse a character that a code-point count would have admitted: pastinga🎨binto amaxLength(2)field yieldsaand nota+ 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.disposehas run, the bindings are closed, the subtree is gone. The one thing a finalEXITEDcould have been for — releasing something the enter acquired — is whatdisposeis, anddisposehas already run. Nothing is lost by not telling them, which is ADR-0317’s argument transferred unchanged. - The
Attributeshook beside it still runs.onPointerEnter/onPointerExitare 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, andHoverHookTest.anUnmountedSubtreeIsToldItLostThePointerpins 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.updateHovertells 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 ofemit. What the review asked for. It breaks ADR-0327 and its test, silently, by dropping the application’s exit. - Making
State.setStatetolerant 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
EXITEDbefore 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.theDeadAreNotToldTheyExitedholds the crash with the showcase’s own shape — a stateful panel whose leaf callssetStateonEXITED— andanAncestorUnmountedMidDispatchIsSkippedholds 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
EXITEDis not guaranteed. A widget that needs to release something on the way out releases it indispose. That is written atemitrather 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 byCssParser, which does not know it is inside a sheet, let alone which one. Renumbering atStyleResolverconstruction would rewrite every rule and makeordermean different things in aStylesheeta 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.sheetOrderWithinALayerholds it, withsheetOrderReversed,layerBeatsSheetOrderandspecificityBeatsSheetOrderpinning the two orderings the new key must not disturb. resolveStartingis fixed for free:@starting-stylerules 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 —
SupportedPropertyTestholds 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_BASEsheets 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.
Sdl3Backendkeeps anundeliveredlist and delivers through onedeliver(sink, events)used by the pump, byemitDueFramesand by the resize watch.HeadlessBackend’s queue became aDequeand arequeueputs 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 inforget(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 to0, which the branch below read as “poll” — so the pump returned having delivered nothing andEventLoop.runcame straight back round, spinning for the last millisecond of every frame interval whenever the display rate was adopted.waitMillisceils, which also removes the extra pump a 16.6 → 16 truncation buys, and caps atInteger.MAX_VALUEbecause SDL reads a negative as “wait for ever”. - A directory that will not open is not knowledge (C9).
listDirectoryreturnedOptional.empty()for “not a directory” and threwUncheckedIOExceptionfor “a directory I cannot read” — two ways of not knowing, one of them fatal, from a lister called in theSdl3Backendconstructor for a cosmetic diagnostic. The constructor catches onlySdlExceptionandUnsatisfiedLinkError, so the throw left SDL initialised and the event buffer unclosed. Both cases areOptional.empty()now, which the comment beside the throw already claimed. - The flag
closesets is the flagwakeupreads (C17).closedisvolatile. It has two off-thread readers rather than the one the review names:wakeup(), anddrawDuringModalLoop, 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
synchronizedblock for C17 instead ofvolatile. It orders more than is needed and costs more; the residual overlap — awakeup()already past its read whenclose()runs — is not a visibility question and no lock on this flag would order it either. It is harmless becauseclose()sets the flag first and reachesSdl.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.
Sdl3EventPathTestis 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()andforget(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
Sdl3EventPathTestbuilds a realSdl3Backendunder SDL’sdummydriver — 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 throughdiagnose; 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.
ImageSourcenow tells an application to hold a source rather than mint one insidebuild.
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. AnImageSourceis 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.
BlockReuseTestnow holds all three invalidation paths the review found missing: the picture after a kept block,tasksSeenresumed 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=trueand the job published rather than rehearsing. Seven modules finishedpublishMavenPublicationToMavenCentralRepository—:common,:core,:gpu,:emoji,:toolkit,:bomand:natives, the last two after:widgets:javadochad already failed, because Gradle finishes the tasks it has started.:widgetsand:htmlnever 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 reachpublish.yml. - Every
checkacross 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 runbuild. That is the price, and it is paid against a failure that otherwise costs thirteen minutes and a half-finished upload. :assetsand:weaverkeep their fourreference not founderrors. They are recorded here rather than fixed: neither module is published, andjavadocis not wired into theircheck. A later decision to publish either one has to clear them first, which is exactly the gate this ADR installs.PublishedJavadocTestholds both halves — the doclint flags and thecheckwiring — 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-listis 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/%ZZis a malformed escape pair andfile:///tmp/a bhas a raw space;URI’s own parser refuses both. - No scheme. A bare
/tmp/xis dropped rather than read as a path. Guessing here is howC:\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%00bis a legal URI:%00is a well-formed escape and NUL is a byte. It is not a legal path, andPath.ofsays 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-listis readable in five lines of application code:UriList.fromClipboard(clipboard).paths(). Writing one isUriList.of(paths).toClipboard(clipboard).UriListTestholds every decision above as a named case — 20 of them, includingpercentDecodes,aBarePathIsNotAUri,anUnparseableLineIsSkipped,aNulByteInTheNameIsSkipped,localhostIsThisMachineandanotherHostIsNotLocal. 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.ofis 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
:nativeschanged, and no native rebuild is needed for this half. The drop-text half cannot be landed without one. FileDropandUriListstay separate types. They are the same information from two different platform mechanisms — a gesture versus a clipboard offer — and ADR-0330’s reason forFileDropcarrying 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.
URIandPath.ofalready do it, including the UTF-8 that%D0%BFis, and a hand-rolled decoder is a second place for the same bug. - Put the type in
input.dropbesideFileDrop. That package is input events; this is a value on a clipboard, and a paste is not an event. - Add
SDL_EVENT_DROP_TEXTtogoldberry_shim.chere. 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
:nativesagainst 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
HeadlessClipboardTestholds both halves.Lazinesshas nine cases, of whichnothingIsSerialisedUntilItIsRead,theCheapQuestionsStayCheapandeveryReadProducesare the contract;Refusalhas five, of whichchangesNothingandoffByDefaultare the ones that would catch this class growing a policy.Seamschecks that the two new methods are UI-thread confined like every other SPI call and that the narrowed return type makes the cast unnecessary.HeadlessBackendlost about sixty lines and two fields, andclipboard()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
falsebranch 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:nativesstill 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 serialisedLazilyflag. Records the claim without making it true; nothing would call a supplier because there would be none. - Cache the first
readand 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, likeHeadlessFileDialogs.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
LazyClipboardtest 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
\nfrom\r\n, or a trailing newline from none, becauseSDL_strtok_rconsumed them. A record holding oneStringwould have to invent them. - Empty lines are gone too —
SDL_strtok_rskips 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.handWrittenLayoutsAgreeWithCfails until the superbuild runs. TheGB_CONSTANTrow is in this change; the.soon 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 reportsSDL_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.TextDropTestholds the gesture — twelve cases, of whichseveralLinesAreOneGesture,textJoinsWithNewline,theTwoKindsDoNotCrossOverandaGestureCarryingBothRaisesBothare the ones that would catch the shared completion being got wrong.SdlDropEventTestgained three: the text reads back out ofdata, 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
sdl3arm is oneswitchcase, 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. BackendEventgained 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.HeadlessBackendstill produces no drops, andWindowis 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, acceptslocalhostcase-insensitively as this machine, and percent-decodes — the same three judgementsUriListarrived at independently. It goes one step further and also accepts this machine’s owngethostname();UriListdoes 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
TextDropperDROP_TEXT, immediately. Simpler, and it breaks the one promiseonFileDropmakes — 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
FileDropCompletedtoDropCompleted. 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_VERSIONis 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.UriListis 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
dummyvideo 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:
| Stage | median | p95 |
|---|---|---|
| buffer | 0.062 ms | 0.131 ms |
paint — begin | 0.060 ms | 0.132 ms |
paint — draw | 5.944 ms | 39.327 ms |
paint — end | 10.158 ms | 20.978 ms |
| present | 0.127 ms | 0.319 ms |
| whole frame | 16.928 ms | 61.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,acquireFramesucceeds, so the frame is rasterized straight into SDL’s own surface and present isSDL_UpdateWindowSurfaceRectsover the damage with nothing to copy. A driver where SDL refuses the surface takesSdl3Window.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 fivenanoTimecalls 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=300under 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 —
PageUpreportstrue, 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 —
PageDownbecomesDownunder a second name, and the key that is meant to cover ground covers none. - The whole text —
PageDownbecomesCtrl+End, which the map already has, and the selection aShift+PageDownbuilds 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.
EditorPageTestasserts that withassertSameon both, because the cheap thing to write would have been aninvalidate()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
PageDownthat reportstrueand 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-areaandtext-inputare untouched. Atext-areaalready divides its measured height by its line height, and atext-inputis one line, whereEditSurface.FIELDgives the page keys no meaning at all.- No picture moved.
gallery-canvas.pngis the same image: a viewport is a key’s meaning and not a pixel. EditorPageTestholds it — a declared viewport in whole lines, in visual lines under a wrap, extending withShift, 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):
| text | before, one paragraph | after, a hard line at a time |
|---|---|---|
| 2 kB | 2.498 | 0.235 |
| 50 kB | 10.860 | 0.553 |
| 500 kB | 111.454 | 1.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()andlayout()are gone, replaced bydocument()andlines(). This is a published API and the break is the point: a method that hands out oneParagraphover 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 aDocumentLines, which is aList<TextLine>in the whole text’s offsets, so everything written againstlayout().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
wrapWidththrew 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-areakeeps its own copy of the arithmetic.TextAreaStateanswers the same four questions inline againstTextDocument, and it is not moved ontoTextGeometry’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 atext-areacarries 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.EditorDocumentTestholds it in counts, likeTextAreaKeystrokeCostTestone layer up: one line re-shaped per keystroke at both sizes, the untouched lines keeping theirParagraphinstances, a resize shaping nothing, a selection measuring nothing. Its second half,Agreement, is the part a user would notice — carets, presses, selections andDownare compared against the whole-text shaping they replace, at everyTextAlign, and land within a logical unit of it.EditorKeystrokeBenchmarkis 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-areashows the top of its value until somebody touches it (ADR-0297); - a read-only
text-inputshows the head of its value, always (ADR-0326); - an editable
text-inputshows 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 pressingHomeon 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 — andgallery-forms-light.png— 2 574 of 1 080 000 (0.24 %), worst delta 191. The difference is one 341 × 14 strip in both: thetext-input#longon the Longer than the box card, which now readsEighteen hundred leagues of road, and a field that is no…where it readagues 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, thenHome, 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 onlyHometo press. - Three existing tests changed, and one of them changed its mind.
ReadOnlyCaretTest.editableStartsAtTheTailasserted “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.AsymmetricPaddingTestandTextInputTest.Scrollingmeasure the chase, so both now focus the field first, which is what they were always really about. - A combobox’s editor inherits it.
SelectStatebuilds aTextInputfor 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
passwordfield 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-areaand 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.FieldOpeningTestholds 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:
StyleElementdocuments three members as “or null” … Unannotated, matchingStyleElementandSelector.Compound, both of which document a nulltypeandidand 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 withelement.type(). Its body opens withif (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@Nullablenow. 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’sparentcomponent, 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 howprobeFormakes 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()andProbe.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.lintand write the three overrides non-null. What thepackage-infocalled “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.lintunmarked and move on. The state this closes. Unmarking is cheap once and compounds:css.contrastis unmarked too, andThemeAudit.Rootis annotated anyway, so the only thing the unmarked package buys is that nobody checks whether it kept doing that. - Annotate
StyleElementand notSelector.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
widgetwhile the sweep was open.Elementis the largestStyleElementimplementation and itsparentfield is unannotated, so markingwidgetmeans 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.lintis@NullMarked, and itspackage-infono 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.candidatesFortakes 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()andid()are@Nullablein 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.StyleElementNullnessTestasserts 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 assertsclasses()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-stylerules, 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
hudreading nameddisplaypicked up §1.4’s.displaytype 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-headingrenames 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-selectedrenames 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:
-
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 inTextRankand 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. -
No class is written both unqualified and beside a type. This is the
tree-row.headingfinding, 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-rowis not a registered node name and no inflater builds one. -
No registered widget’s
classes()orclasses(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. -
No enum that mints a CSS class mints a reserved one, except
TextRank. This is the shape thehud’sdisplayactually had, and the one the first three would still miss: the name was not a literal in aclasses()body and the widget was not a registered node — it was an enum constant on a part, read throughcssClass(). It is also howskeleton-bar.titlewas 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,GroupBoxandCollapseall 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-lightandtree-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 whattree-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.headingistree-row.group. Neither the class nor the selector appears indocs/or anywhere inbook/, and no test asserted on it, so nothing outside the two files changed.Skeleton.Shapemintsshape-text,shape-title,shape-circleandshape-rect. The whole family, not the one that collided: three of them are safe only because §1.4 happens not to have a rank calledcircle, which is not a property anybody is maintaining, andskeleton-bar.textread like thetexttype into the bargain. §5’sshape="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.SkeletonTestasserts both halves.- A widget may no longer name a class
display,title,heading,body,body-strong,captionormono. 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
textwith no ancestor settingcolorrenders black, which is ADR-0066’s deliberateINITIALand a trap all the same: the showcase’s new gain label was unreadable on the dark theme. A control gets away with saying nothing becausecontrols.csssetscoloroncheckbox,radio,toggleandsliderthemselves; a primitive does not. The showcase now setscolor: 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
WARNthe first time atextresolves 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.Kindoncheck(linted)rather than a method of its own. The root’s colour is a fact about everything in force, andcheckis deliberately about the sheets under scrutiny —inForceIsNotLintedasserts that “someone else’s sheet is not this one’s problem”. Folding this intocheckwould make the application’s own sheet answer for the theme’s omission. - Make
ComputedStyle.INITIAL’s colourcurrentColor-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
colorontextincontrols.css, the way controls do. The narrowest fix and the wrong level. It makestextwork 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_ROOTexists, andisDefect()is now written as!= UNTYPED_RULErather 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.
thePremiseresolves a baretextunder an uncoloured root through the real cascade and checks it really does come out atComputedStyle.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.mdhas no sentence about this. It is worth one — §1.2 or §10 could say that an application setscoloron 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:
remcontinues to useContext.rootFontSize(). In CSSremis the root element’s computed font size, so these agree unless the root element itself declares one — and recovering that insideComputedStyle.ofis not possible, because a node is handed its parent’s style and not the root’s. Nothing in the catalog styles a root’sfont-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: 2remresolves 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-sizechanged 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 ownfont-size: 2remwrong by making it its own input. It also has to mirrorStyled.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
remmean 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 forem: 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
rembelow a root that declaresfont-sizechanges, which is the entry’s case and the thing that was broken.window { font-size: 20px } button { padding: 2rem }is 40 and was 32.rembelow a root that declares nothing changes too, and this one is worth stating plainly: a silent root computesTypography.INITIAL’s 13, so2remis 26 where it was 2 × the configured 16. The configured number now reaches only the root’s ownfont-sizedeclaration. That is the cost of the decision above, paid once, and nothing shipped is affected — ADR-0242 checked that not oneemorremappears innord-dark.css,nord-light.css,controls.cssor the showcase’s sheets, and that is still true.- One existing test changed meaning and was rewritten, exactly as ADR-0242’s
emtest did.ComputedStyleTest’s “rem multiplies the root font size, not the local one” built an element with no parent, passedContext(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 and2remis 26. On a root,1remand1emcoincide — 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 toRootFontSizeTest, where there is a descendant to tell them apart. RootFontSizeTestneeds a real renderer, and that is the ADR in one sentence. The missing half was never arithmetic, it was reach, so a test built onComputedStyle.ofalone 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 soremandemcannot 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’sremis exact now. That seam runs withcurrentElementset, so the walk has passed the root. Itsemnarrowing — 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.withRootFontSizeis the only wither on the record. There is deliberately none forfontSize: 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:
Optionwas moved into a package of its own the day it had two callers, and this now has two; the CSS type it carries isselect-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.
| consumer | layouts | what it does with the geometry |
|---|---|---|
masonry | 2 | moves cards between equal-width columns |
scroll | 2 | draws bars, absolutely positioned |
split-pane | 2 | sets the first pane’s size from its own |
table | 1 | banks a header width as a drag anchor |
text-area | 1 | wraps text at its measured width |
text-input | 1 | places 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.propertiesafter 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.ymlneedscontents: writeandpull-requests: writeon that one job. The workflow’s ownpermissions:stayscontents: 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 createfails with a 403 after the branch has already been pushed. It is indocs/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.nextReleasehas a caller. It was written by ADR-0333 and used by nothing, which is the state a method is in just before it drifts.ReleaseBumpWorkflowTestholds the job as text, the way ADR-0082’s other drift guards do — theneeds:, the event condition, both permissions, the default branch, and the fact that the arithmetic is delegated rather thansed-ed. It also holds the guard’ssedpattern against the realgradle.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-develandmesa-libEGL-develall install and all provide their.pcfiles;libdecor-develandxkeyboard-configare in no repository the container has … Extending both toSDL_VIDEO_DRIVER_WAYLANDandHAVE_LIBDECOR_His the remaining work, and the honest form of it is probably aCapability.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-develat 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-develis unavailable there — and the publishedlinux-x64library will reportWINDOW_DECORATIONSabsent. 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:nativesjar loaded against an older library should fail saying the library is old rather than saying a constant is missing. :natives:testand:core:testneed a rebuilt library../gradlew -Pgoldberry.allowDegradedPlatform=true :natives:cmakeBuildfirst, as after every ABI bump.PlatformIntegrationBuildTestgains 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 existingbitsMatchTheShimcovers 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:
layOutruns 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.captureRegionssets the window bounds and then hands the capture to the router, in that order, because aLocatedwidget 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 out | paint | capture | |
|---|---|---|---|
| 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:testand:widgets:testran 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, andhudis. Stagesis 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 ahudshows, so a number from a frame that happened to be traced would be a different frame’s.captureRegionsrefuses 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.
framenames 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:corerather than a promise to an application, which is whatOffscreenis for. An application that found and called it would be assembling a frame loop by hand. FrameSequenceTestis 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 missingflushsurvived long enough to be committed nine times. Each case now names the step that would go missing and the symptom:flushesBeforeItStylesis ADR-0284’s exact bug,preparesBeforeItBuildsis ADR-0254’s,setsTheWindowBoundsWithTheRegionsis ADR-0119’s.Launcher.paintlost 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
layOutalready protects. - Leave it, and trust the goldens. ADR-0284’s position, and it was defensible
while
Offscreenwas 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
FrameSequenceinpaint.tree, besideRenderTree. It would be the fifth package it depends on deciding to own it.widgethas 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
Offscreenthe 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:
| kept | why | |
|---|---|---|
| the element tree | yes | the whole point. State, scroll offsets and a learnt width carry over; dispose runs once, at close() |
| the render tree | yes | retained layout, so Yoga re-lays out only what moved (ADR-0069) |
| the renderer | yes | its shaping cache holds every paragraph already shaped, and self-tunes to the frame (ADR-0299) |
| the router | yes | a Measured widget is told its region changed rather than told it again |
| the clock | yes | it is the one object the caller is actually driving |
| the pixel buffer | no | see 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-arealearns 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.
Offscreenadvances 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, becauseframe()ends its own; what remains is the render tree before the element tree, because aStatethat owns aFontcloses it indisposeand 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.
framesSurviveClosingasserts 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 ignoresfont-family,font-sizeandfont-weightentirely, and a transition whosefont-sizemoves is one of the things a strip exists to photograph. Refused atstrip()rather than producing a picture that is quietly not of the animation. - A strip from a
Studioshares 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
Offscreenhas and for the same reason. - The name. It is the strip, and the caller develops it one frame at a time;
image.animalready calls a multi-frame picture what it is (ADR-0382), so the vocabulary was there.AnimationStripsays the same thing and one word longer, and a name likeRecorderorSessiondescribes the machinery rather than what comes out.
Alternatives considered
Offscreen.render(Widget, int[] times)returning a list of images. One call, no lifetime, noclose(), 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.advanceandframe()being separate is what lets a caller take two pictures of one instant, or jump 400 ms in one step. - Make
Offscreenitself stateful, withrendercallable repeatedly. The builder would then have two modes and a caller would have to know which one they were in; andOffscreen’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:
Fontsgained theownercheck every other confined object on the path already had, on both use andclose. 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.Studiocarries 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 throughOffscreen.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.matchesAPlainRendercompares every pixel of a render from a warm studio against a plainOffscreenrender 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.
keepsRendersIndependentrenders 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.
Fontsnow 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 belowFontswould 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.REPORTEDand 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
OffscreenThreadTestis 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. Studiodoes not serve aFilmstrip’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
Widgetis a description and often a record, so it has anequals; that is precisely what makes this tempting and wrong. ACard(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
Offscreenitself hold the renderer acrossrendercalls. No new type, and it needs aclose()— a kept book has to be released — soOffscreenwould becomeAutoCloseableand 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
Fontsthread-safe instead of thread-checked. Synchronizing twoLinkedHashMaps is the easy half. The hard half is that what it vends is confined: aFontis aShapedFontand aBlendFont, 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
@ThreadConfinedannotation instead of a runtime check. Documentation with better spelling.Fontshas carried the sentence since ADR-0044 and the recommendation inOffscreencontradicted 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
spacerwithflex-growwould 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:
| source | inlines |
|---|---|
one␠␠\ntwo | Text, LineBreak[hard], Text |
one\\\ntwo | the same |
one␠␠\n\\\ntwo | Text, LineBreak[hard], LineBreak[hard], Text |
\\\none | LineBreak[hard], Text |
one␠␠\n | Text — 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.stretchgives 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-wordhas 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-h2lands 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 mistakemarkdown.cssalready records about setting the size on words. - A fill is painted once. An application that gives
.md-prosea background, a padding or a border expects one box round the paragraph. Had the column been the unnamed one and the lines carriedmd-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 break inside a link does not break the line
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 ofMarkdownWidgets’s “what this cannot do” list is gone rather than reworded.html-viewgot the same fix in the same commit;MarkdownHtmlwas already right and was not touched.- No golden in
:htmlmoved. 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-markdownin:examplemoves, 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:examplepasses.- 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-cellto make a cell break too; a box that is provably one line does not need them.
Alternatives considered
- A
spacerwithflex-growin 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
:corelearning 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, andhtml-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>twoasone<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.throughATranslucentBoxwas renamed and inverted. It used to assertluminance(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 withShadow.NONE, and that a 50% red over white is a light pixel.blurredUnderATranslucentBoxjoins 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:
golden differing worst 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.candGoldberryShim.SUPPORTED_ABI_VERSIONtogether.BL_FILL_RULE_NON_ZEROandBL_FILL_RULE_EVEN_ODDare rows on the layout table throughBlendFillRule, 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. -
BlendFillRuleTestproves 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-shadowis still not implemented and this does not bring it closer in kind, but it does bring it closer in parts:insetis 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 thatbl_image_scalehas 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)andImage.scaled(int, int, Resampling)exist, withResamplingandImageScaleExceptionbeside them inio.github.digitalsmile.goldberry.image. The exception is separate fromImageEncodeExceptionbecause 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_scalejoins the export list and the fourBL_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 returningBL_SUCCESS. BlendScaleTestpins the binding at the level where it can be wrong silently: that theBLSizeIcrosses the right way round (an 8×2 is not a 2×8), thatNEARESTdoubles a checkerboard into exact blocks of four and invents no colour, and thatBILINEARon the same input does not — which is what proves the filter argument reaches the library rather than being ignored.ImageScaledTestcovers 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.drawImagestill 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_dalready 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_FLOORcarries 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_FLOORis empty, and is asserted to be. §1.2’s non-text sentence has no exceptions left.ContrastTest.MARKSgained three rows —slider thumb,:hoverand:active— and an optionaledge, measured asmax(fill, edge)withNaNmeaning “this shape has no edge to credit”. Atransparentedge is an absent edge, not a failing one: alpha is ignored byContrast.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%) andcontrols-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’ssliderrow 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 totransparentrather 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.lengthand has been since ADR-0251 — but it is arender-time read, and the pointer arrives atonPointerwhere 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.
Scaleis untouched: the curve is applied to the fraction, and only the fraction changed. --gb-slider-thumb-sizeis a token now, andslider-thumbsizes 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, andslider-ticks’ inset, which is also half of it. §8’s subset has nocalc()—CssLength.parsetakes a single token and acalc(…)is many, sopadding: 0 calc(var(--gb-slider-thumb-size) / 2)parses, resolves to nothing, and is dropped with a warning.SliderTest.ticksAreInsetByHalfAThumbalready asserted the relation rather than the number and now carries the weight of it;theTokenAgreesWithTheDefaultholds the CSS declaration againstSliderControl.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
slidernode by taking a widget’s own element now has to walk to the first styled one —SliderTest.styleOfandSliderGoldenTest.PseudoStateboth do, and the second is the interesting one: a:hoverforced onto the composition is a:hoverno 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.
Slideris no longerStyled,Paints,HandlesorSemantics. It is stillAttributed<Slider>andBindable<Slider>, so every chained call an application writes still compiles and#gainstill reaches the node, which is the whole point of a composition handing its attributes down.Knobhas 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 istransparentand whose hover is a wash;--gb-selection, which is#5e81ac66and#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
ContrastTestcarries 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:activeon the dark theme’s--gb-surface-2and--gb-surface-raisedis 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-selectionon the same two is 5.41:1 —controls.cssclaims 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.
INSETsampled three pixels into an unpadded row and took a bite out of theA, which reads as ink-over-fill and moved--gb-selectionon--gb-surfacefrom 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().ContrastTestneeds 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.linkis still not swept as a variant. Its fill istransparentand it could now be measured the waybutton.ghostis; 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
popoverfollows; amenuand aselectdo not. Following is a property of having been opened by id […]MenusandSelectStateresolve their anchor to a rectangle themselves because they need a minimum width and aFitas well, and there is noHostoverload 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
idwas the other way and it is worse here: aselectthat a document gave noidwould 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
selectwith anidwould 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 forselectto hand over itsLocatedrectangle, 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 onelocatedlast reported, which ADR-0270 already rejected as one frame late at the start of a scroll. - Making
Menuskeep the name itself and re-place the popup by hand. It would work, and it would be the second implementation ofreplacePopups— 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
menufollows 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.anchorcan find. The privateopeninMenusnow 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 anOpenMenuthat does not exist until the popup does, and that wiring is the same either way. Hostgained 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 noFit, so the two cannot drift.- More frames end in a re-placement.
replacePopupsruns at the end of any frame with an id-anchored popup open, and menus are now in that set. The cost is oneanchor(id)scan of the last capture per open popup per frame, and only while a menu is showing. - A
selectlist 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.forEachPlacedBoxstarts the walk atClip.NONErather 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
selectlist is not covered. It is anchored to a rectangle reported byLocatedrather 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.RegiongainedisVisible(), which iscontainsasked of the rectangle instead of a point. It answers about the clip only, and says so: the window is the caller’s to know, andLauncher.stillOnScreenis 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.anchorrefuses to open against. If the render tree ever learns to cull whole off-screen subtrees from the capture, this becomes a false positive, andisVisibleis where the distinction would have to be made. - One existing test was asserting the defect. ADR-0270’s
followsAScrollingAnchorscrolls 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 isPopupAnchorVisibilityTest’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
PopupAnchorVisibilityTestnow depend on a viewport that really clips.PopupLifecycleTest’sScrolledtranslates 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:
| multiplier | as a fraction | offsets visited |
|---|---|---|
| 2 | 2/1 | {0} |
| 1.5 | 3/2 | {0, ½} |
| 1.25 | 5/4 | {0, ¼, ½, ¾} |
| 1.75 | 7/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) |
|---|---|---|
| none | 2.4 s | 3.3 s |
| 2 | 3.5 s | 5.9 s |
| 2, 1.5 | 4.4 s | 8.3 s |
| 2, 1.5, 1.25 | 5.3 s | 11.2 s |
| 2, 1.5, 1.25, 1.75 | 6.3 s | 13.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:
| multiplier | pixels with no match | worst nearby delta |
|---|---|---|
| 1.5 | 0.605% | 163 |
| 2 | 0.743% | 163 |
| 1.75 | 1.189% | 229 |
| 1.25 | 1.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.ymlandwindows.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-canvasis that one, and is now the only golden here with no scale sweep behind it.GoldenImage.assertMatchesAtOneScaleis 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.
checkcosts 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 — the2,1.5,1.25,1.75run above is the same mechanism, not a new one.- The four classes of direct pixel assertion
TODO.mdlisted are unchanged and stay unchanged:BoxPainterTestandTextPaintTestcarry their own scale cases,DamageTestis excluded because a damage rectangle is in physical pixels by design, andThreadedPaintTestis about worker counts. Adding a third multiplier does not make any of those four a better idea. - Taken with [ADR-0435],
checknow 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: ellipsisshipped, 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, andOverflowWatchsaid 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
GalleryTextScaleTestlays 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, againstGalleryGoldenTest’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 runsOffscreen’s settling sequence with the paint removed — the two measuring passes are still there, or everymasonryandtext-areawould 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
TextScaleAuditTestproves 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],
checknow 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=trueprints what every screen measured, at both text scales, alongside what the display-scale sweep measured. One switch, reused rather than added, becauseexample/build.gradleforwards 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
Offscreenaffords 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.renderruns 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 whyMasonrySettleTestasserts 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.
Wallcarries 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.ShowcaseDocumentsTestasserts the two legal shapes and no third.- One golden moved, and it is the one the entry is about.
basic.kdlsaysmin-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-basicandgallery-basic-lightare unchanged, andgallery-basic-narrowis 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
wrapis 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
masonryis told its width, including the fixed ones, which throw the number away. OneMasonryBoxis cheaper than two, and the router only notifies on a change, so a still window notifies nothing. design-system.md§3 saysgap 16 (12)andcontrols.csssays12flat. 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#focusByIdgoes through a newfindNamed, which is the only code change in:core.findByIdis untouched and still what the fallback uses.ListState#rowIdkeeps the list’sidas a prefix. It is no longer what keepsEndin 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.FocusNameScopeTestin:corestates the rule over bare widgets, the wayFocusTrapTeststates 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.ListFocusScopeTestandTreeFocusScopeTestin:widgetsare 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
goldberryumbrella cannot pickgoldberry-natives:<v>:linux-x64for 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
runtimeElementstoo. Then the plain jar is platform-specific, which it is not:goldberry-nativesis the FFM bindings, shared by every target, and the.sois 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
runtimeOnlylines 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.findAncestorStatelooks 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:
ElementimplementsBuildContext. The walk isfor (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 — anElementin the widget stack”, andHost.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.findAncestorStateis 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-levelscrollIntoViewfrom 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 questionTODO.mdhas 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, inRole’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:
| X11 | Windows | macOS | Wayland | |
|---|---|---|---|---|
| Reparent the window into the SDL window | XReparentWindow | SetParent | addSubview: | no |
| Align a separate window to a widget’s box | yes | yes | yes | no |
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
WKWebViewand a WebView2HWNDcreated on that thread.pumpis 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-viewnode in markup. There is nothing for arowto 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
dialogis 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, thatdocs/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.openreturns somethingAutoCloseablefor 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-viewthat 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.
| Session | What happens |
|---|---|
| X11 (including XWayland) | XReparentWindow — a real widget |
| Windows | SetParent — unverified |
| macOS | addSubview: — unverified |
| Wayland | nothing 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,tooltiportoastoverlapping the page is drawn underneath and is invisible where they meet. - A
scrollviewport does not clip it — the child clips to the window, so a page scrolled halfway out is still drawn whole. opacity,transformand 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:
| Hook | Catches | Installed |
|---|---|---|
g_log_set_default_handler | g_log, so g_warning, g_message, g_critical, g_debug | always |
g_log_set_writer_func | g_log_structured, which a default handler never sees | opt-in |
SDL_SetLogOutputFunction | everything SDL emits | always |
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
NSViewrather 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.showinto 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_realizealone 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:
| Lost | Why it does not matter |
|---|---|
| Decorations | Already off — gtk_window_set_decorated(FALSE) on the line above |
| Focus from the WM | The page’s keyboard comes from being a child of a window that has focus, which is ADR-0442’s arrangement and unchanged |
| Placement | It 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.dlleventually talks to; - the SDK header
WebView2.hdoes not ship with anything. It is delivered exclusively in theMicrosoft.Web.WebView2NuGet package, andwebview.hincludes 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.
Why not record in Bindings.link
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:
| leg | frames | late | paint mean | worst | display |
|---|---|---|---|---|---|
linux-x64 | 302 | 75 | 10.14 ms | 1799.59 ms | 0.0 Hz |
macos-aarch64 | 300 | 200 | 6.42 ms | 200.26 ms | 60.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 ofEventLoop#run. - That loop parks in SDL for up to
WEB_VIEW_TIMEOUT, 8 ms, and GLib’s file descriptors are not in that wait.EventLoopsays so itself: “Nothing wakes this loop when WebKit has work to do.” See ADR-0441. goldberry_webview_pumpthen runs at most 16g_main_context_iterationcalls 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:
| clock | what it is | what a low reading means |
|---|---|---|
timer | setInterval(…, 4), a plain GLib timer source | the main context is not being drained — the embedder’s fault |
timeline | how often document.timeline advances, sampled from the timer | the engine is not compositing |
raf | requestAnimationFrame | the 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.
requestAnimationFrameis 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 owngtk_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
setIntervalis 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:
| Build | Format | Size | At 200% |
|---|---|---|---|
NotoColorEmoji.ttf | CBDT — 136 px PNG strikes | 10.7 MB | scaled bitmaps, soft |
Noto-COLRv1.ttf | COLR v1 — paint graphs of outlines | 5.0 MB | outlines, 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;
PaintComposite567 times, every one of them a flag: the stripes, maskedSRC_IN, with a shading ramp laid over themSOFT_LIGHTso 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
Transformmatrix — 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:
BLRadialGradientValuesandBLConicGradientValuesrows, 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
BLCompOpa 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.
PaintColrGlyphdraws 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
CFFtable 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:
- Nothing on macOS could embed.
libgoldberry-webview.dylibwas built and loaded,Capability.WEB_VIEWwas reported, SDL handed over anNSWindow*— and the shim’s#elsebranch returned NULL with a comment saying the call had not been written because it could not be run. - 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 over | AppKit wants | Done by |
|---|---|---|
| device pixels | points | dividing by the window’s backingScaleFactor |
| origin at the top | origin at the bottom, unless the view is flipped | measuring y from the other edge |
| a box that moves only when the widget’s box does | a frame that stays put when the window grows | an 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:
| Platform | What the box says |
|---|---|
| Linux | Wayland cannot embed; run under X11 or XWayland |
| macOS | the engine did not start inside this window; the log says why |
| Windows | embedding is not implemented on this platform yet |
Alternatives considered
- Pass SDL’s window to
webview_createand put SDL’s content view back afterwards. TwosetContentView: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
.mmtranslation 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:YESon 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
NSApplicationsubclass handles key events inCocoa_DispatchEventand passes them on to the window, which delivers them to the focusedWKWebView. 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
WKWebViewis 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 asTEXT_INPUTwhen text input is on.Cocoa_HandleKeyEventcallsinterpretKeyEvents: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.
Sdl3BackenddropsKEY_DOWN,KEY_UP,TEXT_INPUTandTEXT_EDITINGfor 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_DOWNthat SDL reports blurs that window’s pages. A press on a page belongs to the page and never reaches SDL — theWKWebViewis 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
SDL3TranslatorRespondersubview 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 owner | Done here |
|---|---|
| COM | CoInitializeEx(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. |
| Clipping | WS_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. |
| Placement | SetWindowPos(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 state | A 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.
SetParenta 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
BitBlthonoursWS_CLIPCHILDRENthrough 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.
- whether SDL’s
-
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.
vmemhands 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_CODECnaming 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-networkand no protocols. Every byte arrives through a JavaMediaIOover a customAVIOContext. 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
libgoldberryfor 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
libgoldberrywould 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.libdircan 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.
:nativeswould be a pass-through that exports them to:mediaalone, 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…Callsrecords 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.cprints sizes, offsets, library majors and constants for every struct and field the Engine touches. The superbuild packages the output beside the libraries.FfmpegStructsis 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_packetandseekare bound to one stream’s callbacks. They do not dispatch onopaque(ADR-0017). - Nothing leaks out. The exported packages (
…media,…media.codec,…media.io) name no FFmpeg type. A codec is aCodecId, 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
:nativesand 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.:nativeswould 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, andmodule-info.javafor:mediasays it.- Consumers pass
--enable-native-access=io.github.digitalsmile.goldberry.mediaas well as the one for:natives. - The GraalVM reachability metadata for this module (three upcall shapes, the
downcall descriptors that
FfmpegDowncallsrecords) is this module’s to generate. It is an open item indocs/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:mediabinds only FFmpeg. ADR-0461 gave:mediaits 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 somethinglibgoldberryalready 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
:mediarequires: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_BITEXACTturns 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
onChangeStartandonChangeEnd, as Flutter has. The start is the firstonChangeof 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-playerandmedia-playerscrub 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
MediaIOof 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-. A206makes the stream seekable and says its length; a200means the server ignoresRange. - 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 tomaxReconnectsin a row. A4xxother than408and429is 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
IcyIOwrappingHttpIO. 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_timestampand 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
MediaIOthat stalls at an exact byte, so the BUFFERING, PLAYING and BUFFERING sequence is checked sample for sample. PlayerStatusgainsbufferedAhead,bufferedRanges,nowPlayingandlive();MediaInfogainslive. The widgets showLIVEonly 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
probesizefor network sources was measured and not kept: it helped WAV alone. - The seek bar did not draw
bufferedRangesat first:sliderin:widgetshad 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
progressunder 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-playerandmedia-playershow 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-spansandslider-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:
- checks that the track has a decoder, and keeps the playing one if not;
- 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;
- selects the new stream in the demuxer, and starts a new audio thread on a new queue;
- 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-playerandmedia-playershow 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.audioTrackandvideoTrackreport 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
.srtand.vttfiles, 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:
- checks that the track has a decoder, and keeps the playing one if not;
- retires the video thread (a flag its waits and reports check), releases the
frame queue’s waiters (
FrameQueue.releaseWaiters(): a waitingobtainreturns 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; - selects the new stream in the demuxer, and starts a new video thread on a new packet queue and the same frame queue;
- 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
stepwould 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
obtainwaits as before.
Consequences
MediaPlayer.selectTracktakes an audio, video or subtitle track, andPlayerStatus.videoTrackreports 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
VirtualSinkby hand has to wait for the flush’sclear()before it plays on, or it plays samples the flush then discards. - The fixture
clip-two-angles.mkvcarries 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 carryAVCodecParameters, and the device, theget_formatstub 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
libvalazily. 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),AUTOby 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
libvabefore 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_formatwith every demuxer compiled in. This is the same as the previous option: the probe only knows demuxers that exist.
Consequences
MediaErrorgainsUnsupportedContainer(format). Code that switches overMediaErrorexhaustively needs a case;MediaErrorTestholds 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.tsandclip-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:
| Provider | Codecs | Framework |
|---|---|---|
videotoolbox | H.264, HEVC; 8- and 10-bit 4:2:0 | VideoToolbox |
audiotoolbox | AAC (LC, HE, HEv2), AC-3, E-AC-3 | AudioToolbox |
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
MaxDpbMbsdivided by the picture size, at most 16; - HEVC:
sps_max_num_reorder_picsof 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_EnableTemporalProcessinginstead 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
CMTimethe output callback receives.
Consequences
- Tests use
media-platform/src/test/fixtures/make-fixtures.sh. Every H.264 and HEVC fixture ships with FFmpeg’sframemd5in 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. AMediaPlayerplays H.264 with AAC, and HEVC, to the end on the two providers. -Pgoldberry.platform.required=trueturns the macOS skips into failures. The Media workflow runs:media-platform:checkon 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:foreignMetadatawrites it from the bindings into the jar, as:mediadoes.PlatformForeignMetadatainitialises 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.Sdl3WindowcallsSDL_SetWindowFullscreenwith no display mode set, so it is borderless fullscreen on the desktop’s own mode, never an exclusive mode change.HeadlessWindowagrees and reports, andreportFullscreendrives the user’s route in a test.BackendEvent.FullscreenChanged(window, boolean), from SDL’sENTER_FULLSCREEN(0x217) andLEAVE_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.setFullscreenasks.Window.onFullscreenChangedis aSubscriptionwith any number of listeners, told of changes only.HostgainscanFullscreen,isFullscreen,setFullscreenandonFullscreenChanged, defaulting to “no window”. A widget asks its host, nothost.window():Host’s own docs say reaching for the window means the call belongs onHost, 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.
FandEscstill 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
ForEscleaves, 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 playsratetimes as much stream.SdlAudioSinkreports 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 anOutputLatency.OutputLatencyis an SPI in:media, found byServiceLoaderand 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-platformprovidesCoreAudioLatency, 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 − latencyis 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
ENDEDwould cut that tail off. AnAudioTailcounts 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-platformalready 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.
CoreAudioLatencylogs 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.
ENDEDarrives 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:
- A device needs the video subsystem, and a video driver that can make a
Metal view or a Vulkan surface.
SDL_CreateGPUDeviceWithPropertiesasks the current video device (SDL_gpu.c). The Metal driver’sPrepareDriverneedsMetal_CreateViewand the Vulkan driver’s needsVulkan_CreateSurface. SDL’sdummyvideo driver, which every headless test in the project runs under, has neither, so under it there is no device at all. Theoffscreendriver has a headless Vulkan surface, so lavapipe works under it with no display. On macOS, onlycocoahas a Metal view. cocoastarts only on the process’s first thread (Cocoa_CreateDevicereturns NULL otherwise). That is ADR-0039’s failure, “No available video device”, met again in a place where the flag cannot be passed: Gradle’sTesttask runs tests on a worker thread however its JVM is started.- The exports cost almost nothing.
libgoldberryfor 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,SdlGpuResourceCallsandSdlGpuCommandCalls, plusSdlPropertiesCallsfor the property groups a device is configured with. They are exported ingoldberry.symbolsonly as they gain a caller, soExportListTestholds. - 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,SdlGpuCommandBufferwith itsCopyPass, andSdlGpuFence.- 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
ByteBufferscoped to an arena thatunmapcloses, so holding it too long throws rather than reading freed memory.
- No public signature carries a
natives.sdl.gpuis exported to:coreand:gpu, and to nobody else.:corewill claim windows and present;:gpuis the public API. It is the first package sealed to two readers, andExportedSurfaceTestnow 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=truemakes that a failure, which is ADR-0016’s rule applied to a device. -Pgoldberry.gpu.videoDrivernames the video driver:offscreenfor lavapipe on a runner with no display.- A launch that finds no tests fails.
Alternatives considered
- Vulkan through MoltenVK under
offscreenon 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
javalauncher 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
offscreenorcocoa, notdummy. The plan’s “headlessGpuSurfacein readback mode” (D5) is corrected: the golden tests that render GPU content run in agpuTest-style task under a GPU-capable driver. The CPU goldens keepdummyandOffscreen, 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:
- 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.
- 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. - 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 pointmain0.
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.hlsland.frag.hlsl.:gpu:compileShadersis run on purpose, not inbuild. It writes.spv,.dxiland.mslfor each source under the module’s resources, andshaders.propertieswith each source’s SHA-256 and the tools’ versions. The output is deterministic: a second run gives the same bytes. ShaderManifestTestfails when a source’s hash differs from the manifest, when a source has no bytecode or bytecode no source, and whenBuiltInShaderand the sources disagree. It needs no DXC and no device, so every leg runs it.BuiltInShaderstates each shader’s stage, samplers and uniform blocks.ShaderLibraryloads 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, whichDeclaredResourcesTestholds to.- The first three shaders are
quad.vert,texture.fragandsolid.frag.quad.vertmakes a quad from the vertex id and one uniform block (destination in normalised device coordinates, source in texture coordinates).Quadturns 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,SdlGpuSamplerandSdlGpuGraphicsPipeline. 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, orPREMULTIPLIED_OVERfor the UI.beginRenderPasstakes a sealedSdlGpuLoad(keep, clear, don’t care), andclearis one such pass.- The
RenderPassrefuses, 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
BuiltInShaderstates), 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.mdunder “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) andyuv3.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:Layer CPU per frame (copy + record + submit) frame interval, median / p95 4K NV12 (12 MB) 0.94 ms 8.32 / 8.93 ms 4K P010 (24 MB) 1.37 ms 8.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,YuvMatrixandYuvConversionare in…gpu.renderfor phase 6 to use.BuiltInShaderhasYUV2_FRAGMENTandYUV3_FRAGMENT. The sharedyuv.hlsliis 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_BITEXACToutput (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,ShaderandGraphicsPipelineextend a sealedGpuResource. Each isAutoCloseableand made from a record:TextureSpec,SamplerSpec,ShaderCode, orPipelineSpecwith its builder. The enums those records name map onto SDL’s by exhaustiveswitch, 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
GpuDevicebelongs to the thread it was handed out on. Every call from another throwsWrongThreadException, as a confinedArenadoes, before the driver is reached. - The device is not an application’s to make or close.
GpuDevice.wrapis package-private. The backend will own the one device of the process (D2) and hand it out throughgpuSurface()(phase 3). - Passes are scoped by lambdas.
GpuFrame.copyPass(body)andrenderPass(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. AGpuFrameisAutoCloseable: 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 aPixelBufferof premultiplied BGRA, swizzling an RGBA texture. - Driver refusals are
GpuException. What the API can see for itself isIllegalArgumentExceptionorIllegalStateException.
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
SdlGpuPipelineDescriptionwith 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
AutoCloseableobjects 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.
awaitreturning 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:compileShadersnow compiles fromsrc/test/shadersinto test resources that do not ship.ShaderManifestTestholds 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 secondRenderTarget(the interface is sealed and permits onlyGpuTexturetoday), andCopyPass.upload(texture, pixelBuffer, damage)is its UI upload.SDL_CancelGPUCommandBufferis 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, andclose();CompositedWindow:present(PixelBuffer, damage),lastPresent()andclose();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.:coreusesthe service and:gpuprovidesit (SdlCompositor). With no:gpuon the module path there is no provider, and every window presents on the CPU exactly as before. An application needs norequiresfor 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.alwayscomposites every window from its first frame, which is how the path is run and measured now. -
The window (
Sdl3Window) enters the composited mode inacquireFrame, 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.presenthands 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.driverandgoldberry.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.renderso both share it) and one composite pass, shared by every window. - Each window has a
B8G8R8A8_UNORMUI 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.vertandtexture.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 separateui.vert/ui.fragare not needed. - A swapchain format the bindings do not model is blitted to instead.
- Two frames in flight. VSYNC, or MAILBOX then IMMEDIATE when
-
Pacing.
FramePacerstays 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):time late paint mean composited, pacer aside 0.93 s 0 2.88 ms composited, pacer on 2.9 s 14 3.83 ms CPU 2.8 s 13 5.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
:coreagainst the natives wrappers.:corereadsnatives.sdl.gpualready. 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.ServiceLoaderneeds only the module path, and:corealready 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,GpuLayerandCompositionModecome with phase 4, their consumer, and not before (ADR-0019’s rule). The layer interface will be:gpu’s, since it renders withGpuFrame.:corewill 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.
PresentTimingsis kept per window but does not reachFrameStatsor thehudyet.- 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 screen | device | paint mean | late | |
|---|---|---|---|---|
| through the GPU | 1016.9 ms (median of 3) | 19.5–21.3 ms at the first frame | 3.67 ms | 10 |
| through the window surface | 1013.4 ms (median of 3) | none | 5.34 ms | 13 |
- 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.compositedefaults toalways.autokeeps its meaning, composite only for GPU layers, andneverandgoldberry.gpu=offstill 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 happens Where it is logged Level the backend starts the policy, and how to change it INFO no :gpuon the module paththe backend: “add goldberry-gpu” INFO the device is made the compositor: driver, formats, time INFO no device can be made the compositor: why, and the two properties WARN a window is claimed the window: “presents through the GPU” INFO a window is refused the window, with the compositor’s reason INFO a page is embedded in a composited window the window, from now on INFO a composited present fails the window, with the exception WARN A claim now answers with a sealed
Claim, eitherClaimed(window)orRefused(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
autoas 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=offis the switch, and the default can be narrowed then.
Consequences
- Every application with
goldberry-gpuon 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
:gpuon its path. The CPU path stays covered by every backend test in:core, which has no:gpu, and bygoldberry.gpu=off, whichCompositedBackendTestdrives. - 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
canvaspainter, 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.
:coreplaces layers and cannot name:gpu’sGpuFrame, 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.GpuContentis an empty interface, an identity.:gpu’sGpuLayerextends 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 arender.GpuPlacement(content, target, scissor), andFrame.gpuPlacements()lists them in paint order. -
The frame mirrors its clip in Java, as it already mirrored its transform (ADR-0390):
clipTo,resetClip,saveandrestorekeep 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)confinesbodyto the region being repainted. It is a clip as far as pixels go, it outlastsresetClip(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.GpuSurfaceis 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.paintasks for it afteracquireFrame. Empty by default, withgoldberry.gpu=off, and with no:gpuon 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,:gpualone) gainsCompositor.readback(), aReadbackSurfaceper window, andCompositedWindow.present(frame, damage, layers).
The backends
- sdl3. A composited window gives a
Compositedsurface 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 undergoldberry.gpu.composite=never. goldberry.gpu.composite=autonow 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=offis a policy of its own now,OFF, rather thanNEVER: no window is composited and no layer is rendered, whereneverstill reads layers back.- headless windows give a read-back surface when
:gpuis on the module path. Its device needs a video driver the GPU can use:offscreenunder lavapipe,cocoaon 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.targetis aB8G8R8A8_UNORMcolour 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 paintsfallbackwhere 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
GpuLayershows as black too. GpuDevice.wrapand a texture’s SDL handle stay package-private in the public package.gpu.renderreaches them throughApiAccess, whichGpuDeviceregisters 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
opacitygroup 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
attachanddetachon 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
CompositionModeenum 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
canvas3dre-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. RenderTreeculls 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
canvasbox only when its painter changes, and compares painters withequals. 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-unavailableand the reason, asweb-viewdoes.”web-viewgives its reason as amessagechild, which is chosen at build time. A canvas learns it has no GPU only when it is painted. - Where its module sits.
:gpuhad 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 beforeiniton 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
continuousor on demand.continuous: the canvas needs a render on every frame, and its leaf reportsisAnimating, which keeps frames coming.- On demand: it needs one on its first frame and when
revisionchanges.Canvas3d.revision(n)is what an application rebuilds with, and markup’srevision=sets it.
- The painter is a record,
Canvas3dPainter(layer, stamp, nanos, …), so the render tree’sequalsdecides 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
messageover the canvas (classcanvas3d-notice) saying why. This isweb-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
opacitygroup, or a window with none; - the GPU failed.
- the GPU is off (
Frame.hasGpu()is what tells the first two cases from the third.Offscreenhas 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
:widgetsasapi; requires transitiveit;- 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
hudwith 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)withdevice()andrequestRedraw(). 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
messagewidget 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.
- the cube at a fixed angle is a golden in
- A new GPU lane.
:example:gpuTestruns on the first thread like:natives’ and:gpu’s, and is part ofcheck. - 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:
| Layout | Bytes | Planes copied | Row by row (padded rows) | Converted to BGRA (SWS_BITEXACT) |
|---|---|---|---|---|
| NV12 | 12.4 MB | 0.35 ms | 0.46 ms | 12.22 ms |
| I420 | 12.4 MB | 0.34 ms | 0.43 ms | 12.00 ms |
| P010 | 24.9 MB | 0.80 ms | 1.46 ms | 13.60 ms |
| I010 | 24.9 MB | 0.80 ms | 1.28 ms | 12.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,P010orI010); - 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 anAttachment. The attachment can change its form (setForm) and closes idempotently.- The pictures are
PLANESonly while at least one view is attached and every attached view asks for planes. With no view attached, or with anyCONVERTEDview, they areCONVERTED. MediaPlayer.pictureForm()reports the form in use, and it carries over to the next source opened.video-viewandmedia-playerattach asCONVERTEDwhile they show pictures, fromFollowingState. 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.PLANESsays 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()returningOptional<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-viewbecomes 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.
VideoPlaybackTestconverts 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 unexportedrenderpackage.video-viewis in:media.:gpumust not depend on:media, and:mediamust play video without:gpu. - Who letterboxes.
video-viewplaces a picture byFitover 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.gpuLayerreturns 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_BITEXACTpicture”, 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, aGpuLayer;VideoImage, sealed overPlanesandBgra;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), thenyuv2.fragoryuv3.fragdraws them withYuvConversion’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.VideoLayerwithClass.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
:gpuis 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
:gpunothing 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.uniformsnow sites chroma centred (Siting.CENTRED, an offset of 0).Siting.LEFTremains 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 isvideo-view, and an application shows video with that. A public API would have one caller and an obligation to keep it (ADR-0019). :gpuproviding a service that:mediafinds. The service interface would have to live in:media, which:gpucannot 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:gpuoff the class path, as an application without it runs.
Both are part of
check. -
D8’s second half, measured by
:gpu:videoLayerProbeon 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
:gputhrough 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_locationthrough 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-viewcomposited 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:
| Count | What it counts |
|---|---|
decoded | pictures decoded |
late | dropped before being prepared |
passed | queued, due, and replaced before any view was handed them |
shown | handed 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.shmakes: a minute oftestsrc2at 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:andpresents: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 VP9 | GPU present, 8-bit | GPU present, 10-bit | CPU present, 8-bit |
|---|---|---|---|
| decoded / shown | 3600 / 3600 | 3600 / 3600 | 3598 / 2018 |
| dropped late / passed over | 0 / 0 | 0 / 0 | 1581 / 0 |
| video thread, a picture shown | 1.4 ms (9% of a core) | 2.0 ms (12%) | 13.9 ms (47%) |
| UI thread, a picture shown | 2.8 ms (17%) | 3.1 ms (19%) | 17.6 ms (59%) |
| process | 7.5 ms (46%) | 8.5 ms (51%) | 66.6 ms (224%) |
| frames painted, late | 4077, 0 | 4031, 0 | 5286, 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
AudioQueueGetCurrentTimewould 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.
VideoStatisticsis public API, for ahudor 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:
| library | linux-x64, configure’s -O3 |
|---|---|
| libavutil | 1002 KB |
| libswresample | 174 KB |
| libswscale | 2026 KB |
| libavcodec, dav1d inside | 5163 KB |
| libavformat | 612 KB |
| total | 8980 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:
| build | total |
|---|---|
-O3, as configure chooses | 8976 KB |
FFmpeg --optflags=-O2 | 6952 KB |
… and dav1d -Doptimization=2 | 6696 KB |
… and --enable-lto | 6608 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, decode | 60.4–60.7 fps | 60.7–62.1 fps |
| AV1 10-bit (dav1d), one thread, decode | 54.4–56.4 fps | 56.2–57.6 fps |
| VP9 8-bit → BGRA (CPU present) | 5.68–6.08 ms a picture | 5.71–5.78 ms |
| VP9 10-bit → BGRA | 14.5–17.1 ms | 13.8–14.5 ms |
| AV1 10-bit → BGRA | 13.0–13.3 ms | 13.1–13.6 ms |
| VP9 10-bit → P010 | 8.1–8.4 ms | 9.9–10.2 ms |
| Opus, decode | 31.8–32.6 k packets/s | 30.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, sinceclhas no-O3. The flag changes nothing there, and dav1d’s-O2is 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.mdis from-O3until 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.txthas no option to drop AVX-512, onlyenable_asm, which would drop all of it. -Pgoldberry.media.required=true:media:checkagainst 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_STACKand load without it.FfmpegLibrariesTestwrites a text file under that name to test a failed load. HotSpot reads the ELF stack note beforedlopen, 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.openthrew, andPlaybackreported the throw asMediaError.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
SdlExceptionfalls back. That is whatSdlAudioStream.opendocuments 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
opentries 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.sinkis the application’s, and its failures are its own. SilentAudioSinkis 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
MediaErrorvariant 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 newPlayerStatusfield and a decision of its own. SdlAudioSinkgained a package-private constructor that takes the device opener and the clock for the silence, so the fallback is tested without SDL:SdlAudioSinkFallbackTestandSilentAudioSinkTest, 9 tests.- Checked from start to finish on the Linux machine that failed, with the test
task’s
SDL_AUDIO_DRIVER=dummyremoved for one run, so that SDL really found no device.clip-vp9.webm, a second of VP9 and Opus, played toENDEDin 1.28 s with no error. - The machine still has no sound. That is a toolchain fix, not a code fix:
install
libasound2-devandlibpulse-dev, and rebuildlibgoldberry. 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_LIBDIRthat hid onlyalsa.pcandlibpulse.pc,checkToolchainfailed, even withallowDegradedPlatform, and named the two packages. - With the headers installed,
:natives:cmakeBuildpassed. SDL’s header has both drivers, andlibgoldberry.socarrieslibasound.so.2andlibpulse.so.0to load. - Reconfiguring with
-DSDL_PULSEAUDIO=OFFfailed onSDL_AUDIO_DRIVER_PULSEAUDIO, reading the intermediate file. It was then set back.
- With a
- A machine without the headers can no longer build
libgoldberryat 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.requiresAudiofor both rows,theContainerWorkflowInstallsAudio, and inPlatformIntegrationBuildTestaudioStopsTheConfigureandsdlAnswerIsReadFromThisConfigure. - 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 whateverappsinkhas, 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
GstClockTimeis 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
MFTEnumExsorts 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
HEAACWAVEINFOuser data around theAudioSpecificConfig, 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_INFOandMFVideoArea; - 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.bitstreamholdsParameterSets(moved frommacos),BitReaderand the newAnnexB.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.md5hashes I420 and I010 as their NV12 and P010 twins, so the fixtures’framemd5files check planar decoders too. - The native-image generator moved to
…platform.nativeimage, and writes the union ofMacBindings,LinuxBindingsandWindowsBindings. goldberry.platform.requiredfails 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:checkwith 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’sframemd5. - 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.
MediaPlayerfinds the providers throughServiceLoaderand plays H.264 with AAC, and HEVC, to the end.
- H.264, in MP4 and Matroska, decodes on
- A Linux system plays these codecs only if its distribution installed the
decoders:
gstreamer1.0-libav(oropenh264,faadanda52dec) and the parsers in plugins-good and plugins-bad. Without them the providers support nothing, and the file isUNSUPPORTED_CODEC, as it would be without the module. - Every packet is copied once into a GStreamer buffer, since the pipeline
decodes after
sendreturns. - 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.soneedslibavcodec.so.62, and glibc satisfied it with ours, which decodes VP8, VP9 and AV1 and nothing else. Soavdec_h264could 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:mediawas exposed. - The system’s first. Our
libavformatthen links against the system’slibavcodec: 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_SUFFIXin the superbuild sets the configure line.FfmpegPlatform.BUILD_SUFFIXis the one the loader looks for.FfmpegSuperbuildTestfails if they differ, if the suffix is empty, or if the configure line stops using it. package.cmakenames 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
NEEDEDentry 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’savdec_h264andavdec_h265now decode in the same process, which ADR-0489’s tests depend on. :media:checkwith FFmpeg required passes: 473 tests.FfmpegPlatformTestnames the suffixed files on all three systems.- macOS and Windows take the same configure option. Their names follow
FFmpeg’s own rules for
--build-suffixand 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-viewopens 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 withHost.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.repaintdamages 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 becamewindow.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=autothe 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
autoover 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
XRGB8888andARGB8888, 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 withhost.repaint()put back),CompositorTest.exposed, andWindowIdentityTest(fails without the hint; it needs-Pgoldberry.gpu.videoDriver=x11where SDL would choose Wayland). - Left as found: in
:gpu:gpuTest,GpuLayerBackendTestsegfaults inVULKAN_DestroyDevicewhen it closes its device, which ends the run. With it disabled,Canvas3dGoldenTest.cubefails its golden (52% of pixels, delta 1), and under WaylandCompositorTest.givesTheWindowBackandoneDevicefail 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.ofis 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 …”, fromSdlCompositor. 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
neveroroff. - 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”.Sdl3Windowanswers 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.HeadlessWindowis on the CPU, “headless: nothing is shown on a screen”.presentAslets 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, andon screen: 385 frame(s) through the GPU (vulkan), 0 on the CPU. Underauto:[CPU]at start,[GPU]when a layer showed,[CPU]again six seconds later, and370 … 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.ShowcaseDocumentsTestnames the new badge. CompositedWindowhas 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), andCompositedBackendTestassertingGpuwith a compiled-in driver andCpu("goldberry.gpu=off"). - A long CPU reason makes a long badge. Under
autoit 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
:mediawithout 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
foreignMetadatagenerator 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-accessflag, its own CI step and its own paragraph inNOTICE. 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.platformand itsbitstream,macos,linuxandwindowspackages move into:mediaunchanged. The descriptor exports…media.platform(for PlatformDecoders) and nothing under it. Theprovideslines for the sixDecoderProviders and CoreAudio’sOutputLatencymove with them.- One metadata file.
…media.nativeimageis new and unexported.MetadataGrammaris the tracing agent’s grammar, written once, with the sealed switch overValueLayoutthe platform copy had.FfmpegDescriptors(in…media.ffi) andPlatformDescriptorsgive each family’s shapes.MediaForeignMetadatawrites both into the module’s singlereachability-metadata.json.:natives’ForeignMetadatakeeps 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.ymlruns:media:checkwith bothrequiredflags. - 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-mediaplays the patent-pool codecs wherever the operating system can, with no second dependency and no second--enable-native-accessflag. One that wants FFmpeg only lists its own providers withMediaPlayer.builder().decoderProviders(…), which is how it always opted out ofServiceLoader’s list. goldberry-media-platformwas never published, so no coordinates are withdrawn.- The licence position does not change. The jar carries no copy of any system
decoder, and
NOTICEandTHIRD-PARTY-NOTICES.mdnow 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):platformis “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. imagenow holds every format the toolkit owns:gif,png,anim,qr.
Alternatives considered
image.codec.qr, withgifandpngmoved undercodecas well. It would be tidier, but it would churn two more exported packages for a word that adds nothing. The parent is alreadyimage.
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 oneruntimeOnly '…: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-x64andlinux-aarch64, whichmedia.ymldoes not build yet. The release refuses without them. Also the LGPL corresponding-source offer thatdocs/media-plan.mdlists as open. - Snapshots of
goldberry-html,-emojiand-gpugo out again with the next green run. None has since run 31.
Alternatives considered
- A
goldberry-ffmpeg-nativesartifact, as the spec named it. It would need a second publication in:mediaor a Gradle project of its own, and a fourth kind inPublishedModule. 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-mediawithout 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:
| Module | From | To | Types | Promotions |
|---|---|---|---|---|
:gpu | gpu.render | gpu.composite | SdlCompositor, SdlCompositedWindow, SdlReadbackSurface, UiComposite, LayerTextures, DeviceOptions, ApiAccess and their 7 tests | none |
:natives | sdl.gpu | sdl.gpu.enums | the 17 SdlGpu… enumerations | SdlGpuShaderFormat.createProperty() |
:example | example.ui | example.ui.sheet | the Icons and Emoji screens, tiles, catalogs and specimens, and 2 tests | none |
:example | example.ui | example.media | ShowcaseMedia, ShowcaseServer, JavaPcmDecoder and 1 test | none |
:widgets | widgets.data | widgets.data.plot | Scale, Ticks, LogTicks, TimeTicks, Lttb, Gaps, Curves and 7 tests | none |
:media | media | media.picture | Picture, VideoPicture, VideoPlanes, PictureForm and 2 tests | none |
:media | media.view | media.view.gpu | GpuVideo, GpuVideoPresenter, VideoPresenter and 3 tests | VideoPresenter, GpuVideo, GpuVideo.available(), GpuVideo.presenter(…) |
:core | paint | paint.stroke | Stroke, Cap, Join, Dash | none |
:core | render | render.clipboard | Clipboard, UriList and 1 test | none |
:widgets | widgets.core | widgets.core.presence | Phase, Departure and 1 test | none |
:assets | assets | assets.prepare | the whole build tool, and 4 tests | none |
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 therender.compositeSPI in:corethat 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 oneblend2d.enums,harfbuzz.enumsandmd4c.enumsalready 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, andGpuVideoPresenter, 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 likecolumnorrow.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
:gpuservice file - the
:assetsmainClassstrings in:core,:emojiand: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 plaincodementions 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.dataormedia. Nothing is released, and each new package is exported where the old one was. - The test suites pass after the moves:
:core2823,:widgets2893,:media714,:natives569,:html296,:example236 and the rest, with 0 failures. The exception is:gpu:gpuTest, whereGpuLayerBackendTestcrashes inVULKAN_DestroyDeviceon 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 intogpu.spec. About 30 package-privatesdl()/of()mappers would become public, puttingnatives.sdl.gputypes in the exportedgpuAPI.media.ffiinto 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 needFfmpeg’s constructor to be public, and that constructor bypasses the layout verification.paint’s box painters. It would publishFrame’s path-pool protocol andPath.replayIntoin an exported package.paint’s glyph painting. It would makeFrame.drawGlyphspublic again, undoing ADR-0290.paint.canvas(Painter,StyledPainter,CanvasStyle). There are no promotions, but it makes a package cycle withpaint.widgets.core.image’s loader. The internal sealedImageLoadwould become exported API.:core’s root package, includingPlacementtorender.popup. ADR-0172 already rejected the root split (21Windowpromotions).Placementis application-facing, whilerender.popupis the backend SPI.css’s root. There are no promotions, butStylesheet,ComputedStyleandThemehave about 600 importers, andcss/is a resource directory.example.ui’s documents. They load resources by relative name, and the paths appear in native-image metadata globs andopensrules.markdown.modelinto block and inline. It is one sealed AST, which is one role.SdlFileDialogs/SdlLogintosdl.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.shcompiled inwarnmode and countederror: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.
:corehad about 154:- about 69 parameters and 29 returns annotated
@Nullablewhere the code already handled null - about 11 fields made
@Nullable - 27
requireNonNullcalls stating an invariant. Thirteen of them go through a newRenderObject.appliedBox(). - seven wrong annotations from the auto-annotator corrected, among them
Paragraph.layout, which never returns null NullAway.InitonLauncher’s six fields thatrun()creates
No real bug could happen today. One latent one was made explicit:
Window.repaintIfRestyledassumes a pointer router, which both callers install first. - about 69 parameters and 29 returns annotated
-
:widgetshad about 353 findings,:example42,:html5 and:media1. They were resolved the same way.docs/refactor-2026-09-30.mdhas the breakdown. One real bug came out of it:Accelerators.unbindpassed 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.paintingandTraySpec.tooltipdocumented or stored null and did not declare it. They do now, and the last suppression that stood in for them is gone. What remains isNullAway.Initon fields a lifecycle method sets:Launcher’s six, two text states, and the showcase’s screens. -
Public signatures that now say
@Nullableare listed in the sweep’s record indocs/refactor-2026-09-30.md. Each widens a contract the code already honoured.Host.popup’sfitis one of them, so an implementation must match. -
docs/static-analysis-plan.mdQ1 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.yamlasked for an inspection that does not exist. It includedUnusedDeclaration, and the Java inspection’s ID isunused. 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 theinflatemethod the woven catalog calls.- 101
AutoCloseableResourcefindings were one false positive. A getter hands out aFont, aBackendor aMediaPlayerthat 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 aPropertymade empty. Fivecase nullarms and a dozenvalue == nullchecks 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:
unusedis 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:@Bindand@Actionmembers, and every widget’sinflate.AutoCloseableResourceignores 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.Subscriptionis not on it: the plan listed it as owned, and one of its two findings was a real leak, below.OptionalUsedAsFieldOrParameterTypeis off. It is a style opinion this codebase decided against.EmptyStatementBodycounts 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
whileloops 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-RangeorRangewith more digits than alongor anintholds threwNumberFormatExceptionout ofHttpIOand the showcase’s server. The reader now fails with anIOException, 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, andModels.frameListenerCountlets a test say so. - An
@Actiontaking adoubleand handed null threw a bareNullPointerExceptionout ofDouble.valueOf, while one taking anintrefused it by name. Both now refuse it by name.
- A
DownloaderandAssetCacheareAutoCloseable, so the standard downloader’sHttpClientis closed.PngEncodercloses itsDeflaterwith try-with-resources, which JDK 24 made possible.Subtitles.parselost its unusedformatparameter.:mediais 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:
| Override | What it writes |
|---|---|
ColorSwatch | background: the colour it shows |
SegmentedDivider | inset at boundary / count; opacity: 0 beside the pill |
SegmentedIndicator | width of 1/count; a translation of index cells; which corners stay round |
Tab | color: the tab’s own colour; a transform while it is dragged |
TabIndicator | background: the tab’s colour when selected; the displacement it slides out of |
ScrollContent | flex-shrink: 0; padding plus the gutter; the scroll offset as a transform |
ScrollThumb | its length and its travel |
ScrollViewport | the caller’s height, when one was given |
AffixContent | how 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’sopacity: 0. No selector can tell which lines are beside the pill. Once the widget says which,opacity: 0is an ordinary declaration, and a theme might reasonably want to dim the lines instead.scroll-thumb.draggingalready has this shape: the widget supplies a fact as a class, and the sheet decides what the fact means.ScrollContent’sflex-shrink: 0.controls.cssalready 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 inrestylebecauseBoxhad no wither for it.Box.shrinkhas existed since ADR-0076. Every other pin in the catalog is applied inrenderafter.style(style), for example the segmented indicator’sposition: 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:
| Override | Verdict | Kind |
|---|---|---|
ColorSwatch | Kept. The colour is the model’s value. Written here so the closed control fades between colours rather than jumping. | data |
SegmentedDivider | The 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 |
SegmentedIndicator | Kept. 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 |
Tab | Kept. The colour is application data (ADR-0107). The drag offset is the pointer’s (ADR-0372). | data, input |
TabIndicator | Kept. The tab’s colour, and a displacement that is the difference between two painted rectangles (ADR-0377). | data, measurement |
ScrollContent | The 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 |
ScrollThumb | Kept. The proportion of the content on screen, from measured extents (ADR-0117). | measurement |
ScrollViewport | Kept. The caller’s height, which in the toolkit is Fitted capping a menu at the screen it opens on. | measurement |
AffixContent | Kept. 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
restylefails, 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 onfast. The content box still does not shrink. The segmented and scroll goldens pass unchanged, as doSegmentedTest’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.draggingdoes. Before,restylerebuilt 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,:commonand:widgets, not the modules downstream.:html,:mediaand:exampleimplementStyledand none of them overridesrestyle. If one does, the same test belongs in that module’sarchpackage. A source scan from:widgetswould not be enough, because Gradle would treat:widgets:testas 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.atskips a word whose clip does not contain the pointer, so a pointer below the pane is over no word andatanswers nothing.Selection.extendToignores 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 itsrenderand answersisAnimating()withisScrolling(). That is howScrollGlideandScrollFadealready 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()andy(): 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-areare-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-areait 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-areadrag 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.
EdgeScrollis 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:
EdgeScrollTestcovers the speed, the band, the clamp, the stepping andnudge.SelectionTest$HeldAtTheEdgedrives both views through a real frame loop on a virtual clock and checks the offset per frame against speed × time.TextAreaTest$HeldAtTheEdgedoes the same fortext-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.onSystemThemeChangedreturned 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.onFullscreenChangedalready returns aSubscription, 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 ownSystemUsesLightTheme, which SDL does not read. On Linux it is the portal’scolor-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.closedoes. 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.onSystemThemeChangedchanged its return type fromvoidtoSubscription. Callers that ignore the result compile unchanged. Anything that implementsHostmust return one: the launcher,TestHostandTourTestHosthere.TrayIcon’s first component is nowpicture, aPicture, where it wasicon, aPixelBuffer.TrayIcon.spec()still describes the tray for a desktop that says nothing, andspec(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
forLightShellon 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 onforLightShellwhen 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.SystemThemeTestchecks 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) | Pass | Cascade | Custom properties | of which: copy and compare | Substitution |
|---|---|---|---|---|---|
| 51 (103) | 1288 µs | 348 (27%) | 815 (63%) | 799 | 109 (8%) |
| 101 (203) | 2822 µs | 990 (35%) | 1567 (56%) | 1510 | 241 (9%) |
| 201 (403) | 6867 µs | 3063 (45%) | 3271 (48%) | 3064 | 502 (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:
| Depth | First-frame resolve, before → after | First render() | render() after the middle panel’s hover |
|---|---|---|---|
| 51 | 1337 → 505 µs (−62%) | 1765 → 836 µs (−53%) | 1000 → 497 µs (−50%) |
| 101 | 2651 → 1168 µs (−56%) | 3383 → 1805 µs (−47%) | 1949 → 1130 µs (−42%) |
| 201 | 7796 → 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
SelectorMatcherrather 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
DeepTreeStyleBenchmarkand listed indocs/testing.md§1.5. It prints the first-frame pass, custom properties alone, the warm walk, a firstrender(), 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.
- The glob. Mesa’s packaging names the file
lvp_icd.jsonnow; older releases wrotelvp_icd.x86_64.json, and the lane globbed only forlvp_icd.*.json. This machine’s Mesa 26 has the new name. - A crash behind it. With the ICD found,
:gpu:gpuTesttook the JVM down inVULKAN_DestroyDevice, fromGpuLayerBackendTest’s@AfterEach. That is the crashdocs/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@BeforeEachto prove one could be made, kept it, and closed it after the test body — where anSdl3Backendhad already been closed, andSdl3Backend.close()ends inSDL_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. - 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):
| channel | differing | stray | |
|---|---|---|---|
RASTER — every Blend2D golden, and every golden before this | 2 | 2% | 0 |
GPU — canvas3d-cube | 2 | 100% | 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:gpuTest27 of 28 with one skipped by its own condition, and:gpu:gpuTest67 of 67, with-Dgoldberry.gpu.required=trueand theoffscreendriver. :gpu:gpuTestalso 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 andGpuLayerBackendTest.autoCompositesForLayersstill fail withThis surface does not support presentingandVK_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’sgallery-gpu-drawnwas 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 underRASTER: 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
GPUis 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
canvas3ddraws, which is single-sampled::gpuhas no multisampled target to ask for. - Widening
RASTERuntil 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:
Sdl3Backendanswers present only when the driver SDL chose isx11orwayland—hasPrimarySelection(String), decided once afterSDL_Initand unit-tested over driver names with no display.HeadlessBackendanswers with an in-memoryHeadlessPrimarySelection, present by default so the widgets’ behaviour is testable, withprimarySelection(false)to model a platform without one, andprimaryBuffer()to prove nothing was written while it was off. It counts writes and can refuse them, asHeadlessClipboardcan.Launcherforwards the backend’s answer. AHostthat 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 —
Shiftwith a movement, andCtrl+A, which publishes even when everything was already selected; - not the select-all a
text-inputdoes whenTabarrives. 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
Editornames 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 inSdl3Backend. SelectableDocumentpublishes and never pastes: a document is not a target. Its tests reach a primary selection through aHostproxy, because a press finds one only through the element that heard it.- The showcase’s canvas sticky wires its
Editorto the host’s primary selection, which is the oneEditorin 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’SdlClipboardTestround-trips the three calls against the real library, and proves the two buffers are separate.
Alternatives considered
PrimarySelection.none()on a non-Optionalaccessor, 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 theOptionalalready 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
HeadlessBackendthat proved nothing about the platforms where it matters. - A method on
Clipboard—primaryText()besidetext(). 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.
BoxPainterstrokes it inside the box’s edge, over the padding, andRenderObject.applynever callsYGNodeStyleSetBorder— bound per edge inYogaNode.setBorder, and unused. Every bordered widget’s padding assumes this:segmented’s padding “is that edge’s own width”, andTextAreaBoxinsets 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;
Cornersare 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 loneborder-lefton 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.cssandmarkdown.cssa row draws the line above it and a cell the line before it, andfirst— 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: 0now, an equal share of the row — what both stylesheets’ comments said they wanted whileflex-basiswas 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 becameborder-left: 2pxandpadding-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-titlehas 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-rulesits undertable-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-rulehas the travelling indicator drawn over it, and a border on the list would be painted before the tabs.tab-indicatortravels by atransform(ADR-0377), which a border on one tab cannot do.segmented-dividerfades 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.menubarstill 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-darkandmarkdown-selectionby the table’s inner rules (and, in the Markdown ones, a right-aligned400that moved one pixel to line up with the600under it now that the columns are equal);group-box-darkandgallery-panelsby the line under a group box’s title. One golden is new,borders: a uniform border, a loneborder-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()andborderColor()are gone. Four call sites moved:TextAreaBoxreads each side it insets against,Animatablesanimates theBorder, and two widget tests compare aBorder.- 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
Cornersare 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-stylelonghand that acceptssolidand refuses the rest. It would makeborder-style: nonea way to turn a border off, whichborder: nonealready 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 aboutborder-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-ruleandtab-ruleto 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:
| Launch | First frame, median | Range |
|---|---|---|
| JVM, cold | 1980 ms | 1948–2187 (5 runs) |
| JVM, JDK 25 AOT cache | 1340 ms | 1220–1406 (5 runs) |
| Native image, GPU on | 519 ms | 491–557 (7 runs) |
| Native image, GPU off | 365 ms | 351–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 andREADME.mdcarry 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.mdentry:- A native image resolves a host name before
main, throughlibnss_mdns4_minimal, about 50 ms. The JVM does not: its onlynsswitch.confread is for the user’s name. Logback resolvesHOSTNAMElazily and this configuration never asks for it, so the caller is not logback’sContextBase. A stripped image did not say whose it is. libgoldberry-webviewis opened at start-up, to answerCapability.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=offis the measured alternative.
- A native image resolves a host name before
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_destroycalledwebview_destroyand 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’sweb-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 defaultWebKitWebContext; finalising it releases the website data manager, whose~WebsiteDataStorereachesallDataStores()— main-thread-only state — and WebKit crashes on purpose,WTFCrashWithInfoatWebsiteDataStore.cpp:124. WebKit’s main thread is the one that started GTK, Goldberry’s UI thread; the stockjavalauncher runsmainon a thread of its own and callsexit()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 runsmainon 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. WebviewExitTestrunsWebviewExitProbein a child JVM — open a page, let it load, close it, return frommain— 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=1is 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.exitthere would run the exit handlers on WebKit’s main thread. It would also makeGoldberry.launchend 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
mavenjob 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
ffmpegordav1dtag 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 thepkg-configpath (avutil_configuration()). dav1d names its version1.5.4rather than1.5.4-0-g54706fcwhen 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-sourcesjar beside them meets it, andTHIRD-PARTY-NOTICES.mdsays so. UpstreamSourceTestmakes a repository with git, archives a tag throughexport-ignoreandexport-subst, refuses a moved tag, and archives again offline.CorrespondingSourceTestrefuses 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
VERSIONfile 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-mediawith 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-pagesbranch, or a folder in this one. - How the domain reaches it.
.devis 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.mdhad 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-pagesbranch, with mdBook’scnameoption. It puts a second history besidemasterthat nobody reviews, and the option writesCNAMEinto the book’s output root, which would be/docs/CNAME, not the site root. - A separate
DigitalSmile.github.iorepository. 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
masterthat touchessite/,book/orgradle.propertiesdeploys. 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.tomlnow gives every page an “edit this page” link to its source onmaster.
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.