Skip to content

GTK4 / GSK — the seam is the scene, and the renderer interface is four methods

Category: retained render tree. Last reviewed: August 23, 2026. Pinned at 817caae3.

GSK ("the GTK Scene Kit") is the survey's clearest case of a reified, semantic, self-describing scene consumed by four independent renderers. It is also the survey's strongest evidence for comparison.md's F7, which finds that most subjects answer at least one extent question from the scene: GSK puts a bounds rectangle on the base class of every node, maintains it at construction, and builds three separate fallback paths on top of it.

FieldValue
LanguageC (GObject)
LicenseLGPL-2.1-or-later (meson.build license:)
RepositoryGNOME/gtk (GitHub mirror of gitlab.gnome.org/GNOME/gtk)
Documentationdocs/reference/gsk/ in-tree; the API reference is generated from it
Categoryretained render tree
Pinned revision817caae3dd5bc8ff6f4a96d5bef0aa0dc0bec671 (version: '4.23.1', development toward 4.24)
Target rangedesktop GPU (Vulkan, OpenGL), desktop CPU (Cairo), and a remote browser (Broadway)
Backends shippedcairo, gl, vulkan, broadway — all four run the same golden suite
Node kinds37 (GskRenderNodeType, gskenums.h L160–L196)

Overview

What it solves

GTK widgets never draw. They describe: a widget's snapshot vmethod appends nodes to a GtkSnapshot builder, and what leaves the widget layer is an immutable tree of GskRenderNode values. A GskRenderer then consumes that tree. The seam between them is not a painter interface — it is a data structure, and the renderer interface proper is four methods with no drawing primitives in it at all.

Design philosophy

The scene is transient, immutable, and refcounted — stated verbatim in the class documentation (gskrendernode.c L22–L34):

c
 * The basic block in a scene graph to be rendered using [class@Gsk.Renderer].
 *
 * Each node has a parent, except the top-level node; each node may have
 * children nodes.
 *
 * Each node has an associated drawing surface, which has the size of
 * the rectangle set when creating it.
 *
 * Render nodes are meant to be transient; once they have been associated
 * to a [class@Gsk.Renderer] it's safe to release any reference you have on
 * them. All [class@Gsk.RenderNode]s are immutable, you can only specify their
 * properties during construction.

"Each node has an associated drawing surface, which has the size of the rectangle set when creating it" is the load-bearing sentence for Q8. Every node knows its own extent, at construction, by construction.

How it works

GskRenderNode is a GObject-style type hierarchy, not a tagged struct. The base instance carries only what every node has — a gatomicrefcount, a graphene_rect_t bounds, and eight bitfield flags (preferred_depth, copy_mode, fully_opaque, is_hdr, clears_background, contains_subsurface_node, contains_paste_node, needs_blending) (gskrendernodeprivate.h L32–L48). Each kind embeds that base and adds exactly its own fields — a sum type expressed by subtyping, allocated at the registered instance_size by gsk_render_node_alloc (GType). A GskColorNode is a base plus GdkColor color; GskRectSnap snap;; a GskBorderNode adds an outline, four widths, four colours and two snap words. Nothing pays for a field belonging to another kind.

The defining declaration of the seam is the node class vtable, not a painter interface (gskrendernodeprivate.h L71–L97, abridged):

c
struct _GskRenderNodeClass
{
  GTypeClass parent_class;
  GskRenderNodeType node_type;

  void          (* finalize)      (GskRenderNode *node);
  void          (* draw)          (GskRenderNode *node, cairo_t *cr, GskCairoData *data);
  gboolean      (* can_diff)      (const GskRenderNode *node1, const GskRenderNode *node2);
  void          (* diff)          (GskRenderNode *node1, GskRenderNode *node2, GskDiffData *data);
  GskRenderNode**(* get_children) (GskRenderNode *node, gsize *n_children);
  GskRenderNode*(* replay)        (GskRenderNode *node, GskRenderReplay *replay);
  void          (* render_opacity)(GskRenderNode *node, GskOpacityData *data);

