ADR-0064: A rounded rectangle is four cubics, and opacity is a multiply
- Status: Accepted
- Date: 2026-08-16
- Relates to:
docs/design-system.md§1.5, §2.1, §2.2;docs/ARCHITECTURE.md§8; extends ADR-0049 and ADR-0053
Context
The design system pins four numbers that nothing in the toolkit could draw:
- Radii — 4, 8, 12,
full(§1.5).Boxfilled axis-aligned rectangles. - The focus ring — 2px
--gb-focus, 2px offset, following the control’s radius (§2.2).controls.cssfaked it with a background change and said so in a comment. - Borders — the checkbox’s glyph is an outlined square before it is a filled one, and there was no outline.
:disabledat 45% opacity, never colour-remapped (§2.1).ComputedStylehad parsedopacitysince the CSS engine landed andBox.styledropped it on the floor with a comment explaining that group opacity needs a layer.
Every one of these blocked button from complying with its own §3 metrics row,
and three of the four blocked checkbox from existing at all. They arrive
together because they are drawn by one shape: a rounded rectangle, filled for the
background, stroked inward for the border, stroked outward for the ring.
Decision
The path is built from cubics, not bound
Blend2D has a round-rect geometry call. Using it would mean adding a symbol to
the export list, and that list has caught the same class of bug three times —
--exclude-libs,ALL, then Blend2D’s BL_STATIC making BL_API expand to
nothing, then HarfBuzz’s bare HB_EXTERN — each of which linked a symbol in and
left it local, and each of which was only answered by a CI run across four
targets. The MSVC .def and Mach-O exported_symbols_list branches are still
answered by the next run rather than by argument.
bl_path_cubic_to is already exported and already exercised by 1544 Lucide
icons. A quarter circle as a cubic with control points at κ = 0.5522847498 of
the radius is off by about one part in 10,000 — at a 12px corner, a thousandth of
a pixel, well inside the golden images’ tolerance
(ADR-0050). So RoundRect builds the
path and no new symbol crosses the boundary. The corner works on every
platform on the first run, rather than on the run after the one that found out.
The radius is clamped to half the shorter side, which is CSS’s own rule and what
makes border-radius: 9999px a pill rather than an error — so §1.5’s full is
expressible without a keyword.
Six properties, one record
border-radius, border-width, border-color, outline-width,
outline-color, outline-offset, plus the border: and outline: shorthands.
They live in one Decoration record on both ComputedStyle and Box rather
than as six components on each, because they are only ever read together: the
painter that strokes a border needs the radius to stroke it along, and the ring
needs both to sit outside them.
Percentages are refused rather than carried. A percentage radius resolves
against the box’s own size, and the box has no size until Yoga has run — long
after the cascade. border-radius: 50% is a dropped declaration with a warning
naming it, rather than a corner that is silently square.
The ring is outline rather than a widget’s own drawing for one reason: a widget
that drew its own would have to know its own radius to follow it, and §2.2’s
numbers would then live in as many places as there are controls. As a rule in the
toolkit-base layer it is written once:
button:focus-visible,
checkbox:focus-visible {
outline: 2px solid var(--gb-focus);
outline-offset: 2px;
}
CSS outlines are drawn outside the border box and take no space, which is exactly what a ring at a 2px offset needs — it cannot move a control by existing.
Opacity multiplies alpha down the subtree
BoxPainter accumulates opacity as it walks, and hands the visitor a box whose
colours already include every opacity above it. Box.fade(alpha) scales the
alpha of the background, the text, the icon, the mark, the border and the ring;
alpha >= 1 returns the same box, so an ordinary frame allocates nothing.
This is not CSS group opacity, and the difference is stated rather than
hidden. The specification renders the subtree into a layer and composites that
layer once, so two overlapping children at opacity: .5 each show the backdrop
rather than one showing through the other. Multiplying per box differs exactly
where children overlap, and nothing in the widget canon overlaps: a control is a
mark, an icon and a label placed side by side by Yoga. The design system asks for
opacity in one place — :disabled at 45% — and there the two are
indistinguishable.
stack (§11, z-layering) is the widget that will make the difference visible,
and a compositing layer is what it will need. It is also what damage tracking and
the animation overlay (§1.7’s “layer promotion”) want, so the three arrive
together or not at all.
A disabled control does not light up
Removing the colour remap exposed a second problem: button.danger:disabled and
button.danger:hover no longer fought, so a disabled button lightened under the
pointer. CSS would spell the fix :not(:disabled):hover, and :not() is not in
§8’s subset. Writing it out is a rule per variant per state per control — a dozen
selectors, each able to be wrong on its own.
Instead PointerRouter.mark — the single choke point for :hover and :active
— refuses to set a state on a disabled widget. Clearing always goes through, so
a button that disables itself in its own press handler does not keep the state.
The ENTERED/EXITED events are not suppressed: this is about what a
control looks like, not what it is told, and a tooltip explaining why something
is disabled needs the event.
Consequences
buttoncomplies with §3’s metrics row: height 32, padding-x 12, gap 6, radius 8, the design system’s ring, and 45% opacity when disabled. Four golden images were regenerated and the diff is the argument.- Disabled is one number instead of eight tokens.
--gb-button-disabled-bg,--gb-button-disabled-textand--gb-button-bg-focusare gone from both themes: the first two were the colour remap §2.1 forbids, and the third was the focus stand-in. A disableddangerbutton now still reads as dangerous, which a remap to one grey surface had made impossible. - One
BlendPathis allocated perpaintcall andresetbetween shapes, rather than one per rounded corner per box. A path is a native allocation and an arena; a window of forty rounded controls would otherwise make eighty of them a frame to draw the same four arcs. ComputedStyle.withis now written with per-field withers instead of fourteen-argument positional constructor calls. The old form was correct and unreadable — a reader could not tell a case that setwidthfrom one that setheightwithout counting commas, which is precisely the mistake the shape invites.- Still not expressible, and absent rather than approximated: the
body-strongweight on a button’s label, which needs a second Inter face (or the variable font’swghtaxis) and the typography-token scale of §1.4; and every transition in §1.7, which needs a frame clock and an animation overlay. Both are inbook/src/status.mdas open, not faked with a value that looks close. - Untested claim: the arcs have only been rasterized on linux-x64. Blend2D JITs its pipelines per CPU, so the corners on AVX-512, on an Apple Silicon NEON path and under MSVC are answered by the next CI run — which is what the golden images with their per-channel and area tolerance are for.