- 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 indocs/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:
- A shared section was faked.
size(WIDTH) is shown on box + text + component, but it is registered only inCOMPONENT_SECTIONSand "smuggled" into the other two sidebars by direct render. No reuse mechanism — a copy in three places. - A per-layer condition was hardcoded inline. The root artboard's WIDTH is
Tela-dictated and locked; this lived as
getActiveTree()?.getTree().id === node.idinsideSize.tsx, invisible to anyone reading the WIDTH section. - Variants exist in code but the docs deny them. Three sidebars
(
BoxSidebar/TextSidebar/ComponentSidebar) dispatch vialayerTypeOf, whilearchitecture.md/_index.mdstill 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/ <name>/, bundling its descriptor (<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).
TRACKSis a consumer of@arno/ponteprimitives — 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 (appearance → fill/stroke/radius, size → width) 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)
- Foundation:
block-types.ts,conditions.ts. (done) - Pilot: migrate
width/(move files,width.block.ts, root lock → condition, re-point sidebars). (done — 438 punta tests + web typecheck green) - 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.)
- Deferred (optional, not done). Inverting the central registries to be
derived from block descriptors (
blocks/registry.ts) + a datavariants.tstable + data-driven sidebars was judged not worth it now: the property schema is already centralised and readable inproperty-registry.ts, blocks render against it, and the three thin hand-composed sidebars +layerTypeOfalready 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. - Docs:
architecture.md/_index.mdupdated (blocks + variants, "no variants" removed), staledev-tools/studio/punta/code links fixed topackages/punta/,docs/punta/blocks.mdadded,Rules.mdroot-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/ortracks/; 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, likesections//inputs//sidebars/); ADR 0059's Italian chord governs product-visible entities, not internal directories.