  /* GPU renderer */
  GskGpuRenderPass * (* occlusion)(GskRenderNode *node, GskGpuOcclusion *occlusion);
};

draw is the decisive entry: every node kind ships its own CPU rasterization against Cairo. That one fact pays for the Cairo renderer (228 lines total, gskcairorenderer.c, whose whole render step is gsk_render_node_draw_with_color_state (root, cr, …)) and for every other renderer's fallback path.

The renderer interface itself is startlingly small (gskrendererprivate.h L36–L54):

c
struct _GskRendererClass
{
  GObjectClass parent_class;

  gboolean supports_offload;

  gboolean     (* realize)        (GskRenderer *renderer, GdkDisplay *display,
                                   GdkSurface *surface, gboolean attach, GError **error);
  void         (* unrealize)      (GskRenderer *renderer);
  GdkTexture * (* render_texture) (GskRenderer *renderer, GskRenderNode *root,
                                   const graphene_rect_t *viewport);
  void         (* render)         (GskRenderer *renderer, GskRenderNode *root,
                                   const cairo_region_t *invalid);
};

Two lifecycle hooks, two submit hooks, one boolean. There is no fillRect, no drawText, no capability enum. Everything a backend must understand is in the tree it is handed.

Q1 — measurement units, and who answers

Nobody measures at the seam, because text has already been shaped before a node exists. gsk_text_node_new takes a PangoFont and a PangoGlyphString (gsktextnode.h L36–L39) — positioned glyph IDs, not a string. GSK never sees the characters.

The node's extent is then computed once, from Pango, at construction (gsktextnode.c L240, L280–L284):

c
  gsk_get_glyph_string_extents (glyphs, font, &ink_rect);
  /* … */
  gsk_rect_init (&node->bounds,
                 offset->x + pango_units_to_float (ink_rect.x),
                 offset->y + pango_units_to_float (ink_rect.y),
                 pango_units_to_float (ink_rect.width),
                 pango_units_to_float (ink_rect.height));

The detail worth stealing is how it measures. gsk_get_glyph_string_extents (gskprivate.c L123–L149) is documented as "like [method@Pango.GlyphString.extents], but it ignores hinting of the font", and implements that by reloading the font with CAIRO_HINT_STYLE_NONE before asking. The measurement is deliberately de-hinted so that all four renderers agree on a node's bounds regardless of how any of them will rasterize it.

That is a sharper statement of F1 than any prior subject supplies. It is not merely that measurement lives above the painter; it is that a scene shared by disagreeing rasterizers requires a measurement no rasterizer influences. Size measure(const(char)[]) is one of the five methods isCanvas requires, and it fails on both counts: it sits on the painter, and its answer (cellsOf(text)) is a property of one target's grid.

The unit is device-independent logical pixels, arrived at from Pango units (PANGO_SCALE = 1024 per pixel) via pango_units_to_float. Sub-pixel advance survives into the node; device pixels appear only inside a renderer.

Q2 — is the contract stated in one place?

Stated in one place, but that place is an enum of node kinds, not a capability list. GskRenderNodeType (gskenums.h L159–L197) is the whole vocabulary: 37 kinds, publicly documented, versioned with Since: annotations, and — crucially — the same enum a backend switches over. Compare isCanvas's five required methods, the eight DrawOp arms behind them (OpKind is derived from the sum by an eight-arm match!, never stored), and four further primitives — rule, scrollbar, pushClip, popClip — each discovered by __traits(compiles) at the call site that wants it (friction §2).

