Rules

Operational rules для Claude при работе с этим проектом.

Naming chord — Arno + Renaissance

Canonical: ADR 0059. This section is the prose summary; the ADR carries the four-point checklist + alternatives + enforcement.

ARNO взято от реки Arno в Тоскане, на берегах которой зарождалась эпоха Возрождения. Все продуктовые имена внутри ARNO должны держать этот аккорд — итальянский / Renaissance reference, dignified, уникальные в поиске.

Канонические имена в продукте:

  • ARNO — сам продукт. Река.
  • Arno Foundation — базовый design-system package (packages/foundation/). Что-то вроде Vasari's La Pratica: исходный canon, от которого все пляшут.
  • Arno Sorgente — self-hosting проект. Sorgente = «исток / ключ». Sorgente del Falterona — реальная гора в Тоскане, где физически рождается река Arno. Метафора: проект-исток, откуда вытекает живой ARNO UI. См. ADR 0049. id = prj-arno-sorgente, route /app/sorgente.

Когда добавляется новая публично-видимая сущность — имя проверяется по тем же критериям: Italian / Renaissance roots, лексически уникально в SEO, metaphora прозрачна без сноски. Никаких generic core/source/hub.

Чтение spec

  1. _index.md — canonical source of truth. Читать первым перед любой содержательной работой.
  2. Detailed reasoning, audit findings, rejected alternatives — preserved в chat history исходных specs (ARNO v6, Observability v3, Tech Stack v2). Master spec — condensed reference.
  3. Новые знания о ARNO (после реальной разработки/тестов) — дописывать в _index.md с version bump в changelog.

Decision authority при конфликтах

Per §0.6 master spec:

  1. Master spec wins над individual specs (_index.md canonical)
  2. ARNO Product Design wins над Tech Stack по product behavior
  3. Tech Stack wins над ARNO Product Design по infrastructure mechanics
  4. Observability wins над both по что monitored/alerted
  5. Конфликты не workaround'ятся — resolved в next master spec version

Изменения spec

  • Minor clarifications: directly в _index.md, version bump (1.1 → 1.2), changelog updated
  • Major architecture changes: ADR created first в docs/adr/, обсуждается, потом master spec обновляется (version 1.x → 2.0)
  • Парковка возврат: trigger в § V совпал → unparked, добавлено в active scope
  • Audit findings: apply P0/P1 fixes как version bump

Парковка

§V парковка имеет explicit triggers. Не делать "preventive work" по парковке — только когда trigger срабатывает.

Scale ceiling

All architectural decisions targeted at 30K MAU ceiling — see ADR 0021. Re-evaluation triggers explicit в ADR §"Re-evaluation triggers". Любой patterns/tier/dependency выбирается под этот ceiling. Above 30K = open new ADR.

Implementation guidance

  • Перед началом implementation: week 1 prototyping (§VI) — 3 critical verifications + bundle measurement
  • Atomization MVP scope (§IV) — после week 1 results
  • Phase order: §VII.1
  • Launch readiness gates: §VII.2
  • Scale target: 30K MAU ceiling per ADR 0021 — не overengineer для 100K+, не underengineer для < 1K

Branch strategy — trunk-based (ADR 0022 v2.0)

main = единственный trunk и source of truth. Долгоживущих веток (dev/test/feat/layout-grid/ui-claude2/…) НЕТ — упразднены 2026-05-29 cutover'ом на trunk-based.

