Skip to content

Comparison — thirty-eight seams, and what they decide for isCanvas

Coverage: 38 of 38 subjects surveyed. Every subject file is cited below; no question is answered from an unsurveyed subject. Last reviewed: August 23, 2026.

The subjects were chosen to bound a space, not to sample a population: they run from a 1982 paper in which a picture is a function of its bounding box (functional images) to a browser compositor that serialises its display list across a process boundary (Chromium cc::PaintOpBuffer), and from a seam of three constructors that cannot address below a character cell (Notty) to one that hands a compute shader six parallel byte streams (Vello). Eight questions, Q1Q8, are defined in the umbrella; each tests one entry of canvas-seam-friction.md.

Where the 38 agree, the agreement is reported as a finding and the absence of dissent is stated explicitly, because unanimity across 38 subjects is a much stronger claim than unanimity across a handful. Where they disagree, the disagreement is the finding, and the axis that separates the camps is what the proposal has to choose on.


The matrix

38 rows by 8 columns is unreadable. Each question below is a table of distinct answers, with the subjects that give each one. Grouping by answer is what makes 38 subjects legible — and the size of a group is itself evidence.

Q1 — what unit does measurement return, and who answers?

AnswerSubjectsNote
Unshaped text cannot cross the seam at allFlutter (DlText), WebRender (GlyphInstance), Chromium (SkTextBlob), Qt Quick (QGlyphRun), GSK (PangoGlyphString), Avalonia (IGlyphRunImpl), Vello (Glyph{id,x,y}), egui (Galley)The strongest form: the seam has no string to measure. Flutter's Paragraph must be laid out before it may be measured or drawn — measurer and painter cannot disagree.
A free function or fixed table above the seamRatatui (cell_width), Textual (cell_len), Mosaic (code-point count), Vty, NottyVty installs a process-global width table generated by interrogating the terminal, precisely so two consumers cannot disagree. Notty fixes width as a constructor postcondition.
A peer service with its own contractGodot TextServer, Pango, Java2D, Skia, Cairo, GPUI, MonomerGodot's TextServer::has_feature is a live 15-flag capability query — on the measurement seam, while the rendering seam's equivalent is dead code.
Measurement type chosen by the backendSlint (Font::Length), Doodle (type Bounds), iced (Paragraph), piet (TextLayout)Only Slint and Doodle make the unit backend-chosen. iced and piet fix logical pixels; iced's fallback.rs shows the price — two backends compose only if the types are identical.
Removed from the libraryVg, gloss, diagrams, elm-ui, elm-canvas, SDLdiagrams is the negative control: mkText' gives text pointEnvelope origin — "takes up no space" — and the layout engine cannot lay out a string.
On the painterRacket dc<%>, libvaxis (wrapped extent only), imtui (by redefining the unit)The only outright dissent is Racket's get-text-extent, and Racket prices it: a global dc-for-text-size parameter defaulting to a 1x1 Cairo surface allocated to hold metrics.

Q2 — is the backend contract stated in one place?

AnswerSubjectsNote
Total: every member mandatory, nothing to probeFlutter (49 pure virtuals), Chromium (45), Avalonia (27), Monomer (45 record fields), Notty (12 closures), GPUI, gloss, GodotAffordable only when the seam is private or the vendor writes every backend. Avalonia buys it with [Unstable]/[PrivateApi]; Skia's real device seam is in src/, not include/.
Capability declared as dataQt QPaintEngine, wgpu, SDL, WebRender, libvaxis, Mosaic, Vty, Avaloniawgpu types the three kinds apart by refusability. SDL scopes the query to a domain (blend modes, texture formats). Mosaic consumes it at the lowering step, never at a draw call.
Encoded in the language, no capability dataSkia, Java2D, Ratatui, Masonry/imaging, Doodle, diagrams, icedSkia: pure virtual = floor, virtual-with-default = negotiable, return false = refusal. Java2D's floor is the superclass — a backend cannot forget it, because forgetting means not overriding.
Refusal per call, argument-dependentCairo, SDL, Vg, libvaxisCairo's paginated surfaces replay each page in CAIRO_PAGINATED_MODE_ANALYZE and partition it into supported and fallback regions. Vg's warn carries the offending image back.
Prose onlyVg, piet, Qt QuickVg's per-backend "Render warnings and limitations" sections are the best written contracts in the survey and are entirely unchecked.
Nothing statedimtui, elm-ui, Textual, functional imagesTextual makes negotiation unnecessary rather than missing: the whole driver contract is write(str).

Q3 — do semantic widgets reach the backend?

AnswerSubjectsNote
Semantic operations live in the seamSlint (8, incl. draw_box_shadow), GSK (GskBorderNode, GskInsetShadowNode), Qt Quick (private node kinds), Skia (DrawShadowRec), Flutter (drawShadow), Vello, piet (blurred_rect), Pango (draw_error_underline), Godot (CommandNinePatch)Every one stops far below a widget. GSK's admission test is "do the backends disagree about how to draw it?"; Godot's and Vello's is "does lowering need knowledge only the backend has?"
Identity travels with no appearanceWebRender (IS_SCROLLBAR_CONTAINER, one bit), Chromium (DrawScrollingContentsOp, CustomDataOp, AnnotateOp), Cairo (tag bracket), Masonry/imaging (push_context), GSK (GskDebugNode)One bit on a shared header buys WebRender a tile-cache slice barrier. The Scrollbar payload carries a whole widget's state on its own arm and buys less.
Primitive seam, semantics resolved above itJava2D, SDL, Godot, iced (fill_quad), GPUI, egui, Avalonia, Monomer, gloss, Vg, diagrams, Notty, Vty, Mosaic, Ratatui, Textual, libvaxisRatatui, libvaxis and Textual each degrade a scrollbar in the widget, in the toolkit's own vocabulary, in 33–60 lines. libvaxis's whole Scrollbar.zig is 33 lines with one character field.
Zero semantics — and the problem invertsimtuiWith nothing semantic in the seam, the scrollbar has to know what the target is. The bill was a permanent fork of ImGui's widget layer.

Q4 — what shape is a draw command?

