ADRs
ADR 0031 — Workflow tab redesign: unified Figma-style canvas
  • Date: 2026-05-31
  • Status: Proposed (awaiting implementation kickoff)
  • Phase / Feature: Workflow UX overhaul
  • Supersedes/builds on: ADR 0020 (Layout Grid), ADR 0027 (Composition zones), ADR 0030 (Page reconstruction). Does NOT supersede them — Layout Grid + zones + reconstruction stay; this ADR changes only how Workflow tab presents and edits screens.

Context

Today /app/workflow?project=<id> renders one of two completely different surfaces under the same tab:

  1. Capture-проект с captured_page → sandboxed iframe with reassembled HTML (read-only, reconstructed-page.tsx (opens in a new tab)).
  2. Anything else → React Flow graph of screens (workflow-canvas.tsx (opens in a new tab)).

This branching came from ADR 0030 (capture-project has only one screen, graph of one node is pointless). It is a stopgap, not an architecture:

  • The word "Workflow" in navigation means two different things for two project types — sometimes a graph of screens, sometimes one captured page. Discovery breaks.
  • When a capture project grows to 2+ screens (Phase 15 onboarding, B3 pattern detection), the reassembly view has nowhere to live.
  • Editing a screen happens on a separate route (/app/editor?id=...), so the user context-switches every time they want to touch a screen — unlike Figma, Whimsical, or Lovable where everything happens on one canvas.

User goal (verbatim):

Это будет общий канвас под одной вкладкой много экранов — как в Фигме — это рабочая зона с большим количеством экранов, их можно зумить и двигать. Один URL должен быть.

This ADR commits Workflow to a single Figma-style infinite canvas where every screen — captured or composition-built — is a node ("artboard") that can be panned, zoomed, selected, edited inline, and connected to other artboards.

Decision

1. Single canvas, no mode branching

workflow/page.tsx stops choosing between graph and reassembly. There is one canvas (React Flow), and every screen in the project is a node on it. Captured screens and composition screens are visually the same kind of node — they differ only in the source of their inner content (captured tree vs screen_composition.zones).

reconstructed-page.tsx becomes a node-content renderer (one of several), not a page-level mode.

2. Shell — top navbar + existing slim sidebar + on-demand right sidebar

  • New top navbar. Spans full viewport width. Left: ARNO logo (variant №1 from /brand/explore/v8-finals). Right: icon buttons for Share and Deploy. Reserve a slot left of Share for future Liveblocks awareness avatars — do not implement, do not render.
  • Existing slim sidebar (56px) stays untouched. All current tabs, the + menu, capture-mode behavior — unchanged. The sidebar sits below the navbar (the navbar is the top edge of the viewport; sidebar's top edge meets navbar's bottom edge).
  • Right sidebar (240px) appears when an artboard is in edit-mode. Disappears when nothing is selected. Inner content of the sidebar is out of scope of this stack — for now it renders a placeholder. Mechanics (open on artboard enter-edit, close on exit-edit) are in scope.

DeployButton moves from absolute-top-right into the navbar as an icon. Share is a new navbar icon — stub for now (opens a "coming soon" toast or no-op; backend exists but UX is deferred).

3. Figma-style interaction semantics — 1:1

User-facing rule: "as Figma does." Specifically:

  • Single click on artboard → select (highlight, no edit-mode).
  • Double click on artboard → enter edit-mode (right sidebar appears, click events now route into content).
  • Esc or click outside → exit edit-mode.
  • Drag on selected artboard → move (snap to 8px grid).
  • Space + drag → pan canvas.
  • Cmd/Ctrl + scroll → zoom; plain scroll → pan.
  • Cmd+0 → fit view; Cmd+1 → 100%.
  • Multi-select via Shift-click or drag-rect-select on empty canvas.
  • Cmd+D → duplicate; Delete/Backspace → delete; Cmd+Z / Cmd+Shift+Z → undo/redo.

4. Undo/redo through Y.UndoManager

