- 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, optionallayout-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(definedpackages/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/librarytab сейчас — 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:
-
Engine baseline (hardcoded, non-overridable):
- HTML semantic invariants:
<button>не содержит interactive children,<a>не содержит<a>, form controls не содержат blocks. - Depth ≤ 50 (hard limit, error при попытке вложить дальше).
- HTML semantic invariants:
-
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 →
pointerWithincollision 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 functionlibrary.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 viastorageevent).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:
- Phase 1: Tokens tab (Colors, Typography, Spacing) — переписать первой. Иллюстрирует tokens references + Publish flow.
- Phase 2: Primitives, Composite, Layout sections.
- Phase 3: Adaptive, Status, Anti-patterns sections.
- 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
| Question | Decision | Phase to resolve |
|---|---|---|
| Inline edit freeform vs dropdown UX | TBD | Phase 2 (when inline-edit lands) |
| Multi-device git conflicts | TBD | Phase 4 (when GitHub sync lands) |
| Adaptive cascade direction toggle | desktop-first default; toggle in DS settings | Phase 3 |
| Cross-parent multi-select batch-move semantics | TBD | Phase 2 (when multi-select lands) |
| Inline accessibility (ARIA, keyboard nav) | partial in Phase 1, full in Phase 4 | Phase 4 |
Consequences
Breaking changes
composition-canvas.tsx:74-87— полный rewrite (HTML5 DataTransfer → @dnd-kit). Existing screens мигрируют автоматически (ComponentInstancestructure unchanged).ScreenComposition(packages/shared/src/composition.ts:3-6) — добавится полеzones: { sidebarLeft, header, body, footer, sidebarRight }. Migration: existinginstances: ComponentInstance[]→ wrap вzones.body.children. Migrate at load time для existing project repos (silent upgrade).library-panel.tsx— стает drag-source для Layout Grid (через @dnd-kituseDraggable).library.ts:15—FALLBACK_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)
| Alternative | Why rejected |
|---|---|
| Имя «Composition» | overloaded с existing ScreenComposition; Layout Grid clearer scope (структурный движок) |
| Имя «Lattice» | unique, но obscure; Layout Grid — instantly understandable |
| Parser HTML → tree | fragile, breaks on mock changes, not production-grade |
| Single page root | не покрывает sidebar layouts |
| Mobile-first cascade | wrong default for desktop product |
| Separate Container type | split brain с existing ComponentInstance |
| Native HTML5 DnD (без @dnd-kit) | 4-side indicators + cross-level moves требуют tree-aware DnD; native API недостаточен |
| Resolved tokens values в props | kills live-update perf |
| Soft depth limit | юзер ignores warning → stack overflow |
| Native clipboard | type-limited; doesn't carry structured tree |
Phased rollout
Phase 1 — MVP (Sprint 1-2)
Acceptance criteria:
- Recursive renderer for
ComponentInstancetree - 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 ^3packages/shared/src/library.ts— addlayout-grid:containerto FALLBACK_LIBRARYpackages/shared/src/composition.ts— extendScreenCompositionсzonespackages/editor/src/composition-store.ts— add undo/redo middleware, history cap 20apps/web/src/lib/composition-store.ts— extend client APIapps/web/src/components/composition-canvas.tsx— REWRITE (recursive Layout Grid renderer)apps/web/src/components/library-panel.tsx— wrap items вuseDraggableapps/web/src/components/layout-grid/(new dir) —Container.tsx,Sortable.tsx,BgPlateRowsDnd.tsx-equivalent generalized,EmptyZone.tsx, hooksapps/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.htmlsections 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 разработку:
-
Прочитать в порядке:
- Этот 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)
- Этот ADR (
-
Установить deps:
pnpm add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities --filter @arno/web -
Создать структуру:
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) -
Тестировать against design-system.html Tokens section (первая что портируется в JSX). Acceptance: drag chip между rows работает, undo возвращает позицию.
-
Deferred questions (D13) фиксировать как
TODO(adr-0020)comments в коде — не закрывать silent.
Changelog
- 2026-05-25 v1.0: Initial ADR. Status Accepted. Sprint 1 starts next.