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.