AnswerSubjectsNote
Sum type / tagged union with per-variant payloadegui, GPUI, WebRender, SDL, Cairo, Vg, gloss, Vty, Notty, Doodle, elm-canvas, diagramsWebRender's #[repr(u8)] enum serialises as discriminant + that variant's payload, so the union is cheaper than a flat record. SDL groups 11 tags over 4 union arms.
Per-op struct in variable-stride storageFlutter (68 records in a byte arena), Chromium (36 final subclasses), Skia (SkRecord over an arena), Godot (bump-allocated subclasses)The three largest subjects all landed here. Each op pays only for its own fields; Chromium's LargestPaintOp union exists only as a deserialisation scratch buffer.
Open class hierarchyGSK (37 GType kinds), Avalonia (9 node classes), Qt QuickGSK added twelve node kinds since 4.0 without touching a backend, because a NULL vtable entry degrades automatically. A closed sum type makes every backend non-exhaustive on each addition.
Parallel streams / tag-plus-index into side arenasVello (six streams, prefix sum), Masonry/imaging (Command = tag + one id)Vello's own doc/pathseg.md records abandoning a fixed 36-byte element record. The same repository chose a plain Rust enum for its sequential consumer.
The result, not the instructionsRatatui (Buffer), Textual (Strip), Mosaic (TextSurface), imtui (TScreen), Vty (SpanOp rows)Buys diffing, read-back composition (Exact.merge("│","─") == "┼"), hit-testing and value-comparable goldens for free — and does not reach a GPU.
Generated from the method setRacket record-dc%A define/record macro emits both a replay closure and a serialisable (list 'name arg ...). One declaration, no artifact that can drift.
Nothing reifiedSlint, Qt QPaintEngine, Java2D, Pango, piet, iced, elm-ui, functional imagespiet's trait doc anticipates a recording context; nobody ever wrote one, and cross-backend verification degraded to tolerant per-OS PNG diffs.
Reified but unusableMonomer (a queue of IO () closures), imtui (untagged ImDrawCmd), elm-canvas (opaque {type, name, args})Three ways to reify and cash nothing. imtui's discriminant relocates into a UV-equality heuristic; a heuristic can be wrong where a tag cannot.

Q5 — how is sub-unit placement expressed?

AnswerSubjectsNote
Continuous coordinates — and the problem still arrivesGSK (GskSnapDirection, Since: 4.24), Pango (pango_quantize_line_geometry), GPUI (snap_stroke), Java2D (minPenSize + normPosition + KEY_STROKE_CONTROL), Avalonia (layout rounding by dpiScale), Racket (set-alignment-scale), iced (Quad::snap), imtuiSix of these add a named policy to a float seam. Pango's contract is "at least one pixel"; GTK's is grow / shrink / round, each documented by the artifact it avoids.
Name a fidelity, not a positionNotcurses (blitter ladder), Flutter (strokeWidth == 0), Skia, Racket (pen width 0, device answers), SDL (SDL_HINT_RENDER_LINE_METHOD), Ratatui (symbols::Marker), Godot (SubpixelPositioning), functional images (Henderson's ε)Arrived at independently from a 1982 paper, a terminal library, a GPU rasteriser and a PostScript device. The strongest form is Flutter's: an ordinary parameter value, not a separate enum.
Query the device unit and express the answer in iticed (1.0 / hint_factor()), libvaxis (DrawContext.cell_size, pixel_offset, Cell.Scale), SDL (SDL_SetRenderLogicalPresentation)libvaxis is the second cell-native sub-cell library and it does not copy Notcurses: it hands every widget the pixel size of a cell and routes finer content through a negotiated protocol.
An accumulator that composes across opsTextual (Quad + combine_quads)A per-cell line weight per compass direction, resolved to a glyph once at render. Two ops touching one cell can agree; RuleEdge's edge-per-op cannot.
Derive fidelity from the accumulated transformgloss (circScale, circleSteps)Only available while transforms are still nodes. A pre-flattened absolute-coordinate stream has thrown the scale factor away.
Refuse, and substitute a glyphVty (clipText inserts '…'), Notty (space for a severed wide cluster), MosaicMosaic knows the terminal's pixel size and the Kitty sizing flags and deliberately keeps all of it out of DrawScope.

Q6 — resolved appearance, semantic role, or both?

AnswerSubjectsNote
Resolved only, on the opFlutter, Chromium, GPUI, GSK, Vello, egui, Godot, Monomer, Doodle, Vg, Notty, VtyThe majority answer, and every one of them lacks a re-resolving consumer. Broadway is GSK's Browser target and gets no semantic help at all.
Resolved, but the leaves are symbolicRatatui, libvaxis, Vty (Color = Default | Indexed | Rgb)One 4-byte field holds either a role or a value; the terminal re-resolves against the user palette past every backend. One field doing what a stored Slot beside a resolved Ink does with two.
Role by op identity, value in stateQt Quick (node type + QSGMaterial), Pango (PangoRenderPart per call, colour in renderer state)In a sum type the tag is the slot. Qt's NodeType is too coarse and the software backend pays a five-deep dynamic_cast per node per frame.
Appearance as stream or device state, not op payloadSDL (SDL_RENDERCMD_SETDRAWCOLOR, deduplicated), Racket, Java2D (pipe recompiled per state change), Flutter (delta-encoded setColor)Cheapest per op, and structurally incompatible with an order-independent, pairwise-comparable op stream — i.e. with RecordingCanvas. A genuine fork, not a defect.
A late-bound handle into a per-frame tableMasonry/imaging (BrushIndex), WebRender (PropertyBinding)"Enables updating of brush details without performing relayouts." Strictly better than carrying both channels.
Style as a scope, not a fieldgloss (Color c p wraps a subtree), elm-canvas (mergeDrawOp lattice join, save/restore bracketing), Mosaic (Unspecified sentinels compose in the cell)Per-op resolved appearance is a cost of flatness, not of hedging. Adopting this requires the display list to retain grouping structure, which a flat DrawOp[] discards.
A second channel that buys something the first cannot expresswgpu (Limits + DownlevelFlags), Avalonia (ServerPen + ClientPen, by thread affinity), Vg (glyphs + text, on one constructor of five), Skia (SkPaintFilterCanvas decorator), Cairo (tag bracket)The rule is not "never two channels" — it is never the same fact twice. Avalonia's second field buys a capability; ours buys a consumer.

Q7 — who owns a command's payload?

AnswerSubjectsNote
Copy by value into the record's own storageSkia, Cairo (snapshot at record time), WebRender (inline, read back as ItemRange into the list), SDL ((first, count) into a per-frame arena), Flutter, PangoThe most performance-obsessed 2-D library in existence pays a copy or an atomic bump on every op. That removes performance as a defence of a borrowed DrawOp.text.
Reference countQt, egui, Flutter, GSK (atomic), Avalonia (IRef + a one-bit transfer flag), Chromium, Vello, icedGSK's atomic refcount is what makes the GPU renderer's Cairo fallback legal — the node deliberately outlives the frame walk.
A backend-owned cache keyed by identitySlint (draw_cached_pixmap), GPUI (PlatformAtlas), Java2D, Vg, Notty (ephemeron memo), gloss (makeStableName), Doodle (the backend's own command type is the cache)Doodle's variant is the sharpest: an expensive backend-specific measurement is computed at layout and travels to paint time inside the backend's own command type.
A key into an out-of-band storeWebRender (ImageKey), Chromium (PaintCacheId, mailbox, transfer cache), Godot (RID)The only answers that survive IPC and a thread hop. Chromium chose a mirrored cache over refcounting because it "avoids the need for cross-process ref-counting".
Weak reference plus inline geometryiced (paragraph::Weak { min_bounds, align_x, align_y })A dropped payload degrades to "nothing drawn" rather than dangling, and damage tracking still has the rectangle.
Borrow for the frame, own the rasterlibvaxis (Cell borrows, InternalCell owns), imtui (TScreen)Legal precisely because the only thing outliving the frame is a copy in a type that owns — and only because a cell grid is small and self-contained.
A declared synchronisation windowQt Quick (updatePaintNode with the GUI thread blocked)Enforced by prose, not by types.
Move the scene, not the commandsMonomer (Either Renderer (TChan (RenderMsg s e)))The Renderer never leaves the GL thread; an immutable widget tree is what crosses. Possible because the seam is a value.
Immutable and GC-managed — the question dissolvesTextual, Mosaic, Vty, Notty, gloss, diagrams, Doodle, elm-ui, elm-canvas, functional imagesThe null result, and worth stating: DrawOp.text's hazard comes from choosing a borrowed slice, not from reifying a command.

Q8 — can the scene answer its own extent?

AnswerSubjectsNote
Yes — cached on every node, maintained at constructionGSK (bounds on the base node), Vty (imageWidth/imageHeight, O(1)), Notty (dim per composite), Racket (pict's eager width/height/ascent/descent), Avalonia (Rect? Bounds per node, union cached behind _boundsValid), diagrams (Envelope)Cheap because it was never derived: the constructor is the only place it could be computed, so it costs one addition per node.
Yes — accumulated during recordingFlutter (AccumulateOpBounds), Cairo (cairo_recording_surface_ink_extents), Godot (Item::get_rect() behind rect_dirty), Chromium (DisplayItemList::bounds() from producer-declared visual rects)Cairo computes it by replaying the scene into a measuring backend — RecordingCanvas's role generalised into a measurement service.
Yes — layout already knew itDoodle (Finalized.boundingBoxSize.FitToImage), Mosaic (the measured root sizes the surface), libvaxis (Widget.draw returns a Surface.size), Textual (get_optimal_width/get_height), Ratatui (Buffer::with_lines), Monomer (widgetGetSizeReq)libvaxis's is the cheapest mechanism in the survey: make paint return a size. Mosaic's terminal size never sizes the surface — content decides.
Deliberately refusedSkia (cullRect() is the caller's argument), GPUI (BoundsTree private), WebRender, Vello (root), Vg, Masonry/imagingSkia is the strongest witness for refusing: it holds per-op bounds and a spatial index of them and still exposes only approximateOpCount(). WebRender's reason is stronger — items are explicitly allowed to be logically infinite, so extent-by-scanning is unsound.
The extent is an input to the sceneVg (renderable = size2 * box2 * image), functional images (p(a,b,c)), elm-canvas (toHtml (width, height) …)Henderson makes the box an argument, so the query can never arise. That is the fix for §8 stated as an architecture rather than an API.
The surface declares it and nothing else doesQt QPaintEngine, Notcurses, SDL, Java2D, iced, gloss, imtui, egui, elm-ui, pietegui derives extent from primitives, which is what skia-canvas-render.d does — and egui is the only subject in this camp that reaches the number the same way.

Findings

F1. Text measurement is not a method of the drawing seam

Support: 35 of 38. Every subject that measures at all puts advance measurement somewhere other than the painter, by six distinct routes: shaped payloads that make the question unaskable (Flutter, WebRender, Chromium, Qt Quick, GSK, Avalonia, Vello, egui); a peer service (Godot, Pango, Java2D, Skia, GPUI, Monomer); a free function (Ratatui, Textual, Mosaic); a process-global table (Vty); a constructor postcondition (Notty); or removal from the library entirely (Vg, SDL).

Dissent, and it is real: Racket's get-text-extent is a method on dc<%>. It is the only outright counter-example in 38 subjects, and it is a priced one — pict cannot measure without a device, so it carries a global dc-for-text-size parameter whose default is a 1x1 bitmap surface allocated purely to hold font metrics, and errors with "no dc<%> object installed for sizing" when it is unset.

Two qualifications. libvaxis puts wrapped extent on the painter deliberately: Window.print(.{ .commit = false }) is a dry run returning PrintResult { col, row, overflow }, because wrapping needs the target rect and a width function cannot answer it. imtui keeps ImGui's measurement on the painter and survives only by redefining the unit (SizePixels = 1.00, every glyph quad forced to one unit wide and zero tall).

So the claim is not "unanimous"; it is "the ordinary answer, with one dissenter that documents its own cost, and one exception for wrapping". Friction §1 is confirmed on placement.

F2. Relocating measure is necessary and nowhere near sufficient

Moving measurement off the painter leaves five independent decisions, and the survey shows each one being got wrong somewhere.

  1. Relocate, do not delete. diagrams removed measurement and gives every Text pointEnvelope origin — "takes up no space, as text size information is not available" — producing a layout engine that cannot lay out a string. elm-ui can only get away with the same because its single backend is a complete constraint solver; neither a cell grid nor Skia will line-break for us.
  2. One unit is not one oracle. Ratatui has exactly one unit and ships two width functions that disagree on U+FF9E/U+FF9F. libvaxis makes the oracle a negotiated capability (gwidth.Method = unicode / wcwidth / no_zwj, measurably disagreeing in its own tests) plus an unverified per-cell caller override. Vty responds by making the oracle a process-global table generated from the terminal, precisely so two consumers cannot disagree.
  3. The unit need not be backend-chosen. That is Slint's and Doodle's answer, not the field's. GPUI, iced, Avalonia, Textual and piet all fix one unit, and iced's fallback.rs prices the alternative: two backends compose only if their Font, Paragraph and Editor types are identical.
  4. Size is the wrong return shape in any unit.Cairo and DirectWrite both separate the inked box from the advance (cairo_text_extents_t vs x_advance; width vs widthIncludingTrailingWhitespace), and Pango returns ink and logical extents accumulated by deliberately different rules — a zero-ink glyph contributes nothing to the ink rect and still reserves advance. Flutter's Paragraph additionally answers intrinsic widths, baselines, per-range boxes and getPositionForOffset, none of which a Size measure(text) can express: the replacement should be an object, not a function.
  5. Measurement is device-parameterised even when it is device-independent.Pango keys its font map on the merged cairo_font_options_t and rounds extents to whole device pixels whenever metrics hinting is on — which is why GSK's gsk_get_glyph_string_extents has to opt out. Java2D expresses the same as an immutable FontRenderContext used as a cache key. Racket names the idea outright: get-font-metrics-key returns a metric-universe tag (1 for the default backend, 2 for PostScript, 3 for SVG, 0 when scaled), so a mismatched measurer is detectable — and nothing checks it. Microsoft names the regime as a value (DWRITE_MEASURING_MODE); sparkles:ui has exactly one regime — cells — and no name for it.

And the measured artifact should be the painted one.Flutter forbids unshaped text from crossing the seam; Masonry's Label caches built Parley layouts keyed by the constraint they were built under and measure, layout and paint all read the same cached value; Vello's glifo states the goal of sharing "the hinting instance and hinted advance" between shaper and renderer. Mosaic is the counter-example that proves layering and correctness are independent: perfect placement, and a code-point count with no wcwidth, no East Asian Width table and no grapheme segmentation anywhere in the repository.

F3. Reification is right; the encoding is a live trade

Reifying buys four separable properties, and sparkles:ui cashes all four. Recording, replay, culling and comparison: RecordingCanvas collects a DrawOp[] of plain values, interp/immediate.d replays one against any conforming canvas, the display list culls hidden subtrees before a backend sees them, and the op-stream parity harness compares two targets' streams pairwise. That the four are separable is Monomer's lesson — a queue of IO () closures is reified and buys ordering alone — and elm-canvas's: proper sum types above the seam, lowered to a stringly-typed {type, name, args} at it, cash replay and nothing else.

Not reifying is expensive, and no subject argues otherwise.Henderson shows the cost of dropping it — with no comparable stream he relies on idempotent overdraw ("it doesn't matter if we draw the same object twice"), which is fine for a plotter and useless for a golden test. piet is the empirical case: nothing is reified, its trait doc anticipates a recording context nobody wrote, and cross-backend verification degraded to tolerant per-OS PNG diffs against an out-of-tree snapshot submodule.

The encoding is the live trade, and the field is split down it. DrawOp is a closed sum: a struct over SumType!(FillRect, TextRun, Glyph, Line, Rule, Scrollbar, PushClip, PopClip), dispatched by op.match!, with kind derived through those same eight arms and static assert(DrawOp.sizeof <= 64) bounding every operation by the widest payload, TextRun. Twelve subjects encode the same way, from WebRender's #[repr(u8)] display item to Vty's image constructors. The three largest reifying subjects encode the other way — Flutter (68 exactly-sized records in a byte arena), Chromium (36 final subclasses at ComputeOpAlignedSize<T>() bytes) and Skia (SkRecord over an SkArenaAlloc), joined by Godot (placement-newed subclasses in 4 KiB blocks that "always grow but never shrink") — and all four generate the enum, the structs and the visitors from one macro list.

PropertyClosed sum (sparkles:ui, WebRender, SDL, egui, Vty, Doodle, …)Variable-stride per-op records (Flutter, Chromium, Skia, Godot)
Illegal combinationsUnspellable: an arm holds only its own fields, so there is no trackGlyph to read on a LineUnspellable per record too, but tag and bytes are separate artifacts joined by a cast
Cost per operationThe widest payload's, always — a PopClip carrying no fields occupies what a TextRun occupiesIts own, exactly. Chromium's union over all op types exists once, as a deserialisation scratch buffer
ComparisonPairwise and structural, for free — which is what makes RecordingCanvas a parity oracleA walk of tag-and-offset cursors; Flutter buys equality back with a bulk memcmp in DisplayList::Equals
ExhaustivenessThe compiler's, through match! and final switchA hand-maintained switch, guarded by static_assert(kNumOpTypes == TYPES(M))
Adding an operationEvery walker and every accessor goes non-exhaustive at onceA new record leaves existing visitors compiling

The stride objection is priced, and at our scale the budget answers it.Flutter, Godot and Chromium each argue that a flat arena of exactly-sized records is the better encoding, with real mechanism behind it — ComputeOpAlignedSize<T>(), alloc_command<T>, Push<T> with variable-length text inline after the record. What the objection recovers here is the spread between the narrowest arm and 64 bytes, because the widest payload fits the budget; what it spends is an operation's value semantics, and those are what RecordingCanvas and the parity harness are built on — the friction log lists both among the things that are working. The trade reads differently at four thousand operations bump-allocated and reset inside a frame than at a page's display list serialised across a process boundary: the bytes Chromium recovers pay for an IPC, and the value semantics it gives up it does not need, because its parity story is pixels. Masonry/imaging reaches the same objection from storage rather than stride — a tag plus one index into typed side arenas, sized per kind, Scene: PartialEq over the whole thing — and the budget does not answer that half: what it costs is that an operation stops being self-contained, so pairwise comparison becomes a scene walk and composing two streams means rebasing ids.

Five refinements the split does not settle.

  • Width is not the defect; a tag over width is. wgpu keeps a ~60-field flat Limits record where most fields are zero on any target, and it is fine, because it is tagless (0 means "none available", not "garbage for this variant"), its field list exists once in with_limits!, and a test proves the macro exhaustive. A closed sum reaches the same place from the other side: each arm holds only its own fields, so there are no dead bytes under a discriminant. What it pays is uniform stride, not waste per field.
  • A few bytes are worth a whole op type to the other camp. Chromium ships DrawLineLiteOp/DrawArcLiteOp, differing from their siblings only by carrying a smaller CorePaintFlags, each with an open TODO. Flutter splits DrawPoints/DrawLines/DrawPolygon rather than widen a record: "The point type is packed into 3 different OpTypes to avoid expanding the fixed payload beyond the 8 bytes." Uniform stride is a real cost to people who measure it.
  • A sum relocates illegal states rather than eliminating them.elm-canvas is a total language with proper unions and still documents settings that silently do nothing: maxWidth matches all five Drawable constructors and returns four untouched. The residue here has the same shape and is visible in the accessors — DrawOp.slot answers Slot.inherit for the clip pair, the bar* readers answer neutrally on arms that are not scrollbars, and visualOf is lossy on purpose. That is a caveat about what any sum can promise, not an argument against choosing one. Textual's one-variant Segment grew a tag-shaped dead field (control, None on every drawing segment, filtered before measuring) and libvaxis's tag-free Cell carries image and scale that are null and identity for nearly every cell: an encoding removes illegal combinations, not rare ones.
  • Arm count is its own axis, and the budget does not answer it.SDL groups eleven command tags over four union arms; diagrams pushes variability into a Prim existential under four constructors; Masonry/imaging runs seven variants over five side arenas. Eight arms means eight match! arms in every walker and in each of the seventeen member accessors, each written out because a non-answering arm's neutral value is a per-accessor decision that no field list implies. FillRect, TextRun, Glyph and Line differ little enough that SDL's shape would fit them, with Scrollbar the one payload that genuinely does not. What collapsing costs is exhaustiveness — the discriminant moves inside an arm, where the compiler stops checking it — and, if a payload is type-erased in the process, RecordingCanvas's pairwise comparison. What it does not buy is size: TextRun sets the budget either way.
  • Untagged is worse than tagged. imtui's ImDrawCmd has no dead fields because it has one variant, and the discriminant relocates into a UV-equality heuristic plus an unchecked i += 3 assumption about the producer's tessellation. A heuristic can be wrong where a tag cannot.

And an open hierarchy extends better than either. GSK added twelve node kinds since 4.0 without touching a backend, because a NULL vtable entry degrades automatically; a closed sum makes every walker non-exhaustive on each addition, which is a compile error rather than a silent gap and is the reason the eight arms are a maintenance cost worth naming. The countervailing cost is Qt Quick's: a seven-value NodeType too coarse to dispatch on, and a five-deep dynamic_cast ladder per node per frame ending in // We dont know, so skip.

Two shapes worth taking whichever encoding wins.Vty exports its sum abstractly — constructors hidden, horizJoin pads with BGFill before building — so an unequal-height join is unconstructible. DrawOp's payloads are publicly constructible, so "a TextRun's rect.width is its advance in cells" and the PushClip/PopClip pairing are conventions rather than invariants; both are load-bearing, the first for every consumer that folds op.rect, the second for every clip-aware backend. Racket generates the recorder from the method set, so the seam and the reified stream cannot drift.

F4. The axis is not semantic-vs-primitive but where the lowering lives

The question a seam actually decides is not whether an operation is semantic but where its degradation is written. The survey finds six answers, and several subjects occupy more than one.

Where degradation livesSubjectsMechanism
In the backendSlint, Doodle, Skia, FlutterThe seam is semantic and each backend decides
In the frameworkQt, Avalonia, Godot, SDL, Java2DOne emulation or lowering, written once. SDL lowers unconditionally; Qt only on refusal
In the node kindGSKEvery GskRenderNodeClass ships a Cairo draw vmethod; the fallback travels with the kind — which is why the whole Cairo renderer is 228 lines
In the producerWebRenderpush_shadow/pop_all_shadows desugar inside the builder, "replacing the scene builder's shadow expansion"
In the widgetRatatui, Textual, libvaxis, Monomer, gloss, MosaicThe scrollbar resolves its own geometry and picks its own glyphs before any target exists
NobodyGPUI, Notty, Vty, functional images, elm-canvasAvailable only with one target class

piet adds a seventh position that is really a policy: the framework publishes the fallback (piet::util::compute_blurred_rect) and each backend chooses whether to call it — two do, one uses a native effect, one uses the Canvas shadow, one silently drops the blur. Skia occupies two camps at once: semantic ops in the seam and the default lowering shipped in the framework base class.

Three admission tests were found, and they agree. GSK: "do the backends disagree about how to draw it?" Vello and Godot: "does lowering it require knowledge only the backend has?" Doodle: "an operation that is semantic and unserviceable by some backends becomes its own capability, not another member of the shared seam."

Applied to scrollbar: the semantics pass every test. A cell backend genuinely degrades a scrollbar differently from a pixel backend, which is why content extent, viewport, offset and edge cross the seam at all, and Qt Quick proves geometry-only vocabularies are portable only among like backends — its software renderer silently drops a custom QSGGeometryNode, the documented public way to add content. The lowering sits where the tests want it, too: scrollbarThumb in sparkles.ui.state is the one rail formula, canvas.d re-exports scrollbarCellCount, scrollbarCell and ruleEndpoints over it, and interp/immediate.d applies paintScrollbarCells glyph-per-cell for any backend that declines the primitive — piet's published-fallback shape and Skia's framework-default shape at the same time. Every geometric field the payload carries is an input to that formula rather than a result of it: no rail rectangle crosses the seam.

What fails the tests is trackGlyph and thumbGlyph. They are not inputs to a shared lowering; they are one target's lowering already performed — the cell answer, chosen above the seam and then carried past every backend that will never read it. GSK's semantic nodes carry geometry and colour and never a fallback glyph, and Ratatui keeps exactly that vocabulary, a small glyph record, in the widget. The rule the field applies is that a semantic operation may cross a drawing seam and a particular target's resolved answer may not. Friction §3 is therefore half right about the layering smell, and exact in its own closing sentence about which half it is.

And zero semantics is not the safe choice. imtui is the experiment: retarget a pure-geometry seam to cells and degradation has nowhere to live, so it migrates upward into a permanent fork of the widget layer (RenderArrowAddText("<"), RenderCheckMarkAddText(symbol), CloseButtonAddText("[X]"), table borders → for loops of pipe characters). A second cell backend with different capabilities would need a second fork.

F5. Optional capability is cheap, and D already has every construct it needs

The obvious model for "a stated floor and a refusable degrade" is Qt's PaintEngineFeature — a bitmask of what a backend will draw. Two results argue against reaching for it.

Qt itself abandoned the model. Qt Quick deleted PaintEngineFeature and did not replace it: QSGRendererInterface reports API identity, QRhi::isFeatureSupported reports hardware, and nothing anywhere asks "will you draw this". The replacement policy is documented silence — "any attempts to use unsupported features are ignored".

And the distinction needs no capability data at all.Skia encodes the whole contract in C++ constructs the compiler checks: pure virtual = floor (10 of 26 SkDevice draws), virtual-with-a-working-base-lowering = negotiable (drawArcdrawPath), return false = refusal (drawBlurredRRect). All three exist in D today. Java2D goes further and makes the floor unforgettable: it is the superclass, so MTLSurfaceData.validatePipe calls super.validatePipe for every state it cannot accelerate and getMaskFill returns null so "the validation code will choose a more general software-based loop". A backend cannot forget the floor, because forgetting means not overriding.

Refusal likewise has cheaper shapes than a flag. Ratatui's trait Backend { type Error; } makes every method return a Result so refusal is typed at every call site, unforgettably. Masonry/imaging shows the ergonomic form: sink methods return (), the backend records set_error_once(Error::UnsupportedFilter), and finish() -> Result surfaces an enumerated RenderUnsupportedError — refusal deferred and stream-scoped, because making every draw call fallible poisons the authoring API. Cairo makes it argument-dependent (CAIRO_INT_STATUS_UNSUPPORTED per call, with a whole ANALYZE replay pass partitioning a page into supported and fallback regions). Vg reports the offending value back, not merely a code.

Four further refinements:

  • Capability belongs to a domain, not to a global probe, and is consumed at the lowering step, not at each call site. SDL splits a hard-coded floor of seven blend modes from a per-driver query and refuses with SDL_Unsupported() rather than approximating; texture formats are a separate domain. Mosaic's thirteen-field Terminal.Capabilities is excellent and is read exactly where cells become bytes — never by drawing code.
  • Refusability is a type, not a value. wgpu has three capability kinds: Features (requestable, refused loudly), Limits (requestable, refused with the number), DownlevelFlags (observable, with norequired_downlevel_flags field, so an impossible demand is unrepresentable). It also closes the grant above: "Exactly the specified set of features, and no more or less, will be allowed" — not requesting a capability forbids it even on hardware that has it, which turns cross-backend parity from a discipline into a consequence of the seam.
  • Observability may substitute for refusal. GSK has no refusal flag and instead paints every Cairo fallback with a pink checkerboard under GSK_DEBUG=cairo, documented as letting application code "detect where it is using Cairo drawing". Capability is otherwise asserted at test time via per-renderer expected-failure tables. libvaxis adds an override channel (VAXIS_FORCE_WCWIDTH, VAXIS_FORCE_UNICODE, VHS_RECORD, NO_COLOR) that can pin or downgrade a negotiated capability — worth more than refusal for a repository with PTY and GUI golden oracles.
  • Static checking does not prevent the silent degrade, and prose contracts rot. diagrams makes an unsupported primitive a compile error at the use site and still documents render _ _ = mempty as a courtesy instance the type system cannot distinguish from a real one; worse, its cairo backend declares Renderable (DImage Double External) and then refuses anything but PNG at run time with a putStrLn. Vg's written limitation sections are the best in the survey and nothing fails if one rots — an argument for generating ours from a RecordingCanvas conformance run.

Dissent worth recording: Flutter, Chromium, GPUI, Godot's render seam, Notty and Textual ship zero negotiation and are fine, because their seam is total or narrow enough that every primitive degrades to nothing. Chromium inverts the direction entirely: the backend's abilities arrive as plain fields on SerializeOptions, while the stream declares its own needs via predicates computed at push time (has_draw_text_ops_, num_slow_paths_up_to_min_for_MSAA_). Nobody probes anybody.

F6. Continuous coordinates relocate the sub-unit problem; they do not dissolve it

Floating the coordinates is the obvious escape from friction §5. Two subjects falsify it outright, and six corroborate the falsification.

  • GSK has had float coordinates since 4.0 and GTK is adding GskSnapDirection (NONE/FLOOR/CEIL/ROUND) to the seam at 4.24 — four directions packed per node as a GskRectSnap, with three named compositions (GROW, SHRINK, ROUND) each documented by the artifact it avoids: seams, overlap, or fitting the allocation.
  • Pango measures in 1/1024 of a device unit and still ships pango_quantize_line_geometry, which quantises thickness and position "to whole device pixels … to avoid such lines looking blurry" and takes care "to make sure thickness is at least one pixel".
  • imtui is the negative control: continuous floats over a discrete device, and the problem becomes untypeable and is answered with scattered magic constants — ScrollbarSize = 0.5f, GrabMinSize = 0.1f, p_max - ImVec2(+0.1, 0.1), a clip rect narrowed by - 2.1f, a circle drawn at radius 0.1f to be invisible. Those are rounding steering, not geometry.

Corroborating: GPUI's three role-differentiated rounding functions (snap_bounds, snap_stroke — "clamps any non-zero input up to 1 dp so thin strokes do not disappear" — and cover_bounds); Java2D's triple of minimum feature size, snap tolerance and snap policy (minPenSize, normPosition, KEY_STROKE_CONTROL, bounded by a javadoc contract that normalization "should never move the coordinates by more than half a pixel"); Avalonia's layout rounding parameterised by dpiScale and performed above the seam; Racket's set-smoothing / set-alignment-scale; iced's Quad::snap.

What replaces RuleEdge is three mechanisms, not one enumerator.

  1. A named fidelity rather than a position — the survey's most-converged answer, reached independently by Notcurses (blitter ladder), Flutter and Skia (strokeWidth == 0 means hairline, the backend decides what that is — Impeller clamps to one device pixel and pays the shortfall in alpha coverage), Racket (pen width 0, 1/sx on screen and 1/(4*cx) for PostScript), SDL (SDL_HINT_RENDER_LINE_METHOD, three defensible rasterisations named as user-settable policy), and Henderson in 1982 (a single render-time ε below which nothing is drawn).
  2. Query the device unit and express the answer in iticed's 1.0 / renderer.hint_factor() is one device pixel in the toolkit's own unit; libvaxis hands every widget cell_size and routes anything finer through real device units under a capability gate. This needs no new toolkit vocabulary at all.
  3. An accumulator where ops composeTextual's per-cell Quad (a line weight per compass direction) combined by combine_quads and resolved to a glyph once at render. RuleEdge names one edge of one rect on one op, so two ops touching a cell cannot agree; an accumulator is what box-drawing joins actually need.

WARNING

Do not delete RuleEdge before the replacement exists. Its six enumerators are a finite, named, greppable set; imtui shows what un-named sub-unit steering degenerates into, and it is strictly worse.

F7. Extent is three questions, and most subjects answer at least one of them from the scene

Extent looks like the surface's property, and the offscreen case looks narrow. Twelve subjects publish extent from the scene instead, and the axis that separates them from those that refuse is a different one entirely.

Scene-side extent ships in: GSK (bounds on the base node; gsk_renderer_render_texture(renderer, root, NULL) is documented "NULL to use root's bounds" — friction §8's exact case), Vty, Notty, Racket, Avalonia, diagrams, Flutter, Cairo, Godot, Chromium, Doodle, Mosaic, libvaxis.

And is deliberately refused by: Skia — the strongest possible witness for the surface-only answer, since it computes conservative per-op bounds and builds a spatial index of them and still exposes only approximateOpCount() and approximateBytesUsed(), with cullRect() returning "bounds passed when SkPicture was created" — plus GPUI (its BoundsTree is private and used only to assign draw order), WebRender, Vello at the root, Vg and Masonry/imaging.

What separates the camps is not surface-vs-scene. It is maintained-at- construction versus derived-by-scan. Every subject that publishes the number computed it for another reason and did not throw it away: Vty and Notty compute it in the constructor (one addition per node), Doodle and Mosaic get it from layout, Godot caches it behind a rect_dirty bit set by alloc_command, Chromium takes it from producer-declared visual rects, Flutter accumulates it during recording using the op-flags table the recorder already consults. Every subject that refuses either cannot bound its ops in principle — WebRender states that items are "logically infinite" and bounded only by their clip_rect, so extent-by-scanning is unsound, not merely inconvenient; Vg's image denotes the infinite plane — or has a caller-declared rect it can clamp to (Skia).

skia-canvas-render.d scans because buildDisplayList discards a number that layout already had. That is the derived-by-scan failure mode, and imtui supplies an accidental demonstration of how quietly it fails: drawTriangle's bounding box seeds ymin with screen->size() (the cell count) where screen->ny was meant, and it is harmless only by accident.

Two details the friction entry does not name. Extent without its scale is not actionable at a seam that spans units — Avalonia's RenderTargetSceneInfo carries PixelSize, Scaling and LogicalSize together, and Direct2D's GetImageLocalBounds is a method on the context, not the image, because the bounds "reflect the current DPI, unit mode, and interpolation mode". And extent is genuinely three questions: Racket ships all three separately — surface (get-size), layout (pict-width/height/ascent/descent) and ink (get-ink-extent, opt-in, backed by cairo_recording_surface_ink_extents, justified against exactly friction §8's failure: it "takes into account the visible effect of drawing with different pen widths and the shape of drawn text, as opposed to just collecting path coordinates and nominal text extents").

The cheapest mechanism in the survey is libvaxis's: make paint return a size. The most architectural is Henderson's: make the box an argument, so the query can never arise.

F8. Nothing borrows a payload across a frame — no dissent in 38 subjects

Every subject that could borrow either owns, copies, refcounts, caches, or keys out of band; the ones for which the question dissolves do so because their language is garbage-collected. There is no counter-example.

The eight mechanisms are tabulated in the Q7 section of the matrix above. sparkles:ui is in the first of them. CmdBuffer.textRun copies each run into a frame arena — FrameArena bump-allocates over never-moving pureMalloc chunks and keeps them across reset(), GcArena idups and treats reset as a no-op — and RecordingCanvas interns on the collected heap so its operations outlive the call that drew them. Nothing in the stream points at a caller's buffer, which is what makes a scope source safe to draw from. What the operation holds is a 16-byte const(char)[] into that arena, valid while the buffer that built it is alive and unreset: a rule stated on the type and enforced by the buffer being move-only, so a copy cannot hand out a second set of live pointers.

Four results bear on that anchor.

  • Performance is not what the choice is about. Skia stores SkPath by value and copies POD arrays into the record's own arena; Skia's own accounting is that appending costs 8 + sizeof(T) bytes. The most performance-obsessed 2-D library in existence pays a copy or an atomic bump on every op. The copy is not the cost to defend against; the question is what the payload points with.
  • The stronger form of the same copy is an offset pair.WebRender writes its payload inline and reads it back as an ItemRange into the list; SDL copies into a per-frame arena and hands out (first, count). An operation that names a position rather than an address is trivially copyable, needs no cast to survive a lifetime analysis, and travels to another thread with its arena. imtui's VtxOffset/IdxOffset are the same property in a library with no lifetime analysis to satisfy — which is exactly the property the two @trusted islands of friction §4 exist to stand in for.
  • The anchor decides what else becomes possible. GPUI's Window::reuse_paint splices an unchanged element's primitives forward out of the previous frame's scene by index range; Vty retains the reified result across frames in prevOutputOps and diffs against it. Both need a payload that outlives a reset, which is the boundary UI-O4 holds open: CmdBufferT!(FrameArena!()) and CmdBufferT!GcArena yield the same DrawOp, so a walker's signature cannot say which kind of text it was handed (libvaxis, wgpu both press this from their own mechanisms).
  • The thread question can be answered by the artifact.Flutter's DisplayList::isUIThreadSafe() is conjoined during recording from each payload's own declaration — the seam does not force everything to be shareable, it lets payloads declare their constraint and lets the finished list answer one question. That is precisely what UI-O4 / friction §7 asks for at M7/T5.

The cheapest answer depends on the payload. For a cell-bounded grapheme, Ratatui copies into inline small-string storage. For variable-length runs, the offset pair. For anything that must cross a process boundary, only the out-of-band-key answers survive — and no surveyed subject retains a raw pointer into storage it does not control.

F9. Nobody carries resolved appearance and semantic role on every op

No dissent: not one of the 38 pays for both channels per operation. The seven cheaper encodings are tabulated in the Q6 section of the matrix above. Three deserve naming because they fit sparkles:ui specifically.

  1. The tag is the slot. Qt Quick carries the role as the node's type and the value as its material. Fused with F3 this becomes one change instead of two: a variant per semantic op makes the discriminant carry the role and leaves the resolved fields as the only styling channel. Qt also shows the failure mode — a NodeType too coarse to dispatch on forces a dynamic_cast ladder, so the variant set has to be fine enough to be the role.
  2. Resolved values whose leaves are symbolic. Ratatui, libvaxis and Vty all use one colour type that can hold Default, a palette index, a named ANSI colour, or an RGB triple. One field holds either a role or a value, and the terminal re-resolves against the user's theme past every backend. That is what one channel looks like when a resolved colour can name a palette entry instead of a triple.
  3. A separate, ignorable channel. Cairo's tag is its own vtable slot, its own recorded command kind and its own public API, emitted once per styled region rather than per primitive; a backend leaving the slot NULL ignores it and still paints correctly. Masonry/imaging ships literally this, with a Slot context kind, in a channel documented as not affecting pixels.

The rule is not "never two channels". wgpu carries Limits and DownlevelFlags with no duplication because the semantic channel is not recoverable from the resolved one; Avalonia carries two pens because its two consumers run on different threads; Vg carries glyphs and text on one constructor out of five, with a named failure (`Textless_glyph_cut) when a backend needed the channel it did not get. The rule is never the same fact twice, and a second channel must buy something the first cannot express.

The asymmetry decides which channel is the redundant one, and the seam demonstrates it in both directions. Each payload stores the resolved fields its own primitive paints from — a 16-byte Ink on the four content primitives, FillRect's own colour fields plus a const(BoxChrome)* that is null unless the box has a border, shadow, radius or arrow — and DrawOp.visual reconstructs a whole Visual from them on demand through visualOf, documented lossy on purpose: a fill reports box chrome, a run reports text chrome, and the combinations no backend reads take defaults. So a resolved appearance is recoverable from what a payload keeps, and the seam does not store one. A Slot is not recoverable the other way — a role plus a theme yields a colour, and a colour never yields the role that produced it — so Slot is stored, on six of the eight payloads, with DrawOp.slot answering Slot.inherit for the clip pair that carries none. The consumer that spends it is the HTML interpreter, which re-resolves the role into class names; no other surveyed subject has that consumer except GSK's Broadway, and GSK gives Broadway no semantic help at all.

That is what makes friction §6 a fork rather than a defect. The channel with a cheaper encoding is the resolved one, and the cheapest encodings in the survey put it in the stream instead of the op — SDL's deduplicated SDL_RENDERCMD_SETDRAWCOLOR, Racket's device state, Java2D's pipe recompilation, Flutter's delta-encoded setColor. Every one of those is structurally incompatible with an order-independent, pairwise-comparable op stream, i.e. with RecordingCanvas and the parity harness, and Racket prices its own version at fourteen state settings saved and restored per replay. The two encodings that spend the resolved channel without a device state machine are the tag-is-the-slot fusion — available only if F3's arms are made fine enough to be roles — and a symbolic-leaf colour type in which one field holds either a role or a value (Ratatui, libvaxis, Vty).

F10. Almost every friction entry is the invoice for spanning two target classes

Every subject with a single target class converges on the same shape: a tiny primitive vocabulary, no capability query worth the name, no sub-unit vocabulary, and one styling channel. Notty has three op constructors; Vty has six image constructors and three span ops; Mosaic has two drawing operations and defines no border, scrollbar or shadow in the library at all; Ratatui and Textual have no drawing operations, only a result; libvaxis has writeCell. None of them lost any semantics — those live in the retained widget or image tree above the painter.

Conversely, every subject that spans unlike hardware grew the same four complications the friction log records: semantic operations (Slint, GSK, Qt Quick), a capability vocabulary (Qt, wgpu, Avalonia), a sub-unit policy (GSK, Java2D, GPUI), and payload lifetime machinery (Chromium, Flutter).

So the friction log is not primarily a list of mistakes. It is the price of the span, and a proposal can hope to reduce it, not to eliminate it. The entries that are genuinely defects rather than invoice items are the ones no subject with our constraints pays: a target's own resolved answer riding the drawing vocabulary (F4), the dual styling channel (F9), a payload anchored to an address rather than a position (F8), and the contract written in three places that can disagree (F11).

F11. State the contract once and derive the rest

Friction §2 records that isCanvas names five methods while the real vocabulary is eight kinds, with rule, scrollbar, pushClip and popClip discovered by __traits(compiles) at each interpreter call site and each degradation — ruleEndpoints plus a cell-aligned line, paintScrollbarCells glyph-per-cell, nothing at all for the clip pair — stated in prose beside its probe. That is one contract written in three places: the concept, the probe sites, and the prose. DrawOp.kind is the remedy already applied once — an eight-arm match! over the payloads rather than a stored tag, so the kind view and the payloads cannot drift apart. Nothing else in the contract is derived that way, and the three that are not are exactly the three a backend author reads in the wrong order.

Every large subject that reifies generates them from one declaration: Skia (SK_RECORD_TYPES(M) emits the enum, the structs and the visit/mutate switches), Flutter (FOR_EACH_DISPLAY_LIST_OP), Chromium (TYPES(M) with static_assert(kNumOpTypes == TYPES(M))), wgpu (with_limits! plus an exhaustiveness test), Racket (define/record generates the whole recorder from the method set). D's static foreach and CTFE make this cheaper than any of them.

The corollary is a per-kind obligation table. Skia tags each op kind (kHasImage_Tag, kHasText_Tag, kHasPaint_Tag) so passes select by property instead of enumerating kinds; Flutter's DisplayListOpFlags declares per operation which paint attributes it consults; Avalonia and GSK put Bounds and HitTest on the node so adding an operation cannot leave the extent walker behind. In D that property is recoverable only if the walkers are written as final switch.

F12. Reification pays only while the value stays inspectable

Three subjects reify and cash nothing. elm-canvas has proper sum types above the seam and lowers to a stringly-typed {type, name, args} at it, cashing exactly one of the four properties (replay) and never culling, diffing or comparing. Monomer reifies a queue of IO () closures, which buys ordering and forecloses everything else. Chromium shows the ceiling: under serialisation the stream is not type-stable (DrawTextBlobOp becomes DrawSlugOp), two op types are NOTREACHED() and flattened, and a field is dropped — "compare the op stream" is a weaker oracle than it looks once a boundary is involved.

Meanwhile the subjects with the best golden coverage compare something else. GSK serialises its node tree to a text file format whose documented purpose is "creating testsuites and benchmarks, exchanging nodes in bug reports", backing 279 .node/.png goldens run across four renderers in nine variants. Textual's golden format is its second backend: 448 SVG snapshots, the same artifact a user exports. Ratatui's own documentation points past its TestBackend — "it is preferable to write unit tests for widgets directly against the buffer" — because an op stream is unstable under drawing-order refactors that leave the result identical. Mosaic compares frames.

So RecordingCanvas stays, and friction is right that it pays for itself — but its job is narrower than the friction log implies. The cell arm's natural golden is the grid, the GPU arm's is the image, and the op stream is what proves the two agree. Avalonia adds a design note for it: its headless backend deliberately answers false to SupportsIndividualRoundRects/SupportsRegions where Skia answers true, so the fallback path is exercised on every headless run. RecordingCanvas should decline some optional capabilities on purpose rather than implement everything.


Verdicts on the eight friction entries

One row per entry, each a judgement about the seam as it stands.

§Friction entryVerdictDecided byThe judgement
§1measure is denominated in cellsConfirmed, and enlargedF1, F2Measurement does not belong on the painter: 35 of 38 put it elsewhere, and the one dissenter prices it. Placement is one decision of six — unit, oracle authority, return shape, device parameterisation and the identity of the measured artifact are all still open.
§2five methods, eight kindsConfirmed, reframedF5, F11Optionality is not the defect; probing is the field's ordinary answer and D already expresses floor / defaulted / refusable. The defect is one contract written in three places that can disagree, where DrawOp.kind shows the derived alternative working.
§3scrollbar is a widget concept in the drawing seamHalf-confirmedF4The semantics pass every admission test the field uses, the lowering already lives once above the backends, and a semantics-free seam is worse (imtui). What fails is trackGlyph/thumbGlyph — a cell target's own answer riding past backends that cannot read it.
§4the encoding is neither @safe nor variable-widthConfirmed on safety; a live trade on widthF3, F8The two @trusted islands are load-bearing, and an offset-pair payload retires both. Uniform stride is a genuine trade, not a defect: it buys comparable values and compiler-checked exhaustiveness for the spread between the narrowest arm and 64 bytes. Arm count is the open axis.
§5sub-cell placement as a compass directionReframed, not refutedF6Continuous coordinates relocate the problem rather than dissolving it (GSK 4.24, Pango, imtui), so a float seam is not the answer. The replacement is three mechanisms — a queried device unit, a named snap policy, an accumulator for joins — not more enumerators.
§6a resolved appearance and a semantic role on every drawing opConfirmed, with a forkF9No dissent: nobody carries both per op. The seam already derives the recoverable channel and stores the unrecoverable one, so the channel to spend is the resolved appearance — and its cheapest encoding, stream state, forecloses pairwise op comparison.
§7DrawOp.text is borrowed, and the borrow is not expressibleConfirmed, unanimousF838 of 38 anchor a payload in storage that outlives the operation. The copy into the arena is right; the anchor is the question — an offset pair makes the operation trivially copyable and thread-transferable, and the finished list can answer the thread question by conjunction.
§8no extent queryConfirmedF7Twelve subjects publish extent from the scene. The axis is maintained-at-construction versus derived-by-scan, and buildDisplayList discards a number layout already computed, which leaves skia-canvas-render.d folding op.rect over a finished stream.

The open question: should the terminal and GPU targets share one seam?

The umbrella asks this and says the survey may not settle it. It settles it partially, and the part it settles is the part that matters.

The evidence against one drawing seam spanning both is strong and, unusually for this survey, includes failures rather than only designs.

  • imtui is the direct experiment: one geometry seam retargeted to character cells. It did not fail at the backend — it failed upward, because a seam with no semantics gives degradation nowhere to live. The cost was a permanent fork of the widget layer, and a second cell backend with different capabilities would need a second fork.
  • Cairo is the long-run empirical result: its 1.18 NEWS removes "GL and GLES drawing", and cairo.h marks ten surface types deprecated — Glitz, BeOS, DirectFB, OS/2, Qt, VG, GL, DRM, Skia, Cogl. Every GPU-targeting backend it ever had is gone; the survivors either are the image surface or serialise to a vector format. A seam shaped by its first backend did not survive a genuinely different second one.
  • piet is the signed post-mortem, and the reason is not mechanism. Raph Levien, in Vello's doc/vision.md: "I think we want to move away from abstracting over platform capabilities … One is that it's harder to ensure consistent results. Another is that it's hard to add new features." The failure mode piet also documents in-tree is duplication: piet-web's line breaking opens "currently basically copied and pasted from cairo backend".
  • Qt Quick states the technical limit: geometry plus shader is portable only among shader backends. Its software adaptation's handler for a custom QSGGeometryNode — the documented public way to add content — ends // We dont know, so skip.

The evidence for a shared layer is equally strong, but it is about a different layer.

  • Mosaic reuses androidx.compose.runtime verbatim through a four-method Applier and re-declares Compose's whole measure/place protocol in cell integers — and then has no renderer seam at all. The portable artifact is the retained node tree; the painter is two operations.
  • Textual grew a second and third target cheaply because it has no drawing seam: widgets produce a reified, immutable, self-measuring result, and every target is an encoder of that result. Its pixel target was made to comply with the cell grid, not the reverse.
  • Ratatui reaches the same shape from the other direction: the seam is a readable grid, which is why widgets can compose by read-back (Exact.merge("│","─") == "┼") — something no command stream can do.
  • Doodle ships both designs and documents the loss: a wide capability-decomposed algebra, and a closed Image DSL whose docs repeatedly say what it cannot reach. A single closed DrawOp union is structurally the Image half of that pair.

Slint remains the encouraging counter-example and its range must be read honestly. One ItemRenderer really does span an MCU software renderer and Skia — but that is small-GPU to large-GPU, not character cells to shaped glyphs, and Slint's seam is semantic (eight operations including draw_box_shadow), which is exactly what Qt says is required to span unlike backends. No surveyed subject spans what sparkles:ui spans.

Verdict. The evidence supports one shared vocabulary above the painters and per-target painters below, with the reified op stream retained as the cross-target parity artifact rather than as the portability abstraction (F12). That is not a rejection of isCanvas — it is a statement about what the seam should be allowed to grow into. Every entry in the friction log that is a genuine defect (F10) is fixable without answering this question; every entry that is an invoice item gets cheaper the more the shared vocabulary moves up.

What would settle the remainder, and it is a measurement rather than more reading: enumerate every use of every OpKind in sparkles:ui's widget set and ask whether the widget could emit cells and rects instead. Ratatui, Textual and libvaxis answer yes for scrollbars, rules, meters and trees, in a few dozen lines of widget code each — libvaxis's whole widgets/Scrollbar.zig is 33 lines. The open cases are exactly two — clipping (where WebRender's CLIPPING_AND_POSITIONING.md documents a hierarchical clip stack failing against real content, replaced by out-of-band clip and spatial trees) and the re-resolving HTML interpreter, which is a consumer no other surveyed subject has except GSK's Broadway — and GSK gives Broadway no semantic help at all.


Recommendations

Input to a proposal in docs/specs/ui/, not decisions. Ordered so that each is cheap on its own and unblocked by the ones before it.

  1. Delete measure from isCanvas. Resolves friction §1; justified by F1. This is nearly free: layout is already parameterised on isTextMeasure and never touches a canvas, every in-tree layout call passes the default CellMeasure, and no interpreter call site calls .measure — the elm-ui deep-dive verified this by grep. Do not design a font abstraction in the same change; F2 says that is five decisions, and diagrams is the warning about deleting rather than relocating.
  2. Name the measurement regime and make layout and paint call one oracle. Resolves the rest of §1; justified by F2, and unblocked by 1 because the painter has stopped being a second answer. Follow Ratatui's failure (two width functions that disagree) and Vty's remedy (one authoritative table), and adopt Racket's get-font-metrics-key idea — a small comparable tag identifying the metric universe — so a mismatched measurer is detectable. Widen the return type past Size (F2.4) only when a proportional consumer exists.
  3. Derive the concept and the interpreter's probe sites from the declaration DrawOp.kind is already derived from. Resolves friction §2; justified by F11. kind is an eight-arm match!, so the kind view cannot drift from the payloads; the five named methods, the four probe sites and their stated degradations are derived from nothing, and that is where drift lives. D's static foreach and CTFE make one declaration cheaper than the macro lists Skia, Flutter, Chromium and Racket maintain by hand. Write every walker as a final switch over op.kind or an eight-arm match!, so a ninth kind cannot leave the extent or hit-test pass behind (F11, F3).
  4. State the capability contract as floor / defaulted / refusable, using constructs D already has. Resolves the rest of §2; justified by F5, and unblocked by 3 because the declaration is the place to say it. A mixin template of default lowerings is Java2D's unforgettable-superclass floor; an Expected-returning method is Ratatui's typed refusal; a stream-scoped deferred error is Masonry's ergonomic form. Scope the query to a domain (SDL) and consume it at the lowering step (Mosaic), not at each call site. Generate the written contract from a RecordingCanvas conformance run rather than writing prose that rots (Vg).
  5. Keep scrollbar's semantics; take the cell target's own answer out of the payload. Resolves friction §3; justified by F4, and unblocked by 4 because "declines the primitive" needs a spelling. The lowering is already published and already applied — scrollbarThumb in sparkles.ui.state, scrollbarCellCount/scrollbarCell/ruleEndpoints re-exported from canvas.d, paintScrollbarCells applied by the interpreter for a backend that does not implement it — which is piet's published-fallback shape and Skia's framework-default shape at once; keep both. What moves is trackGlyph/thumbGlyph: carry the degradation vocabulary the way Ratatui does, as a small glyph record the cell target owns, rather than as two fields every backend is handed.
  6. Keep the closed sum, and buy the two things it does not give for free. Answers the width half of friction §4; justified by F3, and unblocked by 3 because the arms and the concept then come from one place. The 64-byte budget answers the variable-stride objection at our scale (Flutter, Chromium and Godot argue the other side with real mechanism and are answered on comparability, not dismissed), and pairwise value comparison is what RecordingCanvas spends. Two additions carry their own weight: hide the payload constructors (Vty) so "a TextRun's rect.width is its advance in cells" and the PushClip/PopClip pairing become invariants rather than conventions two consumers already rely on; and keep every payload a plain comparable valuediagrams's existential is the warning, since type-erasing a payload takes the parity oracle with it. Expect the encoding to remove illegal combinations and not rare ones (elm-canvas): the neutral accessor answers and visualOf's deliberate lossiness are that residue. Arm granularity (SDL, diagrams, Masonry/imaging) stays open, and is decided against exhaustiveness, not against size.
  7. Hold DrawOp.text as an offset pair into the stream's own arena. Resolves friction §7 and the safety half of §4; justified by F8, and unblocked by 6 because it is a payload change behind a constructor boundary. The copy is already right — CmdBuffer.textRun interns into the frame arena, so nothing points at a caller's buffer. What an offset pair changes is the anchor: WebRender's ItemRange and SDL's (first, count) name a position rather than an address, which retires the launder cast and the @trusted assignment, makes the operation trivially copyable, and lets it travel to another thread with its arena. Then let the finished list answer the thread question by conjunction (Flutter's isUIThreadSafe()) rather than constraining every payload — that is UI-O4 at M7/T5.
  8. Have buildDisplayList return the extent its Frame[] already carries, and make the target rect an input. Resolves friction §8; justified by F7, and unblocked by 7 because it lands in the same builder. Accumulate at construction, never scan (Vty, Notty, Flutter); carry the scale beside the size (Avalonia); and consider libvaxis's cheaper form — paint returns a size — before adding any query to the seam.
  9. Spend the resolved channel, and decide the stream-state fork explicitly. Resolves friction §6; justified by F9, and unblocked by 6 because the tag-is-the-slot fusion (Qt Quick) is an arm-granularity change. Slot is the channel to keep: it is stored precisely because a role cannot be recovered from a colour, while DrawOp.visual already demonstrates the other direction by reconstructing a whole Visual on demand. A symbolic-leaf colour type (Ratatui, libvaxis) serves the HTML interpreter with one field. Do this late, because the cheapest alternative — appearance as stream state — forecloses pairwise op comparison and therefore has to be decided against the parity harness, not against the seam.
  10. Replace RuleEdge with a queried device unit plus a named snap policy, and add an accumulator for box-drawing joins. Resolves friction §5; justified by F6. Last because it is the one entry where the current design beats the obvious alternative (imtui) and where removing before replacing makes things worse.

All ten preserve the five properties the friction log records as working: structural typing with attribute inference, cell-space layout, reification itself, RecordingCanvas as reference implementation and test seam, and the optional-primitive bargain — which F5 shows is not merely acceptable but the field's ordinary answer, just usually written down.

Sources

Every claim above is carried by a subject file in this directory, and each of those pins its primary sources to a commit SHA per Writing Research Docs. The friction entries under test are canvas-seam-friction.md; the seam itself is libs/ui/src/sparkles/ui/canvas.d. The eight questions and the survey's scope are defined in the umbrella.