There is no capability query. GskRendererClass carries exactly one capability boolean, supports_offload, and it is about Wayland subsurfaces, not drawing. What replaces QPaintEngine::hasFeature is a three-layer fallback ladder, and the layering is the interesting part:

  1. Backend selection is itself a ladder terminating in Cairo.renderer_possibilities[] (gskrenderer.c L707–L719) tries display override → GSK_RENDERER env var → per-backend preference → Vulkan → GL → Vulkan-as-fallback → GL-as-fallback → get_renderer_fallback, which unconditionally returns GSK_TYPE_CAIRO_RENDERER. Something always realizes.

  2. Within the GPU renderer, an unimplemented node kind falls through to Cairo. The GPU renderer dispatches through a sparse table indexed by node type (gskgpunodeprocessor.c L3655–L3819), with a NULL entry meaning "not implemented here" (L3837–L3847):

    c
    if (nodes_vtable[node_type].process_node)
      {
        nodes_vtable[node_type].process_node (self, node);
      }
    else
      {
        g_warning_once ("Unimplemented node '%s'",
                        g_type_name_from_instance ((GTypeInstance *) node));
        /* Maybe it's implemented in the Cairo renderer? */
        gsk_gpu_node_processor_add_cairo_node (self, node);
      }

    gsk_gpu_node_processor_add_cairo_node (L718–L740) rasterizes the node through gsk_render_node_draw_fallback, uploads the result, and composites it as an image. The degradation is universal — it works for any node kind, present and future — because the node type owns its own CPU renderer.

  3. A backend may decline a kind wholesale. Broadway, which speaks a protocol to a browser, emits a native protocol op for roughly fifteen of the 37 kinds and lets every other kind hit a single default: that rasterizes with gsk_render_node_draw into an image sized from node->bounds (gskbroadwayrenderer.c L905–L957). Its own source labels the group "Fallbacks (=> leaf for now" (L299).

IMPORTANT

This is the subject behind the node kind entry in F4's list of places a lowering can live. Qt emulates in the framework and hands the engine an image; Slint degrades in the backend. GSK degrades in the node kind — the one location where adding a kind cannot break an existing backend, because the fallback ships with the kind rather than with either the framework or the renderer.

What GSK does not offer is refusal. There is no NODEGRADE flag. Instead it offers observability: gsk_render_node_draw_fallback (gskrendernode.c L480–L529) is gsk_render_node_draw plus, under GSK_DEBUG=cairo, a pink-and-black 2×2 checkerboard painted over the result at 60% alpha, with a distinct colour for a genuine GskCairoNode. Its docstring states the purpose: "1. It allows detecting fallbacks in GPU renderers / 2. Application code can use it to detect where it is using Cairo drawing".

Q3 — semantic operations, and where the semantics stop

GSK's vocabulary is semantic, but at exactly one layer: the CSS box model. GskBorderNode carries an outline, four widths and four colours (gskbordernode.h); GskInsetShadowNode carries an outline, colour, dx, dy, spread and blur_radius (gskinsetshadownode.h). A renderer is told "this is a border", never "fill these four rects".

But there is no scrollbar node, and no widget-level node of any kind. GTK resolves widget semantics into CSS boxes above the seam: gtk_css_style_snapshot_border (gtkrenderborder.c L656–L745) turns a resolved CSS style into gtk_snapshot_add_border calls. The layering is explicit and one-directional — widget → CSS style → render node → renderer.

This is the sharpest available statement of F4's second half. F4 holds that semantic operations are legitimate and that derived geometry in the seam is not; GSK supplies the test for where to stop. That test is not "is this meaningful to a user" but "do the backends disagree about how to draw it?" A border is a node because a GPU can do it in one shader pass and Cairo cannot; a scrollbar is not a node because every backend would draw one identically once the CSS is resolved.

By that test, sparkles:ui's scrollbar op is defensible only because a cell backend genuinely draws a scrollbar differently — but the Scrollbar payload's fourteen fields run from content extent, viewport extent and offset through track colour, alpha and a lit-track flag to a rail-expansion percentage, and they end in two fallback glyphs. trackGlyph and thumbGlyph are widget policy riding in the drawing vocabulary, which is what GSK never does: its semantic nodes carry geometry and colour, never a fallback glyph.

One node exists purely to carry meaning with no rendering effect at all: GskDebugNode wraps a child and a char *message (gskdebugnode.h L36–L41), which renderers pass through and tooling reads. A precedent for annotating a stream without teaching the painter anything.

