- Date: 2026-07-17
- Status: Accepted
Context
Filo (ADR 0072/0073/0075) lets a designer author a navigate-link from a component to a target artboard, and click-navigate between linked artboards on the free 2D canvas. Today every artboard renders unconditionally in .artboards-grid (apps/web/src/components/artboards-canvas.tsx) — there is no way to declutter the canvas down to just the artboard you're working on, and no visual indication on the canvas of WHICH artboards are linked to which, or how (a happy-path continuation vs. a fallback vs. an error state).
Per the maintainer: a tree icon on the top-right corner of the main (first-created) artboard toggles all other artboards' visibility — the entry point into "albero". Inside albero, when other artboards are visible, arrows should be drawn from each linked component to the artboard it navigates to, colored/styled by scenario (green solid = primary, gray dashed = secondary, red dashed = negative), user-cycled by clicking the arrow (green → gray → red → green). This connector-visualization piece is named Rotta (Italian: route/course).
Both are requested as standalone, reusable, single-place-to-edit modules — the same bar every other Studio tool in this codebase is held to (ADR 0058, ADR 0063).
Decision
Package: packages/albero/ — a new top-level Studio package, not a quadro feature-folder
packages/quadro/src/index.ts's own doc comment previously sketched albero as a planned feature-folder living inside @arno/quadro (alongside filo), with albero-as-a-VIEW-inside-tavola as the mental model. That was a doc comment, not a ruling ADR — it is amended here, not violated: per ADR 0063, every Studio tool (dito, layers, punta, tela, passo, selection-frame, stepback) is its own top-level package over the shared ponte kernel. Albero is exactly this kind of tool (a canvas-level chrome + state module a host wires up), so it gets the same treatment — packages/albero/, scaffolded identically to packages/selection-frame/ (package.json/tsconfig.json/vitest.config.ts/src/index.ts, @arno/ponte + react/react-dom peer deps).
packages/quadro/src/index.ts's doc comment is updated to point at the real package instead of describing an unbuilt plan.
Rotta lives inside packages/albero/, not as its own package
Rotta is requested as independently reusable and edited in one place — satisfied by packages/albero/src/features/rotta/ as a self-contained feature-folder with its own barrel export (export { Rotta, type RottaProps } from "./Rotta"), mirroring how filo lives inside quadro as an independently-exported, single-place-to-edit unit without needing its own package.json. Rotta only makes sense in the context of albero's visible-artboards state (arrows are hidden when albero is collapsed) — promoting it to a sibling top-level package would add packaging overhead (its own package.json/tsconfig/vitest scaffold) for a module with exactly one consumer today. If a second host ever needs Rotta without albero's visibility toggle, it can be extracted then (YAGNI).
Albero's "main artboard" and visibility state
No isMain column exists on workflow_artboard. Per the original spec ("один главный артборд - тот что был первым - это основа"), "main" is derived, not stored: the artboard with the lowest sortOrder in useWorkflowArtboards()'s array. apps/web/src/lib/albero-main-artboard.ts exports mainArtboardId(artboards: WorkflowArtboard[]): string | null — a pure function, not a hook, so it's trivially unit-testable and has no state of its own.
Visibility ("are non-main artboards shown") is ephemeral CLIENT state, not persisted — packages/albero/src/shared/albero-visibility.ts exports createAlberoVisibilityStore(), a factory following the exact createSelectionStore()/createNavigationHistoryStore() pattern already established in packages/quadro/src/shared/connector-stores.ts (ADR 0073): a second future consumer of albero gets its own instance, never a shared singleton. apps/web/src/lib/albero-visibility.ts is the Workflow tab's own one-line instantiation, matching artboard-selection.ts's shape exactly. Default is true (all artboards visible) — the current, unchanged baseline behavior; collapsing to just-main is the new opt-in action. Persisting this choice server-side is explicitly deferred (YAGNI) — revisit only if users report it resetting is a real annoyance.
Rotta status: persisted server-side as a new status column on entity_link
The three states (primary/secondary/negative) are user-authored data, not derived — they must survive a reload. Adds status to entity_link:
entity_link
...(unchanged columns)...
status text NOT NULL DEFAULT 'primary'Named by SCENARIO (primary/secondary/negative), not by color (green/gray/red) — consistent with source_type/target_type being free-form slugs rather than presentation values (ADR 0073's philosophy: the data model describes what a thing IS, the UI layer decides how to draw it). This keeps a future re-theme (different colors, or a 4th scenario) a client-only change. The client maps primary → green solid, secondary → gray dashed, negative → red dashed, and treats any other/unrecognized value as primary (forward-compatible fallback, same spirit as TYPE_RE-validated free-form slugs elsewhere).
PUT /api/v1/entity-links gains an optional status field (validated against a fixed 3-value enum server-side — unlike sourceType/targetType, status IS a closed set by the spec: "у этой стрелки три состояния"). Omitting status on an upsert defaults to 'primary' for a new row and preserves the existing value on a retarget (a link's status is a property of the edge, independent of which target it points at).
Migration: ALTER TABLE entity_link ADD COLUMN status text NOT NULL DEFAULT 'primary' — additive, non-breaking, every existing row backfills to 'primary' (the correct default: "по дефолту все зелёные").
Rotta rendering: portalled into .artboards-world, coordinates in LOCAL (untransformed) space
.artboards-world (artboards-canvas.tsx) is the single element carrying the pan/zoom transform: translate(pan) scale(zoom); every artboard cell is laid out inside it via CSS flex-wrap (NOT the WorkflowArtboard.position field — that field exists on the data model but isn't consumed by the current flex-wrap layout, confirmed by reading artboards-canvas.css). Two positioning strategies exist elsewhere in this codebase:
- Filo's badge: portals into a picked node's OWN carrier,
position: absolute+ counter-scale via--artboard-zoom— zero JS recompute, but only works because the badge has exactly one anchor point that's already a DOM descendant of the thing it's pinned to. - BackButton:
position: fixed+ manualgetBoundingClientRect()measurement on mount/state-change +scroll/resizelisteners — measures in SCREEN space, does NOT re-measure during manual pan/zoom (no native scroll event fires for a transform-based pan), acceptable there because it's one-shot chrome positioned right after an auto-fit navigate.
Rotta needs neither: it draws a line between two arbitrary, independently-scrollable-into-view endpoints (a source node inside one artboard, a target artboard elsewhere in the grid) that must track pan/zoom CONTINUOUSLY and smoothly, which strategy 2 can't guarantee and strategy 1 doesn't generalize to two independent anchors.
Decision: Rotta portals a single <svg> overlay as a direct CHILD of .artboards-world (a new data-arno-world-host attribute added to that div for a stable, decoupled query — avoids coupling to the .artboards-world className, matching the data-arno-box-id/data-artboard-id attribute-selector convention already used everywhere else). Because the SVG is a child of the transformed element, it inherits translate(pan) scale(zoom) for free — panning and zooming require ZERO Rotta-side recompute. Each arrow's endpoints are computed in the world's own LOCAL coordinate space via one rect-diff per endpoint:
localX = (endpointEl.getBoundingClientRect().left - worldEl.getBoundingClientRect().left) / currentZoom
localY = (endpointEl.getBoundingClientRect().top - worldEl.getBoundingClientRect().top) / currentZoom(the same screen→world conversion formula the focus-request subscriber already uses in artboards-canvas.tsx, applied per-endpoint instead of per-viewport-center). This recompute runs on: mount, links array change, artboard list change (flex-wrap reflow), and window resize — NOT on every pan/zoom tick, since the transform handles that for free. currentZoom is read once at recompute time (not subscribed continuously) since it only affects the LOCAL-space division, not the rendered position after that.
Arrow endpoints: source NODE → target ARTBOARD (not target node)
Matches WorkflowArtboardLinkDTO's existing shape exactly — there is no target-node concept in the link data model (a link points at a whole artboard, ADR 0072). Source endpoint: the linked component's own [data-arno-box-id] carrier (same element Filo's badge portals into) on artboards that are the CURRENTLY visible/rendered ones only — a link whose source artboard is scrolled far off in the grid still resolves its DOM rect correctly since flex-wrap keeps every artboard mounted (not virtualized). Target endpoint: the target artboard cell's own [data-artboard-id] rect (its left-center edge, arrowhead pointing at the artboard, not a specific node inside it).
Visibility gating
Rotta only renders when albero's visibility store is true (all artboards shown) — when collapsed to just-main, there's nothing coherent to draw a line TO (the target artboard isn't rendered), so Rotta returns null outright rather than rendering zero-length/hidden arrows.
Consequences
WorkflowArtboardLinkDTO(and the genericEntityLinkDTO) gain astatusfield;putWorkflowArtboardLink/putEntityLinkaccept an optionalstatus;useWorkflowArtboardLinksgains acycleStatus(sourceArtboardId, sourceNodeId)method (optimistic local update + fire-and-forget PUT, same pattern aslink/unlink).ArtboardsCanvasgains ahiddenArtboardIds(or equivalent boolean-per-cell) prop, sourced from albero's visibility store +mainArtboardId(), to actually hide non-main cells — today's unconditionalartboards.maprender becomes conditional.WorkflowShell(workflow/page.tsx) instantiates albero's visibility store and renders the toggle icon +<Rotta/>alongside the existing<Filo/>/<BackButton/>composition, workflow-view-only (mirrors how Filo/BackButton are absent from the Screen tab).- A second future albero/Rotta consumer (a different sidebar tab with its own artboard-like entities) instantiates its own visibility store and its own Rotta wiring — no schema, API, or package change required, same story as Filo's second-consumer path (ADR 0073).
Alternatives considered
- Rotta as its own top-level package — rejected for now: one consumer today, no reuse pressure yet: extracting later if a second host needs it is cheap; extracting preemptively is packaging overhead with no payoff (YAGNI, mirrors this codebase's own stated preference against premature abstraction).
- Status as a free-form slug like
source_type/target_type— rejected: the spec is explicit about exactly 3 states ("у этой стрелки три состояния"), so a closed server-side enum catches a typo/bad client write immediately instead of silently rendering as an unstyled 4th state. - Persist albero's visibility toggle server-side — rejected for v1: adds a project-scoped settings row for a per-session convenience toggle with no stated requirement to survive a reload; deferred, not designed away (a future column on
projector a new tiny table, if requested). - Anchor Rotta's SVG in SCREEN space (fixed overlay, recomputed on every pan/zoom frame) — rejected: requires exposing live pan/zoom values outside
ArtboardsCanvas(a new subscribable storeusePanZoomdoesn't currently offer) and recomputing N arrow endpoints on every animation frame during a drag-pan, vs. the chosen approach's zero recompute during pan (the transform does it for free) — strictly worse for both code surface and runtime cost.