ADR-0068: The transform stack is accumulated in Java, and hit testing inverts the matrix the painter used
- Status: Accepted
- Date: 2026-08-17
- Relates to:
docs/design-system.md§1.7;docs/ARCHITECTURE.md§8, §11; closes thetransformgap left open by ADR-0067; extends ADR-0054 and ADR-0064
Context
transform is on §1.7’s whitelist of properties that may animate, and it is the
only one of the five that was not implemented. ADR-0067 named it as a gap and
said why it was being deferred rather than shipped inside that change:
Boxcarries no transform, and adding one means the painter and hit testing, which needs the inverse to map a pointer back through it and silently mis-routes clicks if it does not. A correctness trap worth arriving on its own.
That is the whole of the problem. A transform is not hard to draw — Blend2D has had the machinery since the display scale was first applied to a context. It is hard to draw and route input through consistently, because the two run in different places at different times: painting happens inside the frame callback with a live rendering context, and hit testing happens on the input path against a snapshot of the last frame, with no context anywhere near it (ADR-0054).
The failure mode is what makes it worth its own record. A transform applied by the painter and ignored by hit testing produces no error and no wrong pixel. The control is drawn exactly where the stylesheet asked. It simply does not respond where it looks like it should, and it does respond somewhere invisible. Nothing in a screenshot, a log or a test of either half on its own would show it.
Two further things were unresolved and had to be decided here, because both are consequences of the same question:
- What a computed
transformis. Every other property inComputedStyleis finished when the cascade produces it. This one cannot be:translate(50%)and thetransform-origindefault of50% 50%are proportions of the element’s own border box, and the box has no size until Yoga has run. - How a transform interpolates. §1.7’s one named use is the check mark’s
scale 0.6→1, and the obvious implementation — interpolate the six matrix entries — is wrong in a way that only shows on rotation.
Decision
1. Blend2D gets an absolute matrix, and no new symbol crosses the boundary
Blend2D’s bl_context_save and bl_context_restore are not on the export
list. The natural implementation of a transform stack — push on the way into a
subtree, pop on the way out — is therefore not available without adding two
symbols to a boundary that has caught the same class of local-symbol bug three
times (--exclude-libs,ALL, then BL_STATIC making BL_API expand to nothing,
then HarfBuzz’s bare HB_EXTERN), each answered only by a CI run across four
targets (ADR-0064).
It turns out not to be needed. bl_context_apply_transform_op is already
exported — it is how the display scale reaches the rasterizer — and its
operation set includes BL_TRANSFORM_OP_ASSIGN, which replaces the context’s
transform with a BLMatrix2D rather than composing onto it. So the stack is
accumulated in Java and each box states its whole matrix. Zero new symbols; one
new enumerator on a call that already crosses.
This is the same trade ADR-0064 made for the rounded rectangle, and it is being recorded as a pattern rather than as a coincidence: before adding to the export list, check whether an already-exported call has an operand that does the job.
The one cost is real and is stated: BL_TRANSFORM_OP_ASSIGN’s operand crosses as
const void*, so nothing on either side checks that six doubles are what it
reads. BLMatrix2D is therefore in the layout registry, and the probe compares
its six offsets against what the C compiler computed for the library actually
loaded. That check is not ceremonial — the named members live in an anonymous
struct inside a union with a double[6], and the upstream header carries a
TODO to remove the union.
2. The matrix type is Java’s, and hit testing inverts that instance
Affine is a record of six doubles with compose, invert, map and decompose. It
lives in :core, not in :natives, and Blend2D never sees it — what crosses is
the six numbers, the same way a BLRect is four numbers rather than a type.
The load-bearing part is what happens to the inverse. HitTest.Region carries
it, and it is computed once, while painting, from the very matrix that was
handed to Blend2D. It is not recomputed on the input path from the same inputs
by different code. That is the whole point: two derivations of an inverse that
must agree exactly is precisely how the silent failure above gets in, and the
second derivation is the one nobody tests because the first one looks right.
Region.contains then maps the pointer backward rather than mapping the
box’s corners forward. That is not only less arithmetic — it is the only version
that is correct, because a box under nested transforms is a general quadrilateral
on screen and a bounding-box test would claim its corners while a corner-mapping
test would need a point-in-polygon routine that is a second implementation of
geometry the painter already did.
A box whose transform has no inverse is dropped from the snapshot.
scale(0) collapses the plane onto a point: there is nothing on screen for a
pointer to be inside of, and the alternative — every point in the window mapping
into it — would route every click in the application to an invisible control.
3. A computed transform is a function list, not a matrix
ComputedStyle.transform() carries the functions an author wrote plus the
origin, and Transform.matrix(width, height) resolves them when the rectangle is
known, inside the paint walk. Two independent reasons:
- Percentages need a box. Above.
- Interpolation needs the parts. Halfway between
rotate(0)androtate(180deg), interpolated entry by entry, is a matrix of zeroes — a box collapsed to a point. Keeping the functions means the common case interpolates the numbers an author wrote, which is both correct and predictable:scale(0.6)→scale(1)isscale(0.8)at the midpoint.
Where the two lists have different shapes, the shorter is padded with the
identity of the other side’s function — so none → scale(1.1) grows from
scale(1) rather than from a zero matrix. That is not a detail: it is the
transition every :hover rule will write, because the resting state has no
transform at all.
Where two functions at the same position are different kinds, the value swaps at the halfway point. CSS resolves that case by multiplying both sides out and decomposing, which needs a box to resolve percentages against, and interpolation runs in the animation overlay — before layout. Nothing in the design system asks for it. A stylesheet that does gets a jump, and this sentence, rather than a shape that is in neither end state.
4. Layout runs first, and the transform moves the result
Yoga never sees a transform. The walk accumulates positions from the layout pass
and applies matrices to what comes out, which is CSS’s rule and is the reason
transform can be on the animation whitelist at all: a control that scales on
hover moves no sibling and costs no layout pass. It is the same argument that
made transition: width a dropped declaration with a warning in
ADR-0067 — and §1.7’s answer to “but I want to move something” has always been
“use a transform”. Now there is one.
Consequences
transformandtransform-originparse, cascade, inherit their effect down the box subtree exactly asopacitydoes, animate through the overlay, and route input correctly.Transitions.Animatablehas its fifth and last member.- No new native symbol. The export list is unchanged; one enumerator and one struct layout were added, and both are checked against the compiled library by the probe.
Animations.Runningnow holds anObjectrather than adouble. Four of the five animatable properties are numbers — a colour is a number, because adoubleholds every 32-bit integer exactly — andtransformis the first that is not. A second map keyed by the same enum was the alternative, and would have givenobserve,apply,settleandcurrentOra second half to keep in step with the first.- The 2D subset only:
translate,scale,rotate,skew,matrixand the axis variants. The 3D functions need a projection the painter has no concept of, andperspectiveon a CPU rasterizer is a different feature wearing this one’s name. emandremin a transform resolve againstCssLength.Context’s fixed numbers rather than the node’s ownfont-size— the same known gap the rest of the cascade has (ADR-0066), inherited rather than newly introduced.
What this does not do
- The check mark still does not scale. §1.7 specifies the checkbox tick’s
animation as “scale 0.6→1 + opacity”, and the opacity half has shipped since
ADR-0067. The scale needs the mark to be a cascade node of its own — a second
part beside
check-indicator— because the mark is drawn onto the indicator’s box and scaling that box would scale the 16px square with it. Adding a part is a decision ADR-0065 took carefully once, and it should be taken carefully again rather than as a side effect of this change. - Layer promotion does not exist, so a frame with an animating transform
repaints the window. §1.7 promotes an animating node to a repaint-boundary
layer so the per-frame cost is compositing only. That mechanism is the same one
CSS group opacity needs (ADR-0064)
and the same one damage tracking wants, and — unlike everything in this record
— it does need new exports:
bl_context_blit_image_dat minimum, andbl_context_set_global_alphato composite the layer at anything but full opacity. That is the reason it is not in here: it is a change to the native boundary, answered by a CI run across four targets, and it deserves the record that goes with one. - None of this has been rasterized off
linux-x64.transforms.pngis the eleventh golden image resting on arcs and matrices that AVX-512, Apple Silicon’s NEON path and MSVC have never drawn. Blend2D JITs its pipelines per CPU; the next CI run is what answers it, which is what the goldens’ per-channel and area tolerance is for (ADR-0050).