DESIGN.md
Rama main · réplica de lectura del repositorio original.
# Astrolabe design spec — the bar is "people ditch Obsidian for this"
Reference quality: Obsidian's default theme + Linear's polish. Identity: illuminated manuscript —
iron-gall ink, parchment light, gold-leaf accent — iron-gall is the BRAND room (the icon, the
metaphor); the room a reader lands in is `THEMES[0]`, astrolabe-dark — neutral near-black
greys with gold only as the accent — paired with astrolabe-light. Everything below is normative.
## Layout (CSS grid, full viewport, no page scroll)
```
┌──────────┬─────────────────────────────┬───────────┐
│ sidebar │ tab bar │ backlinks │
│ ≤292px ├─────────────────────────────┤ 300px │
│ │ center content (editor OR │ (collaps- │
│ │ graph), own scroll │ ible) │
├──────────┴─────────────────────────────┴───────────┤
│ status bar 28px │
└────────────────────────────────────────────────────┘
```
- Sidebar and backlinks panel have `--bg-raised`; center is `--bg`; 1px `--border` separators.
- Backlinks collapse: chevron button in the panel header; collapsed = 0 width. Animate width
180ms ease. The sidebar collapses the same way (`Ctrl/Cmd Alt B`; the panel is
`Ctrl/Cmd Alt Shift B` — the pair moved one modifier out when `Ctrl/Cmd B` became **bold**).
- A collapsed pane leaves a **reopen handle**: a 14px full-height strip on that pane's own edge,
`--bg-raised` with the shared 1px border and a `--text-muted` chevron pointing into the
content; hover fills `--bg-hover` and turns the chevron gold. Always visible while collapsed —
a pane with no visible way back is a lost pane — and never a hover-reveal.
**It is a POINTER's door**: a finger that cannot hover never meets the desktop shell at all
(`PHONE_SHELL_QUERY`, below), so the strip is drawn only where a mouse can aim at it.
- Which edge the sidebar sits on is a preference (palette: "Move sidebar to the right/left"),
defaulting to the reading direction's leading edge. All four dir × side combinations must look
deliberate: separators, indent, active-row bars and every chevron follow.
- **Zen** (`Ctrl/Cmd Shift Z`): sidebar, panel, tab bar and status bar animate to zero — never
`display: none`, so entering and leaving is one movement — and the prose column centers at
800px with 96px of air above it. The only chrome left is a hairline ✕ in the top inline-end
corner at 50% opacity, which fades out after ~2s and returns on mouse movement (and is
`pointer-events: none` while faded — no invisible hit targets). `Esc` also leaves.
- Never let any panel's content overflow the viewport horizontally.
- **The reading column is monotone in viewport width.** A pane may take grid space only at
widths where the prose already has its full 760px box, so no reader is ever better off in a
narrower window: the outline pane auto-collapses to its door below 1360, the sidebar takes
`clamp(224px, 100vw - 776px, 292px)` (224px is its floor; there is no overlay drawer), and the prose
gutter is `min(56px, 7.37%)` — the shipped 56px wherever the measure is full, proportional
below it, never stepped. Measured `.cm-line`, en and ar: 648 at every width from 768 up, then
597 / 546 / 409 / 333 at 700 / 640 / 480 / 390.
- **A phone gets a shell of its own, not the desktop's folded** (3.26.0, contracts/shell.md "The
phone shell"). ONE question decides it, `PHONE_SHELL_QUERY` in client/shellQuery.ts:
`(max-width: 700px), ((pointer: coarse) and (hover: none))`. Every stylesheet's "phone" width is
that 700 (tests/breakpoints.test.ts). Below 700px, or on a finger that cannot hover at any width, the screen is a stack of
screens under a 48px top bar, a 56px bar of five labelled doors (Today, Notes, Search, Calendar,
More) or a 72px rail on a tablet, and sheets from the bottom edge — never a tab strip, a status
bar, a pane grip or a drawer. Rows are 52px, sheet rows 48, fields 16px; motion is `transform`
and `opacity` only; the note screen shows nothing at its foot but the keyboard's accessory bar.
- **Below 700px, or on ANY coarse pointer, every target is ≥44px** — in the phone shell by
construction, and in the desktop shell on a coarse pointer that can hover (a touchscreen laptop):
tree rows, tag pills, icon buttons, status-bar buttons (and the bar itself). The Classic drawer
layout, its swipe and its ☰ were deleted in 3.27.0.
## Sidebar
*Sizes in the sections below are the design's pixels at a 14px root. The stylesheets write them in
`rem` (px ÷ 14 — `0.929rem` is 13px), and the shipped root is 15.5px (`--font-base` in
client/styles/app.css, times the reader's `--font-scale`), so on screen each is 1.107× what is
written: 11px reads 12.2, 12px 13.3, 13px 14.4, 14px 15.5, 15px 16.6. Borders, hairlines, radii
and the 44px touch floor are real pixels and do not scale.*
- Header: wordmark `✦ Astrolabe` — serif (--font-serif), small-caps feel, gold accent star, 15px,
letter-spacing 0.08em; right side: "new note" (+) and "new folder" icon buttons (inline SVG,
16px, --text-muted, hover --accent).
**The NAME shrinks and ellipsises; the mark never does** (`.s-title__name`, 3.23.0). A vault
may be called anything, the pane is 224px at its narrowest, and a name that collides with the
tools is the one thing a wordmark may not do. It has to be its own element to be one: a bare
text node in a flex box is an anonymous item, which no rule can address and which will not
shrink below its own min-content width.
- Search input: subtle raised field, 13px, rounded --radius, focus ring in --accent-soft; search
hits list replaces the tree while active: each hit = title line (serif, 14px) + snippet line
(12px --text-muted, `<mark>` = gold text, no bg block).
- Tree: 13px UI font, 26px row height (44px on a coarse pointer), rows full-width hover --bg-hover rounded 4px, 8px left pad
per depth. Folders: chevron (▸ rotates 90° when open, 150ms) + name; files: no icon clutter —
just the name minus `.md`; active note row: --accent-soft bg + --accent 2px left bar. Smooth.
- **A folder MAY carry one 14px glyph, and a file never may.** The admin assigns it from the
folder's own context menu ("Folder icon"), out of a closed set of twenty (shared/folderIcons.ts)
— the shape of the thing, currentColor, the tree's own stroke. It sits in the attachment
glyph's slot, between the chevron and the name, at --text-faint full opacity, --accent on
hover. Unmarked folders are not padded to match: the mark steps its row in by one slot, the
way an attachment row already sits under its notes. The no-icon-clutter rule above is
unchanged for FILES — a vault has hundreds of notes and a dozen folders, and a mark that
everything wears is not a mark. Marks are stored in settings.json (`folderIcons`, keyed by
folder path, re-keyed by the server when a folder is renamed or moved) and reach only admin
sessions, because a visitor's sidebar has no folders in it at all.
- Attachments (non-`.md` files) sit under a folder's notes with a 14px type glyph in the
chevron's slot and their extension kept in the label; notes keep the no-icon rule above. The
row reads at --text-muted like a note row (--text on hover): a FILENAME is text, and the
quiet is carried by the glyph, the extension badge and the position under the notes — not by
painting the name at 3.3:1. Only the glyph is --text-faint, at full opacity. The footer counts them beside the notes and carries
the paperclip that hides them — and a folder the filter empties says "N files hidden" rather
than opening onto nothing.
- Tags section pinned under tree: header "Tags" 11px uppercase --text-faint letterspaced; pills:
12px, 3px 8px, --bg-hover bg, rounded-full, `#` in --accent, count in --text-muted (the pill's
ground is --bg-hover, where --text-faint measures 2.7:1); hover fills --accent-soft.
- Sidebar footer: "N notes" 11px --text-muted, with the attachment count + paperclip toggle at
the inline end (admin, when the vault has any).
- Attachment viewer: full-viewport scrim rgba(0,0,0,.72) + 4px blur (the ‹ › handles are painted
for that scrim — accent-tinted ground, lit rim, --text glyph — not for the page behind them), the file at natural size
capped to `calc(100vh - 120px)` (never upscaled), a pill caption bar under it (name serif,
then `PNG · 1,045 × 657 · 92 KB`, then position, then open-in-tab / download / close), and
round ‹ › handles on the logical edges. Esc, click-out, ← / → with wrapping.
## Tab bar
- 36px tall, tabs are 13px, max-width 180px ellipsis, padding 0 12px, separated by 1px border;
active tab: --bg (merges into content), inactive: --bg-raised + --text-muted; dirty dot: 6px
gold circle replacing the × until hover; × appears on hover, 14px hit area.
## Editor column
- Content max-width 760px, centered, padding `48px var(--prose-gutter) 120px` (the gutter is
56px at every width where the column is at its cap). Line-height 1.7, editor font 16px
--font-serif for prose. THE TEXT IS SERIF — this is the manuscript feel. Code/inline-code in
--font-mono 85%.
- Headings (live preview rendered): h1 1.9em serif + hairline bottom border --border padding 0.2em;
h2 1.5em; h3 1.25em; every level in `--heading`, each room's own heading ink (a warm gold-cream
in iron-gall, a neutral near-white in astrolabe-dark) — not a mix computed from `--accent`. The `#`
marks when cursor is on the line render --text-faint.
- Heading fold chevron: in the left padding of every heading line, VISIBLE at rest (--text-faint
at full strength), --text-muted on line hover, --accent on itself. Never a hover-reveal — same
rule as the reopen handle, and there is no hover on a phone.
- Blockquote: 3px gold left bar + --text-muted italic. Lists: gold `•`. Checkboxes: 15px rounded
square, gold fill + white ✓ when checked, text of done items --text-faint strikethrough.
- Wikilinks: --accent, no underline, hover underline; broken links (unresolvable): dashed
underline --danger tint. Tags in text: same pill style as sidebar, smaller.
- Empty state (no note open): centered, the ✦ glyph large in --text-faint (the token is the
whole budget — no opacity over it), "The vault is open." serif 18px, then keymap hints in a
neat 2-col grid of kbd chips, dropping to one column once the centre column is too narrow for
two (≤1100px viewport). The chips never wrap and the pane keeps a 24px gutter: a centered
child wider than its box overflows at both ends in silence. **Below 700px and on any coarse
pointer the keymap is replaced, not shrunk** — a keyboard legend on a device with no keyboard
is a taunt. The same pane then offers the reader's recent notes and New note / Search notes /
Graph view as ≥44px tap targets. Both halves are in the DOM; CSS picks.
## Backlinks panel
- Header: "Backlinks" 11px uppercase --text-faint + count badge pill; collapse chevron right.
- Each backlink: card (--bg-hover, rounded --radius, 10px padding, 8px gap): note title serif 13px
--text, then context line 12px --text-muted with the matching `[[link]]` rendered as a gold span
(never raw brackets). Hover: border --accent-soft. Click opens note.
- Empty: "No backlinks yet — link to this note with [[...]]" 12px --text-faint.
## Status bar
- 28px, --bg-raised, top border, 12px --text-muted. Left: breadcrumb path of open note
(folder › note), which keeps a min-width floor down to 900px AND a max-width ceiling — identity
outranks a character count at every width, and a trail with no ceiling is the one item flexbox
can take every shortfall out of. When it must give, the FOLDERS give: the ancestors are one
ellipsizing run and the note's own name is the last thing standing. Right segments in GROUPS, each marked once by a hairline (never dots
between some neighbours and not others): BOTH counts together (words · chars and N published),
the publish toggle, the backup badge, the mode pills, admin tools (gear · eye),
the pane toggles, the view controls (☾/☀ · graph), the session control. All hover --text.
No `·` anywhere in the cluster, and no hairline on the segment that OPENS it — a separator
with nothing on its far side is a stray tick. Below 640px every hairline drops (the groups
are neighbours by then) and the bar scrolls rather than clipping.
## Command palette
- Backdrop: rgba(0,0,0,.4) + slight blur. Panel: 560px, top 18vh centered, --bg-raised, 1px
--border, radius 10px, shadow 0 24px 64px rgba(0,0,0,.5). Input 15px serif, borderless, 14px
padding, bottom border. Rows 34px: icon (inline SVG 14px: file/command glyphs), name, right-side
kbd hint or folder path --text-faint; selected row --accent-soft with gold left bar. Section
labels ("Commands", "Notes") 10px uppercase --text-faint.
## The tour
A deck of illustrated folios, and the answer to the failure that produced it: a
real reader used this product for months without discovering that it has a site
designer. The designer was never hidden — it has three doors — but every one of
them is a door you have to already know the name of. Depth reachable only by
searching for it is depth nobody meets.
- **A FOLIO, not a tooltip.** One feature per card, centred like a page from the
manuscript: a drawn miniature at 64×48 in `SectionGlyph`'s language
(`currentColor` only, the gold reached through `color` on a wrapper `<g>`, and
`aria-hidden` beside a name that is always present), the feature's name in the
serif, and **two sentences that sell the moment** — "Compose your public site
from fifty-nine designed sections", never "the designer panel allows". A tour
of an interface is not worth reading; a tour of what you could do is.
- **Every card ends in a REAL action.** *Show me* opens the designer, opens the
theme picker, opens Settings scrolled to the exact row, writes a live
`tracker` fence into a new, clearly-named scratch note, runs a search with an
operator already in it. The deck **closes first**, so the thing it opened is
on screen alone rather than behind a modal — the shortcut sheet's action rows,
same rule, same reason. Admin-scoped folios are dropped for a read-only
session, and a folio whose prerequisite is off (comments, a git work tree)
says so in one quiet line rather than selling what this instance cannot do.
- **THE TOUR IS ONLY EVER ENTERED, NEVER SHOWN.** No autoplay, no first-run
interstitial, no toast, nothing to dismiss. Four doors — a palette row, a
quiet line on the empty state, a footer on the `Ctrl/Cmd /` sheet, a line in
`Welcome.md` — and ONE mark: a 6px gold dot beside the empty state's line that
goes out for good the first time anybody opens the deck (a localStorage flag,
written on the click rather than when the chunk lands). The dot never moves,
pulses or animates: a dot that breathes is a notification.
- **Flipping is LOGICAL, never physical.** ← → (and vim's h and l) mean previous
and next *according to the reading direction*, so an Arabic reader's → walks
backwards through the deck exactly as it walks backwards through a line; the
swipe reads the same way and the page-turn animation mirrors with it. The turn
is transform and opacity, ~180ms, and gone entirely under
`prefers-reduced-motion` — the deck still flips, instantly.
- **Below 700px and on any coarse pointer the dot row is DROPPED, not shrunk.**
Fifteen targets at the 44px this shell promises is 660px of row inside a 390px
screen. The two things that are full targets stay (the arrows, at the two
edges where thumbs rest), the gesture stays, and the position is carried by
the count — which said it in words all along. The hint line swaps with it:
a keyboard legend on a device with no keyboard is a taunt.
- **The deck is LAZY and its copy travels with it.** Fifteen names and thirty
sentences in two languages are ~9 kB the entry chunk must not carry to
describe a surface nobody has opened, so they live in `tourCards.ts` in the
`{ en, ar }` shape the fifty-nine presets already use, gated by
`tests/tour.test.ts` the way `assertPreset()` gates those. Only the four
DOOR strings are in the dictionary. Entry cost is the door and the row.
## Graph view
- Fills center. Canvas bg --bg with a very faint radial vignette. Edges: --border color, 1px,
opacity .5. Nodes: gold-tinted discs (lightness varies by degree), 1px rim; label 11px --font-ui
--text-muted under node, hidden below zoom 0.7 except hovered/neighbors. Hover: node + neighbors
full opacity + labels, rest dimmed to .15. Active-note node: ring in --accent. Bottom-left HUD:
"N notes · M links" 11px faint; bottom-right: zoom +/− and ⌖ reset buttons (icon buttons, raised).
- **The zoom rule is not the only cap on labels.** A frame draws at most ~180 of them, keeping the
highest-degree nodes, because two hundred overlapping titles is noise rather than navigation and
a title is the most expensive mark on this canvas. Everything outside the viewport — nodes, edges
and labels alike — is culled before a single canvas call, the idle edge web is drawn to its own
bitmap and blitted (it changes with the view and the physics and with nothing else), and the
cooling schedule runs on the CLOCK, not on the frame counter, so a large vault settles in the
same thirteen seconds a small one does instead of taking longer the slower it draws. Repulsion is
summed through a quadtree (Barnes–Hut, θ = 0.7): the approximation is a DISTANCE test, so it
leaves no mark on the picture — an aggregation by fixed grid cell printed the grid into the
settled layout as a square cloud with a lattice in it, and that is the failure this note exists to
prevent. Measured on a 3,000-note vault: 60 fps median from the first frame to rest.
- **A canvas is a picture to a keyboard**, so the nodes also exist as a list behind it: the open
note and its neighbours, one tab stop, arrows to walk, Enter to open, clipped away until it holds
focus. CONTRACTS.md carries the full behaviour.
## Motion & finish
- All interactive elements: 150ms ease transitions on color/bg/transform. `:focus-visible`: 2px
--accent ring, radius-matched. Custom scrollbars: 8px, thumb --border hover --text-faint,
transparent track. ::selection --accent-soft. No layout shift on hover anywhere.
- **Every** theme must pass: contrast ≥ 4.5:1 body text, ≥ 3:1 muted, accent ≥ 4.5:1 on its
own ground and ≥ 3:1 on `--bg-raised` (it is a 2px LINE there: the active-row bar, the tab
rule, the pane grip), the focus ring ≥ 3:1 on all three grounds (a ring is the keyboard's only
cue and a boundary, not text), ≥ 3:1 faint on **both** grounds **and ≥ 18 ΔE from its own body
text** (`check-contrast.mjs` walks every block in tokens.css, holds every id in
`shared/themes.ts` to a block and every block to the whole token set, and holds `:root` to the
default room hex for hex). That last one is not a contrast ratio and cannot be: a theme whose accent is a
shade of its own type — sumi shipped one — has no accent channel at all, and every argument for
the lit mode pill collapses with it. `sidereal` is the room that had to be designed AROUND that
floor rather than checked against it afterwards: starlight and body text are the same pale
blue-white, so its type is held at a neutral silver to keep the pair 29.6 ΔE apart. "No two
themes share a hex" is likewise satisfiable and
meaningless; the test is whether two swatches are separable at a glance. The accent is per theme, and only the astrolabe pair, graphite, iron-gall, lapis and parchment
are gold — a theme is a room, not a tint: it defines its own ground, type, accent, selection,
focus ring, graph colors, thirteen callout hues and eight syntax colors, solved against its own
`--bg`. **A room's syntax palette is part of its identity, not a rainbow it wears**: `phosphor`
is a cathode tube with ONE gun, so its keyword, function and property are three strengths of the
same green and the only non-green inks are a P3 amber and the alarm red — a room whose eight
syntax colors could be swapped with nocturne's is wallpaper. Readers browse them in the theme
picker (grouped dark/light, arrow keys preview live,
Enter keeps, Esc restores); nothing in the product cycles blindly through twenty-one looks, and
nothing LISTS them either — the palette carries one *Themes* row (with a dot showing the theme
in force) and not twenty-one jumps into rooms the reader has not seen.
- **Contrast: `--text-faint` is not a text color.** Its bar is 3:1 — the NON-TEXT bar — and it is
enforced on `--bg` and `--bg-raised` for every theme, so the token may carry UI glyphs (the
heading fold chevron, the attachment type glyph, the OFF paperclip) and deliberately
de-emphasized machine bookkeeping (the properties card's `dg-*`/uuid rows). Anything the
reader has to READ — a filename, a count, a label naming a thing — is `--text-muted` (4.5:1 or
better in all twenty-one) or `--text`. The pair used to print `(info)` against a minimum of zero;
parchment sat at 2.50:1 and text kept moving onto the one token nothing could fail. **Opacity
is invisible to the gate**: `--text-faint` at 0.85 is 2.56:1 on iron-gall, so a fade over a
token that is already at its floor is a way of failing the floor without failing the check.
- **`--bg-hover` is a THIRD ground, not a shade.** Every hovered row, every highlighted menu row
and every tag pill is painted with it, so `check-contrast.mjs` walks it too: `--text` ≥ 4.5:1
and `--text-muted` ≥ 3:1 on all twenty-one (worst measured 4.53:1). `--text-faint` is NOT checked
there and may not be USED there — it is a two-ground token by construction, and on `--bg-hover`
it measures 2.74:1 (iron-gall), 2.89 (sumi, tallow), 2.98 (parchment). The selection menu's
keycaps and group titles shipped exactly that: passing on `--bg` and `--bg-raised`, failing one
substrate over, which is the same "failing the floor without failing the check" as the opacity
case above.
- **The ambient masthead: motion is DECORATION, never a token.** Themes are static token rooms by
architecture — every gate here measures those hexes against one another, and a value that moves
is a value nobody screenshotted at the frame where it failed. So the rooms stay still and a
separate layer moves behind the words: an empty `aria-hidden` div (`.s-amb`,
`client/styles/ambient.css`) at `z-index: -1` inside a masthead carrying `isolation: isolate`,
on the stock blog's `.s-blog-mast` and the designed shell's `.s-dsg-head`. **Off by default**,
behind one boolean (`settings.ambient`) — one switch for the whole feature, because *which*
air a room gets is a CSS question answered per theme, so a theme change repaints the atmosphere
live with no re-render. THREE AIRS, not twenty-one: drifting stars (sidereal, nocturne, lapis,
murex), a creeping scanline grating under the gun's glow (phosphor, void), rising gold dust
(iron-gall, tallow, parchment, palimpsest, solar). Every other room is deliberately still — a
room gets an air when it has something to say with one. Every mark is that room's own
`--accent` through `color-mix`, so twenty-one rooms cost three airs. Four properties are
normative: pure CSS (no canvas, no rAF, both animated properties compositor-only); unreachable
(`pointer-events: none`, empty, `aria-hidden`, out of the tab order); a WHISPER, and that is a
measurement rather than an opacity — the worst *composited* ground under the masthead title,
read off real pixels with the animation running, is 11.41:1 (phosphor), 10.74 (murex), 9.63
(sidereal), 9.08 (iron-gall), 8.31 (parchment) against a floor of 4.5; and
`prefers-reduced-motion` **deletes** it — `display: none` on the container, so neither layer
gets a box and two screenshots 2.6s apart come back byte-identical. The magazine hero
(`.s-dash-hero`) gets none: it is a photograph under a scrim tuned so white type survives a
near-white banner, and an atmosphere over that scrim is fighting the one thing guaranteeing the
contrast. The stylesheet rides the two public shells' own lazy chunks, never the entry.
## The divider
- `---` and `___` draw the plain divider, `***` the ornamental one. Both are a gold rule that
FADES at both ends (`color-mix(--accent 65%)`, 22%→78%) with 2.8em of air; the ornamental one
adds the wordmark's ✦ over a gap in the middle. **The divider is content, not furniture**: it
may never be `1px solid var(--border)`, which is the h1 rule and the blog byline rule, because
an article that carries three chrome hairlines and one content hairline at the same weight,
colour and measure has told the reader nothing. All three surfaces draw it identically — live
preview draws a block widget (`cm-s-hr-rule`), reading view and the blog draw `.s-rv-hr` — and
live preview shows the source only while the cursor is on the line, like every other element.
## The designed public site (`publicLayout: "designed"`)
A third visitor shell, composed from a design config, beside the two that exist. Normative:
- **The stock blog is the base and stays pristine.** `client/blog/*` and `client/styles/blog.css`
are not extended, not overridden and not themed by this feature; the designed shell is a second
renderer in `client/design/` with its own `s-dsn-*` classes and its own stylesheet, and the two
meet at one `if`. A reviewer confirms it from the list of files a diff names.
- **The switch is lossless in both directions.** The design lives in its own file
(`ASTROLABE_DATA/designs.json`) and is never read while the layout is anything else, so going back
to stock is a rescue and going forward again restores the site exactly.
- **A broken design is a VISITOR'S non-event.** An invalid config, a section pointing at a note
that is gone, or a section that throws → the visitor gets the stock blog, automatically, with
no blank page and no stack trace. The ADMIN gets the designed page with the failing section
replaced by a card naming it, under a strip carrying one click back to stock. Gated by
`scripts/shoot-design.mjs`, which breaks the site all three ways and measures both sessions.
- **One column per page.** `.s-dsn-page` sets the measure (`site.width`, 520–1400px, 24px
gutters; 18px on a phone) and nothing inside sets a second one — the rule the stock blog had to
learn twice.
- **Every target is ≥44px below 700px or on any coarse pointer**, and the grid drops to one
column. Measured at 390: document horizontal overflow 0.
- **The byline follows the TITLE's script, not the chrome's** — four rules, because the answer is
chrome direction XOR title direction and `flex-start` is the CONTAINER's start.
- **A design may name real typefaces, and a face it names is never the only answer.**
`chrome.typography` carries three optional catalog ids (heading, body, code). The resolved
family is emitted AHEAD of the instance's own stack, so a face that has not downloaded — or an
imported design naming one this instance never fetched — leaves the page exactly as it was.
Visitors receive a published design's faces through the same generated stylesheet the
instance's own four slots go through; the designer's unsaved draft and the preset gallery are
served by one admin-only sheet in `<head>`, under the live site's family names, so the pane and
the cards show what will ship. **A design decides one script and inherits the other**: the face
it names becomes the half of the composite its coverage says it is, and the other half is the
instance's, because an Arabic vault's naskh face is a statement about the language the site is
written in and a preset applied by a stranger does not get to overrule it.
- **A section decides its SHAPE, and the shape is not one shape.** A run of writing is set as a
river, a ruled ledger with the date hanging in a column of its own, a dotted-leader index, a
numbered run in oversized faint ordinals, or a dateline that breaks it into the days it was
published on. A post is a bordered tile, a bare picture with words under it, a title on a scrim
over the picture, a horizontal row with the thumbnail at the start, or a masonry column that
keeps the proportion the photograph was taken at. An opening is a panel, a band of ground and
type with no picture at all, or a split with the words beside one. Every one of them decides
position, rhythm and weight and NONE of them decides a hue — the same line the whole engine is
drawn on. The first of each is what the engine drew before it, so an older design is
byte-identical after the upgrade.
- **The CHROME decides its shape too, and the page decides what it is printed on.** A masthead is
a centred plate, a plate flushed to the leading edge, a single app-like row, a nameplate set
**between two hairlines** with the menu centred beneath it, or a **banner** — a full-width field
of `--bg-raised` behind the identity. A menu is a plain run, pills, an accent **rail** under the
page the reader is on, or **`[ brackets ]`**, which mirror themselves: both characters are
`Bidi_Mirrored` and the link carries `dir="auto"`, so the opening bracket lands at the reading
start in either script with no rule per direction. The end of the page is a grid of columns, a
centred small-caps **colophon** set the way a book's last page is, or the site's own name at
display size with those same columns run together beneath it. **A sidebar masthead is deferred,
not refused**: a nav column beside the writing is a second column, and one column per page is the
rule two paragraphs up — when it lands it lands as a change to the PAGE.
- **The whole site is printed on a SURFACE, and a surface is a measurement.** `flat`, faint ruled
baselines at the design's own line height, graph `grid`, a `tinted` `--bg-raised` ground, or
`paper` — laid tooth drawn as three hatchings at co-prime periods, no image file. Every value is
`--bg-raised` or a `color-mix` of `--text` into transparent, so all five are the same decision in
all twenty-one rooms and no design has acquired a hue. **The whisper is enforced on real pixels**,
the way the ambient masthead's was: the gutter is screenshotted, the PNG decoded, and the worst
painted colour in it — the crossing of two rules, of three hatchings — measured against every ink
token. Worst of twenty-five readings: `--text` 11.25:1, `--text-muted` 4.70:1 (floor 4.5),
`--text-faint` 3.02:1 (its own 3:1 remit). A first draft at 7% ink measured muted at 3.88 on
parchment and was refused; the table is in `design.css` beside the rules. **A whisper still has
to be audible**, and when the first shots said two of the three were not — a graph grid that
integrated to a flat wash on `murex`, a laid tooth that rendered as offset at 1440 — the answer
was GEOMETRY and not ink, because the ink is spent against the numbers above. The grid's cell
became two line boxes instead of one (four times fewer intersections, every rule with 40–60px of
clean ground beside it) and the tooth's hatchings became 2px lines at 13/17/23px instead of 1px
at 5/7/11 — less ink per square inch, three times the tooth, and every composited value in the
table exactly where it was.
- **A divider between two sections may be the ORNAMENT**, which is the reading view's `***` —
the same fading accent rule with the wordmark over a gap. A composed page breaks its run of
sections for the reason an essay breaks its run of paragraphs, and until now it could only do
it with a piece of chrome.
- **A shelf of the gallery is a BAR, not a job.** Eight families answer "what is this site for".
The ninth — `signature`, and it leads — answers "is this design actually something": a design
earns it by using at least three of the new primitives in anger, naming its own faces, and
colliding with nothing in the silhouette report, so that applying one feels like moving into a
different house rather than adjusting the one you are in. They arrive as STUDIOS of four,
because four designs have to argue with each other as well as with the shelf. The press is the
first: a broadsheet set between two hairlines with its file broken into days, a console in one
monospaced face with `[ brackets ]` for a menu, a scholarly offprint that counts what is in this
issue and rules what is filed, and a poster on graph paper that signs itself at the foot. The
gallery is the second, and its four argue about WHERE THE IMAGE GOES — a private view that hangs
six plates at 5:4 under a field-of-ground masthead and prints a dotted-leader checklist beneath
them, a plate book that asks each photograph how tall it is and prints nothing else, a zine of
typed headlines pasted straight onto eight photocopies on laid paper, and a magazine that sets
its cover line beside a plate and runs its departments as full-width bands. The letters are
the third, and their four argue about WHAT THE PAGE IS MADE OF, one surface each — a letterpress
salon on laid paper whose forme is two brass rules, an observing station on a coordinate grid
that labels its plates in a grotesque and types its log in a monospace, a deluxe manuscript on
ruled lines under a gilded headpiece, and an essayist on nothing at all, with no menu, no
pictures and one numbered column. A studio's four agree on nothing: four grounds, four
mastheads, four menus, and no two runs of writing shaped alike. **Not one of the twelve lists
its writing as a river**, which every one of the other fifty-nine does; the salon was the last
to, and a screenshot with its masthead cropped off settled it.
- **A shape that is about the PICTURE does not invent one.** A post with no banner gets generated
artwork, which is the right answer for a 128px strip above a title and the wrong one at plate
size: five synthetic fields among nineteen photographs is the first thing the eye lands on in a
monograph, and two in a hang of eight is a gallery exhibiting work it does not have. So the two
card shapes whose whole subject is the picture print TYPE instead — a masonry card becomes a
leaf (hairline, air, the title at the design's own h2) and an overlay becomes a wall label (the
same 5:4 field, the scrim dropped because a fixed dark gradient exists to sit on an unpredictable
photograph and over the theme's own ground it is a stripe). The other three still generate, still
on the operator's toggle, and the preset gallery's canvas still generates for all five, because a
200px card of an unknown vault is not the place to judge an image-forward design by its type. The
split hero takes the other road: **a cover shows the cover story**, so a split with no image of
its own borrows the newest post's banner — only the split, because a picture beside the words
carries no type and a picture behind them is a contrast argument against a vault nobody has seen.
- **A newspaper's file is set in COLUMNS.** A dateline run splits into as many 24rem tracks as the
page can hold, to a ceiling of two, with a `--border` rule between them — a width first and a
count second, so a 1240px broadsheet takes two, a 640px essayist's column takes one and a phone
takes one without a breakpoint saying so. Its rows print a HEADLINE and nothing else: the day is
the kicker above them, and a reading time under every headline is the web-furniture version of
the repetition a dateline exists to delete.
- **A bar with no links is not a bar, and a band is as tall as its type.** A design may decline a
menu outright; when it does, the nav element is not rendered and the search box and theme switch
come back into the masthead under the identity, because an empty bar with one control marooned at
the end of it reads as a menu that failed to load rather than as a refusal. And a `band` hero
takes its height from its padding and its own h1 (×1.5) rather than from the 200/360px floors a
hero inherits for a PHOTOGRAPH it does not have — with a hairline above and below it, because
`--bg-raised` against `--bg` is a whole tone apart in some rooms and nearly nothing in others,
and a shape that survives the palette is drawn with a rule. The banner masthead closes itself
with the same hairline for the same reason.
- **Counts go through `localeNum()`.** A topic chip's count sits beside an Arabic-Indic date on
every card in the same column; one numbering system per instance is not negotiable for a new
surface either.
- **The designer panel is a DESIGN TOOL.** Three columns — a grouped, drawn rail; a controls
column that cross-fades when the rail moves; and a preview that keeps the largest share of the
width, all three still on screen on a 1280×800 laptop. The ONE exception is the preset gallery,
which is itself a preview surface at three magnifications and therefore takes the preview's
column (two columns, five cards across at 1440) with the stage UNMOUNTED behind it — a pane
drawing the draft is the wrong answer to "which of these fifty-nine". The page is a BOARD of section cards, each
with its position, a wireframe glyph of its kind and its own controls; a card is dragged by its
grip (pointer events, so a finger works), the rows open a real slot for it and a dashed accent
socket is drawn in that slot, and the order only changes when the reader lets go. Every drag has
a keyboard equal — Space lifts, arrows move, Space drops, and a live region says where the row
landed — and the ↑/↓ buttons never go away. Adding a section is a picker of ILLUSTRATED options,
not a menu of nouns. Empty states invite (three glyphs, a sentence, the two doors) rather than
reporting an empty list. The save bar lights, pulses and COUNTS what is waiting, because nothing
in the panel reaches the public site until it is pressed. Motion is 150–200ms, purposeful, and
gone under `prefers-reduced-motion`.
- Separators between two runs of text are hairlines, never `·`, for the reason the status bar and
the sync lines give: the Eastern Arabic zero is itself a raised dot.
- **The designer's preview is the SITE, in a viewport of its own** — the real renderers, the real
theme, the author's own posts and their pictures, inside an `about:blank` iframe carrying the
app's own stylesheets, at 1280 / 834 / 390. A preview in a narrow DIV answers every media query
with the panel's width, so its "phone" is the desktop design squeezed: the one picture of a
phone that is guaranteed wrong. It is hoverable and it scrolls (`.s-dsn` is the scrollport in
there, as it is on the live site), it settles 120ms after the last edit with a lit dot while it
is behind, and a vault with nothing published still draws a furnished page rather than a column
of empty rectangles.
## Custom themes
One more room is MADE, not shipped: `{ base, tokens }` — one of the built-ins plus a sparse map
of overrides, applied as `data-theme="<base>" data-custom-theme="<slug>"` so every token the
author did not touch still comes from `tokens.css` and an upstream retune reaches them for free.
- **A custom theme is selectable everywhere a built-in is**: the picker (its own "Your themes"
group), Settings → default theme, `DEFAULT_THEME`, the palette dot, the ☾/☀ pairing. The id is
`custom:<slug>`, and it is refused wherever it does not exist.
- **The builder's preview is the app**, like the picker's — a theme is a room and the only
preview of a room is the room. Closing restores what was in force.
- **The contrast warnings are the gate, live**: `shared/contrast.ts` is the single implementation
of the ratios, the 18 ΔE accent-vs-text distance and every floor, imported by
`scripts/check-contrast.mjs` AND by the builder. A second copy of the formula eventually
blesses a theme the gate rejects, and that is the theme that ships.
- **Unset is a real state.** Rows show the value they inherit and reset by DELETING the override,
never by re-deriving it.
- The gate now walks THREE grounds for `--text` and `--text-muted` (`--bg`, `--bg-raised`,
`--bg-hover` — the tag pill's ground, painted at rest) and two for `--text-faint`, which is a
statement about that token's remit rather than an exemption: faint measures 2.7:1 on hover in
most of the rooms, which is exactly why the pill's count is `--text-muted`.
## Hard rules
- No component may render raw markdown syntax to the user outside the editor (backlinks, search
snippets strip/render `[[ ]]`, `#`, `**`).
- Every CONTRACTS.md class name stays; new classes stay `s-` prefixed; zero inline style colors.
- **The stock blog is never mutated, forked or monkey-patched by the design engine.** A new
public shell is a new renderer with new classes in a new stylesheet — never a prop threaded
through the base, because the base is what a broken design falls back to.
- Screenshot gate: a change ships only if the 1440×900 screenshots (dark+light, editor+graph+
palette) look like a product someone would pay for.