doc.json · spec · v4

The Document Spec v4

Every property of doc.json: what it accepts, and what changes when you change it. v4 is the builder's v3 model plus the fields marked v4 — the full delta is Appendix A. The component chapters are generated from the registry, so they cannot drift.

Contents

1 · Overview

Architecture

LAYER 1 · AUTHORING

   website-generator      website-extractor      website-builder
   interview a user,      rebuild a captured     a person edits,
   birth a site           site                   on a canvas
   (planned)              (this repo)
           │                      │                     │
           └──────────────────────┼─────────────────────┘
                                  ▼
                              doc.json
                                  │
                                  ▼
LAYER 2 · RENDERING     compile doc.json into HTML — the builder's own engine

Every author writes the same document, and this reference is what an author reads to know what can be said. The layers meet only at the document, so one document serves two goals at once:

GoalMet when
1 · it renderslayer 2 compiles it into the page the author meant — for this repo, one that matches the origin
2 · it stays a builder documentthe builder's Import accepts it and a user can keep editing it
This spec is v4 — the builder implements v3
Everything v4 adds on top of the builder's model is marked v4 and listed in Appendix A · Changes from v3. Each change is a proof of concept produced by website-extractor: layer 2 renders it, the builder ignores it until adopted, and the document still validates and stays editable either way. --no-extensions renders the v3 view. One document, two renders — never two documents.

The document

{
  "version": 3,             // the builder's counter — this spec, v4, is v3 + Appendix A
  "title":   "…",              // the <title>. A plain field, not a setting
  "grid":    { … },            // §2 — the coordinate system
  "brand":   { … },            // §3 — colours and type
  "assets":  { id: { … } },    // §5 — every file, base64
  "page":    { … }             // §4 — holds sections, which hold elements
}

Depth is fixed: page → section → element. A page holds only sections, a section cannot hold a section, and elements hold nothing. There is no fourth level.

Core concepts

ConceptOne line
gridThe coordinate system. Everything is placed in its cells — position, size and spacing alike. Layout
brandColour and type, named once and referenced everywhere by role. Brand
nodeEvery record in the tree has one shape: id, type, then what applies. There is no other kind of record.
pageThe root. It holds sections and nothing else. Page
sectionOne band of the page: a category (what it is for), a layout (how it sits), a ground (what it is painted on). Section
elementOne thing on the grid — an instance of a component, carrying its settings. Element
componentOwned by the builder: a type (the settings contract) plus its layouts (markup and defaults). Authors instantiate components, never define them — but can propose one when the library falls short. Gaps
The one rule behind all of it
There is no free-form value anywhere. No 17px, no hex outside the brand, no text-[19px]. Everything is a cell, a rung, or a role, and the order of preference is fixed: use a component as it ships → approximate to the nearest one → record the gap and propose. Inventing is not allowed: never add a headline the page does not have, or a colour nobody chose.

Translating another design system into this document — the principles — lives in docs/adapting.md.

2 · Layout

Where everything sits, how big it is, and how much room is around it. One coordinate system does all three — there is no margin, no padding and no spacing scale. Every diagram in this chapter is a real CSS grid carrying the same values a document would.

viewport 1600 × 800
content = min(1600 − 2×16, maxWidth 1200) = 1200px
side 200px
row 30.4px
eyebrow · at [2,10,5,6]
heading · at [2,13,6,11]
subheading · at [2,12,11,14]
cta · at [2,7,15,17]
portrait · at [14,24,4,22]
sticker · float · z 1
sticker · float · z 1
side 200px
column = 1200 ÷ 24 = 50px · columns touch — no gap
viewport 390 × 844
content 358px
portrait
eyebrow
heading
subheading
cta · intrinsic, centred
sticker
sticker
column = 358 ÷ 8 = 44.75px · row = 7.7px — no floor below 768px · gutter is the whole side space

hatched — side space, (1600 − 1200) ÷ 2 = 200px each · amber edge — gutter 16px, the minimum side space, binding only below the cap · 800px of screen ≈ 26 rows. gutter is not a column gap — columns have none

left: a hero at real placements — at: [colStart, colEnd, rowStart, rowEnd], rows counted from the section's own row 1 · the stickers are floats: lifted out of collision, so they may overlap · right: the same hero on mobile — derived, not authored: ordered by row, stacked full width, floats ride along (schematic)