Q4 — command shape

Not a tag plus dead fields, and not a closed sum type either — a GType hierarchy. The base struct holds a refcount, a bounds rect and eight flag bitfields; each kind's struct holds only its own fields; gsk_render_node_alloc allocates the registered instance_size. A GskBorderNode never pays for blur_radius; a GskColorNode carries only a GdkColor and a GskRectSnap past its base.

This is the third distinct answer the survey has found to Q4: virtual dispatch with no command values (Slint, Qt), a Rust enum sum type (egui), and now an open, extensible, per-kind-sized class hierarchy that is nevertheless inspectable as data (gsk_render_node_get_node_type, gsk_render_node_get_children, per-kind accessors).

The extensibility matters: twelve of the 37 kinds carry a post-4.0 Since: marker (4.10 through 4.22, gskenums.h L61–L157), and each addition cost existing backends nothing — the new kind arrives with its own draw, and every GPU backend's NULL table entry degrades it automatically. A closed sum type makes every backend non-exhaustive on each addition. This is a third entry in F3's live trade: reifying the stream is right, and the closed sum that DrawOp is built on buys real properties, but an open per-kind hierarchy buys them too, and it is the shape with the best extension story.

The reification is real enough to be a file format. gsk_render_node_serialize / gsk_render_node_deserialize (gskrendernode.h L118, L124) round-trip the tree through a CSS-syntax text format (node-format.md), whose stated purpose is the exact justification RecordingCanvas was built on:

GSK render nodes can be serialized and deserialized using APIs such as gsk_render_node_serialize() and gsk_render_node_deserialize(). The intended use for this is development - primarily the development of GTK - by allowing things such as creating testsuites and benchmarks, exchanging nodes in bug reports.

testsuite/gsk/compare/ holds 279 .node files with reference PNGs, and testsuite/gsk/meson.build L325–L338 runs them across every built renderer (cairo, gl, broadway, vulkan) in nine variants (plain, flip, rotate, repeat, mask, replay, clip, colorflip, serialize). Per-renderer known failures are an explicit data table, compare_xfails (L353–L385), and a test can opt a renderer out by filename convention — exclude_term = '-no' + renderer_name (L394), which is why rounded-clip-in-clip-nocairo.node exists.

NOTE

That xfail table is GSK's substitute for a declared capability set: capability is not queried at runtime, it is asserted at test time, per node kind, per renderer, with a bug number in a comment. It is a weaker contract than Qt's hasFeature and a considerably more honest one.

Q5 — sub-unit placement

GSK's coordinates are continuous floats throughout (graphene_rect_t, GskRoundedRect), which a naive reading would expect to dissolve the problem. It does not — and F6 is the general statement of why. A per-edge pixel-snapping policy was added to the seam, annotated Since: 4.24 (the pinned tree is 4.23.1, so this is pre-release API) (gskrectsnap.h, GskSnapDirection at gskenums.h L631–L651):

c
typedef enum {
  GSK_SNAP_NONE,
  GSK_SNAP_FLOOR,
  GSK_SNAP_CEIL,
  GSK_SNAP_ROUND
} GskSnapDirection;

A GskRectSnap packs four of those, one per side, into a guint32 (GSK_RECT_SNAP_INIT (top, right, bottom, left)), and it is stored on the nodeGskColorNode.snap, and two on GskBorderNode (snap and border_snap, exposed as gsk_border_node_get_snap / gsk_border_node_get_border_snap, both Since: 4.24). Three named compositions ship: GSK_RECT_SNAP_GROW, GSK_RECT_SNAP_SHRINK, GSK_RECT_SNAP_ROUND, each documented by the artifact it avoids — grow "is useful to avoid seams but can lead to overlap with adjacent content"; shrink makes the rect "fit into the allocated area"; round is for rects "placed next to each other at the same coordinate … without any seams".