Yjs has Y.UndoManager — attach it to nodes Y.Map, edges Y.Map, and screen_composition zones Y.Map (when implemented). One manager spans all three so a user can undo across navigation+composition changes inside the same session. Local-only (does not propagate undo through CRDT to other users).

5. Selection state is local, never in Yjs

Each user has their own selection. Selection is never written into Yjs (would conflict between users and pollute history). Future awareness work may broadcast selection as presence (read-only badge on the node) — not now.

6. URL deep-link for open screen

URL shape:

  • /app/workflow?project=<id> — overview canvas, nothing selected.
  • /app/workflow?project=<id>&screen=<sid> — that artboard is in edit-mode on load.

Browser back/forward navigates between these naturally. /app/editor, /app/editor/preview, /app/screen, /app/screen/preview → 301 redirect to the new URL shape (/app/editor?id=X&project=P/app/workflow?project=P&screen=X). Routes themselves can be deleted after a soak window.

Slim sidebar's "UX editor" tab is NOT removed. It continues to link to /app/editor, which redirects into workflow. Semantic mismatch (tab named "UX editor" lands in workflow) is accepted debt — sidebar is out of this stack's scope.

URL desync handler: if the currently-open screen is removed by another user (subscription on nodes Y.Map detects removal), close edit-mode, drop ?screen= from URL, show toast "Screen removed."

7. Edges — scale-invariant SVG with anchor to component

Edges are rendered in SVG, stroke-width is screen-pixels not world-pixels (minimum 1.5px on screen at any zoom). Arrowheads also screen-sized. This means edges remain visible from far zoom and proportional from near zoom.

Edge endpoint = (screenId, instanceId). The actual draw point is computed each frame from the instance's bounding box inside its parent artboard. When the user moves or rerenders composition, the edge "tracks" the component, not a fixed coordinate.

Current Yjs payload already has data.instanceId — no schema change. What changes is the renderer.

Edge picker UX (click button → pick target screen → OK creates edge) is out of scope of this stack. For now, manual connect via React Flow handles stays as the only way to draw edges — handles are visually muted but functional.

8. Pixel grid background, snap kratно spacing scale

  • Canvas background renders a pixel-aware grid. Far zoom: light dots/lines at 24px (current behavior preserved). Near zoom (≥200%): real 1px grid lines fade in.
  • Artboard positions on the canvas snap to 8px (smallest meaningful step from the project spacing scale 0/2/4/8/12/16/20/24/...). This is "неуклонное правило самого продукта."

9. Artboard sizing

  • Default new artboard: 1600px wide. Height grows from content. No manual resize — width is a preset, height is content-driven. Custom widths and viewport presets (mobile 375 / tablet 768) — separate block, not this stack.
  • Artboards may overlap freely. Z-order = creation order. Context menu provides Bring to front / Send to back. Allowed because Figma allows it and the alternative (forced layout) is more work for no MVP gain.

10. Performance — onlyRenderVisibleElements + LOD hooks

  • React Flow onlyRenderVisibleElements={true} from day one — keeps off-viewport nodes out of DOM.
  • No LOD (level-of-detail) implementation in this stack. Full DOM for every visible artboard, including iframe for captured ones. This is conscious: until first user hits ~20 artboards, building snapshot infra is premature.
  • But the architecture leaves hooks: node renderer is a switch on content type (composition | captured | placeholder). Adding a snapshot tier later means adding one more case + invalidation key — no shell rewrite.
  • Hard promise: up to ~20 artboards work without LOD. Above 20, expect degradation; that triggers LOD as the next stack.

11. Viewport state per-user, per-project