grid — the six fields

FieldDefaultWhat it doesChange it and…
cols24 Columns in the desktop grid. Every at means something different. This is the coordinate system.
mobileCols8 Columns below mobileMax. Same, for the mobile view.
rowPct2.15 Row height as a percentage of content width. From Squarespace. Every row changes height, so the page changes height with no placement changing.
maxWidth1200 The cap. Stops the content column, the row and the text scale — all three together. All three stop at the new width together. Nothing else in the document changes.
mobileMax767 The width the mobile view applies up to. Tailwind's md boundary. The breakpoint moves. One number, because the two views meet in one media query.
gutter16 The least margin the content box keeps from the viewport edge. The content column narrows everywhere — and rows shorten with it.
rowMin v41.9rem Rows stop shrinking here, above mobileMax. The floor moves. Derived so it never engages at the width a document was quantised at.
maxWidth is the cap v4
The builder ships the field and reads it nowhere — this repo honours it, which is the spec's own rule restored rather than a new one. The row and the type scale are both expressed against --content, so capping the column caps all three.

Placement — at

at: [colStart, colEnd, rowStart, rowEnd]

1-indexed, ends exclusive. It becomes grid-column: colStart / colEnd directly, so the last legal column value is cols + 1 = 25.

There is no gap between columns: space between elements is made by leaving columns empty. Elements may share rows, and an element is as tall as its row span says, not as tall as its content — you place a box; the words go inside it. Whitespace is authored, never injected: "website sections are made of intentional whitespace" — the engine's own rule, and why nothing compacts, on desktop or derived mobile.

Rows have no maximum
rowEnd is unbounded — a section is as tall as its deepest element. Columns are bounded: a placement past cols + 1 is refused. So horizontal overflow cannot happen, and vertical overrun can.

Overlap. Two elements whose cells intersect collide, and collision is prevented rather than resolved. To overlap deliberately, lift one out:

FieldDoes
floatexempt this element from collision, so it may sit over another
zstacking order among the elements that overlap
groupa shared id on siblings — they move together. Fewer than two members is refused

Mobile — the derived view

Below 768px the grid is 8 columns, and the desktop at does not carry over — a mobile view is derived from it, and an element that stopped following writes its own mobile.at. derive decides what its box does with the band it is given: stretch fills the width, intrinsic keeps its own size and centres.

The width decides the view, not a mode toggle
Otherwise you could sit in Desktop, drag to 400px, watch the mobile layout render, and have every edit land somewhere other than where you were looking.

Examples

Two-up with a channel. The library's two-up is [1,12] and [14,25] rather than [1,13] and [13,25] — columns 12–13 are the channel. Dashed means columns nobody claimed.

[1, 12] — 550px
channel
[14, 25] — 550px

Three-up. Same rule, two channels.

[1, 7]
[10, 16]
[19, 25]

A band narrower than the column. A 1040px band inside the 1200px column wants 1.60 free columns each side; no such placement exists, so it rounds to 2 — 1000px against 1040px asked. Match the free count on both sides; the match is what centres the band.

col_width = 1200 ÷ 24           = 50.00px
side      = (1200 − 1040) ÷ 2   = 80px
          = 80 ÷ 50.00          = 1.60 columns  →  2

at: [3, 23]   20 columns = 1000px, 2 free columns each side
2 free
at: [3, 23] — 1000px
2 free

2 + 20 + 2 = 24 columns · centred because the free count matches

Mobile placements, same vocabulary. Full width is [1, 9]; a split is two placements.

at: [1, 9] — full width
[1, 5]
[5, 9]

the mobile grid · 8 columns

3 · Brand

brand: {
  name:    "…",
  colours: ["#…", …],     // AN ORDERED LIST, not a palette
  variant: 0,             // which curated light/dark pair fills what you left out
  type:    { display, body, scale },
  shape:   { radius, shadow }
}

Shape

shape.radius and shape.shadow are scales — corner rounding and elevation, named once for the whole brand rather than a number per element.

3.1 · Colour

You give an array of hexes. Everything else — section colours, text colours, scrims — is computed from it. The worked example throughout: jennakutcher.com's seven.

