ADRs
ADR 0030 — Page reconstruction: decompose capture → distribute → reassemble
  • Date: 2026-05-28
  • Status: Accepted (B1–B6 implemented; commercial-gate Tranco lookup deferred)
  • Phase / Feature: Capture onboarding — page reconstruction subsystem
  • Supersedes/builds on: ADR 0019 (extension capture path), v2 layout capture (lib/find-layouts.js)

Context

The capture onboarding scenario (create capture project → ARNO Capture plugin → "Open in Arno") lands extracted material in a project. The product goal (per user):

Не тупо копировать код, а разобрать страницу на составляющие, забрать то что важно (токены, компоненты, правила), разложить по местам в Arno, и собрать снова целиком в Workflow.

So a captured page must be decomposed and distributed across three Arno surfaces, then reassembled faithfully:

SurfaceGets
Tokens (/app/tokens)every design token the page is built on
Design System + Storybookthe leaf components, with auto-generated stories (in-app preview)
Workflowthe whole page rebuilt from those components, preserving layout rules

Today the Design System tab shows a static legacy mock (v9-synth), not real captured components. v2 capture (find-layouts.js) finds flex/grid containers but de-nests them (removeNestedLayouts → "prefer outermost layout"): it produces a flat set of layouts, not the full page hierarchy.

Decision

1. Capture model — full recursive layout tree (not de-nested)

Reconstruction needs the page hierarchy. B1 adds a recursive tree capture: each node is either a container (flex/grid + layoutCss: display, direction, gap, padding, grid-template, align/justify, radius) or a leaf (button/input/icon/text/media). Each node carries the design tokens it consumes. The existing de-nested "layouts" mode stays for the list UX; reconstruction uses the new tree.

2. Fidelity — structural, normalized to detected patterns (not literal copy)

We do not reproduce the source pixel-for-pixel. We detect the page's dominant scales (spacing rhythm, radii, type) and snap outliers to the pattern on reassembly — e.g. if spacing is dominantly 32 and one value is 28, 28 is treated as a site mistake and rendered as 32. The site's bug is not carried over.

Outliers are not silently dropped — they are collected into a Fidelity report (deviation list: { where, capturedValue, expectedPattern, kind }) surfaced in the Design System. MVP = a plain list (separate feature; no auto-fix UI yet).

3. Tokens — stored exact, deviations flagged

Token values are persisted exactly as captured (no rounding on storage). Places that contradict the recognized pattern are highlighted via the Fidelity report. (The snap-to-pattern in §2 happens at reassembly/render time, not in stored token values.)

4. Scope — desktop only (MVP)

Capture and reassembly target the desktop breakpoint only for now. Responsive / multi-breakpoint capture is deferred.

5. project_id binding — already solved (do not rebuild)

The extension↔project binding exists: arno-bridge.js + arno_capture_project + SET_PROJECT; uploads carry project_id; OPEN_ARNO_TAB/app/library?project=. Environment selection (prod/preview) is bridge-driven (SET_ENV). B builds on this.

6. Font fidelity — license-aware, BYOF (the Figma model)

ARNO must never redistribute a font it wasn't given — embedding/re-hosting arbitrary captured font bytes is a licensing risk (Adobe prohibits self-host; many EULAs are single-domain). Figma sidesteps this: free Google/Apple defaults + user-uploaded custom fonts with a license attestation (liability on the uploader) + locally-installed fonts. We adopt the same model, plus precise detection so the user gets an exact "fonts to provide" list (better UX than a generic missing-font alert).

Capture detects the font requirement (families + actually-used weights/styles via font-family computed values + document.fonts FontFaceSet; Google families parsed from fonts.googleapis.com <link> query params) and stores it — not the bytes. Three buckets at render:

BucketSourceRenderLicense
Google Fontsparsed from ?family= in the page's Google <link>inject <link> to Google CDNlicensed CDN delivery, no redistribution
Apple / system-apple-system, BlinkMacSystemFont, system-ui, Segoe UI, Arial…keep the system font stack (OS renders, no download)OS-provided, free
Everything else (BYOF)user uploads the file + license-attestation checkbox; auto-map family/weight via the font's OpenType name tableembed user-provided bytesliability on uploader (Figma model); never re-hosted without the user providing it

Self-hosted fonts on a verified-owned site (see §7) may be offered as a one-click "add detected self-hosted font" (the user already serves it). Apple fonts have no web CDN — they are handled only via the system stack, not a link.

Update 2026-05-29 — local-first rendering, license is a deploy-time gate