Last zoom + pan position saved in localStorage under arno.workflow.viewport.<projectId>. Restored on mount. Not synced through Yjs (private to each user's session).

12. Empty state for new projects

New projects start with an empty canvas + CTA "Add screen" in the center. The current initialScreens seed (5 screens Login/Onboarding/Home/Profile/Settings) is dropped for new projects. Existing projects keep their data — no migration of nodes, no deletion.

13. Desktop only

Mobile and tablet viewports show a banner: "Open on desktop to use Workflow." No mobile editing path. Phase 16 launch readiness gate may revisit.

14. Captured-node edit-mode — by rules extracted from the page

When the user enters edit-mode on a captured-node, the right sidebar exposes editing constrained by the design tokens the page itself uses: same colors, same type scale, same spacing rhythm, same radii. The user does not edit arbitrary HTML — they edit through the page's own design vocabulary.

For this to work, the browser extension must capture more than the current buildLayoutTree() produces. The extended capture spec is delivered as a separate brief for the extension session (see Open items §3) — this ADR commits to consuming that data once available, and to a placeholder right-sidebar UI until then.

Scope of this stack

InOut
New top navbar (logo + Share/Deploy icons + awareness slot)Share modal/UX (stub button only)
Right sidebar (240px) mechanics: open on edit-mode, close on exitRight sidebar content
Single canvas: drop branching in workflow/page.tsxLOD / snapshot infra
Figma click/drag/zoom semanticsEdge picker UI (handles stay)
Multi-select + drag-rectLiveblocks awareness (cursors, avatars)
Y.UndoManager wired for nodes + edgesComposition undo (when composition Yjs lands)
Keyboard shortcuts (Cmd+Z, Cmd+Shift+Z, Delete, Cmd+D, Cmd+A, Cmd+0, Cmd+1, Space+drag, Cmd+scroll)Mobile editing
Pixel grid background + 8px snapResize artboards
URL ?screen=<sid> + URL desync handlerRemoving UX editor tab from slim sidebar
Redirects for /app/editor* + /app/screen*Removing the route files (do after soak)
Empty state for new projects (no seed)Migrating existing projects
onlyRenderVisibleElementsCaptured-node real edit UI
Viewport state in localStorageCross-device viewport sync
Z-order context menu (Bring/Send)Per-artboard layers panel
Desktop-only banner on narrow viewportsTablet adaptation

Breaking changes

  1. /app/editor, /app/editor/preview, /app/screen, /app/screen/preview → redirect. Anyone holding deep links lands in workflow. URL params translate (idscreen).
  2. workflow-canvas.tsx double-click stops navigating to /app/editor. It enters inline edit-mode instead.
  3. screen-mode-toggle.tsx (edit/preview toggle) — current implementation is page-level. It either moves into the right sidebar inside edit-mode, or is deprecated. Decided at right-sidebar design time.
  4. reconstructed-page.tsx demoted from page-level branching to a node-content renderer. Its container styling (rp-canvas, rp-head) goes away; only the inner iframe + content-building logic survives, refactored to mount inside a React Flow node.
  5. DeployButton removed from workflow/page.tsx as absolute-positioned. Re-mounted as icon button inside top navbar.
  6. initialScreens seed stops applying to new projects.
  7. Sidebar UX editor tab semantics drift — tab name no longer matches landing page. Acknowledged debt, deferred.

Risks

Risk 1 — iframe inside React Flow node under transform

React Flow moves nodes via CSS transform on the node wrapper. Iframes do not always survive transform on a parent — some browsers reload, repaint, or freeze the iframe content. If this happens for captured-node iframes during pan/zoom, the user sees content flash or freeze every time they move the canvas.

Mitigation: smoke-test in the first hour of implementation. Mount 5 captured-style iframes in a React Flow scene, pan/zoom in Chrome, Safari, Firefox. If broken, LOD (snapshot at far zoom, iframe only on near zoom or focus) returns to this stack's scope — the deferral above is conditional on the smoke-test passing.

Risk 2 — /app/editor references outside the editor file

Grep found 7 references (sidebar, workflow-canvas, screen-mode-toggle, two screen/page.tsx redirects). There may be more in components not under apps/web/src (extension popup, stories, MDX docs). A full grep at implementation start, not just spot-checks.

Risk 3 — Multi-store undo

Y.UndoManager can wrap one or many Y.Doc sub-roots, but undo of an action that spans nodes + composition + edges needs careful grouping (transactions). If the same user gesture writes to two stores, undo must atom them. Easy to get wrong; needs a small spec for which actions group together.

Risk 4 — Visible upgrade burden for existing projects

Existing projects opened in the new workflow see: artboards laid out from their old nodes[].position, edges as before, UX editor tab still in sidebar but landing on workflow now. No data migration, but the visible experience changes hard. Consider a one-time "What's new" toast on first load post-deploy. Decided at implementation time.

Alternatives rejected

A. Keep two routes — /app/workflow for graph, /app/page for captured reassembly. Honest separation but loses the unifying mental model. User wants one canvas, not two views of two different things.

B. Generalize the canvas but keep a separate edit page. Canvas at /app/workflow is overview, editing still happens at /app/editor. Closer to current model. Rejected because user's explicit benchmark is Figma — inline edit is the point. Adding context-switch undermines the whole exercise.

C. Build LOD now. Snapshot infra (R2 bucket, snapshot regen on composition change, client cache, far-zoom switch) is a meaningful stack on its own. With ~5 captured pages today, LOD is premature optimization. Deferred behind a hard 20-artboard promise.

D. Drop React Flow, render canvas on Canvas2D/WebGL. Figma-grade perf path. Rejected as out-of-scope rewrite. React Flow remains and is sufficient for the 20-artboard promise. Migration is open should that promise break.

Consequences

Positive:

  • One mental model for users: a canvas with screens. Capture and classic projects look the same.
  • Editing without route changes — faster than current, less browser-back gymnastics.
  • Aligns with industry mental model (Figma); reduces learning curve.
  • Backend contract unchanged — no schema migration, no API rework.
  • Existing zones / composition / capture work is preserved and consumed, not replaced.

Negative:

  • Iframe risk (see above) — possibly forces LOD into this stack, expanding scope significantly.
  • Sidebar UX editor tab carries semantic debt indefinitely (until separately addressed).
  • Existing users see a notable UX change without an explicit feature announcement infra.
  • More state to manage on the page (selection, viewport, edit-mode, undo stack) — larger surface for bugs.
  • Some legacy routes redirect rather than delete cleanly until the soak window passes.

Neutral:

  • Pixel grid + snap codify a product rule that was previously only documented in memory/reference_spacing_grid.md.
  • Empty state change for new projects is product-positive but visible.

Open items (deferred — not blockers)

  1. Right sidebar content design — what panels (layers / inspector / library / interactions / properties?), what they show, how they switch.
  2. Edge picker UX — click on instance → pick target screen → OK creates edge. Replaces manual handle-based connect. Phase 7 spec mentions this; not in this stack.
  3. Browser extension capture extension — additional data needed for captured-node edit-mode by rules of the page. To be delivered to the extension session as a separate brief:
    • All CSS custom properties (:root --*) + usage map
    • All hex/rgb/hsl colors deduplicated + frequency + source selectors
    • All font-size / line-height / font-weight → typography scale
    • All border-radius → radius vocabulary
    • All box-shadow → shadow vocabulary
    • All spacing values (gap/padding/margin) → spacing vocabulary
    • display modes used (flex/grid) + their gap/alignment patterns
    • @media breakpoints from stylesheets
    • Viewport on capture (width, dpr, breakpoint)
    • HTML semantic tags (header/nav/main/footer/section/article) + ARIA roles
    • For each interactive element: computed styles in :hover, :focus, :active
    • transition / animation duration + easing
    • Asset URLs (img src, background-image, inline SVG)
    • Z-index vocabulary
    • Cursor styles
    • Per leaf node — a stable instanceId (so edges can anchor to captured-page components)
  4. Share prototype mechanics — modal, public link UI, scope (project vs screen), expiration. Backend exists; UX is a separate stack.
  5. Liveblocks awareness — collaborator avatars in navbar slot, live cursors on canvas, selection presence badges on nodes.
  6. LOD / snapshot infra — triggered by the 20-artboard promise breaking, or by Risk 1 firing during smoke-test.
  7. Captured pages with multiple viewports (same site captured mobile + desktop) — currently each is its own captured_page row, so naturally becomes two artboards. UX for grouping them ("variants of one screen") — open.
  8. Mobile / touch editing — desktop-only banner is the MVP; full mobile UX is post-launch.
  9. /app/editor/preview (prototype run for a single screen) — its replacement in the new model is "open share link" or a future "Run" mode inside edit-mode. Settled at right-sidebar design time.

Implementation kickoff checklist

Before first commit on feat/workflow-canvas:

  1. git worktree add -b feat/workflow-canvas ../Arno-workflow-canvas origin/main
  2. Smoke-test iframe in React Flow under transform (Chrome / Safari / Firefox). Decision tree:
    • All three OK → proceed without LOD as planned.
    • Any browser broken → reopen this ADR, add LOD to scope, replan effort.
  3. Full grep for /app/editor and /app/screen to confirm no missed callers.
  4. Confirm Y.UndoManager integrates cleanly with current workflowStore.attach(doc) flow before relying on it in the spec.
  5. Pull logo asset (variant №1) from https://arnomake.com/brand/explore/v8-finals into apps/web/public/brand/.

After implementation lands and is verified:

  1. Master spec _index.md version bump + changelog entry referencing this ADR.
  2. ADR index regenerated (pnpm docs:adr-index).
  3. HANDOFF.md updated with "Workflow canvas redesign — done, X artboards in test."
  4. This ADR moves Status: Proposed → Accepted.

Addendum — Studio-in-Workflow, Phase 1 (2026-07-14)

Realises the deferred right-sidebar content (Open items §1) for a new artboard kind. The Workflow tab now hosts the same Studio shell as /app/library (ADR 0063): one StudioDndShell wraps the LayersSidebar (left), the Tela preset strip, the artboards canvas, and Punta + Dito (right). A composition artboard renders a ponte LayoutNode tree via StudioRenderer in the SAME document as the rails — not a sandboxed iframe — so Tweak ⌘-click picking and the rails act on it through studio-bus. The designer builds design-components on the artboard: ⌘-click a box → Dito adds/duplicates/deletes children, Punta edits padding / gap / typography / appearance (instant-write). Rulers are preserved (world-origin, unchanged).

Key files. components/artboards-canvas.tsx (composition-first render), components/workflow-composition-artboard.tsx, lib/use-artboard-tree.ts (bus registration gated on active, prune-on-write, undo), and dev-tools/studio/seeds/workflow-artboard-seed.ts. Editable trees are namespaced under routePath = workflow/<projectId>/<artboardId>. The edit loop can't be driven by synthetic browser events, so it's covered at the bus level in workflow-artboard-seed.test.ts.

Two tree types stay distinct. Captured pages remain read-only iframes (LayoutTreeNode); composition artboards are ponte LayoutNode. This slice does NOT implement §14 (captured-node edit-by-rules) — captured artboards are still display-only.

Phase 2 (2026-07-14). Multiple composition artboards per project (use-workflow-artboards.ts, list in localStorage), a "+ Add design" affordance, and selection-as-activation: only the selected artboard registers on the studio-bus (the active gate in use-artboard-tree.ts), so N artboards never fight over the one registerActiveTree handle. Inactive artboards render their tree read-only (pointer-events:none), so the single shared DndContext and Tweak ⌘-click only ever act on the active tree. The first artboard is auto-selected on load so the rails have a tree.

Deferred to later phases. Double-click edit-mode routing (single-click activate is enough to ⌘-pick + edit today, §3); server persistence for both the artboard list and their trees (currently localStorage); the component palette (drag-source atoms/molecules); drag precision under canvas zoom; §14 captured-node edit-by-rules. This ADR stays Proposed until those land.

Verification note. In the automated browser harness, MCP coordinate clicks did not deliver DOM click events (a capture-phase recorder saw none), and synthetic ⌘-click can't drive Tweak — so the pick/add/select interactions were verified programmatically (.click() + patchActiveBox/patchActiveComponentProps on the mounted tree) and by code review of the pan-hook click-vs-drag threshold, not by simulated mouse events. Render, bus wiring, and persistence were confirmed live on the stand.