brand.colours — what you give

colours: ["#eb6c2f", "#6579be", "#eec3e5", "#f2dc53", "#ffffff", "#f2efe8", "#18161b"]

An ordered list, any length, and you do not assign roles — classification is the builder's. The only thing order decides is which accent leads.

#eb6c2f#6579be#eec3e5#f2dc53#ffffff#f2efe8#18161b

Computed: the six section colours

Chroma splits accents from neutrals; lightness orders the neutrals; anything structural that is missing is filled from a designed light/dark pair (variant picks which). Six surface roles always resolve, and a section background names one of these — only these. Missing accents collapse onto the one before, so the set is fixed while the number of distinct colours is not.

canvas#ffffffraised#f2efe8deep#18161bprimary#f6753a ← #eb6c2fsecondary#8298df ← #6579betertiary#eec3e5
A ground that cannot carry text is shifted
#eb6c2f tops out below the contrast floors, so the primary ground is its sibling shifted along lightness — the raw hex stays available as a swatch.
#6579be tops out below the contrast floors, so the secondary ground is its sibling shifted along lightness — the raw hex stays available as a swatch.

Computed: text colours — four roles, resolved per surface

Text never holds a brand hex. It names one of 4 ink roles — ink-strong · ink-body · ink-muted · line — and the surface it sits on decides the value. Every ink is solved for contrast on its own ground:

Strong Body Muted on canvas Strong Body Muted on deep Strong Body Muted on secondary

What a picker offers, and what the document stores

Button fill can select — the surface roles, deduped (roles resolving to the same colour show one swatch, most general name wins) and context-filtered (a role too close to the band it sits on is dropped — on a canvas band, canvas itself is not offered), then "your other colours", then any hex. Here, for a button sitting on canvas:

raiseddeepprimarysecondarytertiary#eb6c2fshifted original#6579beshifted original#f2dc53swatchany hex…

Text colour can select — the four ink roles (resolved here on canvas), then your other colours, then any hex:

ink-strongon canvasink-bodyon canvasink-mutedon canvaslineon canvas#eb6c2fshifted original#6579beshifted original#f2dc53swatchany hex…

"Your other colours" holds two kinds, both exactly as typed: the originals of any shifted ground — somebody who typed a colour and cannot find it is looking for precisely that — and the colours no role took. Picking one stores a literal hex.

