ADRs
ADR 0020 — Layout Grid: recursive Container/Content tree as foundation for all Arno pages
  • Date: 2026-05-25
  • Status: Accepted
  • Feature: Layout Grid Engine (product-level core)
  • Affects:
    • packages/shared/src/library.ts (ComponentSpec extensions)
    • packages/shared/src/composition.ts (tree operations)
    • packages/editor/src/composition-store.ts (state + history)
    • apps/web/src/lib/composition-store.ts (client wiring)
    • apps/web/src/components/composition-canvas.tsx (rewrite renderer)
    • apps/web/src/components/library-panel.tsx (drag source for Layout Grid)
    • apps/web/src/app/app/library/page.tsx (Design System tab — replace iframe gradually)
    • apps/web/src/app/app/editor/page.tsx (editor uses Layout Grid)
    • apps/web/package.json (add @dnd-kit/* dependencies)
    • GitHub project repos: new format pages/{id}.layout.json, optional layout-grid.config.json
    • Arno-ui-claude2/mocks/design-system.html (vanilla DnD playground — sunsetted as sections migrate)

Context

Arno editor лежит на recursive tree of components instantiated from user's design-system manifest. Сейчас:

  • ComponentInstance (defined packages/shared/src/library.ts:27-33) уже recursive через children: Record<slot, ComponentInstance[]>slot-based modeling.
  • composition-canvas.tsx:74-87 рендерит instances через HTML5 native DataTransfer DnD (DND_MIME), без library.
  • /app/library tab сейчас — iframe на mocks/design-system.html (static HTML 4400+ строк со spacing/align inspector'ами и vanilla DnD playground'ом).
  • Vanilla DnD в design-system.html — свободное перемещение через transform: translate, history 20 steps, persist в localStorage. Это playground, не production tree.

Чего не хватает для масштабирования продукта на multiple user projects:

Q1. Generalized layout primitives

Slot-based model (children: Record<slot, ComponentInstance[]>) подходит для compound компонентов (Form с header/body/actions слотами). Не подходит для произвольной структуры страницы где Container нужен только как «коробка с правилами расположения». Юзер не должен создавать component spec'и под каждый layout-need.

Q2. Page structure beyond single root

Реальные страницы имеют multi-root zones: sidebar-left + (header + body + footer) + sidebar-right. Существующая ScreenComposition (packages/shared/src/composition.ts:3-6) держит flat instances: ComponentInstance[] — нет concept of zones.

Q3. Adaptive behavior

Юзер настраивает breakpoints в отдельной DS вкладке. Каждый Container/Content должен иметь per-breakpoint overrides. Cascade direction (mobile-first vs desktop-first) — design choice.

Q4. Tokens live update

User edits color/spacing token в DS → должно applies во всех Content'ах ссылающихся. Без full tree re-render. Это killer feature Arno (стратегическое позиционирование).

Q5. Persistence at scale

Tree state per project page → должен жить в user's GitHub repo (single source of truth, version-controlled, diff'able, no infra cost к Arno). Format должен быть git-friendly для diff/merge.

Q6. DnD semantics

Sortable reorder + 4-side drop indicators + cross-level moves + drag-from-library — стандартный block-editor pattern (Notion, Webflow, Builder.io). Требует tree-aware DnD library (@dnd-kit/core рекомендован, используется в parallel проекте Tools/DevPanel).

Q7. Inline editing

Двойной клик → редактирование. Текст через contentEditable, enum-props через dropdown из DS manifest. Должен взаимодействовать с DnD без перехвата pointerdown (the data-no-dnd гочча).

Q8. Editor primitives

Undo/redo, duplicate, copy/paste cross-page, multi-select (Shift+click), keyboard delete — must-haves для serious editor.

Decision

D1. Container — это ComponentInstance extension, не parallel type

Container = special componentId: 'layout-grid:container' (вариант: :row, :column). Spec живёт в FALLBACK_LIBRARY рядом с button/input. Children через existing slot "children":

{
  id: "layout-grid:container",
  name: "Container",
  props: [
    { name: "layout",  type: "enum",   values: ["vertical","horizontal"], default: "vertical" },
    { name: "padding", type: "string", default: "0px" },
    { name: "gap",     type: "string", default: "0px" },
  ],
  slots: ["children"]
}

Почему так:

  • Backward compat: existing ScreenComposition, composition-store.ts, composition-canvas.tsx работают без breaking changes.
  • Сериализация уже отлажена в packages/shared/src/composition.ts.
  • Тесты уже есть в packages/editor/src/composition-store.test.ts — расширим, не переписываем.
  • Юзер DS-автора может добавить свои Container'ы (e.g. :grid, :absolute) через manifest — Layout Grid engine разбирает их по тем же правилам.

Rejected alternative: новый Container type параллельно с ComponentInstance. Split brain, double maintenance, double serialization.

D2. acceptsChildren — baseline engine + user manifest subset

Engine применяет 2-слой validation при onDragOver:

  1. Engine baseline (hardcoded, non-overridable):

    • HTML semantic invariants: <button> не содержит interactive children, <a> не содержит <a>, form controls не содержат blocks.
    • Depth ≤ 50 (hard limit, error при попытке вложить дальше).
  2. User DS manifest (optional, subset of baseline):

    {
      "id": "my-card",
      "acceptsChildren": ["my-button", "my-text", "layout-grid:container"]
    }

    Если manifest не объявил acceptsChildren → fallback "any" (любой компонент допустим, ограничен только baseline).

Engine ≥ Manifest: даже если manifest сказал «можно», engine baseline override'ит «нельзя».

Rejected alternatives:

  • Polynomial manifest без baseline (юзер может создать broken UI: button в button).
  • Fixed whitelist в engine без user customization (не масштабируется на различные DS).

D3. Tokens via CSS variables + Publish button

  • Content props ссылаются на токены по имени через CSS custom properties: { color: "var(--color-primary)" }.
  • Update token в DS меняет document.documentElement.style.setProperty('--color-primary', newValue) → браузер repaint бесплатно, без React re-render.
  • В DS UI — Publish button. До publish юзер видит изменения только в DS preview area. После Publish — --color-* обновляются глобально → все Content'ы инстант обновляются.

Rejected alternatives:

  • Resolved values в Content.props (full tree traversal on token change — perf killer).
  • Live update без Publish (юзер может accidentally change palette → ломает все pages → потеря работы; Publish — explicit commit step).

D4. Multi-root: 5 zones, each addable/removable

Page = 5 fixed zones:

{
  "id": "onboarding",
  "version": "1.0",
  "zones": {
    "sidebarLeft":  null,                            // null = zone hidden
    "header":       { "id": "...", "componentId": "layout-grid:container", ... },
    "body":         { /* required, non-null */ },
    "footer":       null,
    "sidebarRight": null
  }
}

