Shapes vs Derived Marks
Open design question: are shapes (rect, circle, ellipse, polygon, text, ...) and derived marks (connect, arrow, enclose, ...) the same kind of thing, or different kinds of things that the engine happens to plumb through the same pipeline?
The categories today
Shapes (src/ast/shapes/) — rect, circle, ellipse, polygon, text, image, petal. Each one is self-sized: you give it props (circle({ r: 25 }), text({ text: "x" })) and it computes its own intrinsic dimensions from those props. It contributes a bbox up to its parent layer, which then places it. Channel-bindable (rect({ h: "value" })) for data-driven charts.
Derived marks (src/ast/graphicalOperators/) — connect, arrow, enclose. Each one is ref-driven: you give it ref(...) children, and its visual is derived from the resolved positions of those refs. connect([ref("A"), ref("B")]) draws a line whose endpoints come from A's and B's bboxes. It has no intrinsic size — its bbox is induced by its references.
The two move through the same GoFishAST → resolveUnderlyingSpace → layout → render pipeline, but the relationship to that pipeline is fundamentally different:
| Shape | Derived mark | |
|---|---|---|
| Size source | own props | bboxes of ref() children |
| Layout precondition | none (place anytime) | refs must already be placed |
| Contributes to parent's bbox | yes | yes, but the bbox is induced |
| Channel-bindable | yes (rect({h: "value"})) | no, and unclear what it would mean |
| Common combinator form | rarely | always (it takes children) |
Why the asymmetry matters
The layout-precondition row is the load-bearing one. Shapes can sit anywhere in a Layer's child list; the layer places them however it likes. Derived marks need their refs already placed before their own layout can run — they have nothing to compute against otherwise.
That's the whole reason the nested-tier pattern exists. The pulley story (Pulley.stories.tsx) splits its layer into three tiers:
- Inner shapes layer — places the wheels, weights, ceiling rect.
- Outer-tier
connectmarks — read the placed shapes viaref(). - Outer-tier text labels — placed beside the ropes.
Each tier lays out after the one it depends on. The pattern works because GoFish's layer-render order matches lexical child order, so tier 2's derived marks see tier 1's resolved bboxes. Get the order wrong and ref("A") returns nothing useful.
If shapes and derived marks were the same kind of thing, the engine couldn't notice the difference — and the author has to carry the ordering discipline manually.
How we got here
In Bluefish, this distinction was completely flat. Line, Rect, Group, Align, Distribute — all Components. Line (Bluefish's connect analog) took refs as children and was structurally identical to Rect. The engine ran a single constraint-satisfaction pass that resolved everything in lockstep, so "refs must be placed first" wasn't a thing the author needed to know.
GoFish doesn't do full constraint-satisfaction. Its pipeline is directional (resolveUnderlyingSpace → layout → render), and the directionality is what introduces the ordering question. We got predictability (each pass is a tree walk in declared order) at the cost of needing the author to interleave shapes and ref-consumers in the right tier.
What a sharper split could look like
Three rough directions:
- Encode the layout-order rule in the type. A new
DerivedMarkclass that the layout pipeline recognizes — it gets scheduled after its siblingShapes have placed, regardless of declaration order. Authors stop writing nested-tier scaffolding by hand; the engine does it. - Keep the flat structure, lint for misuse. A dev-mode warning when a derived mark's
ref()resolves to an unplaced sibling (instead of crashing later inlayoutwith a confusing "intrinsicDims undefined"). Cheaper, lower architectural risk. - Push derived marks out of the layer family entirely. They become a separate top-level concept —
Decorations orAnnotations — that live alongside a layout tree but never participate in size/position resolution as siblings. Closer to how SVG<defs>overlays work.
(1) is the most powerful and the most invasive. (2) is the smallest step that pays off — the nested-tier pattern would still be the answer, but the engine helps users discover when they got it wrong. (3) is the cleanest conceptually but requires rethinking how derived marks contribute to their parent's bbox.
Open questions
- Where does
polygonsit? It has explicit local-coord points — fully self-sized like a shape. But the points are often expressed in terms of other marks' coordinates by the author (you computed them from a weight'swidth). Maybe it's a shape that could be derived if we let it consume refs. - Should
ref()itself be a special kind of node? Right now it's a shape-shaped leaf that contributes no visual but does contribute to name-scope. The "ref + ref-consumer" pair is what defines derivation. - The macro-expansion proposal in #144 (label as macro) generates new AST nodes — including refs — at construction time. That's another derivation mechanism, distinct from what
connectdoes but plausibly worth unifying. - See also operators-vs-constraints for the related question on the positioning side;
connectnotably sits in both stories (a derived mark and a layout operator with combinator form).