This is the survey's primary evidence for F6. Continuous coordinates do not remove the sub-device-unit problem; they relocate it, from "where do I put this hairline" to "which way does this edge round when the renderer lands it on a device grid". And GSK's answer has the shape F6 recommends: the scene names a fidelity per edge, and the renderer resolves it against a device unit only the renderer knows. GSK_SNAP_GROW and a RuleEdge are the same species of declaration — a compass with a policy attached instead of a position.

WARNING

The direct read-across is limited: our cells are coarser than device pixels, GSK's logical pixels are finer. But GskRectSnap demonstrates that a per-edge enum in the display list is a shape a mature toolkit chose deliberately in 2025, not a symptom of integer coordinates.

Q6 — resolved or semantic appearance

Resolved only, with one twist. Nodes carry GdkColor / GdkRGBA values, never a style role — CSS has been fully resolved by GtkSnapshot time. GSK does include gtk/css/gtkcss.h (gskrendernode.h L27), but that is the CSS tokenizer the node file format parses with, not the style-resolution machinery; no node has a selector, a class or a state.

The twist is GdkColorState. A GdkColor is a colour state plus values, and gsk_text_node_get_color's own documentation warns that "the value returned by this function will not be correct if the render node was created for a non-sRGB color" (gsktextnode.c L291–L305). So a renderer does perform a resolution step — colour-space conversion into its compositing space — but the thing being resolved is a physical property, not a semantic one.

Broadway is the subject's re-resolving backend, and it is the direct analogue of our HTML interpreter: it must express a node in a foreign vocabulary rather than paint it. GSK gives it no semantic help whatsoever, and Broadway's answer is to map the roughly fifteen kinds whose resolved form it can express (BROADWAY_NODE_COLOR, _BORDER, _INSET_SHADOW, _LINEAR_GRADIENT, _ROUNDED_CLIP, …) and rasterize the rest.

That is evidence against carrying a semantic role beside a resolved appearance (friction §6) — the hedge F9 finds nobody else making. Each DrawOp payload stores the resolved fields its own primitive paints from: an Ink on the four content primitives, and colours plus a const(BoxChrome)* on a fill, out of which visualOf reconstructs the Visual the seam speaks. Six of the eight payloads then store a Slot as well. Broadway is a real re-resolving backend, and resolved values plus semantic kinds served it adequately without a parallel role field. Slot is doing work the op kind could do, if the kinds were finer-grained.

Q7 — payload ownership

Owned, refcounted, immutable — never borrowed. GskRenderNode carries a gatomicrefcount; gsk_render_node_ref / _unref are public (gskrendernode.h L95, L97); and the class docs state nodes are "immutable, you can only specify their properties during construction".

Text is the sharpest contrast with DrawOp.text. gsk_text_node_new2 does not retain a caller slice: it g_object_refs the PangoFont and its font map, and g_malloc_ns a private PangoGlyphInfo array, copying the glyphs while filtering PANGO_GLYPH_EMPTY (gsktextnode.c L251–L280). The node is thereafter self-sufficient.

The copy is not the difference. CmdBuffer.textRun copies into a frame arena too, which is what makes a scope source safe to draw from. The difference is the retention boundary: a node's refcount is atomic and unbounded in time, while a DrawOp is valid while the buffer that built it is alive and unreset. That boundary is what makes the GPU fallback path legal for GSK and closed to us: gsk_gpu_node_processor_add_cairo_node passes gsk_render_node_ref (node) into an upload op with gsk_render_node_unref as its destroy-notify (gskgpunodeprocessor.c L728–L732), so the node deliberately outlives the frame walk and the rasterization happens later. That is precisely the record-on-one-thread / submit-on-another problem friction §7 anticipates, and the question UI-O4 leaves open.

F8 stands, at its strongest setting: refcounting rather than an arena, with an atomic count, so deferred work is expressible.

Q8 — extent query

GSK answers Q8 affirmatively, at three levels, and it is F7's cleanest case of an extent maintained at construction rather than derived by a scan.