UI:

  • Body — required, always present.
  • Other 4 — initially hidden. Hover на page edge dead-zone → серый dashed outline + ➕ click → init Container в этой zone.
  • Каждая zone имеет крестик (top-right corner) → удалить (set null).
  • Внутри zone — root Container где живёт layout grid recursion.

Rejected alternatives:

  • Single root (не покрывает realistic page layouts).
  • N-root configurable (overengineering для Phase 1; 5 zones покрывают 95% web pages; configurable — defer).

D5. Adaptive cascade — desktop-first

Arno = desktop product for designers. Juzeры начинают с desktop layout, потом override для smaller screens.

desktop (baseline)
  → tablet (override)
  → mobile (override)
  → per-instance override (highest)

Resolution at render time: collect overrides desktop → matching breakpoints largest-to-smallest → instance. Last value wins.

Fixed breakpoints для Phase 1: mobile: 0-767px, tablet: 768-1199px, desktop: 1200px+. Customize via DS settings tab — defer to Phase 3.

Rejected alternatives:

  • Mobile-first (Tailwind pattern; counter-intuitive для desktop-first product).
  • Hardcoded breakpoints without customization (limits enterprise use cases — defer customization to Phase 3, but not removed).

D6. GitHub serialization — 1 JSON per page

{user-project-repo}/
  pages/
    onboarding.layout.json          ← page = file
    settings.layout.json
    dashboard.layout.json
  design-system.json                ← DS manifest (user authored)
  layout-grid.config.json           ← global config (breakpoints custom, defaults override)

Page format:

