ADRs
ADR 0078 — Punta blocks + variants
  • Date: 2026-07-27
  • Status: Accepted — all 8 blocks extracted + sidebars thinned; central-registry inversion deliberately deferred (see Migration)
  • Builds on: ADR 0063 (Studio @arno/* packages split). Supersedes the "Singleton — one shape, no variants" framing in docs/punta/architecture.md.

Context

Punta's per-section data was spread across parallel files: a section row in section-registry.ts, its properties in property-registry.ts, its icons + hints in field-meta.ts, its render in sections/<X>.tsx, its logic in sections/<x>-*.ts, and its glyphs in PuntaIcons.tsx. Editing one visible section (e.g. PADDING) meant touching ~6 files. Three things had also drifted from the docs:

  1. A shared section was faked. size (WIDTH) is shown on box + text + component, but it is registered only in COMPONENT_SECTIONS and "smuggled" into the other two sidebars by direct render. No reuse mechanism — a copy in three places.
  2. A per-layer condition was hardcoded inline. The root artboard's WIDTH is Tela-dictated and locked; this lived as getActiveTree()?.getTree().id === node.id inside Size.tsx, invisible to anyone reading the WIDTH section.
  3. Variants exist in code but the docs deny them. Three sidebars (BoxSidebar/TextSidebar/ComponentSidebar) dispatch via layerTypeOf, while architecture.md/_index.md still say "no variant resolution".

export/ was already a self-contained subfolder — proof the block-as-module shape works. This ADR generalises it.

Decision

A block is one self-contained rail module under packages/punta/src/blocks/ &lt;name>/, bundling its descriptor (&lt;name>.block.ts), render, logic, tests and <name>.md. Block/module names mirror the rail 1:1:

blocks/{width, layout, tracks, fill, stroke, radius, export, typography}

FILL, STROKE, RADIUS are three separate modules (three headers on screen), NOT one appearance block. LAYOUT is one module grouping clip + padding + gap

  • align (one header on screen). TRACKS is a consumer of @arno/ponte primitives — it holds no model of its own, reads/writes the node primitive via the bus.

Storage keys are unchanged. A block declares which existing SectionName key(s) it reads/writes through node-bridge (padding, spacing, tracks, appearance, size, typography, export). The module name is the UI/rail identity; the section key is internal addressing. So this is a pure behaviour-preserving refactor — no PropertyPath change, no persisted-data change, no node-bridge dispatch change. Renaming the internal keys to match modules (appearancefill/stroke/radius, sizewidth) is a possible LATER ADR, deliberately out of scope here to keep the move zero-risk.

Per-layer conditions live WITH the block. A block declares conditions: [{ when, effect }]; blocks/conditions.ts resolves the named predicate against the picked node and merges the effect. WIDTH's root lock is now { when: "root", effect: { locked: true, valueSource: "tela-width" } }.

Variants are a declared table (variants.ts), not hardcoded sidebar bodies:

box:       ["width", "layout", "tracks", "fill", "stroke", "radius", "export"]
text:      ["width", "typography"]
component: ["width"]

Central registries become derived. section-registry.ts / property-registry.ts / field-meta.ts are assembled from the block descriptors by blocks/registry.ts (kept as thin re-exports for node-bridge and sibling tools). This is the "edit one place" payoff — reached once all blocks have migrated.

Migration (incremental, green at every commit)

  1. Foundation: block-types.ts, conditions.ts. (done)
  2. Pilot: migrate width/ (move files, width.block.ts, root lock → condition, re-point sidebars). (done — 438 punta tests + web typecheck green)
  3. Migrate remaining blocks one per commit: tracks → fill/stroke/radius (split Appearance) → layout → export-facade → typography. (done — all 8 blocks extracted; the three sidebars are now thin compositions of blocks.)
  4. Deferred (optional, not done). Inverting the central registries to be derived from block descriptors (blocks/registry.ts) + a data variants.ts table + data-driven sidebars was judged not worth it now: the property schema is already centralised and readable in property-registry.ts, blocks render against it, and the three thin hand-composed sidebars + layerTypeOf already make the variants explicit. Folding the schema into blocks is churn + risk for architectural purity with no user-visible gain. Revisit only if a block's schema genuinely needs to live beside its render.
  5. Docs: architecture.md/_index.md updated (blocks + variants, "no variants" removed), stale dev-tools/studio/punta/ code links fixed to packages/punta/, docs/punta/blocks.md added, Rules.md root-lock reference updated (Size.tsx → Width.tsx conditions). (done)

Consequences

  • Each visible section = one folder; adding/editing a block is local.
  • AspectRatioPicker.tsx (media-frame ratio) still needs a home — layout/ or tracks/; decided at its migration step.
  • Until step 4 lands, the block layer and the central registries coexist; the registries stay the source for node-bridge, blocks own render + conditions.
  • Chord-debt: folder name blocks/ is English (internal src module, like sections//inputs//sidebars/); ADR 0059's Italian chord governs product-visible entities, not internal directories.