Refinement after building B5b (same principle — never redistribute a font we weren't given — applied the full Figma way). Two capabilities were being conflated; they are not:

  • Use an installed font for rendering (@font-face { src: local("X") } + a FontFace(...).load() probe): works in all browsers, no permission, no gesture, no bytes stored. This is the default for custom families during editing — the editor preview renders the design with the user's own installed fonts, exactly like Figma.
  • Enumerate every installed font (a "pick from your fonts" list): only the Local Font Access API queryLocalFonts()Chrome/Edge desktop, permission + gesture. Other browsers degrade to free/Google + use-by-name. (Figma achieves cross-browser enumeration with a downloaded font-helper agent, not a browser API; not needed for the web MVP since rendering + per-font detection already work everywhere.)

So the render order per used family is: uploaded data-URI bytes (provided for publishing) → device-installed via local() (editor preview) → Google CDN linksystem stack. The four buckets become: Google (free CDN), system (OS), installed locally (rendered free in-editor, never embedded in output), and provided-for-deploy (uploaded + license, the only path whose bytes travel to other viewers).

License moves to a deploy-time gate, not an upfront stepbut the gate is deferred, not built (decision 2026-05-29). The license-aware principle only bites at redistribution (font bytes reaching other viewers). An audit of current surfaces found none:

  • Editor reassembly/grid: rendered for the authenticated owner only; embeds the owner's own attested upload (data-URI) or their own device font (local(), which can't reach anyone else).
  • Public share viewer (/share): renders the workflow canvas only — it does not use the captured-font path, so no font bytes are served.
  • Deploy: pushes pages/{id}.layout.json (layout only, no font bytes, no renderer — ADR 0028 deferred the JSON consumer).

So a blocking deploy-gate today would guard a door that isn't installed (YAGNI on shared deploy infra). The license is already captured at upload (the attestation checkbox), and the "Missing" signal already lives in the Fonts panel at edit time. Re-open trigger: the gate becomes real the moment a publicly-rendered artifact embeds fonts — i.e. the share viewer starts rendering the reassembly, or ADR 0028 gives the deploy JSON a renderer. The gate's mechanism is ready (upload project_font + /fonts/faces embed, built in B5b); only its wiring waits for that surface.

A missing, non-installed, non-replaced font falls back to the system stack; a replace-picker (free/Google ∪ locally-installed) lets the user swap it (Phase A2). Blocking text editing of a node whose font is absent is deferred to the captured-page text editor (Phase C) — the architecture keeps that gate cheap to add later.

7. Site ownership — attestation + verification (own-site framing)

The product assumption is the user captures their own site, which reduces content-copyright risk to an owner attestation (ToS Clause 2). To rely on it:

  • Tier 0 (MVP): attestation checkbox, logged.
  • Tier 1 (in-flow): <meta name="arno-site-verification" content="TOKEN"> or /.well-known/arno-verify.txt — the extension checks it at capture (it is on the page) → domain verified seamlessly.
  • Tier 2 (durable): DNS TXT record. Required for commercial / top-15k (Tranco) domains.

Font EULAs are independent of site ownership — own-site framing helps content copyright, not the font license, so the §6 license-aware buckets still apply.

Data model (delta)

  • staged_component — already has project_id (leaf components land here, scoped).
  • new captured_page &#123; id, project_id, source_url, tree (jsonb), tokens (jsonb), deviations (jsonb), captured_at &#125;
    • tree — recursive layout nodes referencing staged_component ids for leaves + inline layoutCss for containers
    • tokens — exact extracted token set
    • deviations — Fidelity report rows
    • fonts — detected font requirement (families + used weights/styles + bucket google|system|byof + source URL hints); bytes are not stored (Google linked, system OS-provided, BYOF user-uploaded — §6)
  • new project_font (or asset store) — user-uploaded BYOF font files, keyed by project + family/weight, with license-attestation flag.

Atomization

StepScope
B1Recursive layout-tree capture in the extension (buildLayoutTree); post { tree, tokens, leaf components } bound to project
B2Backend persists captured_page; frontend renders the DS grid from real components
B3Pattern detection (packages/shared/src/fidelity.ts) → Fidelity deviation-list rendered in the Design System grid header + snap-outliers-to-pattern toggle on workflow reassembly (default ON, off-by-toggle to compare with captured-exact). Snap rewrites layoutCss, tokens, and inline style="…" decls inside baked leaf html, only for CATEGORY_PROPS (spacing/radius/type).
B4Workflow reassembly: render tree (+ baked leaf styles, <base> for assets) + B3 snap
B5Font fidelity (§6), full subsystem: B5a Google <link> + system stack; B5b-1 detect requirement at capture; B5b-2/3 BYOF upload (project_font, OpenType auto-map) + @font-face data-URI embed; Phase A device-installed fonts via local()/probe (cross-browser) + queryLocalFonts enumerate (Chromium); A2 replace-picker + font_overrides. Deploy-gate deferred (no redistribution surface — see §6 update). Text-edit gating = future.
B6Site ownership verification (§7). Per-project verification_token auto-generated at create (random base64url, 24 bytes). Extension scans <meta name="arno-site-verification"> + /.well-known/arno-verify.txt at capture and posts the found token; server matches against project.verification_token and upgrades the tier (attest → meta/well-known → dns). DNS path: Library → Ownership tab triggers a server-side Cloudflare DoH TXT lookup for arno-site-verification=TOKEN on the apex. Tier never downgrades. Commercial-domain hard gate (Tranco top-15k → require Tier 2) deferred — infrastructure ready, list lookup not yet wired in.

Consequences

  • Positive: captured pages become editable Arno material (components + tokens + composition), not a static mock; design inconsistencies surfaced, not propagated.
  • Cost: recursive capture is heavier than the de-nested list; pattern detection is a real algorithm (B3) — kept MVP-simple first.
  • Risk / open items:
    • de-nested v2 mode vs new recursive tree: keep both or migrate the list UX onto the tree later (decide in B1).
    • leaf identity / dedup across captures (same button captured twice) — deferred.
    • reassembly fidelity bar will need real-page iteration (resolve in code, not on paper).