{
  "id": "onboarding",
  "version": "1.0",
  "metadata": {
    "lastModified": "2026-05-25T12:34:56Z",
    "author": "vadim@arno"
  },
  "zones": {
    "sidebarLeft":  null,
    "header":       { /* ComponentInstance */ },
    "body":         { /* ComponentInstance */ },
    "footer":       null,
    "sidebarRight": null
  }
}

Rejected alternatives:

  • Split per component (many small files → noise in git log; cross-file references for shared subtrees → complexity).
  • Single pages.json для всех (large file → frequent merge conflicts).

D7. DnD — @dnd-kit/core + custom collision

Stack:

  • @dnd-kit/core — core hooks
  • @dnd-kit/sortable — Level 1 vertical reorder
  • @dnd-kit/utilities — CSS transform helpers

UX:

  • Hover на любой Container/Content → visual feedback (border outline, cursor: grab)
  • Mousedown + drag (activation distance 8px) → drag starts
  • Hover над target → pointerWithin collision detection → compute side (top/bottom если parent=vertical, left/right если parent=horizontal, into если empty Container)
  • Phantom indicators: голубой #7dd3fc, dashed/solid border, 50% width для left/right, 8px line для top/bottom (как в Tools/DevPanel/BasePage)
  • Mouseup → tree mutation через pure functions
  • Cross-level moves поддерживаются (drag из deep nested → drop на root level)

Special case — drag from LibraryPanel:

  • LibraryPanel item имеет useDraggable({ id, data: { source: 'library', componentId }}).
  • Same DnD механика. При drop — engine инстанцирует новый ComponentInstance через createInstance(spec) (existing function library.ts:35) и вставляет в target.

data-no-dnd гочча:

  • Cell оборачивает onPointerDown фильтром: если e.target.closest('[data-no-dnd]') — pointerSensor пропускается.
  • Используется для inline-edit handles, button clicks внутри Content'а.

Empty Container UX:

  • Hover на пустой Container → дashed dropzone + placeholder text.
  • Hover на Container с children → divides into halves (top/bottom для vertical, left/right для horizontal). ➕ в upper/start half → modal с выбором «новый Container» vs «новый Content из LibraryPanel».

D8. Multi-select — Shift+click

Standard editor pattern:

  • Click → single-select (replace selection)
  • Shift+click → toggle inclusion in selection
  • Esc → clear selection
  • Selection state shared across siblings AND cross-parent (Shift+click element в другом Container → multi-select работает)
  • Batch ops на multi-selection:
    • Cmd+D / Duplicate button → дублирует все выбранные subtree'и (new UUIDs, inserted рядом)
    • Backspace/Delete → удаляет все выбранные
    • Cmd+C/Cmd+V → copy/paste через app-level clipboard store (D9)

D9. Undo/redo + clipboard

Undo/redo:

  • 20 steps each direction (max)
  • In-memory state via Zustand (composition-store)
  • Persisted в sessionStorage (per-session)
  • Cmd+Z = undo, Cmd+Shift+Z = redo
  • Mutations: move, edit, duplicate, delete, paste — все push в history

Clipboard:

  • App-level Zustand store, не native clipboard (native limited в типах для structured data).
  • clipboardStore.copy(subtrees) → in-memory + sync в localStorage (cross-tab via storage event).
  • clipboardStore.paste() → deserialize + reassign new UUIDs (避zhitь ID conflicts).
  • Cross-user clipboard — defer (multi-user phase).

D10. Hard depth limit 50

При попытке вложить дальше 50 уровней → engine throws + UI toast "Layout depth limit (50) exceeded".

Rejected: soft limit с warning. Юзер игнорирует warning, дальше break (stack overflow в render). Hard limit — clean failure mode.

D11. DS HTML migration — incremental JSX port

