- 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:
| Surface | Gets |
|---|---|
Tokens (/app/tokens) | every design token the page is built on |
| Design System + Storybook | the leaf components, with auto-generated stories (in-app preview) |
| Workflow | the 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:
| Bucket | Source | Render | License |
|---|---|---|---|
| Google Fonts | parsed from ?family= in the page's Google <link> | inject <link> to Google CDN | licensed 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 table | embed user-provided bytes | liability 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") }+ aFontFace(...).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 link
→ system 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 step — but 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 hasproject_id(leaf components land here, scoped).- new
captured_page { id, project_id, source_url, tree (jsonb), tokens (jsonb), deviations (jsonb), captured_at }tree— recursive layout nodes referencingstaged_componentids for leaves + inlinelayoutCssfor containerstokens— exact extracted token setdeviations— Fidelity report rowsfonts— 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
| Step | Scope |
|---|---|
| B1 ✅ | Recursive layout-tree capture in the extension (buildLayoutTree); post { tree, tokens, leaf components } bound to project |
| B2 ✅ | Backend persists captured_page; frontend renders the DS grid from real components |
| B3 ✅ | Pattern 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). |
| B4 ✅ | Workflow reassembly: render tree (+ baked leaf styles, <base> for assets) + B3 snap |
| B5 ✅ | Font 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. |
| B6 ✅ | Site 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).