graphene_rect_t bounds is a field of the base node struct, populated at construction by every kind, and publicly readable (gskrendernode.h L102–L104) with the documented invariant "The node will not draw outside of its boundaries." (gskrendernode.c L370–L386). It is not derived by scanning children at query time; it is computed once, bottom-up, when the node is built.

Three consumers depend on it:

  1. Surface extent, the first of F7's three questions.gsk_renderer_render_texture's viewport parameter is documented "(nullable): the section to draw or NULL to use @root's bounds", and the implementation does exactly that (gskrenderer.c L377–L417):

    c
    if (viewport == NULL)
      {
        gsk_render_node_get_bounds (root, &real_viewport);
        viewport = &real_viewport;
      }

    This is skia-canvas-render.d's problem, solved by the scene rather than by a scan.

  2. Every fallback rasterization. Broadway sizes its Cairo image from node->bounds directly (gskbroadwayrenderer.c L937–L947), and the GPU processor clips and snaps node->bounds before uploading. A backend that cannot render a kind still needs its extent, and gets it for free.

  3. Culling. gsk_gpu_node_processor_add_node_untracked opens with if (!gsk_gpu_render_pass_in_clip_fast (self, &node->bounds)) return; (gskgpunodeprocessor.c L3827) — whole-subtree rejection before any dispatch.

A backend that allocates a window knows its size because it chose it, and that is the one case where the scene need not answer. It is false for everything else GSK does with bounds, and the generalisation GSK adds to F7 is this: a self-describing extent is what makes partial backend support cheap. Without it, "rasterize this subtree I do not understand" has no surface to rasterize into.

Strengths

  • A backend cannot be incomplete in a way that breaks a program. Because draw ships with the node kind, every kind has a working CPU renderer, so every partial backend has a correct-if-slow path for the rest.
  • The renderer interface does not grow. Twelve node kinds were added since 4.0 without a single new method on GskRendererClass.
  • The scene is data, so it is a file format, a test corpus, an editor (gtk4-node-editor) and a bug-report artifact — one reification paying for four tools.
  • Extent is free and always right, which makes offscreens, culling and fallbacks all fall out of the same field.
  • Per-kind allocation means the vocabulary can be wide without every command paying for the widest one.
  • Fallbacks are observable (GSK_DEBUG=cairo paints them pink) rather than silent.

Weaknesses

  • No capability query and no refusal. A caller cannot ask whether a hairline will be a hairline, and cannot demand failure instead of degradation. The substitute is a hand-maintained xfail table in the build system.
  • Fallback is a cliff, not a ladder. The degradation for "I cannot do this" is always "rasterize on the CPU and upload" — correct, but the performance difference between a supported and an unsupported node is enormous and only visible through a debug env var.
  • 37 kinds is a large surface for a new backend. Broadway, the least capable, natively handles under half of them; a fifth renderer starts from a large table of NULLs.
  • Refcounted GObject nodes have real allocation cost per frame per node, which the GPU renderer's node-level caching exists partly to amortize.
  • The tree must be rebuilt whole; incrementality is recovered afterwards by can_diff/diff comparing two trees, not by mutating one.

Key design decisions and trade-offs

DecisionRationaleTrade-off
The seam is a data structure, not a painter interfaceBackends can reorder, cull, batch, cache and diff; the scene is inspectableEvery frame allocates a tree; incrementality needs a separate diff pass
Node kinds as a GType hierarchy, per-kind sizedKinds can be added without touching backends or paying for the widest kindNo exhaustiveness checking; a backend's NULL entry is silent by default
draw (Cairo) on every node classOne universal, always-correct fallback for any backend and any future kindEvery node kind must be implementable on the CPU — constrains what may be a node
bounds on the base class, computed at constructionCulling, offscreen sizing and fallback rasterization all become trivialBounds must be conservative and correct at build time; a node cannot grow later
Semantics stop at the CSS box modelBackends genuinely disagree about borders and shadows; not about scrollbarsWidget-level intent is unavailable to a backend that might want it
No capability query; ordered renderer fallbackSomething always realizes; applications need no backend knowledgeSilent quality cliffs; capability is asserted in a test table, not the API
De-hinted text measurement (CAIRO_HINT_STYLE_NONE)All renderers must agree on a node's bounds regardless of rasterizationBounds are slightly loose relative to what any one renderer actually inks
Per-edge GskRectSnap on nodes (4.24)The scene states snapping intent; the renderer applies it at its own scaleAnother enum in the seam, on a toolkit whose coordinates are already continuous