SettingThe picker offersdoc.json stores
section backgroundthe six surface roles, nothing else"secondary"
text colour4 ink roles · then shifted originals + swatches · then any hex"ink-strong" | "#f2dc53"
button fillsurface roles (context-filtered) · then shifted originals + swatches · then any hex"primary" | "#3b6ea5"
A literal is half the mechanism
A hex ground or fill renders (bg-[#…]) but gets no surface scope — no solved inks, no scrim. It paints, and nothing checks what sits on it. Recorded in sites/jennakutcher.com/gaps.md; approaches are parked, not designed.

The colour fields this repo carries beyond this model are listed in Appendix A and are under review — they are not part of this chapter.

3.2 · Typography

Three tiers, one rule: the brand names fonts; a component chooses a slot; an element only deviates. type.display and type.body bind real typefaces to slots. A component layout welds a slot and a rung into its markup — font-display, never a typeface name — which is what keeps a component reusable across brands. An element overrides only where the origin disagrees with what the layout carries.

What the brand defines

Two family slots and a bounded scale. There is no text-[17px]: eight rungs — sm base lg xl 2xl 3xl 4xl 5xl — each a [size, leading] pair, everything above lg a clamp() that tracks the viewport up to the cap.

FieldShapeWhat it does
type.displaystringThe font stack for headings.
type.bodystringThe font stack for prose.
type.scaleRecord<rung, [size, leading]>sm5xl. A closed set.
type.styles v4Record<name, {category, …axes}>A treatment named once, referenced from elements by name.
type.families v4Record<slot, string>More than two family slots — one @theme line each.
type.faces v4[{family, src, weight?, style?}]The file a family is. src resolves relative to the document.
type = <rung> × (--content ÷ --content-max)   → nominal at the cap, smaller below

Above 1232px nothing grows: the column, the row and every type rung are frozen and the extra width becomes margin. Measured, the ratio of type size to column width is 1.60 at every width from 768 to 2560.

Body type is the exception: fixed at 16px, because fixed rem is what keeps browser zoom working. That is also why rowMin exists — a proportional row eventually cannot hold a fixed 16px line.

Recurring treatments are named once in brand.type.styles; more than two families needs brand.type.families, and shipping the files needs brand.type.faces — all marked v4 in the table above.

brand.type.styles: {
  "heading-2": { "category": "heading", "family": "display", "weight": "normal",
                 "size": "2xl", "tracking": "normal", "leading": "1.2", "transform": "normal-case" }
}

A flat map. Each style carries a category — heading, paragraph, label, stat, mono — for grouping and matching, and states its six axes fully: nothing is inherited from a group.

How a component or an element uses it

A text layout is a role — a title, an eyebrow, a quote — and carries its rung: pick the layout and the size comes with it. When the origin disagrees with what the layout carries, element.typography v4 overrides it:

FormWhen
"heading-2"the treatment recurs — a name from brand.type.styles. An unknown name throws at render
{family?, weight?, size?, tracking?, leading?, transform?}a genuine one-off — the six axes inline on that element
Name what recurs, inline what does not
Measured on jennakutcher.com: 116 typography overrides collapse into 22 named styles covering 101 elements; 15 one-offs stay inline. Same rule as colour — a value goes in the widest scope true for everything it applies to.

4 · Page

page: { id, type: "page", kids: [ …sections… ] }

The root container, and the thinnest thing in the document. It holds sections and nothing else — an element directly on the page is refused. It carries no settings and no background, because every section paints its own and no page ever shows through.

title lives on the document rather than here: a <title> is not HTML content, and no setting kind fits it.

Its pieces: sections, and the elements inside them.

4.1 · Section

A band of the page. It carries a category — what it is for — and a layout — how it is arranged. Matching and migration read the category; the layout only decides where things sit.

Its own settings

NameKindAcceptsDefaultEdited
background colour a surface role, or a hex canvas panel
image image {"src":null,"alt":""} panel
space number 040, step 1 panel
spaceTop number 040, step 1 panel
space is content, not padding
It is the room below the content, counted in rows of the grid — real rows, so something can be dropped into them. A section with no space ends 12px under its last element, which is .sec's own padding and nothing else.
spaceTop is declared but not rendered
The registry still declares it. In the builder it shifted content down at render time, drawing rows nothing could be dropped into — so this repo does not render it. The field survives; the behaviour is gone.

The background bleeds; the content does not

┌───────────────────────────────┐
│  background — edge to edge    │   ← settings.background, settings.image
│  ┌─────────────────────────┐  │
│  │  content — grid width   │  │   ← the 24 columns live here
│  └─────────────────────────┘  │
└───────────────────────────────┘

settings.image renders as .sec-mediaposition:absolute; inset:0; object-fit:coveroutside the content grid. So a section ground is genuinely full bleed at any width. An element placed on the grid never is.

Categories, and the slots each is built around

CategoryLabelLayoutsSlot vocabulary
hero Welcome 4 headline · subhead · media · action · secondaryAction
features What you do 5 headline · subhead · media · body · action
testimonials Reviews 3 headline · quote · author · photo · rating
pricing Prices 2 headline · planName · price · period · features · action
contact Get in touch 2 headline · subhead · body · action · media

A slot with nothing to put in it is left out. A piece of content with no slot to go in is a gap — worth recording, not worth forcing.

Every section layout

idCategoryNameChildrenElement typesSlots filled
hero.centred hero Centred welcome 4
17 rows
text button eyebrow headline subhead action
hero.split hero Words beside a photo 5
20 rows
text button image eyebrow headline subhead action media
hero.photo hero Photo behind 3
18 rows
text button headline subhead action
hero.banner hero Short banner 2
4 rows
text button headline action
features.split features Text beside image 4
14 rows
text button image headline body action media
features.imageLeft features Image beside text 3
12 rows
image text media headline body
features.three features Three across 7
12 rows
text headline subhead body
features.stats features Three numbers 6
7 rows
text stat label
features.list features A list, beside a photo 3
12 rows
text image headline body media
testimonials.three testimonials Three reviews 4
15 rows
text testimonial headline quote
testimonials.one testimonials One, large 2
13 rows
rating testimonial rating quote
testimonials.pair testimonials Two, side by side 3
13 rows
text testimonial headline quote
pricing.three pricing Three prices 13
14 rows
text button headline planName price period action
pricing.one pricing One price 4
18 rows
text button planName price features action
contact.simple contact Simple 4
16 rows
text button headline subhead action body
contact.split contact Details beside a photo 4
14 rows
text button image headline body action media
blank.empty blank Blank 0
4 rows
Take the layout's placements, do not re-derive them
They satisfy every min, they do not collide, and a mobile view derives from them. Quantising an origin's pixel boxes directly costs all three.

Section fields v4

FieldShapeSays
section.ink{strong?, body?, muted?, line?}this band disagrees with its role
section.gradient{angle, stops[]}this ground is not a colour
section.scrimfalsethis photograph is not tinted

4.2 · Element

One thing on the page: placed on the grid, holding its settings, containing nothing. Page, section and element are all written as the same record shape — id, type, then whatever applies. The fields below are the element's own; layout, tree and settings also appear on sections.

Fields

FieldOnWhat it does
idallUnique in the document. A duplicate is refused.
typeallpage · section · one of the 7 element types.
atelementsPlacement (§2). Required on an element, absent on page and section.
minelements[cols, rows] floor, copied from the layout. A smaller at is refused.
deriveelementsstretch — fill the mobile band given. intrinsic — keep its own size and centre.
layoutelements, sectionsProvenance only. The tree is already copied onto the node; this records where it came from.
treeelements, sectionsIts own copy of its layout's markup — copied, not referenced, which is why a document can carry class strings the registry never had.
settingselements, sectionsThe values the tree renders. Every key must be declared on the type.
mobileelements{ at?, hidden? } — whatever stopped following desktop. Absent means derived.
hiddenelementsNot rendered.
groupelementsSiblings that move together. Fewer than two members is refused.
floatelementsLifted out of collision, so it may overlap.
zelementsStacking order among overlapping elements.
slotelementsWhich of the category's slots this fills — the key migration reads when swapping layouts.
typography v4elementsA named style from brand.type.styles, or the six axes inline.
ink v4elementsThis element's own colour — a hex.
runs v4textColour or family on fragments of the body — one sentence, several accents.
panel v4elements{fill, radius?} — a filled box painted behind the element.
underline v4textfalse — keep this link unmarked.
fit v4imagecover | contain — how artwork sits in its box.
rotate v4elements<number>deg — turn it.

Setting kinds — nine, and this is the whole list

A setting's kind decides what a panel draws and what validation accepts.

KindValue
textA string. Round-trips through an HTML allowlist — anything outside it is refused rather than stripped.
urlA plain address.
image{ src, alt }. src is asset:<id> or null.
link{ href, target }, target _self or _blank.
iconA name from the closed icon set.
numberBounded by min, max, step.
choiceOne of an enumerated set. The set is on the declaration, so a panel can draw it.
booleanOn or off.
colourA role from the brand — palette: ink or palette: surface — or a literal hex.
The tree is a copy, and that matters
It is this node's own markup, not a pointer into the registry. A document can therefore carry a class the registry never had. Every element field v4 adds depends on this.

Components

7 element types, 34 layouts. A type is a data contract; a layout is markup plus defaults. Adding a layout touches no schema and no document.

Authored size and min are in grid cells; the pixel figure beside them is what that resolves to with content at the 1200px cap.

Primitives — edited on the canvas

text

8 layouts 3 settings

Every word on the page. The eight layouts are roles — a title, an eyebrow, a quote — not sizes, though each carries one. The body setting accepts a small HTML allowlist, so a paragraph can hold a link or an emphasis and nothing more.

Settings

NameKindAcceptsDefaultEdited
body text Your headline here canvas
colour colour a ink role, or a hex ink-strong panel
align choice left · center · right left panel

Layouts

idNameAuthored sizeminderive
text.title Page title 16×5
800×152px
4×1 stretch
text.heading Section heading 14×3
700×91px
4×1 stretch
text.subheading Subheading 10×2
500×61px
3×1 stretch
text.eyebrow Eyebrow 8×1
400×30px
3×1 stretch
text.lead Intro paragraph 12×4
600×122px
3×1 stretch
text.body Body 10×4
500×122px
3×1 stretch
text.small Small print 8×2
400×61px
3×1 stretch
text.quote Pull quote 12×4
600×122px
4×2 stretch

image

6 layouts 2 settings

A picture in the grid. The six layouts differ in shape and framing, not in how the file is chosen. src is an asset: reference, never a URL — see Assets.

Settings

NameKindAcceptsDefaultEdited
photo image {"src":"/ph/soap.svg","alt":"A bar of soap on a linen cloth"} toolbar
link link panel

Layouts

idNameAuthored sizeminderive
image.plain Plain 10×8
500×243px
2×2 stretch
image.rounded Rounded 10×8
500×243px
2×2 stretch
image.soft Soft edge 10×8
500×243px
2×2 stretch
image.circle Circle 5×5
250×152px
2×2 intrinsic
image.framed Framed 9×8
450×243px
3×3 stretch
image.linked Links somewhere 10×8
500×243px
2×2 stretch

button

5 layouts 5 settings

A call to action. The five layouts differ in emphasis: a solid fill is the page's main action; a quiet one is closer to a link.

Settings

NameKindAcceptsDefaultEdited
label text Get in touch panel
link link {"href":"#","target":"_self"} toolbar
fill colour a surface role, or a hex primary panel
align choice start · center · end center panel
width choice auto · full auto panel

Layouts

idNameAuthored sizeminderive
button.solid Solid 5×2
250×61px
2×1 stretch
button.pill Pill 5×2
250×61px
2×1 stretch
button.outline Outline 5×2
250×61px
2×1 stretch
button.arrow With an arrow 5×2
250×61px
2×1 stretch
button.quiet Quiet link 5×2
250×61px
2×1 stretch

divider

4 layouts 1 settings

A horizontal rule, with no content. Use it to make a gap deliberate.

Settings

NameKindAcceptsDefaultEdited
colour colour a ink role, or a hex
line · ink-muted · ink-body · ink-strong
line toolbar

Layouts

idNameAuthored sizeminderive
divider.line Full line 10×1
500×30px
1×1 stretch
divider.short Short rule 6×1
300×30px
1×1 stretch
divider.dots Three dots 4×1
200×30px
1×1 intrinsic
divider.mark A small mark 4×2
200×61px
1×1 intrinsic

Composites — edited in a panel

Pieces held together so they cannot drift apart, edited in a panel because a composite has no single caret position. The split is the registry's own opaque flag.

rating

3 layouts 3 settings opaque

Stars. The three layouts differ only in weight.

Settings

NameKindAcceptsDefaultEdited
value number 05, step 0.5 5 toolbar
icon icon star panel
colour colour a ink role, or a hex ink-strong panel

Layouts

idNameAuthored sizeminderive
rating.five Five 4×1
200×30px
2×1 intrinsic
rating.large Five, large 6×2
300×61px
3×1 intrinsic
rating.tight Five, small 3×1
150×30px
2×1 intrinsic

testimonial

4 layouts 3 settings opaque

A quote and its attribution, held together so they cannot drift apart. Opaque: edited in a panel, not on the canvas.

Settings

NameKindAcceptsDefaultEdited
quote text They were wonderful to work with, and the soap is lovely. panel
author text Ella M., Bristol panel
photo image {"src":"/ph/portrait.svg","alt":"A customer"} panel

Layouts

idNameAuthored sizeminderive
testimonial.stacked Stacked 7×8
350×243px
3×4 stretch
testimonial.beside Photo beside 12×6
600×182px
5×3 stretch
testimonial.quoted With a quote mark 8×9
400×274px
3×4 stretch
testimonial.large Large, centred 16×8
800×243px
6×4 stretch

youtube

4 layouts 3 settings opaque may embed

An embed. The only type allowed to carry an iframe, which validation checks by name.

Settings

NameKindAcceptsDefaultEdited
url url null toolbar
caption text A caption panel
fill colour a surface role, or a hex deep panel

Layouts

idNameAuthored sizeminderive
youtube.wide Wide player 14×8
700×243px
5×3 stretch
youtube.plain No caption 12×7
600×213px
4×3 stretch
youtube.thumb Thumbnail 6×4
300×122px
3×2 intrinsic
youtube.framed Framed 13×9
650×274px
5×4 stretch

5 · Assets

Every file the document uses, inline as base64.

assets: { "<id>": { type: "image/jpeg", data: "<base64>", name: "source.jpg" } }

Referenced from a setting as asset:<id>. A reference that does not resolve is a validation error, so a document cannot ship a broken image. Never a URL — a document pointing outward renders differently the day that URL rotates.

Keep the format the source served
Never re-encode: a GIF flattened to PNG loses the animation, a PNG saved as JPEG gains a white box. The engine handles GIF, MP4 and WebM, so format is never the constraint. Set name to the original filename — it is the only record of where the bytes came from.

6 · Gaps

What the vocabulary cannot say. Every entry came from failing to express something during a real rebuild — that is the only way onto this list. Per-site evidence lives in sites/<host>/gaps.md.

Seen on two sites it is a missing feature; on one it is just that page
A gap recorded once is a note. The same gap on more sites is a work item with a case behind it.
WantedNearestWhat the approximation costsSeen on
A full-bleed image at a set height within a band a section background (fills the whole band) or a placed element (capped to the content column) Neither is the shape. A photograph occupying the lower half of a band, edge to edge, cannot be expressed at alljennakutcher.com
A panel — a filled box behind a groupa section, or element.panel (v4) Read as columns on the band's own ground it renders, and says something else. The tell is contrast: white-on-white means the colour is right and the ground is missingjennakutcher.com
More than two font familiesbrand.type.families (v4) A brand fixed at two families against a page setting text in six — two of which are genuinely different typefaces doing different jobsjennakutcher.com
A seventh brand colournothing — it collapses Six surface roles against seven chosen colours. The extra survives only where an element names it directlyjennakutcher.com
A formthe submit, as a button Seven element types and none is a form control. The band still reads as a signup; it cannot collect an addressjennakutcher.com
Colour on a fragment of a sentenceelement.runs (v4) A text element has one colour setting against a sentence setting sixjennakutcher.com

Appendix A · Changes from v3

v4 is the builder's v3 model plus the fields below, every one produced by rebuilding a real site. Each is a new field beside the old ones, never a rewrite of one, and never a setting — the validator refuses an undeclared setting by name. A v4 document still validates in the builder, and --no-extensions renders the v3 view.

FieldChapterSays
brand.type.facesTypographythe file this family is
brand.type.familiesTypographymore than two families
brand.type.stylesTypographya treatment named once
brand.surfacesColourpaint my colour, and do not wash it
grid.rowMinLayoutrows stop shrinking here
grid.maxWidth (behaviour)Layouthonour the field already in the model — the cap
section.inkSectionthis band disagrees with its role
section.gradientSectionthis ground is not a colour
section.scrimSectionthis photograph is not tinted
element.typographyElementa named style, or the six axes inline
element.fitElementhow the artwork sits in its box
element.inkElementthis element's own colour
element.runsElementthis fragment's own colour
element.panelElementpaint a filled box
element.underlineElementnot this link
element.rotateElementturn it

Appendix B · What gets refused

validateDocument runs the same checks the builder's Import runs. A document that fails here cannot be opened, however good its screenshot looks. Binary — there is no partial credit.

AreaRefused
Structure Overview · Sectionpage holds only sections · a section cannot hold a section · kids on a non-container · a duplicate id
Category Sectiona section with no category · a category on anything that is not a section
Placement Layoutno at on an element · colStart ≥ colEnd or rowStart ≥ rowEnd · past cols + 1 · above row 1 · smaller than min
Mobile Layoutan inside-out mobile.at · past mobileCols + 1
Settings Elementa setting not declared on the type · a value not matching its kind · a number outside its bounds
Colour Branda colour that is neither a role the brand defines nor a well-formed literal
Text Elementa value that does not round-trip through the HTML allowlist unchanged
Assets Assetsan asset: reference that does not resolve
Groups Layouta group with fewer than two members
Markup Componentsa tag outside the closed list · an iframe on a type declaring no external reference · a class painting a surface without surface-* to re-ink it
bun doc:validate --doc=sites/<host>/doc.json