design-system.html (4400+ строк mock'ов) переписывается секция за секцией в native JSX inside Layout Grid tree:

  1. Phase 1: Tokens tab (Colors, Typography, Spacing) — переписать первой. Иллюстрирует tokens references + Publish flow.
  2. Phase 2: Primitives, Composite, Layout sections.
  3. Phase 3: Adaptive, Status, Anti-patterns sections.
  4. Phase 4: iframe полностью убрать.

Rejected: HTML → tree parser. Хрупкий (любое изменение mock'а ломает прод), не production-safe.

D12. Tokens reference, no circular detection

By DS design: tokens — unique entities, не reference друг друга. Если юзер создаёт --color-primary: var(--color-secondary) — это malformed manifest, не Layout Grid responsibility. DS validator catches это on manifest parse (design-system-loader.ts, future phase).

D13. Open questions — deferred

QuestionDecisionPhase to resolve
Inline edit freeform vs dropdown UXTBDPhase 2 (when inline-edit lands)
Multi-device git conflictsTBDPhase 4 (when GitHub sync lands)
Adaptive cascade direction toggledesktop-first default; toggle in DS settingsPhase 3
Cross-parent multi-select batch-move semanticsTBDPhase 2 (when multi-select lands)
Inline accessibility (ARIA, keyboard nav)partial in Phase 1, full in Phase 4Phase 4

Consequences

Breaking changes

  • composition-canvas.tsx:74-87 — полный rewrite (HTML5 DataTransfer → @dnd-kit). Existing screens мигрируют автоматически (ComponentInstance structure unchanged).
  • ScreenComposition (packages/shared/src/composition.ts:3-6) — добавится поле zones: { sidebarLeft, header, body, footer, sidebarRight }. Migration: existing instances: ComponentInstance[] → wrap в zones.body.children. Migrate at load time для existing project repos (silent upgrade).
  • library-panel.tsx — стает drag-source для Layout Grid (через @dnd-kit useDraggable).
  • library.ts:15FALLBACK_LIBRARY расширяется тremя layout-grid:* компонентами.

Performance

  • Recursive renderer: React reconciliation для tree с 1000+ узлами требует React.memo на каждом Container/Content узле + useMemo на heavy computations (resolution adaptive overrides). Без этого frame drops.
  • Tokens via CSS vars: browser repaint бесплатно (document.documentElement.style.setProperty). React tree не ререндерится на token change.
  • Bundle size: @dnd-kit/core ~12KB gzipped, @dnd-kit/sortable ~5KB, @dnd-kit/utilities ~1KB. Total ~18KB добавится к bundle. Acceptable.
  • Depth limit 50 → stack guard.

Migration cost

  • Phase 1: ~1-2 недели для MVP (recursive renderer + level 1 DnD + 5 zones + Tokens section JSX port).
  • Phases 2-4: ~2-3 недели каждая.
  • Existing projects: silent migration at load (wrap instances в zones.body.children).

Reversibility

  • Phase 1 не commits на GitHub — localStorage persistence. Можно rollback frontend код, projects не affected.
  • Phase 4 (GitHub serialization) — migration script convert'ит back to old format если нужно. Source files preserved.

Alternatives considered (top-level)

AlternativeWhy rejected
Имя «Composition»overloaded с existing ScreenComposition; Layout Grid clearer scope (структурный движок)
Имя «Lattice»unique, но obscure; Layout Grid — instantly understandable
Parser HTML → treefragile, breaks on mock changes, not production-grade
Single page rootне покрывает sidebar layouts
Mobile-first cascadewrong default for desktop product
Separate Container typesplit brain с existing ComponentInstance
Native HTML5 DnD (без @dnd-kit)4-side indicators + cross-level moves требуют tree-aware DnD; native API недостаточен
Resolved tokens values в propskills live-update perf
Soft depth limitюзер ignores warning → stack overflow
Native clipboardtype-limited; doesn't carry structured tree

Phased rollout

Phase 1 — MVP (Sprint 1-2)

Acceptance criteria:

  • Recursive renderer for ComponentInstance tree
  • 5 zones model in ScreenComposition
  • Level 1 DnD: vertical reorder root Containers within a zone via @dnd-kit/sortable
  • Drag from LibraryPanel → drop into zone → creates Container or Content
  • Empty zone hover → dashed outline + ➕
  • Inline edit text via double-click + data-no-dnd
  • Undo/redo 20 steps via Zustand history middleware
  • localStorage persistence per screen
  • Design System tab (/app/library) — Tokens section ported to JSX
  • iframe remains for non-Tokens sections (gradual sunset)
  • Hard depth limit 50

Files touched:

  • apps/web/package.json — add @dnd-kit/core ^6, @dnd-kit/sortable ^7, @dnd-kit/utilities ^3
  • packages/shared/src/library.ts — add layout-grid:container to FALLBACK_LIBRARY
  • packages/shared/src/composition.ts — extend ScreenComposition с zones
  • packages/editor/src/composition-store.ts — add undo/redo middleware, history cap 20
  • apps/web/src/lib/composition-store.ts — extend client API
  • apps/web/src/components/composition-canvas.tsx — REWRITE (recursive Layout Grid renderer)
  • apps/web/src/components/library-panel.tsx — wrap items в useDraggable
  • apps/web/src/components/layout-grid/ (new dir) — Container.tsx, Sortable.tsx, BgPlateRowsDnd.tsx-equivalent generalized, EmptyZone.tsx, hooks
  • apps/web/src/app/app/library/page.tsx — replace iframe with native JSX Tokens section
  • Tests: extend packages/editor/src/composition-store.test.ts для zones + DnD operations

Phase 2 — Full 2D DnD (Sprint 3-4)

  • 4-side drop indicators (top/bottom/left/right + into)
  • Horizontal Container layout
  • Cross-level moves (drag from deep nested to root)
  • Multi-select Shift+click + batch ops (delete, duplicate, copy/paste)
  • Empty Container half-split UX with ➕ in start half
  • Acceptance: all mocks/design-system.html sections except Adaptive ported to native

Phase 3 — Adaptive + Tokens publish (Sprint 5-6)

  • Per-breakpoint overrides на каждом Container/Content
  • Adaptive breakpoint switcher в editor UI
  • DS Publish button → CSS variables hot-swap
  • Customizable breakpoints в layout-grid.config.json
  • Acceptance criteria: token change в DS → click Publish → все open editors update without reload

Phase 4 — GitHub serialization (Sprint 7-8)

  • Save tree → pages/{id}.layout.json через GitHub Contents API
  • Versioning UI (history viewer per page)
  • Multi-device conflict detection через If-Match eTags
  • Acceptance: open same project на двух устройствах → edit на одном → reload на втором подхватывает изменения

Phase 5 — Multi-user collaboration (deferred)

  • CRDT или OT (Yjs / Liveblocks evaluation)
  • Real-time cursors + selections
  • Conflict-free merges
  • Не блокирует Phase 1-4 launch.

References

  • Tools/DevPanel/_index.md — DevPanel pattern (inspiration для inline edit UX и data-no-dnd гочча)
  • Tools/DevPanel/SpacingInspector/SpacingInspector.tsx — measuring DOM rects (used as inspiration only — Layout Grid не measures, тree-driven)
  • Arno-ui-claude2/mocks/design-system.html — vanilla DnD playground, sunset target
  • ADR 0003 — Design System Source (DS manifest format)
  • ADR 0004 — Render Adapter Protocol (rendering ComponentInstance → DOM)
  • ADR 0011 — Reactive Vision (live updates philosophy)

Implementation handoff notes for next session

Чтобы запустить Phase 1 разработку:

  1. Прочитать в порядке:

    • Этот ADR (docs/adr/0020-layout-grid.md)
    • packages/shared/src/library.ts + composition.ts (existing model)
    • apps/web/src/components/composition-canvas.tsx (current renderer)
    • Tools/DevPanel/SpacingInspector/SpacingInspector.tsx (pattern reference)
    • mocks/design-system.html:4361-4470 (vanilla DnD реализация — Phase 1 sunsets this)
  2. Установить deps:

    pnpm add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities --filter @arno/web
  3. Создать структуру:

    apps/web/src/components/layout-grid/
      index.ts
      LayoutGridRoot.tsx         ← 5 zones renderer
      Container.tsx              ← recursive container
      Content.tsx                ← leaf content renderer
      EmptyZone.tsx              ← dashed dropzone с ➕
      hooks/
        useLayoutGridDnd.ts      ← @dnd-kit wrapper, collision detection
        useLayoutGridSelection.ts ← multi-select state
      utils/
        tree.ts                  ← pure tree mutations (move, duplicate, remove)
        validation.ts            ← acceptsChildren engine baseline + manifest check
        paths.ts                 ← stable path для undo persistence (если переиспользуем из vanilla DnD)
  4. Тестировать against design-system.html Tokens section (первая что портируется в JSX). Acceptance: drag chip между rows работает, undo возвращает позицию.

  5. Deferred questions (D13) фиксировать как TODO(adr-0020) comments в коде — не закрывать silent.

Changelog

  • 2026-05-25 v1.0: Initial ADR. Status Accepted. Sprint 1 starts next.