Bearing on the proposal

  1. Q8 / friction §8: put an extent on the op. GSK carries bounds on the base of every node and gets offscreen sizing, subtree culling and fallback rasterization out of that one field. gsk_renderer_render_texture (…, viewport: NULL) is precisely skia-canvas-render.d's scan, replaced by a lookup. Nothing on CmdBuffer, the display list or the arena answers the question, so a caller that needs painted bounds folds op.rect itself. Report GSK as F7's maintained-at-construction pole.

  2. Q4 / friction §4: let the extension story weigh in F3's trade. GSK gets every property RecordingCanvas needs (values, comparable, serializable, replayable) from an open hierarchy, and has added twelve kinds without breaking a backend. DrawOp is a closed sum of eight payloads, so a ninth arm makes every walker and every accessor non-exhaustive at once. Price that against what the closed sum buys: illegal combinations cannot be spelled, and the operations stay comparable values.

  3. Q2 / friction §2: move the fallback onto the op kind. F4's node-kind location for degradation — neither framework (Qt) nor backend (Slint) but the kind itself — is the one that makes optional support cheap. ruleEndpoints and scrollbarCell in canvas.d are exactly that pattern, and each of the four optional primitives has a degradation stated for it: rule falls back to ruleEndpoints plus a cell-aligned line, scrollbar to paintScrollbarCells glyph-per-cell, and the clip pair to nothing at all, because the display list has already culled the hidden subtrees. What GSK has and the concept lacks is one place to say that, instead of scattering __traits(compiles) across the interpreter.

  4. Q3 / friction §3: keep semantic ops, but apply GSK's test. A kind earns its place when backends disagree about how to draw it. By that test scrollbar survives (a cell backend really is different) but its trackGlyph / thumbGlyph fields do not — those are widget policy, and GSK's semantic nodes carry only geometry and colour.

  5. Q1 / friction §1: F1 confirmed, and sharpened. Measurement must not merely live off the painter; in a multi-backend scene it must be deliberately independent of any rasterizer, which is why GSK measures with hinting off. A sparkles:ui font abstraction should return a unit no backend can perturb.

  6. Q5 / friction §5: GSK is F6's proof. Continuous coordinates did not dissolve the sub-unit problem for GTK; 4.24 added a per-edge GskSnapDirection to the node. The transferable form is F6's named fidelity plus a device unit the backend queries — which is half of what RuleEdge does, and an argument for giving RuleEdge a policy (grow / shrink / round) rather than replacing it with more positions.

  7. Q7 / friction §7: F8 confirmed at the strongest setting. Nodes copy their payloads and hold an atomic refcount, and that is exactly what lets the GPU renderer defer rasterization past the frame walk. The frame arena copies too; what it does not supply is a lifetime outlasting the buffer, which is the M7/T5 record-here-submit-there case and the open half of UI-O4.

  8. Adopt the golden corpus shape. 279 serialized scenes × four renderers × nine transformation variants, with per-renderer known failures as a data table. Our op-stream parity harness is the same idea one backend short, and the same idea as F12: the stream is the oracle, the image is not. The serialize variant (round-trip the scene, re-render, compare) is a free test we do not yet run.

Sources

Every path below was read in a local clone of GNOME/gtk at 817caae3dd5bc8ff6f4a96d5bef0aa0dc0bec671 and verified to exist at that revision with git cat-file -e. GTK's canonical home is gitlab.gnome.org/GNOME/gtk; the GitHub repository is an official mirror carrying the same commit hashes, and is cited here because it pins by 40-character SHA.