Работа над задачей:

  • Короткая ветка feat/<topic> (фича) или fix/<topic> (хотфикс) от свежего origin/main, в своём worktree: git worktree add -b feat/<topic> ../Arno-<topic> origin/main
  • PR → squash-merge в main → удалить ветку + worktree. Частая интеграция, без дрейфа.
  • Оба префикса (feat/** и fix/**) триггерят deploy-test.yml + preview-domain.yml → авто-привязка test-<topic>.arnomake.com. Другие префиксы (chore/, docs/) превью не получают — расширять триггер по необходимости.

Branch names must be globally unique across history (включая удалённые). Имя ветки = публичный subdomain (test-<topic>.arnomake.com) + permanent в GitHub history + кэшируется в extension bindings / proxy KV / audit trail. Переиспользование запутывает дев-loop и аудит.

  • Перед git worktree add -b feat/<topic> обязательно проверить collision:
    git fetch --all --prune
    git log --all --oneline | grep -i <topic>          # local + remote history
    gh api repos/vadimpianov/arno/branches --paginate | grep -i <topic>  # live remote
  • Collision (любая ветка с этим <topic> когда-либо существовала) → добавить suffix: -v2, -rulers, или дата -YYYY-MM-DD. Не переиспользовать имя удалённой ветки даже если фича похожая.
  • При renaming в процессе: git branch -m old new && git push origin new && git push origin --delete old && git worktree move ../Arno-old ../Arno-new + infra/preview-proxy/attach-branch.sh --detach old (orphan proxy binding безвреден, но мусор).

Окружения = деплой-слоты (не ветки) — ADR 0022 v2.0, infra/preview-proxy/:

  • test-<topic>.arnomake.com — превью ветки (свежий CF Pages деплой через proxy-worker). Хост привязывается авто на push в feat/** (.github/workflows/preview-domain.yml); вручную — infra/preview-proxy/attach-branch.sh <topic>.
  • dev.arnomake.comсвежий main на каждый push (ci.yml деплоит --branch dev → proxy; до первого dev-деплоя fallback на последний релиз).
  • arnomake.com — prod.

Прод-гейт (ADR 0022 v2.0): push/merge в main = только проверки CI, прод НЕ деплоится — мержем прод физически не сломать. Прод выкатывается только вручную: Actions → release (prod) → Run workflow (.github/workflows/release.yml: web + migrations + backend → arnomake.com). Запускать только по явной директиве «выкати / релизим». Branch protection не нужен для прод-гейта — гейт на деплое, а не на ветке. (Ранее здесь стояло «на free private GitHub protection недоступен» — устарело: org на плане enterprise, rulesets и merge queue доступны, см. §Shared trunk ниже.) (Fresh-dev реализован: ci.yml на push в main деплоит --branch dev, proxy резолвит ветку dev с fallback на main. Деплой proxy при изменении его кода — .github/workflows/deploy-proxy.yml.)

Shared trunk — merge queue (ADR 0089)

feat/sorg-layout is the shared Studio trunk: several parallel sessions land into it because they share one review surface, test-sorg-layout.arnomake.com. It is the one branch that is never pushed to directly.

  • Work on land/<topic>, open a PR into feat/sorg-layout, then gh pr merge --auto --squash. A session ends there — do not sit on the queue waiting for the merge, and do not poll it. Landing, ejection and conflicts are reported by trunk-pr-sync on the PR and by the local GHCIWatch → Notifier bridge, so nothing needs a session to be open. Nothing else — the merge queue decides ordering, batches entries, runs stand-gate on the speculative commit, and ejects a failing PR while merging the rest.
  • A PR touching tools/migrate/drizzle/** rides alone: the live test Neon is shared and an applied migration cannot be un-batched. stand-gate fails a mixed batch on purpose so the queue re-forms it.
  • After every landing — and right after you push to a PR — trunk-pr-sync.yml re-arms auto-merge on PRs that are mergeable but not queued, and comments on the ones that conflict. It does not push the trunk into PRs: a bot-authored head makes the required check wait for manual approval, and the queue tests trunk + PR speculatively anyway. GitHub drops a PR out of the queue when the trunk moves under it and never puts it back; #158 lay outside the queue for 51 minutes after its conflict was already fixed. Staleness is resolved for you; a PR left DIRTY has a real content overlap and gets a comment saying so — the queue orders merges, it cannot stop two sessions editing the same file.
  • The stand deploys once per merged commit, newest wins (deploy-test.ymlgatefreshnessdeploy). A superseded commit is not deployed.
  • Every other feat/** / fix/** branch keeps its own test-<topic> stand and the direct-push flow.
  • Never cherry-pick shared infrastructure onto the trunk — merge origin/main into it. A cherry-pick puts identical content under a different commit, so the merge base stays behind both copies and every later edit on either side re-conflicts. That is what broke the preview build's merge origin/main step twice on 2026-09-07 (deploy-test.yml, then Rules.md).

Старт сессии: git worktree add -b feat/<topic> ../Arno-<topic> origin/main. Документация (Rules.md/HANDOFF.md/ADR) — допускается прямо в main (canonical для всех сессий).

Бэкап: origin/backup/capture-onboarding — снапшот старой capture-ветки (контент уже в main; держим как страховку, удалить позже).

Два бэкенда (worker arno-api prod + arno-api-test test):

  • Prod api.arnomake.com — деплоить ТОЛЬКО через release (prod) из main (manual). Из feature-ветки прод-бэкенд никогда.
    • Почему: деплой непросмотренной схемы на prod. Инцидент 2026-05-27: feat-backend с колонкой entry_mode без миграции → SELECT FROM project падал 500 на боевом arnomake.com.
  • Test arno-api-test.<subdomain>.workers.devавто-деплоится из feat/** push через .github/workflows/deploy-test.yml. Это штатный поток: preview-билд таргетит его (NEXT_PUBLIC_ARNO_API в deploy-test.yml).
    • ⚠️ Тест-бэкенд общий и один: все feat-ветки катят один и тот же arno-api-test, last push wins. Бэкенд-правка на твоей feat-ветке = выкат на общий тест-стенд (а старая ветка без правки, перекатив, откатит чужой фикс). Старые ветки перед push в backend — rebase на main.
  • Schema-изменения в feature-ветке требуют миграции (Drizzle generate) ДО мержа в main. CI migration drift check ловит несгенерированные, но не забытые колонки — проверять вручную.
  • Чисто-frontend preview: wrangler pages deploy apps/web/out --branch=<branch> (backend не трогается).
  • CORS-грабля (повторяющийся инцидент): allowlist в apps/api/src/cors.ts должен покрывать ВСЕ env-хосты — dev/test-*.arnomake.com (trunk-based, ADR 0022 v2.0) + *.arno-1hu.pages.dev. Новый паттерн env-хоста БЕЗ правки cors.ts → preview-фронт получает ACAO arnomake.com, любой authed-fetch блокируется, токен стирается → залипание на Sign in. Есть unit-guard в cors.test.ts — расширять при добавлении хоста.
  • Granting dev/test access — two independent gates, not one. AUTH_ALLOWED_GITHUB_LOGINS (sign-in) and SORGENTE_MAINTAINER_LOGINS (Sorgente write access) are separate wrangler vars in wrangler.dev.toml / wrangler.test.toml — see auth-allowlist.ts (isLoginAllowed / isSorgenteMaintainer) and ADR 0052 §Amendment 2026-07-14. Adding a login to the first WITHOUT the second = level B (browse dev/test, cannot mutate the shared sorgente.* design system — the default ask). Adding to both = level A (full maintainer, can edit tokens/brand/composition everyone's stand reads). Default to level B unless the requester is explicitly meant to co-maintain Sorgente; asking "A or B?" before granting is the check, not an afterthought. Ship as: edit both toml files → PR from a fresh main-based branch (cherry-pick from a feature branch if the change originated there) → merge → ci.yml deploys arno-api-dev automatically. Never deploy this to prod (no live Sorgente there, per ADR 0052 §2).
  • Wrangler login → GH Actions fallback (если CF Turnstile блокирует локально): wrangler login периодически ловит Cloudflare bot challenge / 403 при OAuth callback (известная Wrangler issue, особенно с FRA Ray ID). Не залипать на retry / VPN — переходить на pattern «через GH Actions с CLOUDFLARE_API_TOKEN». Применённые workflow'ы как референс: .github/workflows/provision-dev-env.yml (KV create), .github/workflows/seed-dev-secrets.yml (secret seed + deploy), .github/workflows/update-prod-oauth-secret.yml (one-shot secret put). Все three: read CF token from secrets.CLOUDFLARE_API_TOKEN, pipe value через printf '%s' "$VAL" | wrangler secret put X -c <config>, GH Actions auto-redact'ит значения в логах. После использования one-shot workflow — git rm файл, удалить временный repo secret. Не оставлять как permanent infra.

Operational

  • SPOF mitigation от day 1: multi-owner Cloudflare, separate DNS registrar, DNS TTL 300s, secrets backup
  • Cost ladder triggers: monitor free tier usage, plan upgrade перед 80% capacity
  • 90-day secret rotation: automated reminders
  • Quarterly: runbook review, alert false-positive review, OTel SDK upgrade review
  • Test surface = authed /app/** всегда. Когда отдаёшь ссылку на test-стенд или зовёшь юзера проверить — это test-<topic>.arnomake.com/app/library (или нужный /app/...) через sign-in, не публичный /foundation-preview. Public preview маршруты существуют только как unauth entry-point, не как канал верификации: project-scoped state (brand, fonts, token overrides, WCAG history) живёт за auth и в /foundation-preview не видно. Если фича принципиально без auth — указать явно почему.
  • HANDOFF.md — читать через offset, не целиком. Файл большой (per-session token-cost). Для «где остановились» хватает верхних ~6 сессий (offset до первого 📦 Старые сессии-указателя) + ## TL;DR. Полный файл / HANDOFF-archive.md — только когда явно нужна старая история или evergreen-раздел (Grep по заголовку). SessionStart-хук handoff_size_check.sh напоминает архивировать когда файл перерастает 1400 строк.
  • Исследование кода — делегировать субагентам (Explore / general-purpose / Plan), не читать десятки файлов инлайн. Один инлайновый «разберись в модуле» затягивает 50–100k токенов содержимого в главный контекст; тот же таск субагентом возвращает только вывод (~2k). Триггеры делегации: «где определён X / кто зовёт Y», обзор незнакомой подсистемы, cross-file аудит, поиск по нескольким конвенциям именования. Инлайн оставляем только точечное (знаю файл+символ — Grep/Read с offset). Несколько независимых поисков — одним сообщением параллельно.
  • Dito — canonical имя box-layer инструмента Studio. Всегда «Dito», не «box layers»/«layer tool»/варианты. (Перенесено из memory — project invariant.)

Language

Every written artifact in this repository is English. Single rule, no per-file carve-outs.

Scope (everything that ends up tracked in git):

  • User-facing surfaces — web app UI (apps/web/**), browser extension UI (packages/url-import-extension/), CLI output (packages/url-import-extractor/), marketing copy, emails, notifications, generated artifacts (TSX comments, manifest fields, story titles).
  • Developer-facing documentation_index.md, Rules.md, README.md, Implementation_Workflow.md, docs/runbooks/**, docs/features.md, every *.md not explicitly exempt below.
  • Source code — comments, JSDoc, log messages, error strings, identifiers (variables, functions, files, branches).
  • Git surface — commit messages, PR titles, PR descriptions, issue descriptions, branch names.

The only live exception is the synchronous chat between the maintainer and the Claude assistant: that chat runs in Russian for fluency. Anything derived from the chat that lands in the repo — a doc edit, a commit message, a code comment, a PR description — is written in English by the time it is staged.

Two scoped carve-outs (historical preservation):

  • docs/adr/** — Architecture Decision Records are an immutable historical log (industry consensus per Michael Nygard; ThoughtWorks; Spotify R&D). Retroactive translation of existing ADRs would rewrite the record of how decisions were made. Policy: from ADR 0037 onward every new ADR is authored in English; ADRs 0001–0036 stay in their original language as historical artifacts.
  • HANDOFF.md — volatile per-session state, fully rewritten over a 2–3 session window. Translation happens through natural attrition: every new session-end entry is authored in English, old Russian entries age out as the relevant context becomes obsolete. No retroactive rewrite pass.

Glossary. Domain vocabulary is locked in docs/translation-glossary.md. Every translation pass — solo or with parallel agents — consults the glossary so terminology stays consistent (artboard, capture, workflow, token, ...). The glossary is the only .md in the repo allowed to contain Cyrillic.

Trigger. Before staging or composing a commit: if any Russian copy survives in the diff outside the carve-outs above → translate first using the glossary, then proceed. Legacy Russian content in non-exempt files is translated incrementally when the file is touched by another change (same trigger-driven model as the feature-folder migration policy below), or in a dedicated translation PR after the first paired feature lands.

Enforcement. scripts/check-language.ts (run via pnpm lang:check advisory, pnpm lang:check:strict enforcing) scans every tracked .md/.ts/.tsx/.js for Cyrillic, skipping the glossary, HANDOFF.md, and docs/adr/**. Currently advisory. Flips to strict (pre-commit + CI gate) once translation pass 1 lands.

Things to NEVER do

  • Add features beyond §IV без обновления master spec
  • Skip pre-edit impact analysis для breaking changes
  • Force-push session-branches automatically
  • Log PII / secrets (§II.2 blocklist)
  • Trust client-side validation only (server-side mutation validation mandatory)
  • Add user_id to metric labels (cardinality budget violation)
  • Lower DNS TTL после initial setup (kept 300s для disaster recovery readiness)
  • Use root Cloudflare API token в CI (scoped tokens only)
  • Use single secret Cloudflare API token without rotation
  • Commit secrets в repo
  • Commit any artifact in Russian (docs, code comments, log messages, commit messages, PR copy, identifiers, branch names). See §"Language" — the only Russian channel is the live chat with the maintainer, and chat output is translated before it is staged.
  • Возвращаться к type-folder в apps/web/src/ / apps/api/src/ / packages/<pkg>/src/ (плоский слой *.tsx/*.ts без feature grouping). Canonical layout — feature-folder (см §"Code structure"). Касание legacy файла без переноса в features/<name>/ допустимо только когда правка <10 LOC и связанный кластер ещё не мигрирован — тогда оставить TODO в HANDOFF.md про последующий move.
  • Регрессировать extension capture-onboarding bind flow (восстановлен 2026-05-31, серия 1ef4f65..abfe9a5). При любой правке packages/url-import-extension/:
    • manifest.json content_scripts.matches покрывает ВСЕ живые arno-frontend хосты (arnomake.com, *.arnomake.com, *.arno-1hu.pages.dev). Перед сужением — grep живых стендов в HANDOFF.
    • permission webNavigation не убирать — без него SPA pushState не ловится, переключение проектов в той же вкладке ломается.
    • arno-bridge.js isProd соответствует фактическому API target frontend'а (проверять в bundle chunk: curl <host>/app/ → /_next/static/chunks/ → grep api.arnomake.com|arno-api-test). Несогласованность frontend↔bridge → JWT отвергается «invalid». На сегодня: arnomake.com / dev.arnomake.com → prod, test-<topic>.arnomake.com / *.arno-1hu.pages.dev → test.
    • Три слоя bind (localStorage flag, URL query, React event) + три слоя re-bind (webNavigation, focus, visibilitychange) — все обязательны. Упрощая — проверь как сломается на: создании / открытии existing / переключении в нав / переключении табов.
    • resolveAndBindProject retry-логика — терпит race SET_PROJECT_ID до AUTH_TOKEN_HANDOFF. Не убирай retry без альтернативного механизма.
    • Extension context invalidated swallowed — это dev-loop noise после reload extension, не баг.
  • Регрессировать docs/capture_v2_measurement.md sequence: до любого редизайна capture pipeline (B3 / capture v2) — measurement pass на 4-5 реальных сайтах. Без него scope = воздушные замки.
  • Регрессировать capture-v3 code-extraction pipeline (восстановлен 2026-06-01, ветка feat/capture-v2, серия 4670680..b431c92). При правке packages/url-import-extension/background.js/content-script.js или apps/web/src/lib/tsx-generator.ts/components/composition-renderer.tsx:
    • extractFiberInPage (background.js) — пройти ВСЕ pre-checks в functioning state: DevTools hook detection + DOM scan для __reactFiber*/__reactInternalInstance*/__reactContainer*, walk до root через .return, iterative DFS с budget 400, typeIdOf Map для stable per-type identity, sampleProps redacts functions/objects/arrays/refs sentinels, fiberName skips host strings + $$typeof providers/contexts/forwardRef/memo/lazy/fragment, firstHostElement + sampleRect + sampleStyles budget=200. Любая из этих фич удалится — composition/spatial layout фолится.
    • manifest.json permissions — нужны scripting (для executeScript world:"MAIN") + webNavigation + activeTab. Не убирать.
    • isFrameworkNoise heuristic в tsx-generator.ts: length ≤ 3 chars + ALL-CAPS ≤ 4 chars + Anonymous/Special/Component. Mantine/Stripe сильно зависят от него — без него top 30 levels = Next.js providers и user content в truncated tail.
    • CodePanel Generated TSX section + Page Composition section — оба читают из clusterByType + generatePageComposition. Если API tsx-generator меняешь, фиксируй consumers сразу — TypeScript должен ловить.
    • composition-renderer.tsx two modes: spatial (когда n.rect && n.rect.w > 0) + nested tree (fallback). Pre-Phase-5 captures rely на fallback, не убирать.
    • Capture flow: runLayoutCapture в content-script.js теперь invokes CAPTURE_FIBER background message после layout tree но перед UPLOAD_PAGE_TREE. Order matters — fiber probe требует paused animations установленных предыдущим scrollToBottom + getAnimations pause.
    • DB schema: captured_page.fiber_tree jsonb nullable (migration 20260601000000). Backend route apps/api/src/captured-pages.ts accepts fiber_tree в payload AND в onConflictDoUpdate set. Не дропать.
    • При reload extension stale content scripts ловят "Extension context invalidated" → silenced в bridge setProject. Не возвращать warn без причины.
  • Регрессировать popup-less capture flow (закреплён 2026-06-02, серия ccb0961..4940207). Click toolbar → capture → spinner → on finish focus/open project tab; click during spinner → cancel. Manifest action БЕЗ default_popup (если вернуть — onClicked не стреляет). chrome.action.onClicked listener в background обязателен. openOrFocusWorkflow матчит по project id на ЛЮБОМ arno-host (arnomake.com/*.arnomake.com/*.arno-1hu.pages.dev) — не сужать до webUrl.origin, иначе cross-env (capture на dev, проект открыт на prod) промахивается и каждый раз новая вкладка. CAPTURE_CANCEL → content-script ставит captureCancelled=true, UPLOAD_PAGE_TREE / UPLOAD_SELECTED становятся no-op'ами — оба guard'а обязательны, без них late upload приходит на бек после отмены. setBrandIcon + setCaptureBadge(false) через статические PNG paths из icons/icon-*.png — никаких canvas/SVG synth (Chrome не гарантирует setIcon с ImageData во время cold SW boot, manifest icons надёжнее).
  • Регрессировать three-env adoption flow (восстановлен 2026-06-02, серия 8a69f8d..4d7afe4). Расширение работает с тремя одновременно открытыми ARNO-вкладками (prod / dev / test); switching между ними не должен «прилипать» к первому env. При правке packages/url-import-extension/:
    • arno-bridge.js стэмпит apiUrl на КАЖДОМ сообщении к background (AUTH_TOKEN_HANDOFF, AUTH_VALIDATE, SET_PROJECT_ID, BIND_LATEST). Background читает msg.apiUrl напрямую, к storage за env-data не обращается. Любой новый message-handler, который вызывает getApiUrl() из storage вместо msg.apiUrl — race в один тик ломает 401-цикл.
    • background.js: chrome.tabs.onActivated + chrome.tabs.onUpdated listeners — единственный source of truth для «какой env сейчас в фокусе». Adopt active tab → setEnv + resolveAndBindProject(pid, apiUrl). Не удалять, не упрощать через bridge focus events: тестовые сценарии (две вкладки разных env, Cmd+Tab между ними) ломаются без этого.
    • manifest.json permissions — нужен tabs (для tab.url в onActivated/onUpdated payload) и host_permissions со ВСЕМИ тремя backends (api.arnomake.com, arno-api-dev.workers.dev, arno-api-test.workers.dev). Не выкидывать.
    • resolveAndBindProject + bindLatestProject — retry на 401 (5×1s, тот же паттерн что и для missing JWT). Без него post-switch fetch на нового env'а с старым JWT упирается в 401 и проект застревает как id-as-name.
    • Bridge reannounce на focus/visibility — пушит env И токен из localStorage.arno.jwt. Не убирать token-push: AUTH_TOKEN_HANDOFF должен прийти в окно ретрая resolveAndBindProject.
    • popup.js: state ходит ТОЛЬКО через liveGet (lib/live-store.js). Любая новая chrome.storage.local.get + render без onChanged подписки — реинтродукция off-by-one бага. См user-memory feedback_oauth_app_verify_callback_first для рассинхрона storage vs UI как класс.

Migration approval gate — was "NEVER skip", now per ADR 0029 auto-applied в CI while we operate solo. Re-evaluation triggers listed в ADR (team > 1, first migration incident, compliance, sustained volume). Do not regress to a manual gate without one of those triggers firing.

UI invariants (do not regress)

  • Tweak selection outline — always on top, never clipped (apps/web/src/dev-tools/inspector/modes/tweak/Tweak.tsx + Tweak.css; canonical invariant docs/canvas/invariants.md I33). The mint→violet outline + corner handles are portalled to <body> as ONE position: fixed layer (.li__scroll-layer, z-index: 9998 — above the canvas ≤ 50, below the inspector panel 9999) and positioned in VIEWPORT space (measureOutline returns the raw getBoundingClientRect, no scroller math). A body-hosted fixed overlay is clipped by no canvas ancestor — selection is always on top.

    • Do NOT host the overlay inside the picked element's scroll container. That was the old design (content-space coords, to ride native scroll with zero lag); its cost was that the container's own overflow / clip-path ate the frame at its edges — the top handles of the first child of a column Reel, the side handles at a folder edge (the "selection clipped by the folder" report). getScroller / isInsideClip / the scroller position-promotion were deleted; there is no scroller-hosting path left to regress into. Reintroducing one re-opens the clip.
    • Position is owned by a self-healing rAF loop (sync), NOT by enumerating layout-change causes. While a selection exists and the tab is visible, the loop re-measures the viewport rect every frame and writes to React only when outlineKey changed — the single source of truth, tracking EVERY layout change (scroll, Tela preset switch, CSS-zoom change, webfont/image reflow, animation) with zero per-cause plumbing. This REPLACED the old notifyDeviceFrameScroll channel (a leak generator). Do not reintroduce per-cause notify hacks — if the frame ever strands, the loop stopped or a rect isn't measured, not "a trigger is missing."
    • A viewport rect is NOT scroll-invariant (unlike the old content-space rect), so a capture-phase scroll listener (window.addEventListener("scroll", sync, true) — fires for any nested scroller, since scroll doesn't bubble) re-lands the frame the instant a container scrolls; on a fast native scroll it can trail by at most one frame. This is the accepted trade for never being clipped. getBoundingClientRect already reflects ancestor scroll + CSS zoom, so the frame lands on-screen at every zoom level.
    • Keep the non-rAF triggers for the BACKGROUNDED case. rAF pauses when the tab is hidden, so subscribeActive (tree edits, 0-frame), the border-box ResizeObserver, resize, and the 250ms detach poll stay — they cover edits/removals that land while hidden (headless tests, a delete from another surface) which the loop can't see. Removing them would re-freeze the hidden-tab paths.
  • Artboard scroll: fill backing + contained overscroll (apps/web/src/components/workflow-composition-artboard.tsx artboardFrameStyle + apps/web/src/app/globals.css + packages/ponte/src/adaptive/primitives/Reel.tsx; contract unit-locked by workflow-composition-artboard.frame.test.ts). A clip-mode device artboard (numeric height + clipHeight ON) is a fixed device-height window whose inner viewport scrolls. Two things are load-bearing and must not regress:

    • The FILL backs the whole scroll window, not just the first screen. The root box's own background-color is pinned to the device height (StudioRenderer.css min-block-size: 0, guarded by fill-height-css.test.ts) and scrolls up out of view, so scrolled-in content sat over the bare dark canvas (report 2026-07-23). Fix: the scroll VIEWPORT carries backgroundColor: root.fill — a background on a scroll container is fixed to its border box, so it always covers the visible window at any scrollTop. Only in clip mode (unclipped root grows to content and backs itself). Do NOT try to un-pin the root box to make its own fill grow — the pin is what keeps the selection frame from detaching (measured from the device-sized frame-wrap). Fill-on-viewport is the decoupled fix.
    • Overscroll is CONTAINED — nothing rubber-bands the app shell, and the selection ring never drifts. The wheel is intentionally released to native content scroll over a selected artboard (use-pan-zoom releaseWheelToContent), so at the scroll limit an uncontained overscroll chained up through .app-canvas-area to html,body and bounced the whole UI; worse, the browser bounce moved the viewport-fixed selection ring while the transform-panned canvas did NOT bounce → the ring stretched/detached with the artboard sitting still (report 2026-07-23). Fix is pure CSS containment, no JS guard: every inner scroll area sets overscroll-behavior: contain (artboard viewport, Reel) so it can't chain, and html,body { overscroll-behavior: none } is the final backstop (also kills trackpad back/forward-swipe navigation — desired for an app shell). If the ring ever drifts on overscroll again, a new scroll surface is missing contain, not "the ring needs a guard."
  • Box fill model — Studio canvas (apps/web/src/dev-tools/studio/render/StudioRenderer.{tsx,css}). Every box strives to occupy the full width of its parent. A box, component, or text leaf with no explicit size fills the parent's cross axis (full page width inside a column) and reflows with it — it never hugs its content when there is room. Mechanism: .studio__box and .studio__leaf get align-self: stretch; the renderer sets inlineSize: node.size (undefined → auto, so stretch governs). Padding and gap inset the content, they do not shrink the box — Studio containers are box-sizing: content-box, and fill is via align-self (not width: 100%) precisely so a definite width + padding can't overflow the parent. A box opts out of fill only by an explicit size; then the parent's Punta align icons (align/justify) position it. Never reintroduce inline-size: 100%/auto defaults on boxes (overflows with content-box padding) and never make leaves align-self: start (re-creates the hug-left bug). Applies at every nesting level, not just the root — and at every Studio surface, not just one page. The box-sizing: content-box switch is NOT global (the app-wide reset is border-box); it is opted into per-surface via an ancestor scope selector, one per surface that mounts StudioRenderer: .slc [data-arno-box] (Design System / Screen tab, sorgente-library-catalog.css § Punta padding semantics) and .wf-composition-artboard [data-arno-box] (Workflow tab's pan/zoom canvas, artboards-canvas.css). A new Studio surface that skips this scoping silently falls back to border-box — padding then eats the content area inward instead of growing the box outward, a bug found live 2026-07-16 (Workflow tab had no scope class at all). Any new surface that mounts StudioRenderer must add its own scope selector to this list — grep both files for data-arno-box before shipping. (Current file location: packages/quadro/src/features/canvas/StudioRenderer.{tsx,css} after the canvas → @arno/quadro move; the dev-tools/studio/render path above is historical.)

    • The ROOT artboard box is a DEVICE FRAME — box-sizing: border-box, the ONE exception to the content-box rule above (.wf-composition-artboard [data-arno-box-id="wf-artboard-root"] in artboards-canvas.css; found live 2026-07-23). Nested boxes fill via align-self: stretch with NO definite width, so content-box padding insets without overflow. But the root gets a DEFINITE width = the Tela device width (as min/max-width, committed by the WIDTH field's Tela clamp). A definite width on a content-box box adds padding OUTSIDE it, so the 20+20 padding pushed the root's border-box to 1480 on a 1440 Tela cell — the artboard stuck out 40px on the right (and the selection ring with it). The root's Tela dimensions are its OUTER bounds: border-box makes the Tela width the TOTAL width (content insets to 1400), so the frame is exactly the device size. Scope border-box to the root frame node ONLY — never widen it to [data-arno-box] (that would re-break the nested fill model). Root-cause fix (2026-07-24): Tela DICTATES the root width, so it is not hand-settable. (a) The root's WIDTH fields are LOCKED in Punta — the WIDTH block (blocks/width/Width.tsx, ADR 0078) resolves the root lock from its declared conditions ({ when: "root", effect: { locked: true } }, via blocks/conditions.ts — was an inline isRoot id-compare) and passes locked to WidthField, which then renders a read-only input showing the Tela width + a disabled Hug (H) badge, so no fixed min/max-width is ever committed (that commit was what pinned the definite width). (b) The root box's min-inline-size/max-inline-size are reset to 0/none !important in the same artboards-canvas.css rule (box-style emits any stored min/max-width as an INLINE style, so only !important overrides it) — the root then fills the device cell via align-self: stretch, tracking Tela preset changes and neutralising any already-stored width. A fresh seed root carries NO min/max-width, so this only matters for nodes an earlier WIDTH-field edit already touched.
    • An EXPLICIT align overrides the fill default per child (added 2026-07-17, StudioRenderer CROSS_ALIGN_SELF, threaded through RenderNode/Container/SortableLeaf). The align-self: stretch fill default holds ONLY while a box's align is unset. When a parent box sets align (the Punta ALIGN matrix), that value threads onto each child as inline align-self, so the child moves to the chosen cross-axis position instead of stretching — this is what lets a row with align: center vertically centre its leaves (e.g. a pasted Figma chip: icon + price). The "never make leaves align-self: start" rule still governs the DEFAULT (unset → stretch, no inline override — verified by a not.toContain("align-self") test); an EXPLICIT align: start is the designer's own Punta choice and is allowed. Unset align emits NO override, so the fill model is unchanged for the common case.
  • Projects-page header user cluster (apps/web/src/components/projects-list.css.projects-user*): box-to-box spacing — border edge to border edge, the gap the eye reads once the chip hover frame is visible — locked at 8 / 20 / 12 (avatar → name → Settings → Sign out). Mechanism = .projects-user { gap: 0 } + per-element margin-left: 8 on name, 20 on Settings, 12 on Sign out. Chips keep padding: 4px 10px + transparent 1px border that goes solid on hover. Never measure these distances text-to-text — chip padding makes that meaningless once the hover frame appears.

  • App navbar header geometry (apps/web/src/components/app-navbar.css + arno-logo.css). Shell height 48, asymmetric padding 0 12px 0 16px (left edge wider to seat the chevron). Left cluster [Logo] | [Project chip] | [Tabs]:

    • Sizes: logo wrap 112 × 16 long form / 32 × 16 short form (both even by design, see arno-logo.css keyframes); dividers .app-navbar-project::before / .app-navbar-tabs::before rendered as 1 × 16 hairlines absolutely positioned at inset-inline-start: 0.
    • Beat: the row runs a 12-pixel beat between every adjacent box, edge-to-edge. The flex container is declarative .app-navbar-left { gap: 12px }. Divider→text inside chip and tabs is also 12: .app-navbar-project { padding-left: 13 } / .app-navbar-tabs { padding-left: 13 } = 12 visual + 1px absolutely-positioned divider. Right cluster: .app-navbar-right { gap: 4 }.
    • Sidebar-seam compensation (the only structural exception): .app-navbar-logo + * { margin-inline-start: -1px }. This pulls the first divider 1px left so it lands on column 59 — the same column as the sidebar's right border directly below the navbar (sidebar width 60, border inside). Sum: shell pad-L 16 + logo 32 + (12 − 1) + divider 1 = 60. Without the −1 the navbar divider sits at column 60 and visibly misaligns with the sidebar border at column 59 by 1px. The 12-beat is preserved declaratively for the rest of the row (chip↔tabs stays exactly 12).
    • padding-left: 13 is non-negotiable — dropping back to 12 produces 11px between divider and text, off the canonical spacing scale (0/2/4/8/12/16/20/24…).
    • Mechanism: dividers live inside the chip/tabs left padding via ::before. Anything that re-introduces a DOM divider element must reset padding to 12. Anything that adds a new section after tabs must declare its divider the same way (absolute ::before + 13px padding-left).
    • Why the beat works: the logo and the dividers are all 16 tall; chip and tabs min-block-size: 32 keeps verticals aligned regardless of inner font/leading. Never raise the divider height past 16 without re-checking the rest of the row.
    • If sidebar width changes, recompute the sibling-compensation: pad-L + logo + (gap + margin) + 1 = sidebar-width (currently 16 + 32 + (12 − 1) + 1 = 60). The −1 is structural, not aesthetic.
  • Media (frame) primitive — per-child ratio, wrapper carries the gap (packages/ponte/src/adaptive/primitives/Frame.tsx, frameChildCss + frameWrapperStyle; tests Frame.test.ts). Applying Media to a folder applies its aspectRatio to every direct child (.frame-<id> > *), not to the wrapper: each layer becomes that proportion and they stack one after another. The wrapper is a flex COLUMN (like Stack) so the folder's gap (Punta spacing) spaces the layers. object-fit: cover stays on the child rule so a replaced-element child (<img>/<video>) crops to fill — it is inert on a plain box, which just gets ratio + overflow: hidden; min-block-size: 0 lets that clip win over a flex child's taller content.

    • Never put aspect-ratio + overflow: hidden on the wrapper with children forced to height: 100% (the original bug): every child stretched to fill one shared 16/9 box, so they overlapped and all but the first were clipped, and because object-fit: cover does nothing on a <div>, a folder of non-media layers rendered empty / transparent. Ratio belongs on the children, spacing on the (flex-column) wrapper.
    • Padding of the Media folder is still the outer .studio__box's content-box padding (containerBoxStyle), not Frame's job — Frame only owns child ratio + inter-child gap.
  • Layers keyboard reorder — arrow = move the selected layer one slot along its parent's axis (systemic, not local). Arrow keys REORDER the picked node (the auto-layout answer to Figma's positional nudge): axis from flowOf(parent) (@arno/punta) — vertical parent → ↑/↓, horizontal (Cluster/Reel-row/Switcher/Sidebar/Grid) → ←/→; perpendicular arrow is not the reorder axis. The behaviour lives in ONE place — StudioDndShell's keydown → nudgeNode(tree,id,"prev"|"next") (@arno/ponte/tree-ops) → moveNodeAt, the SAME path drag-drop uses — so it fires identically whether the layer was picked on the canvas or in the panel. One press = one slot; clamped at the ends (no cross-parent hop in v1); instant-write + undoable via the active tree's setTree; moved node stays selected. LayersSidebar no longer navigates on ↑/↓ (Tab walks the tree instead); its ←/→ collapse/expand bails when flowOf(parent)==="row" so the two handlers never double-fire. Docs: docs/layers/architecture.md § Keyboard reorder, scenarios.md K09/K11/K12/K17/K18. Guarded off while editing text (INPUT/TEXTAREA/contenteditable) and on modified arrows (⌘/Ctrl/Alt).

  • Multi-select operations obey the WHOLE selection — systemic, not per-call-site (docs/layers/invariants.md I31; commit 914d379). Pick N layers (Shift-click) and every structural op — Delete, Duplicate, drag-move, Group — acts on all N, never just the focused/right-clicked/primary row. The rule is ONE function, operationTargets(actedId) (@arno/ponte/studio-bus): the acted node is IN the current pick set → the whole selection; OUTSIDE it → just that node (and the caller re-selects). Targets are normalized by normalizeSelection (@arno/ponte/tree-ops) — drop root, dedupe, and drop any node whose ancestor is also selected (the folder carries its child; acting on the child too would double-delete / strand / over-clone) — the SAME normalization moveNodesAt already used, so move/remove/duplicate agree. Bus multi-ops (removeManyFromActiveTree, duplicateManyActiveBoxes) mutate in ONE setTree, so an N-node delete is a single undo, not N. Every entry point routes here — Layers keydown + context menu, Dito toolbar, Punta footer, canvas keydown, canvas-drop (StudioDndShell), and Group (already multi) — none re-implement selection logic. Never add a mutation that reads a single cur/menu.id/pickedId directly — go through operationTargets. (Arrow-key nudge is the one deliberate single-node exception for now — multi-nudge index math is unbuilt; noted in the code.)

  • Studio panel tops anchor to the Tela width chips (.tela-preset row — Adaptive / 375×667 / …; measured live on the stand: top 54, height 28, centre 68). The three panels left of the canvas align their first element to that chip row, and the offsets are pixel-measured, deliberately off the 0/2/4/8 scale — do NOT "round to scale", it desyncs the shared baseline. Values were established by live DOM measurement (getBoundingClientRect), not by eye — guessing cost several wrong iterations before measuring:

    • Sidebar (apps/web/src/components/sidebar.css.sidebar): the first 40px icon-frame TOP lines up with the chip top (54). Sidebar top = 48, so padding: 6px 0 8px → 48 + 6 = 54.
    • Layers (packages/layers/src/LayersSidebar.css.layers-sb__top-spacer): the root row icon CENTRE lines up with the chip centre (68). Panel top = 48, root row is 26px → block-size: 7px → 48 + 7 + 13 = 68.
    • Punta (packages/punta/src/Punta.css.punta-sections): the first section heading (LAYOUT) centre sits on the same baseline via padding-block-start: 8px (rail top + 8 + 12 = +20, where 12 is half the 24px .punta-section-head).
    • The Tela chip geometry is the source of truth. If the chip size or position changes, re-measure all three on the live stand — changing one panel's top offset in isolation breaks the row. (The stale .sidebar 10px / Layers 17px comments predate this and were replaced.)

Code structure — feature folders (canonical layout)

Canonical target для всего кода в репе — feature-folder (vertical slice). Эталон: apps/web/src/components/layout-grid/. Применяется к:

ScopeLayout rootNotes
apps/web/src/features/<name>/ + shared/Bulletproof React, без FSD layers entities/widgets/pages. App routes (app/**) тонкие, импортят feature entry.
apps/api/src/features/<domain>/ + shared/Vertical slice (Jimmy Bogard). Hono routes — <feature>/routes.ts, handlers/services/types — внутри той же feature. index.ts собирает Hono app из feature.routes.
packages/<pkg>/src/features/<name>/ (когда пакет > 1 концепта) ИЛИ flat (когда пакет = 1 концепт, как db = только schema)Каждый workspace package = уже feature на верхнем уровне; внутреннюю структуру дроби только при > 5 файлов одного жанра.
packages/url-import-extension/features/<flow>/ + lib/ (constrained manifest)Background / content-script / popup остаются как top-level entry points (manifest требует), но per-flow логика (crawl/, bind/, capture/, env-adopt/) — folder per flow.

Любой новый код создаётся в этом виде; legacy плоские файлы мигрируются в features/<name>/ по мере касания.

Out of scope (остаётся flat by design)

ЧтоПочему
apps/api/src/migrations/ + Drizzle journalSequential timestamp-ordered, feature grouping ломает ordering и Drizzle convention.
apps/api/src/schema.ts (Drizzle schema)Single source of truth — все таблицы видимы вместе для FK/index analysis.
apps/api/src/cors.ts / apps/api/src/helpers.ts (cross-feature middleware)Живут в shared/lib/ после миграции, не дробить per-feature.
infra/Infrastructure — Cloudflare configs, terraform-like артефакты, workflow scripts. Группировка по env/service, не по feature.
docs/adr/Sequential numbering — ADR-0001, 0002, … — feature grouping ломает chronology.
scripts/Top-level utility scripts. Если разрастаются — выносить в tools/<name>/ как отдельный package.

Layout per scope

Frontend (apps/web/src/):

├── app/                          # Next.js routes — тонкие, импортят feature entry
├── shared/
│   ├── ui/                       # cross-feature visual primitives
│   ├── lib/                      # api-client, auth-store, sentry, persistence
│   └── styles/                   # globals.css
└── features/<feature>/
    ├── <feature>.tsx             # entry component
    ├── <Component>.tsx + .css    # один UI-элемент = один файл, CSS колокирован
    ├── hooks/  store/  utils/
    ├── _index.md  index.ts       # inventory + barrel

Backend (apps/api/src/):

├── index.ts                      # Hono app composition (import feature routes)
├── schema.ts                     # Drizzle — flat by design
├── migrations/                   # flat by design
├── shared/
│   ├── lib/                      # cors, helpers, db client, auth middleware
│   └── types/                    # cross-feature contracts (если есть)
└── features/<domain>/
    ├── routes.ts                 # Hono router for this domain
    ├── handlers.ts ИЛИ <route>.handler.ts (если > 3 routes)
    ├── service.ts                # business logic (если требуется отделить от handlers)
    ├── schema.ts                 # zod request/response schemas
    ├── types.ts
    ├── *.test.ts                 # tests рядом с тестируемым кодом
    ├── _index.md
    └── index.ts                  # exports routes для index.ts root

Sub-folders внутри feature разрешены, когда внутри > 5 файлов одного жанра. Не плодить ради 1-2 файлов.

Hard rules per feature (apply to all scopes above)

#ПравилоTrigger violation
1Один элемент = один файл (UI-компонент, hook, store, route handler, service, type, helper)Файл > 200 LOC → split. composition-renderer.tsx (1352) — образец того, что НЕ должно повторяться.
2CSS колокирован рядом с потребителем (frontend-only)<thing>.tsx + <thing>.css рядом. Один общий feature.css — только если стилей < 30 строк ИЛИ это shell-grid фичи.
3Feature владеет своим state / contractFrontend: store/, hooks/, types.ts внутри feature. Backend: schema.ts (zod), service.ts, types.ts внутри feature. Не в общем shared/ пока не доказано sharing ≥ 2 features.
4Импорт между features — только через index.ts barrelimport { X } from "@/features/workflow" ✓. import { X } from "@/features/workflow/canvas/artboard-frame" ✗. Tsconfig path alias @/features/* + @/shared/* (frontend) / ~/features/* (backend, Hono workspace) enforce'ит границу; ESLint no-restricted-imports ловит deep imports.
5Каждая feature → _index.mdInventory файлов + dependency tree + публичный API (что в index.ts). Per CLAUDE.md _index.md rule.
6Lift в shared/ только когда ≥ 2 features уже используют И API стабиленПреждевременный lift размазывает домен. Default — держать в feature до доказательства.
7Entry points тонкиеFrontend: app/<route>/page.tsx = <FeatureEntry /> + минимальный data fetch. Backend: src/index.ts = composition Hono app из feature.routes, никакой business logic. Логика — в feature.
8Tests колокированы<thing>.test.ts рядом с <thing>.ts. Backend: тест per-handler / per-service в той же feature folder. Не отдельная __tests__/ папка.

Когда создавать новую feature vs дополнять существующую

  • Новая feature = новая UX-поверхность (новая страница / новая боковая панель / новый режим работы канваса).
  • Дополнить существующую = новое поведение той же поверхности (новый action в navbar = features/navbar/, не features/share-button-v2/).
  • Сомнения → дополнять. Lift в новую feature только когда становится clear что concept живёт независимо.

Domain-aligned naming (frontend ↔ backend)

Principle. When a frontend feature and a backend feature serve the same business domain, their folder names must match. This is the ubiquitous language rule from Domain-Driven Design (Evans): one concept → one name across UI, API, and DB. Industry references: Nx monorepo feature-library conventions; tRPC/Hono workspace patterns; Stripe/Linear domain-registry conventions.

Naming contract.

  • Same domain on both stacks → identical folder name. Example: apps/web/src/features/captured-pages/apps/api/src/features/captured-pages/.
  • Format: lowercase-kebab, noun, no stack hint. Forbidden: captured-pages-api/, web-captured-pages/, capturedPages/.
  • Renaming one side without the other is forbidden. A rename is a registry change (see below) + both folders + import refactor in one PR.

Structural rule — alignment is at the name and the contract, NOT at the file tree. Internal structure follows the per-stack layout in the tables above (frontend has <Component>.tsx + .css + hooks/; backend has routes.ts + handlers + schema.ts + service.ts). Mirroring the directory tree across stacks is not required and is an anti-pattern — file genres differ. The cross-stack contract lives at the boundary, not in the layout.

Shared contract per domain. For every paired domain:

  • zod schemas, request/response types, route paths, error codes → packages/shared/contracts/<name>.ts
  • Frontend feature imports and calls against the contract
  • Backend feature validates incoming requests against the same contract
  • Types are defined once; never duplicate between apps/web and apps/api

Permitted asymmetries (declare, do not pad with placeholders).

ClassTriggerExamples
ui-onlyNo persistence or API endpointzoom, navbar, layout-grid, share-button
infra-onlyNo user-facing surfacecors, helpers, migrations, webhooks
compoundOne root domain, stack-specific sub-structureauth/ — BE has verify/login/oauth, FE has auth-gate/dev-auth-bootstrap; root name aligns, sub-folders diverge

Asymmetric features do not require an empty placeholder folder on the other stack.

Domain registry — docs/features.md (single source of truth). Created in the first refactor PR. One row per domain:

domainfe folderbe foldershared contractclassowner
captured-pagesfeatures/captured-pagesfeatures/captured-pagesshared/contracts/captured-pages.tspaired
zoomfeatures/workflow/zoomui-only
migrationsshared/migrationsinfra-only

Creating a new feature → registry row first, folders second. Otherwise the row gets forgotten.

Drift check (CI gate). Script tools/check-feature-drift.ts, added in the same PR that introduces the first paired feature. On every PR it asserts:

  • Every paired row exists on both stacks under the registered name
  • Every ui-only row exists only on frontend; every infra-only only on backend
  • Folders without a registry row → CI fails
  • Folder name diverges from row → CI fails

Until the script lands, the rule is enforced by review.

Workflow when creating any new feature (the steps I run in every session).

  1. Name the business domain in one noun. Examples: captured-pages, tokens, deploy, auth.
  2. Decide class: paired, ui-only, or infra-only. If unsure → ui-only or infra-only; promote to paired later when the other stack appears.
  3. Add a row to docs/features.md (create the file if it does not yet exist; first refactor PR is responsible for this).
  4. For paired: create packages/shared/contracts/<name>.ts with zod schemas defining the request/response shape, route paths, error codes. Both stacks import from here.
  5. Create the folder(s) on the relevant stack(s) using the registered name. Skeleton: <feature>.tsx (or routes.ts), index.ts barrel, _index.md inventory.
  6. Implement against the shared contract. Frontend uses contract types in API client calls; backend validates incoming bodies with the contract zod schema.
  7. PR scope: registry row + contract + folders + implementation + tests, in one PR. Frontend and backend code may land together in one PR when they share a domain — this is the single explicit exception to the "one cluster per PR" rule of the Migration policy below.

Migration policy (legacy → features/)

Не делать big-bang refactor. Правило применяется одинаково к frontend / backend / packages:

  1. Trigger-driven move: трогаешь legacy файл по содержательной задаче → перед правкой переноси связанный кластер в features/<name>/, один PR на кластер.
  2. Order заданный (low-risk first per scope):
    • Frontend: zoom → workflow → reconstructed-page → остальное. Reconstructed-page (1352 + 538 LOC) — последний, требует split, не просто move.
    • Backend: auth/verify (smallest domain) → captured-pages → deploy/sync/github → остальное. Migrations/schema/cors/helpers НЕ трогать.
    • Packages: только когда package разрастается > 5 файлов в одном жанре. Single-concept packages (db, shared/types) остаются flat.
  3. Pre-flight checklist перед refactor-PR:
    • git worktree list — какие feature-ветки живые?
    • В каждой соседней worktree git diff main --stat -- <scope>/ (apps/web/src/ или apps/api/src/ или packages/<pkg>/) — есть ли касание тех же файлов?
    • Любое касание → сначала squash-merge соседку (или её PR закрыть), потом refactor. Иначе merge-конфликты гарантированы.
  4. Worktree cleanup после squash-merge: git worktree remove ../Arno-<topic> && git push origin --delete feat/<topic> && infra/preview-proxy/attach-branch.sh --detach <topic>.
  5. PR scope: один кластер за раз. Перенос + обновление импортов + _index.md фичи + барель. Никакой попутной правки логики — refactor и behaviour change не смешиваются. Frontend и backend в одном PR — только если кластер кросс-scope (редко).
  6. Tsconfig + ESLint ставятся в первом refactor-PR per scope: path alias + ESLint no-restricted-imports запрещает deep imports. Один раз на scope, переиспользуется.

Things to NEVER do (структурные)

  • Создавать новый файл в плоской раскладке (apps/web/src/components/<X>.tsx, apps/web/src/lib/<x>.ts, apps/api/src/<x>.ts). Новый код = только в features/<name>/ или shared/ соответствующего scope.
  • Импортить из feature по deep path (@/features/X/internal-file). Только через @/features/X barrel.
  • Lift в shared/ потому что «вдруг пригодится». Только после ≥ 2 фактических потребителей.
  • Расширять feature до monolith ≥ 200 LOC одним файлом. Split при пересечении порога.
  • Трогать out-of-scope зоны (migrations, schema, ADR numbering, infra) под видом «структурного refactor». Они flat by design.
  • Расходиться в имени между frontend и backend для одного домена (captured-pages на бекенде ↔ pages на фронте). Same domain → identical folder name. Rename = registry update + both folders + import refactor в одном PR.
  • Дублировать типы / zod schemas между apps/web и apps/api. Shared domain types живут только в packages/shared/contracts/<name>.ts, оба стека импортят оттуда.
  • Создавать feature без строки в docs/features.md. Registry row first, folders second.

Studio tools — documentation pattern

Canonical: ADR 0058. This section is the prose summary — the ADR carries the rationale, alternatives, and enforcement detail. Three load-bearing cross-tool contracts emerged from this pattern and live in their own ADRs:

  • ADR 0059 — Italian / Renaissance naming chord (every tool name)
  • ADR 0060 — Docs/code separation (why docs/apps/)
  • ADR 0061layerTypeOf(node) shared dispatch authority
  • ADR 0062data-arno-box-id carrier contract

Enforcement: scripts/check-doc-completeness.ts runs in CI via .github/workflows/check-docs.yml. Fails the build on missing canonical files, missing matching code folders, 13th non-canonical files, and broken cross-references. The shared kernel under studio/ has its own canonical 12-file set in docs/kernel/.

Every tool living under apps/web/src/dev-tools/studio/<tool>/ is documented by an 11-file canonical set in docs/<tool>/. The shape is non-negotiable — it mirrors the Material + Carbon DS playbook and keeps each tool readable end-to-end without spelunking the code. No exceptions for "small" tools: if the surface deserves its own folder under studio/, it deserves the full set.

File set (docs/<tool>/)

FileRole
README.mdEntry point: what the tool is, audience, strategic role, what it does / does not do, code map, doc map, quick start, roadmap, governance.
_index.mdNavigator: when to open this folder, what to read first per scenario, code links, governance TL;DR.
architecture.mdMental model, core problem, solution, data flow, surfaces, module map, state boundaries, performance, coupling map.
contract.mdWhat a consumer / surface / variant must provide. Types, registration shape, capability flags, versioning policy.
invariants.mdGuarantees and laws — what cannot happen. Each invariant lists protection + test reference.
integration.mdSpread checklist for adding the tool to a new surface or variant. Step-by-step, prerequisites, smoke checklist.
api-reference.mdEvery public function, type, and event. Signatures + parameters + returns + examples.
telemetry.mdEvent schema, payload shapes, digest pipeline, interpretation guide, privacy, extension procedure.
testing.mdTDD policy, layers (unit / component / E2E / visual), coverage targets, CI gates, scenario authoring rules.
migration.mdContract versioning, breaking-change policy, upgrade-function shape, schema-version tag rules.
scenarios.mdCatalogue of regression scenarios with source attribution (chat report / spec rule / ADR §).
glossary.mdTerminology lock — every domain term used in the tool's docs and code.

Code folders

The corresponding code folder (apps/web/src/dev-tools/studio/<tool>/) carries only code — no pointer .md files. The naming convention studio/<tool>/docs/<tool>/ is the discovery contract; an extra pointer file duplicates state and gets stale. Until a tool has its first code file, the code folder does not exist at all; it appears with the first implementation commit. The Studio umbrella _index.md is the single canonical map of tools and their docs locations.

Umbrella

apps/web/src/dev-tools/studio/_index.md is the Studio umbrella manifest. It enumerates active tools, their docs locations, and the shared kernel (studio-bus, studio-events, tree-ops, types, useStudioTree, registry). Updated whenever a new tool lands.

Tests

Every tool ships tests per docs/testing-standard.md — the mandatory layer set: L1 unit (pure modules, gated 95/90/100) + L2 boundary (static-analysis of the tool's invariants) + L8 manual smoke checklist; add L3 component / L5 E2E / L7 a11y if it renders UI, L9 if it emits telemetry, L4 if it has a cross-tool contract. Copy the per-feature checklist from the standard into docs/<tool>/testing.md. Danger soft-warns when a documented tool has code but no *.test.ts.

Language

All Studio docs follow the repo-wide §"Language" rule — English.

When this applies

  • Creating a new Studio tool folder under apps/web/src/dev-tools/studio/<tool>/ → create docs/<tool>/ with all 11 files in the same PR.
  • Splitting an existing tool into two → each half gets its own 11-file set, the old set is archived under docs/<old>/_archived/.
  • A tool with empty / stub content per file is acceptable on day 1 (# scenarios — none yet, will grow with bug reports) — but the file must exist so the structure is discoverable.

Anti-patterns

  • Documenting a tool only in code comments → no.
  • Single mega-README.md covering everything → no.
  • Adding a 12th custom file ("design-notes.md", "history.md") → fold it into the matching canonical file or push it to ADR.
  • Skipping glossary.md because "everyone knows what it means" → no, the glossary protects against terminology drift across tools.

Punta — sidebar variant per layer type (load-bearing rule)

Punta is not a singleton with a section filter — it is a router over three independent sidebar variants, named after the layer taxonomy in docs/layers/glossary.md:

Layer typeDetected bySidebar componentSection set lives in
boxisContainer(node) (row | column)<BoxSidebar />BOX_SECTIONS (padding, gap) / BOX_PROPERTIES
textnode.type === "component" && ref === "Text"<TextSidebar />TEXT_SECTIONS (typography) / TEXT_PROPERTIES
componentnode.type === "component" && ref !== "Text"<ComponentSidebar />COMPONENT_SECTIONS / COMPONENT_PROPERTIES (per-ref, evolves one at a time)

Hard rules:

  • Section names are partitioned across the three registries — no overlap. margin lives only in box; typography only in text.
  • Sidebar component names mirror the layer name 1-for-1 (BoxSidebar / TextSidebar / ComponentSidebar). No PuntaPanel, RailSection, or other neutral names that hide the variant.
  • Punta.tsx owns the shell (footer 5 icons) and the layer-type dispatch. Variants own only their section bodies.
  • Write model is instant. Every property edit flows straight to setTree via a single-path applyStagedToTree call. No Apply / Reset buttons; the canvas updates as the user types. Undo lives in the kernel (undoActiveTree, footer Undo icon), never in Punta.
  • layerTypeOf(node): LayerType is the single authority for the dispatch. Sibling tools that need the same answer call it; they do not re-derive the rule against node.type / node.ref.
  • ComponentSidebar may host per-ref mini-schemas (TokenChip, Color Specimen, …), but each ref ships on its own commit — never pre-batched.
  • Component variants are a 100% certain forward direction: adding the 4th / 5th sidebar is not blocked by an invariant. The singleton rule from earlier punta drafts (invariants.md I1) was relaxed to "one mounted at a time; shape per layer type."

When adding a new section / property:

  1. Decide layer type first — which *_SECTIONS does this belong to?
  2. Append the row to that registry (and only that one).
  3. The sidebar component picks it up automatically.

When extending ComponentSidebar to a new ref:

  1. Add the ref to COMPONENT_REF_SCHEMAS (per-ref schema map).
  2. Ship in its own commit. Tests + scenario per ref.

Paired-link dots (four-side numeric grids)

Canonical: docs/punta/invariants.md §I35 + scenarios.md §paired-link-dots.

Every 2×2 numeric grid that offers per-side linking — Padding (padding-link.ts), Radius (radius-link.ts), Stroke per-side weight (stroke-sides-link.ts) — uses ONE model: two independent link dots (one per column, top↔bottom + left↔right). Each dot mirrors its pair on edit; both dots on → the value fans out to all four; turning on the second dot converges all four to the focused field. Link state is panel-only (seeded from the node on select, reset on reselect — never stored on the tree), and each write lands as ONE commit so a mirrored pair never races a stale snapshot.

A new four-side control MUST reuse this shape — its own pure *-link.ts fan-out helper + tests (unlinked / one-pair / both / convergence). Never a single all-sides toggle or a focus-based mode flip. Stroke's extra rule: an all-equal result is stored as the uniform representation (strokeSides:null + strokeWeight), because box-style reads an all-equal strokeSides as not-per-side.

Columns — one UI control, two primitives

Canonical: docs/punta/invariants.md §I36 + scenarios.md §columns-one-control-two-primitives.

switcher (a FIXED column count, repeat(N, 1fr)) and grid (AUTO-fill by min width, repeat(auto-fill, minmax(W, 1fr))) present to the designer as a single "Columns" entry — one strip icon (grid), a [2..8] count row (selects a switcher count) and a button (flips to grid, revealing its minColumnWidth field). Picking Columns defaults to a fixed count (switcher, 2). Both stay real primitives in code (node.primitive); the Tracks control routes between them.

NEVER split them back into two strip entries, and NEVER merge the two code primitives into one — the split is what keeps fixed vs auto as distinct CSS. The "Columns" name is display-only (PRIMITIVE_LABELS/_ICON_COMPONENTS); the PrimitiveType keys never change. (Same pattern could fold future present-as-one primitive pairs.)

Sidebar header controls (per-layer)

Each sidebar variant may host box-level / text-level / component-level controls above the section grid — affordances that are NOT section-shaped and write synchronously through studio-bus (patchActiveBox / equivalents), bypassing the staged map.

Use the header for structural knobs the user expects to flip with immediate visual feedback (e.g. box.type = row ↔ column, auto-layout direction, sizing mode). The staged + Apply / Reset contract stays for property edits inside sections.

Per layer:

  • <BoxSidebar/> header — rowcolumn direction toggle (two icon buttons; reuses RowIcon / ColumnIcon from studio/layers/LayerIcons.tsx). Click writes patchActiveBox({type}) immediately; Layers icon for that node re-paints in the same tick.
  • <TextSidebar/> header — reserved (no controls yet).
  • <ComponentSidebar/> header — reserved (no controls yet).

Rules:

  • A header control must have a well-defined fallback when the kernel can't write (no active tree, capability flag off) — no silent fail.
  • Header controls never enter the staged map. If a control's effect is reversible only via the kernel's undo stack, document the fact in invariants.md.

Document organization

FilePurposeAudience
_index.mdMaster Spec v1.x — canonicalClaude
Rules.mdThis file — operational rulesClaude
README.mdHuman-facing introЧеловек
docs/adr/Architecture Decision RecordsClaude + future team
docs/runbooks/Operational playbooks (eventually external arno-runbooks repo)On-call engineers
docs/accessibility.mdWCAG compliance documentationAudit + accessibility testers

Когда выйдет implementation phase

После создания apps/, packages/, infra/ — следовать monorepo structure §III.3. Каждая package и app имеет свой _index.md (per project convention). Master spec остаётся canonical reference на верхнем уровне.