doc.json · 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.
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:
| Goal | Met when |
|---|---|
| 1 · it renders | layer 2 compiles it into the page the author meant — for this repo, one that matches the origin |
| 2 · it stays a builder document | the builder's Import accepts it and a user can keep editing it |
--no-extensions renders the v3 view. One
document, two renders — never two documents.{
"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.
| Concept | One line |
|---|---|
| grid | The coordinate system. Everything is placed in its cells — position, size and spacing alike. Layout |
| brand | Colour and type, named once and referenced everywhere by role. Brand |
| node | Every record in the tree has one shape: id, type, then what applies. There is no other kind of record. |
| page | The root. It holds sections and nothing else. Page |
| section | One band of the page: a category (what it is for), a layout (how it sits), a ground (what it is painted on). Section |
| element | One thing on the grid — an instance of a component, carrying its settings. Element |
| component | Owned 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 |
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.
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.
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)
| Field | Default | What it does | Change it and… |
|---|---|---|---|
| cols | 24 | Columns in the desktop grid. | Every at means something different. This is the coordinate system. |
| mobileCols | 8 | Columns below mobileMax. |
Same, for the mobile view. |
| rowPct | 2.15 | Row height as a percentage of content width. From Squarespace. | Every row changes height, so the page changes height with no placement changing. |
| maxWidth | 1200 | 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. |
| mobileMax | 767 | 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. |
| gutter | 16 | The least margin the content box keeps from the viewport edge. | The content column narrows everywhere — and rows shorten with it. |
| rowMin v4 | 1.9rem | Rows stop shrinking here, above mobileMax. |
The floor moves. Derived so it never engages at the width a document was quantised at. |
--content, so capping the column caps all three.atat: [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.
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:
| Field | Does |
|---|---|
| float | exempt this element from collision, so it may sit over another |
| z | stacking order among the elements that overlap |
| group | a shared id on siblings — they move together. Fewer than two members is refused |
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.
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.
Three-up. Same rule, two channels.
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 + 20 + 2 = 24 columns · centred because the free count matches
Mobile placements, same vocabulary. Full width is [1, 9]; a
split is two placements.
the mobile grid · 8 columns
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.radius and shape.shadow are scales — corner rounding and elevation,
named once for the whole brand rather than a number per element.
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.
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.
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.
#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.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:
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:
Text colour can select — the four ink roles (resolved here on canvas),
then your other colours, then any 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.
| Setting | The picker offers | doc.json stores |
|---|---|---|
section background | the six surface roles, nothing else | "secondary" |
text colour | 4 ink roles · then shifted originals + swatches · then any hex | "ink-strong" | "#f2dc53" |
button fill | surface roles (context-filtered) · then shifted originals + swatches · then any hex | "primary" | "#3b6ea5" |
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.
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.
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.
| Field | Shape | What it does |
|---|---|---|
| type.display | string | The font stack for headings. |
| type.body | string | The font stack for prose. |
| type.scale | Record<rung, [size, leading]> | sm…5xl. A closed set. |
| type.styles v4 | Record<name, {category, …axes}> | A treatment named once, referenced from elements by name. |
| type.families v4 | Record<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.
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:
| Form | When |
|---|---|
| "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 |
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.
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.
| Name | Kind | Accepts | Default | Edited |
|---|---|---|---|---|
| background | colour | a surface role, or a hex |
canvas |
panel |
| image | image | — | {"src":null,"alt":""} |
panel |
| space | number | 0…40, step 1 |
— | panel |
| spaceTop | number | 0…40, step 1 |
— | panel |
space ends 12px under its last element, which is
.sec's own padding and nothing else.┌───────────────────────────────┐
│ background — edge to edge │ ← settings.background, settings.image
│ ┌─────────────────────────┐ │
│ │ content — grid width │ │ ← the 24 columns live here
│ └─────────────────────────┘ │
└───────────────────────────────┘
settings.image renders as .sec-media — position:absolute; inset:0;
object-fit:cover — outside the content grid. So a section ground is genuinely full
bleed at any width. An element placed on the grid never is.
| Category | Label | Layouts | Slot 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.
| id | Category | Name | Children | Element types | Slots 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 |
min, they do not collide, and a mobile view derives from them.
Quantising an origin's pixel boxes directly costs all three.| Field | Shape | Says |
|---|---|---|
| section.ink | {strong?, body?, muted?, line?} | this band disagrees with its role |
| section.gradient | {angle, stops[]} | this ground is not a colour |
| section.scrim | false | this photograph is not tinted |
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.
| Field | On | What it does |
|---|---|---|
| id | all | Unique in the document. A duplicate is refused. |
| type | all | page · section · one of the 7 element types. |
| at | elements | Placement (§2). Required on an element, absent on page and section. |
| min | elements | [cols, rows] floor, copied from the layout. A smaller at is refused. |
| derive | elements | stretch — fill the mobile band given. intrinsic — keep its own size and centre. |
| layout | elements, sections | Provenance only. The tree is already copied onto the node; this records where it came from. |
| tree | elements, sections | Its own copy of its layout's markup — copied, not referenced, which is why a document can carry class strings the registry never had. |
| settings | elements, sections | The values the tree renders. Every key must be declared on the type. |
| mobile | elements | { at?, hidden? } — whatever stopped following desktop. Absent means derived. |
| hidden | elements | Not rendered. |
| group | elements | Siblings that move together. Fewer than two members is refused. |
| float | elements | Lifted out of collision, so it may overlap. |
| z | elements | Stacking order among overlapping elements. |
| slot | elements | Which of the category's slots this fills — the key migration reads when swapping layouts. |
| typography v4 | elements | A named style from brand.type.styles, or the six axes inline. |
| ink v4 | elements | This element's own colour — a hex. |
| runs v4 | text | Colour or family on fragments of the body — one sentence, several accents. |
| panel v4 | elements | {fill, radius?} — a filled box painted behind the element. |
| underline v4 | text | false — keep this link unmarked. |
| fit v4 | image | cover | contain — how artwork sits in its box. |
| rotate v4 | elements | <number>deg — turn it. |
A setting's kind decides what a panel draws and what validation accepts.
| Kind | Value |
|---|---|
| text | A string. Round-trips through an HTML allowlist — anything outside it is refused rather than stripped. |
| url | A plain address. |
| image | { src, alt }. src is asset:<id> or null. |
| link | { href, target }, target _self or _blank. |
| icon | A name from the closed icon set. |
| number | Bounded by min, max, step. |
| choice | One of an enumerated set. The set is on the declaration, so a panel can draw it. |
| boolean | On or off. |
| colour | A role from the brand — palette: ink or palette: surface — or a literal hex. |
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.
textEvery 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.
| Name | Kind | Accepts | Default | Edited |
|---|---|---|---|---|
| body | text | — | Your headline here |
canvas |
| colour | colour | a ink role, or a hex |
ink-strong |
panel |
| align | choice | left · center · right |
left |
panel |
| id | Name | Authored size | min | derive |
|---|---|---|---|---|
| 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 |
imageA 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.
| Name | Kind | Accepts | Default | Edited |
|---|---|---|---|---|
| photo | image | — | {"src":"/ph/soap.svg","alt":"A bar of soap on a linen cloth"} |
toolbar |
| link | link | — | — | panel |
| id | Name | Authored size | min | derive |
|---|---|---|---|---|
| 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 |
buttonA 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.
| Name | Kind | Accepts | Default | Edited |
|---|---|---|---|---|
| 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 |
| id | Name | Authored size | min | derive |
|---|---|---|---|---|
| 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 |
dividerA horizontal rule, with no content. Use it to make a gap deliberate.
| Name | Kind | Accepts | Default | Edited |
|---|---|---|---|---|
| colour | colour | a ink role, or a hexline · ink-muted · ink-body · ink-strong |
line |
toolbar |
| id | Name | Authored size | min | derive |
|---|---|---|---|---|
| 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 |
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.
ratingStars. The three layouts differ only in weight.
| Name | Kind | Accepts | Default | Edited |
|---|---|---|---|---|
| value | number | 0…5, step 0.5 |
5 |
toolbar |
| icon | icon | — | star |
panel |
| colour | colour | a ink role, or a hex |
ink-strong |
panel |
| id | Name | Authored size | min | derive |
|---|---|---|---|---|
| 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 |
testimonialA quote and its attribution, held together so they cannot drift apart. Opaque: edited in a panel, not on the canvas.
| Name | Kind | Accepts | Default | Edited |
|---|---|---|---|---|
| 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 |
| id | Name | Authored size | min | derive |
|---|---|---|---|---|
| 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 |
youtubeAn embed. The only type allowed to carry an iframe, which validation checks by name.
| Name | Kind | Accepts | Default | Edited |
|---|---|---|---|---|
| url | url | — | null |
toolbar |
| caption | text | — | A caption |
panel |
| fill | colour | a surface role, or a hex |
deep |
panel |
| id | Name | Authored size | min | derive |
|---|---|---|---|---|
| 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 |
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.
name to the
original filename — it is the only record of where the bytes came from.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.
| Wanted | Nearest | What the approximation costs | Seen 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 all | jennakutcher.com |
| A panel — a filled box behind a group | a 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 missing | jennakutcher.com |
| More than two font families | brand.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 jobs | jennakutcher.com |
| A seventh brand colour | nothing — it collapses | Six surface roles against seven chosen colours. The extra survives only where an element names it directly | jennakutcher.com |
| A form | the submit, as a button |
Seven element types and none is a form control. The band still reads as a signup; it cannot collect an address | jennakutcher.com |
| Colour on a fragment of a sentence | element.runs (v4) |
A text element has one colour setting against a sentence setting six | jennakutcher.com |
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.
| Field | Chapter | Says |
|---|---|---|
| brand.type.faces | Typography | the file this family is |
| brand.type.families | Typography | more than two families |
| brand.type.styles | Typography | a treatment named once |
| brand.surfaces | Colour | paint my colour, and do not wash it |
| grid.rowMin | Layout | rows stop shrinking here |
| grid.maxWidth (behaviour) | Layout | honour the field already in the model — the cap |
| section.ink | Section | this band disagrees with its role |
| section.gradient | Section | this ground is not a colour |
| section.scrim | Section | this photograph is not tinted |
| element.typography | Element | a named style, or the six axes inline |
| element.fit | Element | how the artwork sits in its box |
| element.ink | Element | this element's own colour |
| element.runs | Element | this fragment's own colour |
| element.panel | Element | paint a filled box |
| element.underline | Element | not this link |
| element.rotate | Element | turn it |
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.
| Area | Refused |
|---|---|
| Structure Overview · Section | page holds only sections · a section cannot hold a section · kids on a non-container · a duplicate id |
| Category Section | a section with no category · a category on anything that is not a section |
| Placement Layout | no at on an element · colStart ≥ colEnd or rowStart ≥ rowEnd · past cols + 1 · above row 1 · smaller than min |
| Mobile Layout | an inside-out mobile.at · past mobileCols + 1 |
| Settings Element | a setting not declared on the type · a value not matching its kind · a number outside its bounds |
| Colour Brand | a colour that is neither a role the brand defines nor a well-formed literal |
| Text Element | a value that does not round-trip through the HTML allowlist unchanged |
| Assets Assets | an asset: reference that does not resolve |
| Groups Layout | a group with fewer than two members |
| Markup Components | a 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